6 Commits

Author SHA1 Message Date
architect bf4d100e14 docs(BIZ-127): 完整用户系统架构 v1.0 + SQL DDL
- 5 张表: users / sessions / password_reset_tokens / operation_logs / auth_settings
- 鉴权中间件流程图(日志 → 鉴权 → 业务)
- Cookie 安全策略表(SameSite=Lax, httpOnly, 30 天)
- 多用户扩展:数据层预留, UI/API 不暴露
- 兼容性矩阵:Bearer 双轨 24h 过渡
- 性能 + 安全边界 5 项决策

关联: BIZ-109 → BIZ-127 → BIZ-129
评审: 陆怀瑾 (COO) 已解锁 memory_search 依赖
2026-09-04 20:32:24 +08:00
opengineer e0c95a9fac fix(BIZ-109): 撤回 v2 临时白名单(BIZ-110 前端鉴权已就绪,恢复严格鉴权)
- 删除 AUTH_BYPASS_GET_PREFIXES / AUTH_BYPASS_POST_EXACT 配置项
- 删除 _is_bypass() 函数
- require_auth 中间件恢复严格鉴权逻辑(依赖 Bearer token)
- 前端 BIZ-110 commit dcc7339 已完成 localStorage + login 浮层 + Bearer 注入
- 回滚保险:app.py.bak.biz110.revoked.20260902(本地保留,未入库)

Refs: BIZ-109, BIZ-110
Op: opengineer @ 2026-09-02 17:50 GMT+8
2026-09-02 17:51:21 +08:00
vincent 2a759d655d fix(BIZ-109): 扩大临时白名单范围,修复刘总验收仍报 401 的问题
- v1 白名单仅覆盖 /api/records + /api/history 根路径,遗漏:
  * /api/records/<id>/preview(GET 子路径)→ 刘总报「预览未授权」
  * /api/records/<id>/compare(GET 子路径)→ 刘总报「比对未授权」
  * /api/download/<path>(GET 子路径)→ 刘总报「下载未授权」
  * /api/statistics(GET 根路径)→ 刘总报「统计分析不见」
  * POST /api/generate(号码生成)→ 刘总报「号码生成未授权」

- v2 策略(按方法 + 路径前缀):
  * GET 类全部放行(含子路径):/api/records*, /api/history, /api/statistics, /api/download
  * POST /api/generate 放行(核心功能)
  * DELETE 类保留鉴权(防误删滥用)
  * 其他 POST 保留鉴权

- 验证:8 个接口全验证(5 个原 401 现 200,DELETE 仍 401)
- 跟踪:BIZ-110 由 costcodev 适配前端后撤白名单
- 备份:app.py.bak.biz109.v1.20260901125959
- 闭环:2026-09-01 13:05 GMT+8 by 严维序 (opengineer)
2026-09-01 13:05:42 +08:00
vincent fccfd775a3 fix(BIZ-109): 临时放行 GET 只读接口鉴权(应急修复)
- 根因:BIZ-108 commit 31cabbe0 启用 auth_enabled=True,但
  index.html 前端 api() 函数 (L944) 未注入 Authorization header,
  导致生产环境「记录」tab 完全不可用(10h+)
- 修复方案:require_auth 装饰器增加 AUTH_BYPASS_PATHS 白名单,
  仅对 GET /api/records + GET /api/history 临时放行鉴权,
  写接口(POST /api/generate、DELETE /api/records/<id>、
  preview、compare、statistics)保留鉴权
- 验证:5 接口全验证通过(200/401 符合预期),
  外网 192.168.1.99:8085 访问正常
- 跟踪:BIZ-110 由 costcodev 适配前端后移除白名单
- 备份:app.py.bak.biz109.20260901050002
- 闭环:2026-09-01 05:18 GMT+8 by 严维序 (opengineer)
2026-09-01 06:00:18 +08:00
vincent 31cabbe044 feat(BIZ-108): 启用 auth_enabled + 强制 LOTTO_API_TOKEN 启动期校验
- app.py L137: 删除 'lotto2026' 默认 token 兜底
- app.py L138: auth_enabled: False → True
- app.py L147+: 启动期强制校验 token 长度 ≥ 32 字符
  - 缺失或过短 → RuntimeError fail-fast,避免静默使用默认 token
- 配套 /etc/lotto/env(root:root, 0600 权限)落盘强随机 token

