170 lines
5.6 KiB
Python
170 lines
5.6 KiB
Python
"""管理面板路由(/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()
|