feat: 管理面板(登录认证+触发AI分析+统计+CLI改密码)

This commit is contained in:
Sakurasan
2026-09-01 18:41:32 +08:00
parent bfcc932406
commit 7dba0238a5
7 changed files with 597 additions and 1 deletions
+168
View File
@@ -0,0 +1,168 @@
"""管理面板路由(/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, created_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()