Files
tg-spam-guard/web/api.py
T
2026-07-06 19:26:00 +08:00

127 lines
4.0 KiB
Python

"""Web 管理面板 API"""
from __future__ import annotations
import asyncio
import json
from datetime import datetime
from pathlib import Path
from typing import Any
from fastapi import FastAPI, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from db.models import Action, Ban, GameScore, Message
from services.stats_service import get_daily_stats
BASE_DIR = Path(__file__).parent
app = FastAPI(title="TG Spam Guard Manager")
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
# SSE
_sse_queues: list[asyncio.Queue] = []
def json_safe(value: Any) -> Any:
"""将 ORM values() 结果转成 JSONResponse 可序列化对象。"""
if isinstance(value, datetime):
return value.isoformat(sep=" ", timespec="seconds")
if isinstance(value, list):
return [json_safe(v) for v in value]
if isinstance(value, dict):
return {k: json_safe(v) for k, v in value.items()}
return value
async def broadcast_sse(event: str, data: dict):
dead = []
payload = {"event": event, "data": json.dumps(data, ensure_ascii=False)}
for q in _sse_queues:
try:
await q.put(payload)
except Exception:
dead.append(q)
for q in dead:
if q in _sse_queues:
_sse_queues.remove(q)
@app.get("/", response_class=HTMLResponse)
async def index():
return HTMLResponse((BASE_DIR / "static" / "index.html").read_text(encoding="utf-8"))
@app.get("/health")
async def health():
return {"ok": True, "service": "tg-spam-guard", "time": datetime.now().isoformat(timespec="seconds")}
@app.get("/api/stats")
async def api_stats(day: str = Query(default=None, pattern=r"^\d{4}-\d{2}-\d{2}$")):
return JSONResponse(await get_daily_stats(day))
@app.get("/api/messages")
async def api_messages(limit: int = Query(default=50, ge=1, le=500)):
msgs = await Message.all().order_by("-created_at").limit(limit).values()
return JSONResponse(json_safe(list(msgs)))
@app.get("/api/bans")
async def api_bans(limit: int = Query(default=100, ge=1, le=500)):
bans = await Ban.all().order_by("-created_at").limit(limit).values()
return JSONResponse(json_safe(list(bans)))
@app.get("/api/actions")
async def api_actions(limit: int = Query(default=100, ge=1, le=500)):
actions = await Action.all().order_by("-created_at").limit(limit).values()
return JSONResponse(json_safe(list(actions)))
@app.get("/api/scores")
async def api_scores(limit: int = Query(default=50, ge=1, le=200)):
scores = await GameScore.all().order_by("-score").limit(limit).values()
return JSONResponse(json_safe(list(scores)))
@app.post("/api/unban/{user_id}")
async def api_unban(user_id: int):
import config
try:
from telegram import Bot
bot = Bot(token=config.TG_BOT_TOKEN)
await bot.unban_chat_member(chat_id=config.TG_CHAT_ID, user_id=user_id)
await Ban.filter(user_id=user_id).update(auto_unban=False, unban_at=datetime.now())
await Action.create(action="UNBAN", user_id=user_id, details={"method": "web"})
await broadcast_sse("unban", {"user_id": user_id})
return JSONResponse({"ok": True, "user_id": user_id})
except Exception as e:
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
@app.get("/api/stream")
async def api_stream(request: Request):
from sse_starlette.sse import EventSourceResponse
queue: asyncio.Queue = asyncio.Queue()
_sse_queues.append(queue)
async def event_generator():
try:
while True:
if await request.is_disconnected():
break
try:
data = await asyncio.wait_for(queue.get(), timeout=30)
yield {"event": data["event"], "data": data["data"]}
except asyncio.TimeoutError:
yield {"event": "ping", "data": "ok"}
finally:
if queue in _sse_queues:
_sse_queues.remove(queue)
return EventSourceResponse(event_generator())