跳到主要内容

用户体系:鉴权、配额与通知

概述​

产品形态是私有部署的个人投研工作台,因此用户体系刻意做薄:一套自实现的 HS256 JWT + user / admin 两档角色 + 每日 AI 免费次数配额,没有第三方登录、没有 refresh token。 "薄"不等于"可以乱"——本文给出各能力的唯一实现位置与三条已知缺口, 新增功能时按同样的边界放代码。

能力位置
口令哈希 / JWT 签发校验 / 取用户 / require_adminbackend/app/core/auth.py
注册登录端点backend/app/api/auth.py
AI 每日配额backend/app/core/quota.py
全局限流 / 数据源域名限流backend/app/core/rate_limit.py、core/domain_rate_limiter.py
站内通知 + 邮件backend/app/services/notification_service.py、core/email_sender.py、api/notifications.py
用户生命周期(欢迎信 / 不活跃召回)backend/app/services/user_lifecycle.py

鉴权​

项实现
口令哈希PBKDF2-HMAC-SHA256,16 字节盐,20 万次迭代,dklen 32,常量时间比较
Token标准库自实现 HS256 JWT(无第三方依赖),create_token / decode_token(签名 + exp + iss/aud 校验,失败返回 None)
有效期JWT_TTL_HOURS = 24;iss = xuangu、aud = xuangu-web
签名密钥backend/data/.jwt_secret(32 字节随机),缺失时自动生成并 chmod 600
access / refresh不区分——只有一个 token,无 refresh 流程
取用户解析 Authorization: Bearer <token>,失败 401(带 WWW-Authenticate: Bearer)
登出后端无 logout 端点:前端清 token + 重载(src/store/authAtoms.ts)

端点​

方法与路径作用
POST /auth/register注册(username / email / password)
POST /auth/login登录(username / password),返回 token + 用户摘要(含 ai_usage_count)
GET /auth/me当前用户
POST /auth/profile改资料(username / email / phone)
POST /auth/change-password改密
POST /auth/avatar头像
GET /auth/verify-email?token=邮箱验证回链(见下方缺口)

登录风控​

同一 IP + 用户名 连续失败 5 次 → 锁 300 秒并返回 429; 账号 status 非 active 返回 403。计数在 core/rate_limit.py, 与全局限流是两套不同的桶,别混用。

已知缺口:邮箱验证链路不可用

注册不发验证邮件,直接置 email_verified=1;app_email_tokens 表仍保留但 全仓没有任何构造点,因此 GET /auth/verify-email 的链接实际走不通。 要恢复该链路需要:注册时签发 token → 发邮件(模板 services/email_templates/account.py 已存在但无调用链)→ 校验消费。在此之前,不要在前端放"去验证邮箱"的入口。

角色与权限​

项事实
角色字段User.role(String(16),取值 user / admin),表 app_users
服务端闸门require_admin:角色不符 → 403「需要管理员权限」
覆盖面全仓 Depends(require_admin) 104 处:datamgr 64 / settings 14 / auth 9 / tasks 9 / performance 3 / market_turnover 2 / quant·strategies·tactics 各 1
前端闸门AdminRoute(src/App.tsx)包住 /datamgr 与 /tasks,非管理员跳回 /;useIsAdmin() 读 localStorage 缓存的 user(非实时,改权限后需重登)
未登录访问AuthGate 允许未登录浏览主应用,AI 功能在各入口处单独拦截(useAiGate)
前端闸门不是安全边界

AdminRoute 与 useIsAdmin 只是体验层的隐藏。真正的权限只有服务端 require_admin 一处。 新增管理端点时必须自己写依赖,不能因为"页面已经藏起来了"就省。

数据表​

app_users 关键列:role、status(pending_email / pending_approval / active / disabled)、email_verified、is_activated、avatar、phone、ai_usage_count、 ai_usage_date、last_login_at、last_recall_sent_at。

配套表:app_email_tokens、app_user_favorites、app_user_sector_favorites、 app_user_holdings、app_user_checklist_records / app_user_checklist_answers。

没有 app_admins、没有独立配额表、没有通知表。运行期补列走 core/database.py 的增量迁移(新增列不要手写 ALTER,登记到那里)。

配额与限流​

三层,各管一件事:

层机制参数
AI 每日配额require_ai_quota(用在 api/analysis.py 与 api/chat.py 的 6 个端点)admin 或 is_activated=1 免计;否则共享计数 _DAILY_FREE_LIMIT = 10,跨自然日重置,超限 403 + 固定文案
全局请求限流RateLimitMiddleware 令牌桶120 req/min/IP,仅 /api/,超限 429;/health、/docs 等前缀免限
数据源域名限流domain_rate_limiter 按域名分桶串行每域名 0.3~1.0s 随机延迟(东财/腾讯另有更细的参数,见配置指南)

