Files
auv/backend/routes/admin.py
T

170 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""管理面板路由(/api/admin)
提供:
- 登录认证(密码 + token)
- 触发 AI 分析
- 浏览统计
- 报告管理
"""
import json
import time
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from services import admin_auth
from database import get_connection, dict_from_row
_CST = timezone(timedelta(hours=8))
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
router = APIRouter()
def _require_auth(request: Request) -> str:
"""从 header 或 cookie 提取 token 并验证"""
token = request.headers.get("X-Admin-Token", "")
if not token:
# fallback: 从 cookie 读
token = request.cookies.get("admin_token", "")
if not admin_auth.verify_token(token):
raise HTTPException(status_code=401, detail="未登录或登录已过期")
return token
class LoginRequest(BaseModel):
password: str
@router.post("/admin/login", summary="管理员登录")
async def admin_login(body: LoginRequest):
if not admin_auth.is_configured():
# 自动设置默认密码
import os
default_pw = os.getenv("ADMIN_PASSWORD", "!auvauv")
admin_auth.set_password(default_pw)
if not admin_auth.verify_password(body.password):
raise HTTPException(status_code=401, detail="密码错误")
token = admin_auth.create_token()
return JSONResponse({"data": {"token": token, "expiresIn": admin_auth.TOKEN_TTL}})
@router.post("/admin/logout", summary="管理员登出")
async def admin_logout(request: Request):
token = request.headers.get("X-Admin-Token", "") or request.cookies.get("admin_token", "")
admin_auth.revoke_token(token)
return JSONResponse({"data": "ok"})
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
@router.post("/admin/change-password", summary="修改管理密码")
async def admin_change_password(body: ChangePasswordRequest, request: Request):
_require_auth(request)
if not admin_auth.verify_password(body.old_password):
raise HTTPException(status_code=401, detail="原密码错误")
if len(body.new_password) < 4:
raise HTTPException(status_code=400, detail="密码至少4位")
admin_auth.set_password(body.new_password)
return JSONResponse({"data": "密码已更新"})
@router.get("/admin/stats", summary="管理面板统计数据")
async def admin_stats(request: Request):
_require_auth(request)
conn = get_connection()
try:
stats = {}
# 报告总数
row = conn.execute("SELECT COUNT(*) as cnt FROM ai_reports").fetchone()
stats["reportCount"] = row["cnt"]
# 总 tokens
row = conn.execute("SELECT COALESCE(SUM(tokens_used), 0) as total FROM ai_reports").fetchone()
stats["totalTokens"] = row["total"]
# 最新报告
row = conn.execute(
"SELECT trade_date, title FROM ai_reports ORDER BY trade_date DESC LIMIT 1"
).fetchone()
stats["latestReport"] = dict_from_row(row) if row else None
# 集合数
row = conn.execute("SELECT COUNT(*) as cnt FROM stock_collections").fetchone()
stats["collectionCount"] = row["cnt"]
# 关注股票数
row = conn.execute("SELECT COUNT(*) as cnt FROM collection_stocks").fetchone()
stats["stockCount"] = row["cnt"]
# 分享链接数
row = conn.execute("SELECT COUNT(*) as cnt FROM share_links").fetchone()
stats["shareLinkCount"] = row["cnt"]
# 核心股天数
row = conn.execute(
"SELECT COUNT(DISTINCT trade_date) as cnt FROM daily_core_stocks"
).fetchone()
stats["coreStockDays"] = row["cnt"]
# 近7天报告
rows = conn.execute(
"SELECT trade_date, title, tokens_used, created_at FROM ai_reports "
"ORDER BY trade_date DESC LIMIT 7"
).fetchall()
stats["recentReports"] = [dict_from_row(r) for r in rows]
return JSONResponse({"data": stats}, headers=_NO_CACHE_HEADERS)
finally:
conn.close()
@router.post("/admin/trigger-analysis", summary="触发 AI 分析")
async def admin_trigger_analysis(request: Request):
_require_auth(request)
now = datetime.now(_CST)
trade_date = now.strftime("%Y-%m-%d")
from services.ai_service import collect_ai_analysis
result = await collect_ai_analysis(trade_date)
return JSONResponse({"data": result})
@router.get("/admin/reports", summary="报告列表(管理用)")
async def admin_list_reports(request: Request):
_require_auth(request)
conn = get_connection()
try:
rows = conn.execute(
"SELECT id, trade_date, report_type, title, summary, tools_used, model, tokens_used, "
"generation_count, llm_calls, created_at, updated_at "
"FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 50"
).fetchall()
items = []
for r in rows:
item = dict_from_row(r)
item["toolsUsed"] = json.loads(item.pop("tools_used") or "[]")
items.append(item)
return JSONResponse({"data": items}, headers=_NO_CACHE_HEADERS)
finally:
conn.close()
@router.delete("/admin/reports/{report_id}", summary="删除报告")
async def admin_delete_report(report_id: int, request: Request):
_require_auth(request)
conn = get_connection()
try:
conn.execute("DELETE FROM ai_reports WHERE id = ?", (report_id,))
conn.commit()
return JSONResponse({"data": "已删除"})
finally:
conn.close()