From 7dba0238a5534147dced6778b2f80ddd5e72f56c Mon Sep 17 00:00:00 2001
From: Sakurasan <26715255+Sakurasan@users.noreply.github.com>
Date: Tue, 1 Sep 2026 18:41:32 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AE=A1=E7=90=86=E9=9D=A2=E6=9D=BF?=
=?UTF-8?q?=EF=BC=88=E7=99=BB=E5=BD=95=E8=AE=A4=E8=AF=81+=E8=A7=A6?=
=?UTF-8?q?=E5=8F=91AI=E5=88=86=E6=9E=90+=E7=BB=9F=E8=AE=A1+CLI=E6=94=B9?=
=?UTF-8?q?=E5=AF=86=E7=A0=81=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env.example | 11 ++
.gitignore | 1 +
backend/admin_cli.py | 49 ++++++
backend/dist/admin.html | 275 +++++++++++++++++++++++++++++++++
backend/main.py | 19 ++-
backend/routes/admin.py | 168 ++++++++++++++++++++
backend/services/admin_auth.py | 75 +++++++++
7 files changed, 597 insertions(+), 1 deletion(-)
create mode 100644 .env.example
create mode 100644 backend/admin_cli.py
create mode 100644 backend/dist/admin.html
create mode 100644 backend/routes/admin.py
create mode 100644 backend/services/admin_auth.py
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..3603708
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,11 @@
+# 同花顺金融数据 API 密钥
+fuyao_apikey=sk-fuyao-xxxx
+
+# AI 分析配置
+AI_API_BASE=https://your-api-base/v1
+AI_API_KEY=sk-your-api-key
+AI_MODEL=deepseek-v4-flash
+
+
+# 管理面板密码(默认 !auvauv,建议修改)
+ADMIN_PASSWORD=!auvauv
diff --git a/.gitignore b/.gitignore
index 670fad8..e7a8f3c 100755
--- a/.gitignore
+++ b/.gitignore
@@ -30,6 +30,7 @@ Thumbs.db
# Node.js / JavaScript / TypeScript
node_modules/
dist/
+!dist/admin.html
build/
.next/
.nuxt/
diff --git a/backend/admin_cli.py b/backend/admin_cli.py
new file mode 100644
index 0000000..0c75624
--- /dev/null
+++ b/backend/admin_cli.py
@@ -0,0 +1,49 @@
+#!/usr/bin/env python3
+"""管理密码设置工具
+
+用法:
+ python3 admin_cli.py # 首次设置密码
+ python3 admin_cli.py <新密码> # 修改密码
+ python3 admin_cli.py --check # 检查是否已设置密码
+"""
+import sys
+import os
+
+# 添加 backend 目录到 path
+sys.path.insert(0, os.path.dirname(__file__))
+
+from services.admin_auth import is_configured, set_password
+
+
+def main():
+ if len(sys.argv) > 1 and sys.argv[1] == "--check":
+ if is_configured():
+ print("✅ 管理密码已设置")
+ else:
+ print("⚠️ 管理密码未设置,请运行: python3 admin_cli.py")
+ return
+
+ if len(sys.argv) > 1:
+ new_password = sys.argv[1]
+ else:
+ import getpass
+ if is_configured():
+ print("修改管理密码")
+ else:
+ print("设置管理密码")
+ new_password = getpass.getpass("请输入密码: ")
+ confirm = getpass.getpass("请再次输入密码: ")
+ if new_password != confirm:
+ print("❌ 两次输入不一致")
+ sys.exit(1)
+
+ if len(new_password) < 4:
+ print("❌ 密码至少4位")
+ sys.exit(1)
+
+ set_password(new_password)
+ print(f"✅ 管理密码已{'更新' if is_configured() else '设置'}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/dist/admin.html b/backend/dist/admin.html
new file mode 100644
index 0000000..04ed3ec
--- /dev/null
+++ b/backend/dist/admin.html
@@ -0,0 +1,275 @@
+
+
+
+
+
+AUV 管理面板
+
+
+
+
+
+
+
+
AUV 管理面板
+
A股走势追踪系统
+
+
+
+
+
+
+
+
+
+
+
+
+
AUV 管理面板
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
修改密码
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/main.py b/backend/main.py
index 4700e51..52269fb 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -7,16 +7,23 @@ from contextlib import asynccontextmanager
from dotenv import load_dotenv
from database import init_db
-from routes import stock, collections, shares, themes, core_stocks, fuyao, market_dashboard, ai_analysis
+from routes import stock, collections, shares, themes, core_stocks, fuyao, market_dashboard, ai_analysis, admin
from services.daily_collector import collector_loop, cache_cleanup_loop
from services.ai_collector import ai_analysis_loop
+from services.admin_auth import is_configured, set_password
load_dotenv()
+DEFAULT_ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "!auvauv")
+
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
+ # 首次启动自动设置默认管理密码
+ if not is_configured():
+ set_password(DEFAULT_ADMIN_PASSWORD)
+ print(f"[admin] 已设置默认管理密码,请尽快修改: python3 admin_cli.py")
collector_task = asyncio.create_task(collector_loop())
cache_cleanup_task = asyncio.create_task(cache_cleanup_loop())
ai_analysis_task = asyncio.create_task(ai_analysis_loop())
@@ -50,6 +57,7 @@ app.include_router(core_stocks.router, prefix="/api/core-stocks")
app.include_router(fuyao.router, prefix="/api/v2")
app.include_router(market_dashboard.router, prefix="/api")
app.include_router(ai_analysis.router, prefix="/api")
+app.include_router(admin.router, prefix="/api")
# 生产模式:后端同时托管前端静态文件
# catch-all 路由在 API 路由之后注册,所以 API 优先级更高
@@ -58,6 +66,15 @@ dist_path = os.path.join(os.path.dirname(__file__), "dist")
# HTML 不缓存:保证 index.html 永远最新(引用的资源文件名带 hash,可长缓存)
_NO_CACHE_HTML = {"Cache-Control": "no-cache, no-store, must-revalidate"}
+# 管理面板:/admin → admin.html(在 SPA catch-all 之前)
+ADMIN_HTML = os.path.join(dist_path, "admin.html")
+
+@app.get("/admin")
+async def serve_admin():
+ if os.path.isfile(ADMIN_HTML):
+ return FileResponse(ADMIN_HTML, media_type="text/html", headers=_NO_CACHE_HTML)
+ return JSONResponse({"detail": "Admin panel not found"}, status_code=404)
+
if os.path.isdir(dist_path):
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
diff --git a/backend/routes/admin.py b/backend/routes/admin.py
new file mode 100644
index 0000000..5a3e98f
--- /dev/null
+++ b/backend/routes/admin.py
@@ -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()
diff --git a/backend/services/admin_auth.py b/backend/services/admin_auth.py
new file mode 100644
index 0000000..b2fe306
--- /dev/null
+++ b/backend/services/admin_auth.py
@@ -0,0 +1,75 @@
+"""管理员认证模块
+
+密码存储:data/admin.json
+- password_hash: sha256 哈希
+- salt: 随机盐
+"""
+import hashlib
+import json
+import os
+import secrets
+import time
+
+DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data")
+ADMIN_FILE = os.path.join(DATA_DIR, "admin.json")
+
+# 登录 token 有效期 24 小时
+TOKEN_TTL = 86400
+
+# 内存中的活跃 token: {token: expire_ts}
+_active_tokens: dict[str, float] = {}
+
+
+def _hash_password(password: str, salt: str) -> str:
+ return hashlib.sha256(f"{salt}:{password}".encode()).hexdigest()
+
+
+def is_configured() -> bool:
+ return os.path.exists(ADMIN_FILE)
+
+
+def set_password(password: str) -> None:
+ """设置或更新管理员密码"""
+ os.makedirs(DATA_DIR, exist_ok=True)
+ salt = secrets.token_hex(16)
+ data = {
+ "password_hash": _hash_password(password, salt),
+ "salt": salt,
+ "updated_at": int(time.time()),
+ }
+ with open(ADMIN_FILE, "w") as f:
+ json.dump(data, f, indent=2)
+
+
+def verify_password(password: str) -> bool:
+ """验证密码是否正确"""
+ if not is_configured():
+ return False
+ with open(ADMIN_FILE) as f:
+ data = json.load(f)
+ return _hash_password(password, data["salt"]) == data["password_hash"]
+
+
+def create_token() -> str:
+ """创建登录 token"""
+ token = secrets.token_urlsafe(32)
+ _active_tokens[token] = time.time() + TOKEN_TTL
+ return token
+
+
+def verify_token(token: str) -> bool:
+ """验证 token 是否有效"""
+ if not token:
+ return False
+ expire = _active_tokens.get(token)
+ if expire is None:
+ return False
+ if time.time() > expire:
+ del _active_tokens[token]
+ return False
+ return True
+
+
+def revoke_token(token: str) -> None:
+ """吊销 token"""
+ _active_tokens.pop(token, None)