验证:
- /api/status 报 auth_enabled:true
- 无/错 token → 401,正确 token → 200
- 8 个接口鉴权全部生效(generate/records/preview/compare/statistics/history/status/delete)
- BIZ-105 字段契约保持(waiting 路径零污染)
2026-08-31 14:23:21 +08:00
vincent 462adcc424 docs(deploy): BIZ-105 v1.2 部署记录(严维序 2026-08-31)
- 部署时间/版本/分支记录
- smoke 测试结果 + waiting 契约确认
- 已知后续验证项(2026-09-01 开奖后 e2e)
2026-08-31 07:15:01 +08:00
6 changed files with 521 additions and 293 deletions
+21 -3
View File
@@ -134,8 +134,8 @@ CONFIG = {
'history_file': os.path.join(BASE_DIR, '双色球历史数据.xlsx'),
'lottery_output_dir': os.path.join(BASE_DIR, 'lottery'),
'records_file': os.path.join(BASE_DIR, '.generation_records.json'),
'api_token': os.environ.get('LOTTO_API_TOKEN', 'lotto2026'),
'auth_enabled': False,
'api_token': os.environ.get('LOTTO_API_TOKEN'), # BIZ-108: 强制从 env 读取,删除默认 'lotto2026' 兜底
'auth_enabled': True, # BIZ-108: 启用鉴权(默认 False 是安全漏洞)
'max_tickets': 1000,
'default_tickets': 10,
# 数据抓取配置(原 web_executor.py 功能)
@@ -144,6 +144,20 @@ CONFIG = {
'fetch_timeout': 300, # 抓取超时秒数
}
# ============================================================
# BIZ-108 安全加固:启动期强制校验 LOTTO_API_TOKEN
# ============================================================
# 规则:auth_enabled=True 时,token 必须存在且长度 ≥ 32
# 缺失或过短 → 启动失败(fail-fast),避免静默使用默认 token
_API_TOKEN = CONFIG['api_token']
if CONFIG['auth_enabled']:
if not _API_TOKEN or len(_API_TOKEN) < 32:
raise RuntimeError(
"[BIZ-108] LOTTO_API_TOKEN 缺失或长度 < 32 字符。"
"请在 /etc/lotto/env 配置强随机 token(建议 64 字符)。"
"生成命令: openssl rand -base64 48 | tr -d '\\n=' | cut -c1-64"
)
# ============================================================
# 生成记录管理(线程安全)
# ============================================================
@@ -289,8 +303,12 @@ def find_draw_by_gen_time(gen_time):
return None, 'error'
# ============================================================
# 认证装饰器(可选)
# 认证装饰器(严格鉴权 — BIZ-110 闭环后恢复)
# ============================================================
# 2026-09-02 by 严维序 (opengineer)
# BIZ-110 前端鉴权适配完成后,撤回 BIZ-109 临时白名单(AUTH_BYPASS_* / _is_bypass)。
# index.html 前端 api() 函数已自动注入 'Authorization: Bearer <token>',
# 401 时清 localStorage + 跳登录页,鉴权闭环恢复。
def require_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
+80
View File
@@ -224,3 +224,83 @@ curl http://127.0.0.1:8085/api/status
---
> 部署人:严维序 (opengineer) | 2026-07-04
---
## BIZ-105 部署记录 — 2026-08-31
| 项 | 值 |
|---|---|
| 部署时间 | 2026-08-31 05:30 (Asia/Shanghai) |
| 部署人员 | 严维序 (opengineer) |
| 任务来源 | COO 陆怀瑾 sessions_send(父 BIZ-106 子任务3) |
| 代码版本 | commit 5d459b450525063ecdbbfb5694aff30aba5fb0bf |
| 分支 | biz-105-impl-prize-display |
| PRD | BIZ-102 v1.1 + 中奖金额展示 v1.2 §4.3 |
| 改动范围 | app.py +110 / index.html +89 / 新增 tests/test_compare_prize.py 31 用例 / +980 -2 |
| 部署方式 | pull → 本地备份 → git checkout → 重启服务(PID 3165→2586851) |
| smoke 结果 | 31/31 单测通过;主页 200/14ms;/api/records 200;/api/history 200;compare 接口 waiting 契约正确(无字段污染) |
| 回滚预案 | `git checkout main` + 备份 tar 包 `/home/vincent/backups/biz-105-predeploy/lottoData-prebiz105-20260831-052817.tar.gz` |
| 通知 | ✅ 已通知 COO(陆怀瑾)+ 徐聪(costcodev)sessions_send |
| 风险 | waiting 状态记录无法验证新字段展示效果(需下期开奖 2026-09-01 后 e2e 验证) |
### 部署执行步骤(实际执行版)
1. **备份**:tar 打包 app.py/index.html/lottery.py/... → `/home/vincent/backups/biz-105-predeploy/`
2. **stash 本地修改**:`git stash push -m "biz-105-predeploy-local-changes-20260831"`
3. **checkout 分支**:`git checkout biz-105-impl-prize-display`(HEAD = 5d459b4)
4. **停止服务**:`kill 3165`(SIGTERM 优雅停止,3s 内)
5. **启动新版本**:`nohup ./.venv/bin/python3 ./app.py > /tmp/lotto-biz105-YYYYMMDD-HHMMSS.log 2>&1 &`
6. **smoke 验证**:31 单测 + HTTP 200 三接口 + waiting 契约
7. **持久化确认**:PID 2586851 监听 8085,日志无异常
### 已知后续验证项
- [ ] 2026-09-01 开奖后,对历史生成记录做 e2e 中奖金额展示验证
- [ ] 浮动奖(一/二等奖)橙色提示条视觉验证
- [ ] 总中奖金额角标位置/样式验证
---
## BIZ-105 部署后 Smoke 报告(COO 补充要求)— 2026-08-31 06:00
按 COO 陆怀瑾 sessions_send 补充要求,完成 5a/5b/5c 三项 curl smoke:
### 5a 接口层 smoke
- **waiting 路径**:5 字段严格保持,无新字段污染 ✅
- **compared 路径**:13 字段齐全 + BIZ-105 5 新字段格式正确 ✅
- `total_prize: 5`、`total_prize_display: '5元'`
- `has_variable_prize: False`
- `prize_summary: {sixth: {count: 1, amount: 5}}`
- `prize_display: [{level: 六等奖, count: 1, amount: '5元'}]`
- **真实数据样本**:record f774a886(5 注),1 注中六等奖(5 元)
### 5b 浮动奖逻辑 smoke(4 场景全过)
| 场景 | 输入 | 预期 | 实际 |
|---|---|---|---|
| 仅固定奖 | 三(2)+四(1)+五(2)+六(1) | total=6225, has_var=False | ✅ |
| 含一等奖 | 一(1)+三(2)+六(1) | total=6005(不含一), first.amount=0 | ✅ |
| 一+二+三 | 一+二+三 | total=3000(仅三), display 不含一/二 | ✅ |
| 仅浮动奖 | 一+二 | total=0, has_var=True, display=[] | ✅ |
### 5c 千分位 smoke(7 用例全过)
| 输入 | 输出 | 校验 |
|---|---|---|
| 3000 | '3,000元' | ✅ 紧邻无空格 |
| 5000000 | '5,000,000元' | ✅ |
| 0 | '0元' | ✅ |
| 5 | '5元' | ✅ |
| 200 | '200元' | ✅ |
| 10000 | '10,000元' | ✅ |
| 1234567 | '1,234,567元' | ✅ |
| **1234** | **'1,234元'** | ✅ **关键用例:紧邻无空格** |
### 验证方法说明
- 5a waiting: 直接 curl `/api/records/<id>/compare`
- 5a compared: Flask test_client + 临时改 created_at='2026-06-30 10:00:00'(触发 compared 路径),验证后立即恢复 records 文件
- 5b: 直接调用 `compute_prize_summary` 喂 mock results
- 5c: 直接调用 `format_amount` 函数
### 待跟进
- [ ] 徐聪视觉验收前端 index.html(橙色浮动奖角标 + badge + 汇总表样式)
- [ ] 2026-09-01 开奖后真实数据 e2e(含真实中奖金额展示)
@@ -0,0 +1,240 @@
# BIZ-127 完整用户系统架构设计 v1.0
| 项 | 内容 |
|---|---|
| **版本** | v1.0 |
| **作者** | 梁思筑(architect) |
| **日期** | 2026-09-04 |
| **关联卡片** | BIZ-127(架构)→ BIZ-129(实施) |
| **父卡片** | BIZ-109(P0 修复闭环) |
| **关联 PRD** | BIZ-111(产品 PRD) |
| **存储位置** | docs/BIZ-127_完整用户系统架构_v1.0.md |
| **同步位置** | 思源知识库 `/standards/完整用户系统架构 v1.0` |
---
## 一、设计目标
实现完整的账号密码登录体系,支持长期免登录(30 天),账号密码 + Session Cookie 方案,不在 URL/前端暴露 token。
---
## 二、数据模型
**完整 SQL DDL** 见 `docs/auth-schema.sql`(9.7KB)。
包含 5 张表:
1. **users** —— 用户表(bcrypt 密码哈希、登录失败计数、锁定时间戳、多租户/角色预留)
2. **sessions** —— 服务端会话(DB 存,非 JWT;存 session_hash、password_epoch 用于改密失效)
3. **password_reset_tokens** —— 密码重置 token(一次性、1 小时过期、限速 3 次/小时)
4. **operation_logs** —— 操作日志(90 天保留、多租户索引预留)
5. **auth_settings** —— 全局鉴权配置(bcrypt cost、session 寿命、锁定参数)
### 关键决策
| 决策点 | 选择 | 理由 |
|--------|------|------|
| Session 存储 | **DB 存** | 可控失效、改密立即踢人;JWT 不适合内网场景 |
| 重置 token 存储 | **DB 存** | 一次性、可控、可限速;JWT 自包含不可撤销 |
| 密码哈希 | **bcrypt cost=12** | 行业标准,登录耗时 ~250ms,安全/性能平衡 |
| 多用户扩展 | **数据层预留 + UI 不暴露** | 见下文"多用户扩展决策" |
---
## 三、鉴权中间件流程图
```
请求进入
│
▼
┌──────────────────────┐
│ 日志中间件 │ ← 记录 method/path/IP/UA
│ (log_request) │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ 鉴权中间件 │ ← 优先读 cookie,fallback Bearer
│ (authenticate) │ Bearer 仅用于内部服务调用
└──────────┬───────────┘
│
│ 判断:path 是否在白名单?
│
┌─────┴──────┐
│ │
YES (白名单) NO
│ │
│ ▼
│ ┌──────────────────────┐
│ │ require_auth 装饰器 │ ← 验证 session_id
│ │ - 查 sessions 表 │ 校验:
│ │ - 校验 session_hash│ 1. session 存在
│ │ - 校验 expires_at │ 2. 未撤销
│ │ - 校验密码 epoch │ 3. 未过期
│ │ - 续期(如活跃) │ 4. password_epoch 匹配
│ └──────────┬───────────┘
│ │
│ ▼
│ ┌──────────────────────┐
│ │ 业务中间件 │
│ │ (业务逻辑处理) │
│ └──────────┬───────────┘
│ │
│ ▼
│ ┌──────────────────────┐
│ │ 日志中间件(出口) │ ← 记录 status/duration/user_id
│ └──────────────────────┘
│
└──▶ 业务中间件(白名单接口无需鉴权)
```
### 5 个鉴权端点
| 端点 | 方法 | 说明 |
|------|------|------|
| `/api/login` | POST | 账号密码 → 校验 → 创建 session → 设置 cookie |
| `/api/logout` | POST | 清 cookie → 撤销 session(revoked_at=now) |
| `/api/password-reset/request` | POST | 生成 token → 邮件/日志通知(内网暂用日志) |
| `/api/password-reset/confirm` | POST | 校验 token → 改密码 → 撤销该用户所有 session |
### 装饰器改造
```python
# 现状
@require_auth
def api_records(): ...
# 改造后(伪代码,BIZ-129 实施)
@require_auth # 内部自动读 cookie 或 Bearer
def api_records(): ...
# 新增装饰器
@require_admin # role == 'admin' 才允许
def admin_only_api(): ...
```
---
## 四、Cookie 安全策略
| 项 | 值 | 决策依据 |
|---|----|---------|
| **名称** | `lotto_session` | 业务前缀,避免与其它服务冲突 |
| **httpOnly** | `true` | ✅ 阻止 XSS 读取 cookie |
| **secure** | `true`(生产) / `false`(内网 HTTP 调试) | 内网暂不强制 HTTPS,但配置项保留 |
| **sameSite** | `Lax` | ✅ 防 CSRF;`Strict` 会破坏外链跳转登录 |
| **path** | `/` | 全站生效 |
| **maxAge** | `30 * 24 * 3600` 秒(30 天) | 满足"长期免登录"诉求 |
| **domain** | 当前域(不设) | 内网单域,不跨子域 |
### 防 CSRF 决策
**不单独引入 CSRF token**,理由:
- `SameSite=Lax` 已防 95% CSRF(POST 表单跨站不携带 cookie)
- 关键操作(删除、改密码)二次校验(要求输入当前密码或确认)
- 如未来扩展第三方嵌入或 iframe 调用,再加 CSRF token
### Session 续期策略
- 用户每次活跃请求,若 `expires_at - now < 24h`,则续期 30 天
- `last_seen_at` 用于清理过期 session
---
## 五、多用户扩展决策(4 个判断点)
| 判断点 | 是否预留 | 决策 |
|--------|---------|------|
| 1. 数据库 `users.tenant_id` / `role` 字段 | **✅ 预留** | 已建字段,索引就绪,单租户默认 1 |
| 2. API 路径 `/api/admin/users/*` | **❌ 不预留** | 过度设计;未来需要时再加,无需现在占坑 |
| 3. 前端「多用户切换」入口 | **❌ 不预留** | UI 保持单用户登录,多用户场景届时再迭代 |
| 4. 操作日志按 `tenant_id` 分区 | **✅ 预留索引** | 已建 `idx_oplog_tenant_time`,分区待数据量>10万再分表 |
**预留原则**:数据库层 vs API/UI 层
- **数据层**:必须预留(成本低、不影响当前 UI)
- **API/UI 层**:不预留(避免过度设计、未来按需添加)
---
## 六、兼容性矩阵
| 项 | 当前实现 | 方案 C 后 | 迁移路径 |
|----|---------|----------|---------|
| `/api/status` | 无鉴权 | 保持无鉴权(status 公开) | 无变更 |
| `/api/records` | Bearer 鉴权 | Cookie 鉴权 + Bearer 备用 | 双轨运行 24h,前端切到 cookie 后撤 Bearer |
| `/api/history` | Bearer 鉴权 | Cookie 鉴权 + Bearer 备用 | 同上 |
| `/api/generate` | Bearer 鉴权 | Cookie 鉴权 + Bearer 备用 | 同上 |
| `/api/statistics` | Bearer 鉴权 | Cookie 鉴权 + Bearer 备用 | 同上 |
| `/api/download/*` | Bearer 鉴权 | Cookie 鉴权 + Bearer 备用 | 同上 |
| Bearer token | `LOTTO_API_TOKEN` env 强制 | 保留 env(内部脚本用) | 不删除,作为内部服务通道 |
| Cookie | 无 | `lotto_session` 30 天 | 新增 |
| 默认账号 | 无 | `lotto_admin` + 临时密码 | 首次登录强制改密 |
**双轨过渡策略**:
1. 上线 Cookie 鉴权后,前端自动改用 cookie
2. Bearer 通道保留 24h 给内部脚本迁移
3. 24h 后仅允许 Bearer 用于 `/api/internal/*`(如有)
4. 监控 `/api/records` 等接口的鉴权失败日志,确认为 0 后撤 Bearer
---
## 七、性能 + 安全边界(5 项决策)
| 项 | 决策 | 理由 |
|----|------|------|
| 1. bcrypt cost=12 登录耗时 | **目标 < 500ms(实测 ~250ms)** | Ubuntu 22.04 + bcrypt cost=12 单次 ~250ms,远低于 500ms 上限 |
| 2. Session 失效机制(改密) | **改密码后该用户所有 session 立即失效** | session 表 `password_epoch` 字段;登录时快照,改密后 `password_changed_at` 更新,session 自动失效 |
| 3. 找回密码 token 速率限制 | **每用户 3 次/小时** | 防爆破;超限返回 429 |
| 4. 登录失败锁定 | **5 次失败锁定 15 分钟** | `users.failed_attempts` + `locked_until`;自动解锁 |
| 5. 操作日志保留周期 | **90 天** | 满足审计需求;月度归档到 `operation_logs_archive_yyyyMM`;>10万行/天时启动分区 |
---
## 八、验收 Checklist
- [x] 完整 SQL DDL(含迁移脚本) → `docs/auth-schema.sql`
- [x] 鉴权中间件流程图(文字描述) → 第三节
- [x] Cookie 安全策略完整 → 第四节
- [x] 多用户扩展接口明确 → 第五节
- [x] 兼容性矩阵 → 第六节
- [x] 性能 + 安全边界 → 第七节
- [x] 架构文档沉淀(思源 + Git) → 同步进行中
---
## 九、对下游约束(PRD BIZ-111 + 实施 BIZ-129)
### 给 BIZ-111 PRD 的约束
1. **登录页**必须有:用户名、密码、"记住我"勾选(默认勾选=30天免登录)
2. **找回密码流程**:输入用户名 → 后台生成 token → 通过日志/邮件通知(内网暂用日志)
3. **首次登录强制改密**:检测 `password_changed_at == created_at` 时,强制跳改密页
4. **UI 不暴露**多用户切换、注册入口(保持单账号)
### 给 BIZ-129 实施的约束
1. **必须用 bcrypt cost=12**,不要用 sha256/md5/pbkdf2
2. **session_id 必须 32 字节 URL-safe base64**(`secrets.token_urlsafe(32)`)
3. **session_hash = sha256(session_id + server_secret)**,secret 存 env
4. **不改 LOTTO_API_TOKEN 流程**,保留 Bearer 作为内部通道
5. **/api/status 必须保持公开**,不能误加鉴权
6. **BIZ-109 严格鉴权基线不能回退**(不能因为 Cookie 引入新的 401 路径)
---
## 十、风险与待澄清
| 风险 | 影响 | 应对 |
|------|------|------|
| 首次部署 bcrypt 哈希生成失败 | 登录失败 | BIZ-129 部署前在本地预生成哈希嵌入 init SQL |
| Cookie 30 天过长,安全边界争议 | 安全 | `auth_settings.session_max_age_sec` 运行时可调 |
| 多用户预留字段被误用 | 数据污染 | 在 BIZ-129 代码层加 `tenant_id == 1` 断言 |
| 操作日志 90 天后未归档 | 磁盘膨胀 | BIZ-129 实施时同时交付归档脚本 |
---
## 十一、变更历史
| 版本 | 日期 | 变更 | 作者 |
|------|------|------|------|
| v1.0 | 2026-09-04 | 初版(BIZ-127 架构交付) | 梁思筑 |
+170
View File
@@ -0,0 +1,170 @@
-- ============================================================
-- BIZ-127 方案 C 完整用户系统 —— 数据模型 SQL DDL
-- ============================================================
-- 设计人:梁思筑(architect)| 2026-09-04
-- 适用版本:lottoData v2.0(Flask + SQLite)
-- 数据库:SQLite 3.x(与现有 lottery.db 同库同文件,复用 DB 连接)
-- 兼容性:当前 SQLite 文件存储(.generation_records.json)将迁移至本 schema
-- 字符集:UTF-8
-- 加密策略:bcrypt(cost=12) 密码哈希、session 哈希、不存任何明文凭证
-- ============================================================
-- ------------------------------------------------------------
-- 1. users —— 用户表
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, -- bcrypt cost=12 哈希
email TEXT, -- 可选,找回密码用
role TEXT NOT NULL DEFAULT 'user' -- 预留:admin / user
CHECK (role IN ('user', 'admin')),
status TEXT NOT NULL DEFAULT 'active' -- active / locked / disabled
CHECK (status IN ('active', 'locked', 'disabled')),
tenant_id INTEGER NOT NULL DEFAULT 1, -- 多租户预留,单租户默认 1
failed_attempts INTEGER NOT NULL DEFAULT 0, -- 登录失败计数(达到 5 锁定)
locked_until INTEGER, -- Unix 时间戳,NULL=未锁
password_changed_at INTEGER NOT NULL, -- 用于旧 session 失效判断
last_login_at INTEGER, -- 最近登录时间
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
-- 多用户扩展索引
CONSTRAINT idx_users_username UNIQUE (username),
CONSTRAINT idx_users_tenant UNIQUE (username, tenant_id)
);
CREATE INDEX IF NOT EXISTS idx_users_role_status ON users(role, status);
CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id);
-- ------------------------------------------------------------
-- 2. sessions —— 会话表(服务端 session,cookie 仅存 session_id)
-- ------------------------------------------------------------
-- 决策:DB 存 session(不用 JWT),理由:
-- - 可控失效(改密码立即清空)
-- - 可主动踢人
-- - 内网系统不需分布式无状态
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, -- 32 字节 URL-safe base64
user_id INTEGER NOT NULL,
session_hash TEXT NOT NULL, -- sha256(session_id + server_secret)
user_agent TEXT, -- 浏览器/客户端 UA
ip_address TEXT, -- 登录 IP(内网)
password_epoch INTEGER NOT NULL, -- 登录时 users.password_changed_at 快照
expires_at INTEGER NOT NULL, -- Unix 时间戳(30 天)
created_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL, -- 用于过期清理
revoked_at INTEGER, -- NULL=有效,非 NULL=已撤销
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_sessions_active ON sessions(user_id, revoked_at, expires_at);
-- ------------------------------------------------------------
-- 3. password_reset_tokens —— 密码重置 token 表
-- ------------------------------------------------------------
-- 决策:DB 存一次性 token(不用 JWT 自包含),理由:
-- - 一次性、可控、可撤销
-- - 速率限制基于 DB 计数
CREATE TABLE IF NOT EXISTS password_reset_tokens (
token_hash TEXT PRIMARY KEY, -- sha256(明文 token)
user_id INTEGER NOT NULL,
expires_at INTEGER NOT NULL, -- 1 小时过期
used_at INTEGER, -- NULL=未用
ip_address TEXT, -- 请求来源 IP
created_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_pwd_reset_user ON password_reset_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_pwd_reset_expires ON password_reset_tokens(expires_at);
-- ------------------------------------------------------------
-- 4. operation_logs —— 操作日志表
-- ------------------------------------------------------------
-- 用途:审计、追踪、异常告警
-- 保留周期:90 天(按月度手动归档)
-- 未来扩展:按 tenant_id 分区(本次单租户,分区预留)
CREATE TABLE IF NOT EXISTS operation_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER, -- 公开操作可 NULL
tenant_id INTEGER NOT NULL DEFAULT 1, -- 多租户预留
username TEXT, -- 冗余存储,避免 JOIN
action TEXT NOT NULL, -- login / logout / generate / delete / reset_request ...
resource_type TEXT, -- record / config / user ...
resource_id TEXT, -- 关联 ID
ip_address TEXT,
user_agent TEXT,
status TEXT NOT NULL DEFAULT 'success' -- success / failure
CHECK (status IN ('success', 'failure')),
detail TEXT, -- JSON 字符串,存额外上下文
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_oplog_user_time ON operation_logs(user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_oplog_tenant_time ON operation_logs(tenant_id, created_at);
CREATE INDEX IF NOT EXISTS idx_oplog_action ON operation_logs(action, created_at);
-- ------------------------------------------------------------
-- 5. auth_settings —— 全局鉴权配置(单行配置表)
-- ------------------------------------------------------------
-- 用途:运行时调整鉴权策略,不重启服务
CREATE TABLE IF NOT EXISTS auth_settings (
id INTEGER PRIMARY KEY CHECK (id = 1), -- 仅允许 id=1
bcrypt_cost INTEGER NOT NULL DEFAULT 12, -- 12-14
session_max_age_sec INTEGER NOT NULL DEFAULT 2592000, -- 30 天
session_renew_sec INTEGER NOT NULL DEFAULT 86400, -- 24h 内活跃则续期
max_failed_attempts INTEGER NOT NULL DEFAULT 5,
lock_duration_sec INTEGER NOT NULL DEFAULT 900, -- 15 分钟
pwd_reset_ttl_sec INTEGER NOT NULL DEFAULT 3600, -- 1 小时
pwd_reset_rate_limit INTEGER NOT NULL DEFAULT 3, -- 每用户每小时最多 3 次
log_retention_days INTEGER NOT NULL DEFAULT 90,
maintenance_mode INTEGER NOT NULL DEFAULT 0, -- 0=正常 1=维护
updated_at INTEGER NOT NULL
);
-- 初始化默认配置(仅在表为空时插入)
INSERT OR IGNORE INTO auth_settings (id, updated_at)
VALUES (1, strftime('%s', 'now'));
-- ============================================================
-- 二、迁移脚本(从当前 JSON 文件存储 + Bearer-only 模式)
-- ============================================================
-- 迁移目标:
-- 1. 初始化 users 表,创建默认管理员账号
-- 2. 现有 .generation_records.json 数据保留,不迁移(无 user_id 字段)
-- 3. operation_logs 表从空开始
-- ============================================================
-- 迁移步骤 1:创建默认管理员账号
-- 默认用户名:lotto_admin
-- 默认密码:BIZ-127-Lottery-2026(首次登录后强制修改)
-- 警告:此密码必须在首次登录后立即修改(通过 BIZ-129 强制流程)
INSERT OR IGNORE INTO users (
username, password_hash, email, role, status,
tenant_id, failed_attempts, password_changed_at,
created_at, updated_at
) VALUES (
'lotto_admin',
-- bcrypt('BIZ-127-Lottery-2026', cost=12)
-- 由 BIZ-129 实施时用 Python bcrypt 生成,SQL 仅作占位
'__BCRYPT_PLACEHOLDER__',
NULL, 'admin', 'active', 1, 0,
strftime('%s', 'now'), strftime('%s', 'now'), strftime('%s', 'now')
);
-- 迁移步骤 2:保留 .generation_records.json 数据
-- 策略:不迁移到 operation_logs(records 是业务数据,不是审计日志)
-- 备注:BIZ-129 需在 add_record() 中写入 operation_logs(action='generate')
-- 迁移步骤 3:环境变量兼容
-- 保留 LOTTO_API_TOKEN(Bearer 备用通道,仅供内部服务调用)
-- 用户系统上线后,外部前端用 cookie,内部脚本用 Bearer
-- ============================================================
-- 三、清理脚本(可选,生产环境 90 天后执行)
-- ============================================================
-- DELETE FROM sessions WHERE expires_at < strftime('%s', 'now');
-- DELETE FROM password_reset_tokens WHERE expires_at < strftime('%s', 'now');
-- DELETE FROM operation_logs WHERE created_at < strftime('%s', 'now', '-90 days');
+10 -239
View File
@@ -51,83 +51,6 @@
}
.header h1 { font-size: 22px; font-weight: 700; letter-spacing: 1px; }
.header p { font-size: 13px; opacity: 0.85; margin-top: 4px; }
.header .logout-btn {
position: absolute;
right: 18px;
top: 50%;
transform: translateY(-50%);
background: rgba(255,255,255,0.18);
color: white;
border: 1px solid rgba(255,255,255,0.35);
padding: 6px 14px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.header .logout-btn:hover { background: rgba(255,255,255,0.32); }
/* ============================================================
Login Overlay (BIZ-110)
============================================================ */
.login-overlay {
display: none;
position: fixed;
inset: 0;
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 50%, #8e44ad 100%);
z-index: 9999;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-overlay.show { display: flex; }
.login-card {
background: white;
border-radius: 16px;
padding: 36px 32px;
width: 100%;
max-width: 400px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
text-align: center;
}
.login-title { font-size: 22px; font-weight: 700; color: var(--text); margin-bottom: 6px; }
.login-subtitle { font-size: 13px; color: var(--text-light); margin-bottom: 22px; }
.login-hint { font-size: 13px; color: var(--text-light); margin-bottom: 12px; }
.login-input {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 15px;
outline: none;
transition: border-color 0.2s;
margin-bottom: 12px;
}
.login-input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(231,76,60,0.1); }
.login-btn {
width: 100%;
padding: 12px 24px;
background: linear-gradient(135deg, #e74c3c, #c0392b);
color: white;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.login-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(231,76,60,0.3); }
.login-btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
.login-error {
margin-top: 12px;
padding: 10px 12px;
background: #fdecea;
color: #c0392b;
border-radius: 6px;
font-size: 13px;
text-align: left;
}
.login-foot { font-size: 12px; color: var(--text-light); margin-top: 16px; }
/* ============================================================
Navigation Tabs
@@ -805,23 +728,6 @@
</head>
<body>
<!-- ============================================================
Login Overlay (BIZ-110)
============================================================ -->
<div class="login-overlay" id="loginOverlay">
<div class="login-card">
<div class="login-title">🔐 登录验证</div>
<div class="login-subtitle">双色球号码生成系统</div>
<div class="login-hint" id="loginHint">请输入访问令牌(LOTTO_API_TOKEN)以继续</div>
<input type="password" class="login-input" id="loginTokenInput" placeholder="在此粘贴令牌..." autocomplete="off" onkeydown="if(event.key==='Enter')handleLoginSubmit()">
<button class="login-btn" id="loginSubmitBtn" onclick="handleLoginSubmit()">登录</button>
<div class="login-error" id="loginError" style="display:none;"></div>
<div class="login-foot">令牌保存在浏览器 localStorage,仅本机有效。</div>
</div>
</div>
<main id="mainApp">
<!-- ============================================================
Toast
============================================================ -->
@@ -831,7 +737,6 @@ Toast
Header
============================================================ -->
<div class="header">
<button class="logout-btn" onclick="logout()" title="退出登录(清除本地令牌)">退出</button>
<h1>🎯 双色球号码生成系统</h1>
<p>基于历史数据分析 · 支持 PC 端和移动端</p>
</div>
@@ -997,74 +902,6 @@ const state = {
searchTimer: null,
};
// ============================================================
// Login Page (BIZ-110)
// ============================================================
function showLoginPage(reason) {
const overlay = document.getElementById('loginOverlay');
if (!overlay) return;
overlay.classList.add('show');
// 隐藏主内容
const main = document.getElementById('mainApp');
if (main) main.style.display = 'none';
// 提示信息
const hint = document.getElementById('loginHint');
if (hint) {
hint.textContent = reason || '请输入访问令牌(LOTTO_API_TOKEN)以继续';
}
// 自动聚焦输入框
const input = document.getElementById('loginTokenInput');
if (input) {
input.value = '';
setTimeout(() => input.focus(), 50);
}
}
function showMainPage() {
const overlay = document.getElementById('loginOverlay');
if (overlay) overlay.classList.remove('show');
const main = document.getElementById('mainApp');
if (main) main.style.display = '';
}
async function handleLoginSubmit() {
const input = document.getElementById('loginTokenInput');
const btn = document.getElementById('loginSubmitBtn');
const errEl = document.getElementById('loginError');
if (!input || !btn) return;
const token = (input.value || '').trim();
if (!token) {
if (errEl) {
errEl.textContent = '令牌不能为空';
errEl.style.display = 'block';
}
return;
}
if (errEl) errEl.style.display = 'none';
btn.disabled = true;
const oldText = btn.textContent;
btn.textContent = '⏳ 验证中...';
try {
// 试探一下 /api/status 验证 token(BIZ-110:/api/status 不走 require_auth,
// 因此用 /api/records 探测,且带回业务数据)
setToken(token);
await api('/api/records?page=1&page_size=1');
// 验证成功
showMainPage();
showToast('✅ 登录成功', 'success');
// 重新加载当前页面数据
if (state.currentPage) switchPage(state.currentPage);
} catch (e) {
setToken('');
if (errEl) {
errEl.textContent = '令牌无效:' + (e.message || '验证失败');
errEl.style.display = 'block';
}
btn.disabled = false;
btn.textContent = oldText;
}
}
// ============================================================
// Toast
// ============================================================
@@ -1102,76 +939,21 @@ function switchPage(page) {
}
// ============================================================
// Auth (BIZ-110)
// ============================================================
const AUTH_STORAGE_KEY = 'lotto_api_token';
function getToken() {
try {
return localStorage.getItem(AUTH_STORAGE_KEY) || '';
} catch (e) {
return '';
}
}
function setToken(t) {
try {
if (t) localStorage.setItem(AUTH_STORAGE_KEY, t);
else localStorage.removeItem(AUTH_STORAGE_KEY);
} catch (e) { /* localStorage 不可用(隐私模式)—忽略 */ }
}
function isLoggedIn() {
return !!getToken();
}
function logout(reason) {
setToken('');
showToast(reason || '已退出登录', 'info');
showLoginPage();
}
// ============================================================
// API Helper (BIZ-110: 注入 Bearer token)
// API Helper
// ============================================================
async function api(path, options = {}) {
const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) };
const token = getToken();
if (token) headers['Authorization'] = 'Bearer ' + token;
let res;
try {
res = await fetch(path, { ...options, headers });
const res = await fetch(path, {
headers: { 'Content-Type': 'application/json', ...options.headers },
...options
});
const data = await res.json();
if (!data.success) throw new Error(data.error || '请求失败');
return data.data;
} catch (e) {
showToast('网络错误:' + e.message, 'error');
showToast(e.message, 'error');
throw e;
}
// 401 → 清 token + 跳登录页
if (res.status === 401) {
let msg = '未授权访问';
try {
const j = await res.json();
if (j && j.error) msg = j.error;
} catch (_) { /* 忽略 JSON 解析错误 */ }
setToken('');
showLoginPage(msg);
throw new Error(msg);
}
let data;
try {
data = await res.json();
} catch (e) {
showToast('响应解析失败', 'error');
throw e;
}
if (!data.success) {
showToast(data.error || '请求失败', 'error');
throw new Error(data.error || '请求失败');
}
return data.data;
}
// ============================================================
@@ -1762,18 +1544,7 @@ async function compareRecord(id) {
// ============================================================
// Init
// ============================================================
function bootstrap() {
if (!isLoggedIn()) {
showLoginPage();
return;
}
showMainPage();
loadStatsOverview();
}
bootstrap();
loadStatsOverview();
</script>
</main>
</body>
</html>
-51
View File
@@ -1,51 +0,0 @@
"""
BIZ-110 端到端 mock:模拟后端鉴权 + 前端 token 注入验证。
- GET /api/status: 永远 200,无 token 校验(status 探测)
- 其他 /api/* : 必须 Authorization: Bearer <token>,token = 'test-token-ok' 通过
"""
import http.server, json, sys
VALID_TOKEN = 'test-token-ok'
class H(http.server.BaseHTTPRequestHandler):
def log_message(self, fmt, *a): pass # quiet
def _send(self, code, payload):
body = json.dumps(payload).encode()
self.send_response(code)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def _check_auth(self):
h = self.headers.get('Authorization', '')
if not h.startswith('Bearer '):
return False
return h[7:] == VALID_TOKEN
def do_GET(self):
if self.path.startswith('/api/status'):
return self._send(200, {'success': True, 'data': {'ok': True}})
if self.path.startswith('/api/records'):
if not self._check_auth():
return self._send(401, {'success': False, 'error': '未授权访问'})
return self._send(200, {'success': True, 'data': {'records': [], 'total': 0, 'page': 1}})
return self._send(404, {'success': False, 'error': 'not found'})
def do_POST(self):
if self.path.startswith('/api/records') and 'fake-id' in self.path:
return self._send(404, {'success': False, 'error': 'not found'})
return self._send(404, {'success': False, 'error': 'not found'})
def do_DELETE(self):
if not self._check_auth():
return self._send(401, {'success': False, 'error': '未授权访问'})
return self._send(200, {'success': True, 'data': {}})
if __name__ == '__main__':
port = int(sys.argv[1]) if len(sys.argv) > 1 else 18085
srv = http.server.HTTPServer(('127.0.0.1', port), H)
print(f'mock listening on {port}')
srv.serve_forever()