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

107
app/api/logs.py Normal file
View File

@@ -0,0 +1,107 @@
from __future__ import annotations
import csv
import io
from datetime import datetime
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import String, cast, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db_session, require_admin
from app.models.intercept_log import InterceptLog
from app.schemas.log import InterceptLogItem, LogListResponse
router = APIRouter(prefix="/admin/api/logs", tags=["logs"], dependencies=[Depends(require_admin)])
def apply_log_filters(
statement,
token: str | None,
attempt_ip: str | None,
start_time: datetime | None,
end_time: datetime | None,
):
if token:
statement = statement.where(InterceptLog.token_display.ilike(f"%{token}%"))
if attempt_ip:
statement = statement.where(cast(InterceptLog.attempt_ip, String).ilike(f"%{attempt_ip}%"))
if start_time:
statement = statement.where(InterceptLog.intercepted_at >= start_time)
if end_time:
statement = statement.where(InterceptLog.intercepted_at <= end_time)
return statement
@router.get("", response_model=LogListResponse)
async def list_logs(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=200),
token: str | None = Query(default=None),
attempt_ip: str | None = Query(default=None),
start_time: datetime | None = Query(default=None),
end_time: datetime | None = Query(default=None),
session: AsyncSession = Depends(get_db_session),
) -> LogListResponse:
statement = apply_log_filters(select(InterceptLog), token, attempt_ip, start_time, end_time)
total_result = await session.execute(select(func.count()).select_from(statement.subquery()))
total = int(total_result.scalar_one())
logs = (
await session.scalars(
statement.order_by(InterceptLog.intercepted_at.desc()).offset((page - 1) * page_size).limit(page_size)
)
).all()
return LogListResponse(
items=[
InterceptLogItem(
id=item.id,
token_display=item.token_display,
bound_ip=str(item.bound_ip),
attempt_ip=str(item.attempt_ip),
alerted=item.alerted,
intercepted_at=item.intercepted_at,
)
for item in logs
],
total=total,
page=page,
page_size=page_size,
)
@router.get("/export")
async def export_logs(
token: str | None = Query(default=None),
attempt_ip: str | None = Query(default=None),
start_time: datetime | None = Query(default=None),
end_time: datetime | None = Query(default=None),
session: AsyncSession = Depends(get_db_session),
):
statement = apply_log_filters(select(InterceptLog), token, attempt_ip, start_time, end_time).order_by(
InterceptLog.intercepted_at.desc()
)
logs = (await session.scalars(statement)).all()
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow(["id", "token_display", "bound_ip", "attempt_ip", "alerted", "intercepted_at"])
for item in logs:
writer.writerow(
[
item.id,
item.token_display,
str(item.bound_ip),
str(item.attempt_ip),
item.alerted,
item.intercepted_at.isoformat(),
]
)
filename = f"sentinel-logs-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}.csv"
return StreamingResponse(
iter([buffer.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)