feat: 管理面板(登录认证+触发AI分析+统计+CLI改密码)
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user