feat(core): 初始化 Key-IP Sentinel 服务与部署骨架
- 搭建 FastAPI、Redis、PostgreSQL、Nginx 与 Docker Compose 基础结构 - 实现反向代理、首用绑定、拦截告警、归档任务和管理接口 - 提供 Vue3 管理后台初版,以及 uv/requirements 双依赖配置
This commit is contained in:
123
app/services/alert_service.py
Normal file
123
app/services/alert_service.py
Normal 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})
|
||||
Reference in New Issue
Block a user