用户体系:鉴权、配额与通知
概述
产品形态是私有部署的个人投研工作台,因此用户体系刻意做薄:一套自实现的 HS256
JWT + user / admin 两档角色 + 每日 AI 免费次数配额,没有第三方登录、没有 refresh token。
"薄"不等于"可以乱"——本文给出各能力的唯一实现位置与三条已知缺口,
新增功能时按同样的边界放代码。
| 能力 | 位置 |
|---|---|
口令哈希 / JWT 签发校验 / 取用户 / require_admin | backend/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(均需管理员) |
当前真正会发信的三处:
- 注册欢迎信(
user_lifecycle.send_welcome_email,由POST /auth/register异步触发) - 30 天不活跃召回(任务
user_recall09:10,批 50 / 冷却 30 天,回写last_recall_sent_at) - 每日投资报告(任务
daily_report15:30,收件人 = 配置的smtp_user自己)
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_secret | JWT 签名密钥,泄露即可伪造任意用户(含 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> 这类占位符。