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 管理面板

+
+ + + +
+
+ +
+ +
+ +
+

📊 分析报告

+ + + +
日期标题模型Tokens创建时间操作
+
+ +
+

📈 近期报告

+ + + +
日期标题Tokens
+
+
+ + + + + + + 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)