feat(core): 初始化 Key-IP Sentinel 服务与部署骨架

- 搭建 FastAPI、Redis、PostgreSQL、Nginx 与 Docker Compose 基础结构
- 实现反向代理、首用绑定、拦截告警、归档任务和管理接口
- 提供 Vue3 管理后台初版,以及 uv/requirements 双依赖配置
This commit is contained in:
2026-03-04 00:18:33 +08:00
commit ab1bd90c65
50 changed files with 5645 additions and 0 deletions

View File

@@ -0,0 +1,123 @@
from __future__ import annotations
import logging
from typing import Callable
import httpx
from redis.asyncio import Redis
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.config import RuntimeSettings, Settings
from app.models.intercept_log import InterceptLog
logger = logging.getLogger(__name__)
class AlertService:
def __init__(
self,
settings: Settings,
session_factory: async_sessionmaker[AsyncSession],
redis: Redis | None,
http_client: httpx.AsyncClient,
runtime_settings_getter: Callable[[], RuntimeSettings],
) -> None:
self.settings = settings
self.session_factory = session_factory
self.redis = redis
self.http_client = http_client
self.runtime_settings_getter = runtime_settings_getter
def alert_key(self, token_hash: str) -> str:
return f"sentinel:alert:{token_hash}"
async def handle_intercept(
self,
token_hash: str,
token_display: str,
bound_ip: str,
attempt_ip: str,
) -> None:
await self._write_intercept_log(token_hash, token_display, bound_ip, attempt_ip)
runtime_settings = self.runtime_settings_getter()
if self.redis is None:
logger.warning("Redis is unavailable. Intercept alert counters are disabled.")
return
try:
async with self.redis.pipeline(transaction=True) as pipeline:
pipeline.incr(self.alert_key(token_hash))
pipeline.expire(self.alert_key(token_hash), runtime_settings.alert_threshold_seconds)
result = await pipeline.execute()
except Exception:
logger.warning("Failed to update intercept alert counter.", extra={"token_hash": token_hash})
return
count = int(result[0])
if count < runtime_settings.alert_threshold_count:
return
payload = {
"token_display": token_display,
"attempt_ip": attempt_ip,
"bound_ip": bound_ip,
"count": count,
}
if runtime_settings.alert_webhook_url:
try:
await self.http_client.post(runtime_settings.alert_webhook_url, json=payload)
except httpx.HTTPError:
logger.exception("Failed to deliver alert webhook.", extra={"token_hash": token_hash})
try:
await self.redis.delete(self.alert_key(token_hash))
except Exception:
logger.warning("Failed to clear intercept alert counter.", extra={"token_hash": token_hash})
await self._mark_alerted_records(token_hash, runtime_settings.alert_threshold_seconds)
async def _write_intercept_log(
self,
token_hash: str,
token_display: str,
bound_ip: str,
attempt_ip: str,
) -> None:
async with self.session_factory() as session:
try:
session.add(
InterceptLog(
token_hash=token_hash,
token_display=token_display,
bound_ip=bound_ip,
attempt_ip=attempt_ip,
alerted=False,
)
)
await session.commit()
except SQLAlchemyError:
await session.rollback()
logger.exception("Failed to write intercept log.", extra={"token_hash": token_hash})
async def _mark_alerted_records(self, token_hash: str, threshold_seconds: int) -> None:
statement = text(
"""
UPDATE intercept_logs
SET alerted = TRUE
WHERE token_hash = :token_hash
AND intercepted_at >= NOW() - (:threshold_seconds || ' seconds')::interval
"""
)
async with self.session_factory() as session:
try:
await session.execute(
statement,
{"token_hash": token_hash, "threshold_seconds": threshold_seconds},
)
await session.commit()
except SQLAlchemyError:
await session.rollback()
logger.exception("Failed to mark intercept logs as alerted.", extra={"token_hash": token_hash})