前端 src/store/quotaAtoms.ts 没有对应的"查剩余次数"端点——它按 403 文案 (含「免费次数已用完」「购买会员」)弹提醒;剩余次数展示走 /auth/me 的 ai_usage_count(设置页显示「试用 x/10」)。

按文案判定配额是一种耦合

"看到 403 且文案含某四个字"才提示购买会员——改后端提示文案必须同步改前端, 否则用户只吃到一个红色错误条。新增端点请复用 maybeHandleQuotaError。

通知体系​

站内通知:按日 JSON 落盘(不是数据库)​

services/notification_service.py 把通知写在 backend/app/data/notifications/YYYY-MM-DD.json, 支持读取 / 去重 / 已读标记。端点:

方法与路径作用
GET /notifications/unread未读列表(前端通知中心 30s 轮询)
GET /notifications?page=通知历史分页
POST /notifications/{id}/read标记单条已读
POST /notifications/read-all全部已读
POST /notifications/test-email发测试邮件
两处现状要如实知道

① 这些端点当前没有任何鉴权依赖(单用户本地部署形态下的历史决定)——一旦对外网暴露 (prod 用 nginx 反代时),通知内容与邮箱地址可被匿名读取。部署到公网必须补 Depends(get_current_user)。 ② 前端 /notifications 页与铃铛只用 localStorage,并没有调这套 API—— 「持仓异动」等站内通知目前落盘但不在页面显示。接线时以前端为准改造,别只加后端。

谁会产出站内通知​

portfolio_mon(交易时段每半小时)→ services/portfolio_monitor.py::PortfolioMonitor.run() → 有异动时 _save_station_notifications(details, "portfolio_alert")。只站内、不发邮件。

邮件(SMTP)​

项实现
配置backend/data/smtp_config.json,密码经 secret_store 的 Fernet 解密
发送core/email_sender.py::send_email()——465 端口走 SMTPS,其余走 STARTTLS
第二条通道NotificationService 自己的 SMTP:优先读 smtp_config.json,回退 .env 的 SMTP_*,并受总开关 ENABLE_EMAIL_NOTIFICATION 控制
管理端点GET/POST /settings/smtp-config、POST /settings/test-smtp(均需管理员)

当前真正会发信的三处:

  1. 注册欢迎信(user_lifecycle.send_welcome_email,由 POST /auth/register 异步触发)
  2. 30 天不活跃召回(任务 user_recall 09:10,批 50 / 冷却 30 天,回写 last_recall_sent_at)
  3. 每日投资报告(任务 daily_report 15:30,收件人 = 配置的 smtp_user 自己)
两条 SMTP 通道要分清

core/email_sender.py 与 NotificationService 各自读一份配置。新增发信需求时 先确定走哪条,不要在两条里各配一份凭据。SMTP 未配置时相关任务安全跳过(不报错)。

菜单可见性​

管理员可在设置页隐藏部分入口:

项事实
端点GET /settings/menu-visibility(登录即可读)、POST(仅管理员)
存储backend/data/menu_settings.json
字段hide_for_user / hide_for_admin
白名单只有 7 个 key 可配:/market-center /rule-pick /factor-pick /screener /catalyst /thesis /datamgr
生效src/components/layout/Sidebar.tsx 拉取后与 admin 标志双重过滤

新增可隐藏项必须同时改后端白名单与前端 MenuSettings.tsx 的选项列表, 否则设置页勾不上或勾了不生效。

安全红线​

禁止原因
把真实凭据写进 backend/.env / .env.development这两个文件是有意入库的样例配置,只允许已失效值或占位符
提交 backend/data/.jwt_secretJWT 签名密钥,泄露即可伪造任意用户(含 admin)token
提交 backend/data/.db_key、db_config.json数据库加密密钥 / 含库密码
提交 backend/data/model_configs/含 AI API Key(多管理员各一份)
提交 backend/data/ 下其余运行时配置auth_settings.json / smtp_config.json / search_config.json / mcp_servers.json / 通知 JSON 等均受 .gitignore 保护
日志明文输出 API Key / 密码 / token日志会被持久化(LOG_FILE 落盘 + app_task_event 自动落库),泄露面比控制台大得多

凭据落位三档契约(放错档是最常见的事故来源):

档位位置入库放什么
样例/默认backend/.env、.env.example✅占位符或已失效旧凭据
本机私有backend/data/❌真实凭据(加密的 db_config.json、model_configs/{uid}.json、SMTP 等)
部署注入环境变量 / systemd❌生产凭据
发现真实凭据入库怎么办

先轮换(改密码 / 吊销 Key),再把值移到 backend/data/ 或环境变量。 不要只删文件——git 历史仍可读(本仓库曾有一次 .env 真实 Key 入库,按失效处理)。 文档与截图同理:示例一律用 <admin-user> / <your-password> 这类占位符。

相关文档​