9 Commits

Author SHA1 Message Date
costcodev 111b56f2ee test(BIZ-129): pytest 端到端 19/19 全绿 + 修 reset-confirm bug
测试覆盖:
- login: 成功 / 错密码 / 缺失字段 / 未知用户 (4)
- me: 未登录 / 已登录 / fake cookie → session_expired (3)
- logout: 清 session (1)
- change-password: 成功(强制重新登录)/ 旧密码错 / 密码过短 (3)
- reset-request: 已存在用户 / 未知用户(防枚举)/ 完整重置流程 / 无效 token (4)
- 限流: login 6/min 触发 429 + reset-request 4/min 触发 429 (2)
- 安全约束: session 存 sha256 hash + bcrypt $2b$12$ (2)

修复 bug:
- reset-confirm 把 consume_reset_token 返回的 dict 当 user_id 传给 update_password
  → 修复为 user_info['user_id']

测试结果:19 passed, 65 warnings in 18.34s

依赖:b002e92(7 路由)+ 44f46a0(限流)+ 1df6e7f(前端)

Co-authored-by: multica-agent <github@multica.ai>
2026-09-05 07:22:31 +08:00
costcodev 1df6e7fa83 feat(BIZ-129): index.html 前端鉴权适配方案 C(session cookie + username/password)
替换 BIZ-110 Bearer token 模式 → 方案 C(cookie session + 账号密码):
- AUTH_STORAGE_KEY: 'lotto_api_token' → 'lotto_username'
- getToken/setToken → getUsername/setUsername(localStorage 只存用户名供 UI 提示)
- isLoggedIn() 改为检查 cookie 'lotto_session' 存在性(HttpOnly JS 读不到,仅作为 UI 优化)
- api() 去掉 Authorization: Bearer 注入;改用 fetch 的 credentials: 'same-origin' 让浏览器自动带 cookie
- loginOverlay: 单 token 输入框 → username + password 双输入框
- handleLoginSubmit: POST /api/auth/login + 处理 account_locked / rate_limited 等 code
- logout(): POST /api/auth/logout 吊销 session,再清本地 username
- bootstrap(): 启动时调 /api/auth/me 验证会话有效性

安全要点:
- HttpOnly cookie 防 XSS 读取(即使有 XSS 漏洞,攻击者也拿不到 session)
- 401 → 清 username + 跳登录页(api() 统一处理)
- logout() 走服务端吊销,防止 cookie 被持久化复用

依赖:commit 44f46a0 的 auth_api 蓝图 + Flask-Limiter 集成。

Co-authored-by: multica-agent <github@multica.ai>
2026-09-05 07:15:36 +08:00
costcodev 44f46a0e40 feat(BIZ-129): Flask-Limiter 集成 + 登录/重置限流
- requirements.txt: flask-limiter>=4.0
- app.py: 创建 Limiter 实例(memory:// 存储,60/分钟默认)
- auth_api.py:
  - 简化 register_auth_api(app, limiter=None)——limiter 仅作能力标记
  - _rate_limit(rate, methods) 装饰器:手写 IP+endpoint 滚动窗口限流
    (Flask-Limiter 在测试 client 下不生效,故手动实现;多进程需切换 Redis)
  - login / reset-request / reset-confirm 三个路由应用装饰器

限流策略(架构决策 #5/#6 配合):
  - /login: 5 次/分钟(IP 级爆破防御,配合账号级 5 次锁定 15min)
  - /reset-request: 3 次/分钟(防邮件/短信滥用)
  - /reset-confirm: 5 次/分钟(防 token 暴力猜解)

测试结果(test_client 压测):
  - login: 1-5 次业务 401,6 次起 429 ✅
  - reset-request: 1-3 次业务 200,4 次起 429 ✅

依赖:commit b002e92 的 7 路由蓝图。

BIZ-127 架构决策:
  ✓ #5 限流 5 次/分钟(IP 级,与账号级锁定 15min 双重防御)

Co-authored-by: multica-agent <github@multica.ai>
2026-09-05 07:13:50 +08:00
costcodev b002e929dc feat(BIZ-129): auth_api 蓝图 + 7 接口(login/logout/me/refresh/change-password/reset-request/reset-confirm)
- auth_api.py: Blueprint 独立文件,不污染 app.py 业务路由
- session 存 sha256 hash 入库(架构决策 #2,依赖 auth.py lookup_session)
- cookie HttpOnly + SameSite=Lax(Secure 留给部署层 nginx)
- 错误返回统一:{'success': False, 'error': str, 'code': str}
- 防用户名枚举:reset-request 即便用户不存在也返回 ok
- change-password 成功后强制吊销当前 session(防 session 复用)
- reset-confirm 成功后吊销该用户所有 session(防攻击者已登录状态)

依赖:auth.py + auth_db.py(commit ab9b62e)

BIZ-127 架构决策对齐:
  ✓ #1 bcrypt rounds=12(依赖 auth.py hash_password)
  ✓ #2 session token_hash = sha256(cookie_value)
  ✓ #5 5 次失败锁定 15 分钟(依赖 auth.py authenticate)
  ✓ #6 重置 token token_urlsafe(32) + 2h 过期 + 一次性(依赖 auth.py create_reset_token / consume_reset_token)
  ✓ #8 操作日志 90 天保留(依赖 auth.py write_op_log)

未完成(commit 2/3/4):
  - Flask-Limiter 集成(commit 2)
  - 前端 3 页(commit 3)
  - pytest 端到端(commit 4)

Co-authored-by: multica-agent <github@multica.ai>
2026-09-05 07:09:08 +08:00
costcodev ab9b62e9b3 feat(BIZ-129): 方案 C 用户系统 DB 层 + 业务层(BIZ-127 #1/#2/#5/#6/#8 对齐)
- auth_db.py: users/sessions/password_reset_tokens/operation_logs 4 表 + migrate_legacy_schema 增量升级
- auth.py: hash_password(rounds=12) + create_session(token_hash=sha256) + authenticate(锁定→清零) + cleanup_op_logs(90d)
- 业务层已就绪,未完成:auth_api 蓝图 / require_auth 装饰器 / 限流 / pytest / 前端 3 页

Co-authored-by: multica-agent <github@multica.ai>
2026-09-05 01:31:34 +08:00
vincent dcc7339384 feat(BIZ-110): index.html 前端鉴权适配 - localStorage + login 页 + Bearer 注入
方案 A 落地(严维序 COO 09-01 14:46 GMT+8 确认):
- 新增登录浮层(#/login 概念,hash 路由由 bootstrap() 管控)
- localStorage.lotto_api_token 存/取/清 token
- api() 函数自动注入 'Authorization: Bearer <token>'
- 401 → 清 token + 跳登录页
- Header 右侧 '退出' 按钮
- bootstrap() 启动入口:无 token 跳登录,有 token 进主页

测试:tests/mock_auth_server.py + jsdom 21/21 全过。
闭环:前端适配完成,待严维序撤 AUTH_BYPASS_PATHS 白名单。

—— 徐聪 (costcodev) | 2026-09-02 16:35 GMT+8

Co-authored-by: multica-agent <github@multica.ai>
2026-09-02 16:45:43 +08:00
徐聪 5d459b4505 feat(BIZ-105): 记录-比对 中奖金额展示 v1.2 实施
PRD v1.2 §4.3 落地,参考 BIZ-102 PRD 与 UI 原型。

后端(app.py):
- 新增 PRIZE_AMOUNT / PRIZE_KEY_MAP 常量双表(PRD §4.3)
- 新增 format_amount 千分位格式化(PRD §3.2:紧邻「元」不空格)
- 新增 compute_prize_summary 聚合函数
- api_compare_record 接口扩展:
  - 单注:prize_amount + prize_amount_display 字段
  - 顶层:total_prize / total_prize_display / has_variable_prize /
    prize_summary / prize_display 字段
- 一/二等奖标记为浮动奖(历史数据无奖池字段),不计入 total_prize

前端(index.html):
- 总中奖金额字段(橙色「含浮动奖」角标)
- 中奖奖项汇总表(不含浮动奖金额)
- 浮动奖橙色提示条(区别于固定奖绿色 #1e8e4a)
- 每注金额 badge(固定奖绿 / 浮动奖橙)

测试(tests/test_compare_prize.py):
- 31 个用例,100% 通过
- 覆盖:6 档奖金档 / 未中奖 / waiting 契约 / 0 注 / 全中奖 /
  仅有浮动奖 / 混合 / Mock 一致性(验收 #16)/ 响应结构契约
- running: pytest tests/test_compare_prize.py -v → 31 passed

不变项:比对算法 / 归期规则 / Excel 解析 / 认证 — 全部不动。

Co-authored-by: multica-agent <github@multica.ai>
2026-08-31 04:45:06 +08:00
vincent e4e66f3826 BIZ-102 v1.1: 记录-比对 中奖金额展示 PRD 与 UI 原型
v1.0: 3 字段增量扩展(prize_amount/total_prize/prize_summary)+ 前端 3 处扩展
v1.1: 落实架构评审 3 点意见
- 金额字段统一'(单位:元)'与量级安全说明
- prize_summary key 改英文枚举(first_prize..sixth_prize),新增 prize_level 中文名
- floating 标记落 results[i] 与 prize_summary[key] 两粒度
- 兼容性条款:每次实时计算,不依赖持久化

Co-authored-by: multica-agent <github@multica.ai>
2026-08-30 15:19:33 +08:00
vincent 4bfb9964db merge: BIZ-87 中奖比对算法修复 + P2/P3 迭代
修复中奖比对算法(BIZ-87):
- 新增 find_draw_by_gen_time() 窗口归属模型
- 修复所有生成记录都比对最新一期的 bug

P2/P3 代码质量改进:
- 删除死代码 load_latest_draw / find_next_draw_after
- 抽取 _row_to_draw 公共函数
- compare_type 细化为 same_day / cross_day / waiting
- 边界条件 <= 改为 < 严格对齐规则 2.2
- 删除不可达 elif 死分支

审核通过:架构/产品/COO/运维 四方审核 ✅
2026-07-09 08:33:40 +08:00
12 changed files with 2867 additions and 12 deletions
+140 -1
View File
@@ -31,6 +31,131 @@ from lottery import DoubleColorBallGenerator
app = Flask(__name__)
# ============================================================
# Flask-Limiter 限流(BIZ-129 commit 2)
# ============================================================
# 架构决策:按 IP + 路径限流,内存存储(单进程;多进程需切换 Redis)
# - 默认 60 次/分钟
# - login / reset-* 路由单独在 auth_api.py 里装饰
try:
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
limiter = Limiter(
key_func=get_remote_address,
app=app,
default_limits=['60 per minute'],
storage_uri='memory://',
)
except ImportError as e:
print(f'[BIZ-129] Flask-Limiter 不可用,限流禁用:{e}', file=sys.stderr)
limiter = None
# ============================================================
# 认证 API 蓝图注册(BIZ-129 方案 C)
# ============================================================
# auth_api.py 提供 7 个接口:login / logout / refresh / me / change-password /
# reset-request / reset-confirm。session 存 sha256 hash 入库(架构决策 #2)。
try:
from auth_api import register_auth_api
register_auth_api(app, limiter=limiter)
except ImportError as e:
# auth_api.py 未就绪时不影响主流程(保持向后兼容)
print(f'[BIZ-129] auth_api 蓝图注册跳过:{e}', file=sys.stderr)
# ============================================================
# 中奖金额常量表(PRD v1.2 BIZ-105 §4.3)
# ============================================================
# PRIZE_AMOUNT: 奖项 → (单注金额, 是否浮动奖)
# 一/二等奖金额官方按奖池浮动,历史数据无奖池字段,标记为浮动,
# 不参与 total_prize 汇总,仅在 UI 用橙色角标展示。
PRIZE_AMOUNT = {
'一等奖': (5000000, True), # 浮动:实际金额按奖池浮动
'二等奖': (200000, True), # 浮动:实际金额按奖池浮动
'三等奖': (3000, False), # 固定:3,000 元
'四等奖': (200, False), # 固定:200 元
'五等奖': (10, False), # 固定:10 元
'六等奖': (5, False), # 固定:5 元
'未中奖': (0, False),
}
# PRIZE_KEY_MAP: 中文奖级 → JSON key(便于前端按固定 key 访问)
PRIZE_KEY_MAP = {
'一等奖': 'first',
'二等奖': 'second',
'三等奖': 'third',
'四等奖': 'fourth',
'五等奖': 'fifth',
'六等奖': 'sixth',
}
def format_amount(amount):
"""金额千分位格式化(PRD v1.2 §3.2:数字紧邻「元」不空格)。
>>> format_amount(3000)
'3,000元'
>>> format_amount(5000000)
'5,000,000元'
>>> format_amount(0)
'0元'
"""
return f'{int(amount):,}元'
def compute_prize_summary(results):
"""按 PRD v1.2 §4.3 计算中奖汇总。
入参:results — 比对结果列表,每项含 'prize_level' 字段。
返回:dict,含 total_prize / has_variable_prize / prize_summary / prize_display
- total_prize: 固定奖级总金额(不含浮动)
- has_variable_prize: 是否存在一/二等奖命中
- prize_summary: {key: {count, amount}} 仅含 count > 0 的奖项
- prize_display: [{level, count, amount}] 浮动奖不进入展示列表
"""
# 1. 统计各奖级命中数
prize_counts = {}
for r in results:
level = r.get('prize_level', '未中奖')
prize_counts[level] = prize_counts.get(level, 0) + 1
# 2. 聚合
total_prize = 0
has_variable_prize = False
prize_summary = {}
prize_display = []
for level, count in prize_counts.items():
if level == '未中奖' or count <= 0:
continue
amount, is_float = PRIZE_AMOUNT.get(level, (0, False))
key = PRIZE_KEY_MAP.get(level, '')
if is_float:
# 浮动奖:仅标记,不计入 total_prize
has_variable_prize = True
prize_summary[key] = {'count': count, 'amount': 0}
else:
# 固定奖:计入 total_prize
total_prize += amount * count
prize_summary[key] = {'count': count, 'amount': amount * count}
# 展示列表:浮动奖不进入(按 PRD v1.2 决策 9)
if not is_float:
prize_display.append({
'level': level,
'count': count,
'amount': format_amount(amount),
})
return {
'total_prize': total_prize,
'total_prize_display': format_amount(total_prize),
'has_variable_prize': has_variable_prize,
'prize_summary': prize_summary,
'prize_display': prize_display,
}
# ============================================================
# 配置
# ============================================================
@@ -562,6 +687,9 @@ def api_compare_record(record_id):
prize_level = '六等奖'
prize_desc = f'{red_matches}红+1蓝'
# 该注中奖金额(PRD v1.2 §后端扩展 1)
# 浮动奖实际金额未知,按占位金额展示(前端用橙色角标)
ticket_amount, _is_float = PRIZE_AMOUNT.get(prize_level, (0, False))
results.append({
'index': i + 1,
'reds': t['reds'],
@@ -570,11 +698,16 @@ def api_compare_record(record_id):
'blue_match': blue_match,
'prize_level': prize_level,
'prize_desc': prize_desc,
'prize_amount': ticket_amount, # 该注金额(元,整数)
'prize_amount_display': format_amount(ticket_amount), # 该注金额(千分位字符串)
'is_win': prize_level != '未中奖'
})
win_count = sum(1 for r in results if r['is_win'])
# 中奖金额汇总(PRD v1.2 §4.3 + §后端扩展 2)
prize_info = compute_prize_summary(results)
return jsonify({
'success': True,
'data': {
@@ -591,7 +724,13 @@ def api_compare_record(record_id):
'results': results,
'total': len(results),
'win_count': win_count,
'win_tickets': [r for r in results if r['is_win']]
'win_tickets': [r for r in results if r['is_win']],
# PRD v1.2 新增字段
'total_prize': prize_info['total_prize'],
'total_prize_display': prize_info['total_prize_display'],
'has_variable_prize': prize_info['has_variable_prize'],
'prize_summary': prize_info['prize_summary'],
'prize_display': prize_info['prize_display'],
}
})
except Exception as e:
+480
View File
@@ -0,0 +1,480 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
用户系统业务逻辑 — BIZ-129 方案 C
封装:
- 密码哈希(bcrypt)
- 用户 CRUD(创建/查询/校验)
- Session 创建/校验/撤销
- 密码重置 token 生成/校验
- 操作日志写入
"""
import os
import json
import secrets
import sqlite3
import hashlib
import bcrypt
from datetime import datetime, timedelta
from auth_db import get_conn, init_db, now_iso
# ============================================================
# 常量(BIZ-127 8 条约束对齐)
# ============================================================
# #1 bcrypt cost 显式锁 12,防止未来库升级改了默认值
BCRYPT_ROUNDS = 12
# #6 登录失败锁定策略
LOGIN_MAX_FAILED = 5 # 连续 5 次失败锁
LOGIN_LOCKOUT_MINUTES = 15 # 锁 15 分钟
# #8 op_logs 保留天数(cleanup 默认值)
OP_LOG_RETENTION_DAYS = 90
# ============================================================
# 常量
# ============================================================
SESSION_COOKIE_NAME = 'lotto_session'
SESSION_LIFETIME_DAYS = 30
RESET_TOKEN_LIFETIME_HOURS = 2
RESET_TOKEN_BYTES = 32
SESSION_ID_BYTES = 32
def _sha256(s: str) -> str:
"""#2 session 存 hash 入库:sha256(cookie_value).hexdigest()。"""
return hashlib.sha256(s.encode('utf-8')).hexdigest()
# ============================================================
# 密码
# ============================================================
def hash_password(password: str) -> str:
"""bcrypt 哈希(#1 显式 cost=12),返回 utf-8 字符串。"""
return bcrypt.hashpw(
password.encode('utf-8'),
bcrypt.gensalt(rounds=BCRYPT_ROUNDS),
).decode('utf-8')
def verify_password(password: str, password_hash: str) -> bool:
try:
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
except (ValueError, TypeError):
return False
# ============================================================
# 用户
# ============================================================
def create_user(username: str, password: str, role: str = 'user') -> int:
"""创建用户,返回 user_id。username 必须唯一。"""
init_db()
pw_hash = hash_password(password)
created_at = now_iso()
with get_conn() as conn:
cur = conn.cursor()
cur.execute(
"INSERT INTO users (username, password_hash, role, is_active, created_at) "
"VALUES (?, ?, ?, 1, ?)",
(username, pw_hash, role, created_at),
)
return cur.lastrowid
def get_user_by_username(username: str):
"""根据 username 查 user dict;不存在返回 None。"""
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM users WHERE username = ?", (username,)
).fetchone()
return dict(row) if row else None
def get_user_by_id(user_id: int):
with get_conn() as conn:
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
return dict(row) if row else None
def count_users() -> int:
with get_conn() as conn:
row = conn.execute("SELECT COUNT(*) AS c FROM users").fetchone()
return row['c'] if row else 0
def update_password(user_id: int, new_password: str):
pw_hash = hash_password(new_password)
with get_conn() as conn:
conn.execute(
"UPDATE users SET password_hash = ? WHERE id = ?", (pw_hash, user_id)
)
def touch_last_login(user_id: int):
with get_conn() as conn:
conn.execute(
"UPDATE users SET last_login_at = ? WHERE id = ?", (now_iso(), user_id)
)
# ============================================================
# Session
# ============================================================
def create_session(user_id: int, ip: str = '', user_agent: str = '') -> dict:
"""创建新 session,返回 {session_id, expires_at}。
#2 BIZ-127:DB 存 token_hash(sha256(cookie_value)),cookie 只下发随机串。
session_id 字段名保留作为外部 cookie 值,token_hash 是入库列。
"""
init_db()
session_id = secrets.token_urlsafe(SESSION_ID_BYTES)
token_hash = _sha256(session_id)
now = datetime.utcnow()
expires = now + timedelta(days=SESSION_LIFETIME_DAYS)
with get_conn() as conn:
conn.execute(
"INSERT INTO sessions (token_hash, user_id, created_at, expires_at, ip, user_agent) "
"VALUES (?, ?, ?, ?, ?, ?)",
(
token_hash, user_id,
now.isoformat(), expires.isoformat(),
ip[:64] if ip else None,
user_agent[:512] if user_agent else None,
),
)
return {
'session_id': session_id,
'token_hash': token_hash,
'expires_at': expires.strftime('%Y-%m-%dT%H:%M:%S'),
}
def lookup_session(session_id: str):
"""校验 session_id,返回 user dict(含 session 元数据)或 None。
#2 BIZ-127:按 sha256(session_id) 即 token_hash 入库对比,
外部传入明文 cookie,内部永远不存明文。
"""
if not session_id:
return None
token_hash = _sha256(session_id)
with get_conn() as conn:
row = conn.execute(
"""
SELECT s.token_hash, s.user_id, s.expires_at, s.ip,
u.username, u.role, u.is_active
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ?
AND s.revoked = 0
AND u.is_active = 1
""",
(token_hash,),
).fetchone()
if not row:
return None
try:
expires = datetime.fromisoformat(row['expires_at'])
except (ValueError, TypeError):
return None
if expires < datetime.utcnow():
# 过期 — 标记撤销
conn.execute(
"UPDATE sessions SET revoked = 1 WHERE token_hash = ?", (token_hash,)
)
return None
return {
'session_id': session_id,
'token_hash': token_hash,
'user_id': row['user_id'],
'username': row['username'],
'role': row['role'],
'expires_at': row['expires_at'],
'ip': row['ip'],
}
def revoke_session(session_id: str):
if not session_id:
return
token_hash = _sha256(session_id)
with get_conn() as conn:
conn.execute(
"UPDATE sessions SET revoked = 1 WHERE token_hash = ?", (token_hash,)
)
def cleanup_expired_sessions():
with get_conn() as conn:
conn.execute(
"UPDATE sessions SET revoked = 1 WHERE revoked = 0 AND expires_at < ?",
(now_iso(),),
)
# ============================================================
# 密码重置 token
# ============================================================
def create_reset_token(user_id: int, ip: str = '') -> dict:
"""生成一次性重置 token。"""
token = secrets.token_urlsafe(RESET_TOKEN_BYTES)
now = datetime.utcnow()
expires = now + timedelta(hours=RESET_TOKEN_LIFETIME_HOURS)
with get_conn() as conn:
conn.execute(
"INSERT INTO password_reset_tokens (user_id, token, created_at, expires_at, ip) "
"VALUES (?, ?, ?, ?, ?)",
(user_id, token, now.isoformat(), expires.isoformat(), ip[:64] if ip else None),
)
return {
'token': token,
'expires_at': expires.strftime('%Y-%m-%dT%H:%M:%S'),
'lifetime_hours': RESET_TOKEN_LIFETIME_HOURS,
}
def consume_reset_token(token: str):
"""校验并消费重置 token。返回 user dict 或 None。
校验规则:存在、未过期、未使用。
消费规则:原子地将 used_at 写入;如果已被使用则返回 None。
"""
if not token:
return None
with get_conn() as conn:
row = conn.execute(
"""
SELECT t.id, t.user_id, t.expires_at, t.used_at,
u.username, u.is_active
FROM password_reset_tokens t
JOIN users u ON u.id = t.user_id
WHERE t.token = ?
""",
(token,),
).fetchone()
if not row:
return None
if row['used_at']:
return None
try:
expires = datetime.fromisoformat(row['expires_at'])
except (ValueError, TypeError):
return None
if expires < datetime.utcnow():
return None
if not row['is_active']:
return None
# 原子消费
cur = conn.execute(
"UPDATE password_reset_tokens SET used_at = ? "
"WHERE id = ? AND used_at IS NULL",
(now_iso(), row['id']),
)
if cur.rowcount != 1:
return None
return {
'user_id': row['user_id'],
'username': row['username'],
}
# ============================================================
# 操作日志
# ============================================================
def write_op_log(
action: str,
*,
user_id=None,
username=None,
ip='',
user_agent='',
detail=None,
success: bool = True,
):
"""写入操作日志。detail 可以是 dict(会被 JSON 化)。"""
detail_str = json.dumps(detail, ensure_ascii=False) if isinstance(detail, dict) else (detail or '')
with get_conn() as conn:
conn.execute(
"""
INSERT INTO operation_logs
(user_id, username, action, ip, user_agent, detail, success, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
user_id, username, action,
ip[:64] if ip else None,
user_agent[:512] if user_agent else None,
detail_str[:2000] if detail_str else None,
1 if success else 0,
now_iso(),
),
)
def list_recent_op_logs(limit: int = 50, action: str = None):
"""查询最近操作日志(管理员用)。"""
limit = max(1, min(int(limit), 200))
with get_conn() as conn:
if action:
rows = conn.execute(
"SELECT * FROM operation_logs WHERE action = ? "
"ORDER BY id DESC LIMIT ?",
(action, limit),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM operation_logs ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(r) for r in rows]
# ============================================================
# 登录业务层(#6 失败锁定 BIZ-127)
# ============================================================
def is_account_locked(user_row) -> bool:
"""检查账号是否在锁定期内。返回 True 表示被锁。"""
if not user_row:
return False
locked_until = user_row.get('locked_until')
if not locked_until:
return False
try:
until = datetime.fromisoformat(locked_until)
except (ValueError, TypeError):
return False
return until > datetime.utcnow()
def _lock_account(user_id: int):
"""锁定账号 15 分钟,重置失败计数。"""
until = datetime.utcnow() + timedelta(minutes=LOGIN_LOCKOUT_MINUTES)
with get_conn() as conn:
conn.execute(
"UPDATE users SET failed_attempts = 0, locked_until = ? WHERE id = ?",
(until.isoformat(), user_id),
)
def _increment_failed_attempts(user_id: int) -> int:
"""失败次数 +1,返回当前值。"""
with get_conn() as conn:
conn.execute(
"UPDATE users SET failed_attempts = failed_attempts + 1 WHERE id = ?",
(user_id,),
)
row = conn.execute(
"SELECT failed_attempts FROM users WHERE id = ?", (user_id,)
).fetchone()
return int(row['failed_attempts']) if row else 0
def _clear_failed_attempts(user_id: int):
with get_conn() as conn:
conn.execute(
"UPDATE users SET failed_attempts = 0, locked_until = NULL WHERE id = ?",
(user_id,),
)
def authenticate(username: str, password: str, ip: str = '', user_agent: str = '') -> dict:
"""账号密码登录业务层。
严格顺序(#6 BIZ-127):
1. 查 user → 不存在返 None(记 login_fail 日志)
2. 检查 locked_until → 已锁返 'locked'(记 login_fail 日志)
3. 检查 is_active → 禁用返 None(记 login_fail 日志)
4. bcrypt verify → 失败计数 +1,达 5 次锁 15 分钟(记 login_fail 日志)
5. 成功 → 清零失败计数 + 创建 session + 记 login_success 日志
返回 dict:
None → 凭据错/不存在/禁用
{'result': 'locked'} → 被锁定(含 locked_until 剩余秒数)
{'result': 'ok', 'session': {...}, 'user': {...}}
"""
init_db()
user = get_user_by_username(username)
if not user:
write_op_log(
'login_fail', username=username, ip=ip, user_agent=user_agent,
detail={'reason': 'user_not_found'}, success=False,
)
return None
if is_account_locked(user):
# 计算剩余秒数
try:
until = datetime.fromisoformat(user['locked_until'])
remaining = int((until - datetime.utcnow()).total_seconds())
except Exception:
remaining = LOGIN_LOCKOUT_MINUTES * 60
write_op_log(
'login_fail', user_id=user['id'], username=username, ip=ip, user_agent=user_agent,
detail={'reason': 'account_locked', 'remaining_seconds': remaining},
success=False,
)
return {'result': 'locked', 'locked_until': user['locked_until'], 'remaining_seconds': remaining}
if not user.get('is_active'):
write_op_log(
'login_fail', user_id=user['id'], username=username, ip=ip, user_agent=user_agent,
detail={'reason': 'inactive'}, success=False,
)
return None
if not verify_password(password, user['password_hash']):
cnt = _increment_failed_attempts(user['id'])
if cnt >= LOGIN_MAX_FAILED:
_lock_account(user['id'])
write_op_log(
'login_fail', user_id=user['id'], username=username, ip=ip, user_agent=user_agent,
detail={'reason': 'wrong_password', 'locked': True, 'failed_attempts': cnt},
success=False,
)
return {'result': 'locked', 'reason': 'too_many_failures',
'locked_until': (datetime.utcnow() + timedelta(minutes=LOGIN_LOCKOUT_MINUTES)).isoformat()}
write_op_log(
'login_fail', user_id=user['id'], username=username, ip=ip, user_agent=user_agent,
detail={'reason': 'wrong_password', 'failed_attempts': cnt},
success=False,
)
return None
# 成功
_clear_failed_attempts(user['id'])
touch_last_login(user['id'])
session = create_session(user['id'], ip=ip, user_agent=user_agent)
write_op_log(
'login_success', user_id=user['id'], username=username, ip=ip, user_agent=user_agent,
detail={'session_token_hash_prefix': session['token_hash'][:12]},
success=True,
)
return {
'result': 'ok',
'session': session,
'user': {
'id': user['id'],
'username': user['username'],
'role': user['role'],
'tenant_id': user.get('tenant_id'),
},
}
# ============================================================
# 操作日志清理(#8 BIZ-127,90 天保留)
# ============================================================
def cleanup_op_logs(days: int = OP_LOG_RETENTION_DAYS) -> int:
"""删除早于 days 天的操作日志,返回删除行数。
应由 cron 入口每日调用(例如每天凌晨 3:00)。
"""
cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat()
with get_conn() as conn:
cur = conn.execute("DELETE FROM operation_logs WHERE created_at < ?", (cutoff,))
return cur.rowcount
+446
View File
@@ -0,0 +1,446 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
认证 API 蓝图(BIZ-129 方案 C)
实现 BIZ-127 架构决策 #1/#2/#5/#6/#8:
- login / logout / refresh / me / change-password
- reset-request / reset-confirm(密码重置两段式)
约束(来自陆怀瑾 23:38 5 条):
1. 会话存 hash 入库(token_hash = sha256(cookie_value))
2. cookie 走 HttpOnly + SameSite=Lax(Secure 由部署层决定)
3. 5 次失败锁定 15 分钟
4. 重置 token 一次性 + 2h 过期 + token_urlsafe(32)
5. 操作日志保留 90 天 + login_success/login_fail 必记
依赖:auth.py 业务层(16 函数)+ auth_db.py(init_db)
设计原则:
- 蓝图独立文件,不污染 app.py 业务路由
- 错误返回统一格式:{'success': False, 'error': str, 'code': str}
- 成功返回统一格式:{'success': True, 'data': {...}}
- require_session 装饰器从 cookie 提取 session_id → sha256 查 sessions 表
- 限流留给 commit 2(Flask-Limiter 集成)
"""
from __future__ import annotations
import hashlib
from datetime import datetime, timedelta
from functools import wraps
from flask import Blueprint, current_app, jsonify, request, make_response
from auth_db import init_db, get_conn, now_iso
from auth import (
SESSION_COOKIE_NAME,
SESSION_LIFETIME_DAYS,
RESET_TOKEN_LIFETIME_HOURS,
authenticate,
create_session,
create_reset_token,
consume_reset_token,
get_user_by_id,
lookup_session,
revoke_session,
update_password,
verify_password,
write_op_log,
_sha256,
)
auth_bp = Blueprint('auth_api', __name__, url_prefix='/api/auth')
# ============================================================
# 限流配置(commit 2 整合 Flask-Limiter)
# ============================================================
# 限流维度:按 IP + 路径 组合
# - /login: 5 次/分钟(防爆破;5 次错就锁 15min 是账号级,这里是 IP 级双重防御)
# - /reset-request: 3 次/分钟(防滥用邮件/短信)
# - 其他:默认 60 次/分钟(防止爬虫)
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
# limiter 实例由 app.py 的 register_auth_api(limiter) 注入;这里先给默认 None 占位
_limiter = None
def _get_limiter():
return _limiter
def _rate_limit(rate: str, methods=None):
"""装饰器:限流(手写内存计数器,避免 Flask-Limiter 在测试 client 下不生效的坑)。
实现:按 (IP, view_function_name) 为 key,滚动 60 秒窗口。
- 解析 "N per minute" 中的 N 作为上限
- 多进程部署需切 Redis(架构选型问题,留待后续)
- limiter 仅为占位变量(保留扩展点),不影响计数逻辑
"""
from functools import wraps
methods = methods or ['POST']
parts = rate.split()
try:
max_count = int(parts[0])
except (ValueError, IndexError):
max_count = 5
def decorator(f):
@wraps(f)
def wrapper(*args, **kwargs):
# request 阶段查 limiter(仅为占位验证,不阻断)
current_limiter = _limiter
from flask import request as _req
ip = (_req.headers.get('X-Forwarded-For') or _req.remote_addr or 'unknown').split(',')[0].strip()
key = f'{ip}:{f.__name__}'
import time
now = time.time()
bucket = _rl_state.setdefault(key, [])
# 清 60s 以前的
cutoff = now - 60
while bucket and bucket[0] < cutoff:
bucket.pop(0)
if len(bucket) >= max_count:
from flask import jsonify
resp = jsonify({
'success': False,
'error': f'请求过于频繁,请 {60 - int(now - bucket[0])} 秒后再试',
'code': 'rate_limited',
})
resp.status_code = 429
return resp
bucket.append(now)
return f(*args, **kwargs)
return wrapper
return decorator
# 限流内存计数(key = "IP:endpoint_name",value = timestamp list)
_rl_state: dict = {}
def register_auth_api(app, limiter=None):
"""注册认证蓝图 + 记录 limiter 实例。
Args:
app: Flask app
limiter: 可选 flask_limiter.Limiter 实例。仅作为“能力标记”记录;
实际限流由 _rate_limit 装饰器手动实现(见 _rl_state)。
"""
global _limiter
_limiter = limiter
app.register_blueprint(auth_bp)
# ============================================================
# 工具函数
# ============================================================
def _client_ip() -> str:
"""取客户端 IP,优先 X-Forwarded-For(反代环境)。"""
return request.headers.get('X-Forwarded-For', request.remote_addr or '').split(',')[0].strip()
def _user_agent() -> str:
return request.headers.get('User-Agent', '')[:512] # 截断防爆库
def _ok(data: dict, status: int = 200):
return jsonify({'success': True, 'data': data}), status
def _err(message: str, code: str, status: int):
return jsonify({'success': False, 'error': message, 'code': code}), status
def _set_session_cookie(response, session_id: str):
"""设置 HttpOnly + SameSite=Lax cookie。Secure 留给部署层(nginx 等)。"""
response.set_cookie(
SESSION_COOKIE_NAME,
session_id,
max_age=SESSION_LIFETIME_DAYS * 24 * 3600,
httponly=True,
samesite='Lax',
path='/',
)
return response
def _clear_session_cookie(response):
response.delete_cookie(SESSION_COOKIE_NAME, path='/')
return response
# ============================================================
# 装饰器:从 cookie 取 session_id → 校验 → 注入 user 到 g
# ============================================================
def require_session(f):
@wraps(f)
def decorated(*args, **kwargs):
init_db() # 确保表存在(首次启动兼容)
session_id = request.cookies.get(SESSION_COOKIE_NAME, '')
if not session_id:
return _err('未登录', 'unauthenticated', 401)
sess = lookup_session(session_id)
if not sess:
# cookie 失效(过期 / 被吊销 / 不存在)
resp = make_response(_err('会话已过期,请重新登录', 'session_expired', 401))
_clear_session_cookie(resp)
return resp
user = get_user_by_id(sess['user_id'])
if not user or not user.get('is_active'):
resp = make_response(_err('账号不可用', 'user_inactive', 401))
revoke_session(session_id)
_clear_session_cookie(resp)
return resp
# 注入到 flask.g(在 view 里 g.current_user 可用)
from flask import g
g.current_session = sess
g.current_user = user
return f(*args, **kwargs)
return decorated
# ============================================================
# POST /api/auth/login
# ============================================================
@auth_bp.route('/login', methods=['POST'])
@_rate_limit('5 per minute', methods=['POST'])
def login():
init_db()
payload = request.get_json(silent=True) or {}
username = (payload.get('username') or '').strip()
password = payload.get('password') or ''
if not username or not password:
return _err('用户名和密码必填', 'missing_credentials', 400)
result = authenticate(
username=username, password=password,
ip=_client_ip(), user_agent=_user_agent(),
)
if result is None:
return _err('用户名或密码错误', 'invalid_credentials', 401)
if result.get('result') == 'locked':
return _err(
f"账号已锁定,请 {result.get('remaining_seconds', 0) // 60 + 1} 分钟后再试",
'account_locked',
429,
)
sess = result['session']
user = result['user']
resp = make_response(_ok({
'user': {
'id': user['id'],
'username': user['username'],
'role': user['role'],
},
'expires_at': sess['expires_at'],
}))
_set_session_cookie(resp, sess['session_id'])
return resp
# ============================================================
# POST /api/auth/logout
# ============================================================
@auth_bp.route('/logout', methods=['POST'])
@require_session
def logout():
from flask import g
session_id = request.cookies.get(SESSION_COOKIE_NAME, '')
user = g.current_user
revoke_session(session_id)
write_op_log(
'logout', user_id=user['id'], username=user['username'],
ip=_client_ip(), user_agent=_user_agent(), success=True,
)
resp = make_response(_ok({'logged_out': True}))
_clear_session_cookie(resp)
return resp
# ============================================================
# GET /api/auth/me(前端启动时校验登录态)
# ============================================================
@auth_bp.route('/me', methods=['GET'])
@require_session
def me():
from flask import g
user = g.current_user
return _ok({
'id': user['id'],
'username': user['username'],
'role': user['role'],
'last_login_at': user.get('last_login_at'),
})
# ============================================================
# POST /api/auth/change-password(已登录用户改密码)
# ============================================================
@auth_bp.route('/change-password', methods=['POST'])
@require_session
def change_password():
from flask import g
payload = request.get_json(silent=True) or {}
old_pw = payload.get('old_password') or ''
new_pw = payload.get('new_password') or ''
if not old_pw or not new_pw:
return _err('旧密码和新密码必填', 'missing_password', 400)
if len(new_pw) < 8:
return _err('新密码至少 8 位', 'weak_password', 400)
if old_pw == new_pw:
return _err('新密码不能与旧密码相同', 'same_password', 400)
user = g.current_user
# 验证旧密码
user_row = get_user_by_id(user['id']) # 取完整行(含 password_hash)
if not user_row or not verify_password(old_pw, user_row['password_hash']):
write_op_log(
'change_password_fail', user_id=user['id'], username=user['username'],
ip=_client_ip(), user_agent=_user_agent(),
detail={'reason': 'wrong_old_password'}, success=False,
)
return _err('旧密码错误', 'wrong_old_password', 401)
update_password(user['id'], new_pw)
# 改完密码吊销当前 session(强制重新登录)
session_id = request.cookies.get(SESSION_COOKIE_NAME, '')
revoke_session(session_id)
write_op_log(
'change_password', user_id=user['id'], username=user['username'],
ip=_client_ip(), user_agent=_user_agent(), success=True,
)
resp = make_response(_ok({'changed': True}))
_clear_session_cookie(resp)
return resp
# ============================================================
# POST /api/auth/reset-request(申请重置 token,不要求登录)
# ============================================================
@auth_bp.route('/reset-request', methods=['POST'])
@_rate_limit('3 per minute', methods=['POST'])
def reset_request():
init_db()
payload = request.get_json(silent=True) or {}
username = (payload.get('username') or '').strip()
if not username:
return _err('用户名必填', 'missing_username', 400)
# 安全考虑:无论用户是否存在,都返回 ok(避免用户名枚举)
# 但仅在用户存在时实际生成 token + 写日志
user = None
from auth_db import get_conn
with get_conn() as conn:
row = conn.execute("SELECT id, username FROM users WHERE username = ?", (username,)).fetchone()
if row:
user = {'id': row['id'], 'username': row['username']}
if user:
token_info = create_reset_token(user['id'], ip=_client_ip())
write_op_log(
'reset_request', user_id=user['id'], username=user['username'],
ip=_client_ip(), user_agent=_user_agent(), success=True,
)
# 注:实际项目里 token 通过邮件/短信下发,这里直接返回便于测试 + 后续接邮件
# 生产环境应去掉 token 字段,仅返回 ok
return _ok({
'requested': True,
# 'token': token_info['token'], # 生产关闭
'lifetime_hours': token_info['lifetime_hours'],
})
else:
# 用户不存在 — 写一条无 user_id 的日志便于审计
write_op_log(
'reset_request_fail', user_id=None, username=username,
ip=_client_ip(), user_agent=_user_agent(),
detail={'reason': 'user_not_found'}, success=False,
)
return _ok({'requested': True}) # 同样返回 ok(防枚举)
# ============================================================
# POST /api/auth/reset-confirm(凭 token 重置密码)
# ============================================================
@auth_bp.route('/reset-confirm', methods=['POST'])
@_rate_limit('5 per minute', methods=['POST'])
def reset_confirm():
init_db()
payload = request.get_json(silent=True) or {}
token = payload.get('token') or ''
new_pw = payload.get('new_password') or ''
if not token or not new_pw:
return _err('token 和新密码必填', 'missing_fields', 400)
if len(new_pw) < 8:
return _err('新密码至少 8 位', 'weak_password', 400)
user_info = consume_reset_token(token)
if not user_info:
write_op_log(
'reset_confirm_fail', user_id=None, username=None,
ip=_client_ip(), user_agent=_user_agent(),
detail={'reason': 'invalid_or_expired_token'}, success=False,
)
return _err('token 无效或已过期', 'invalid_token', 400)
user_id = user_info['user_id']
username = user_info.get('username')
update_password(user_id, new_pw)
# 重置后吊销该用户所有 session(强制重新登录)
with get_conn() as conn:
conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
write_op_log(
'reset_confirm', user_id=user_id, username=username,
ip=_client_ip(), user_agent=_user_agent(), success=True,
)
return _ok({'reset': True})
# ============================================================
# POST /api/auth/refresh(续期当前 session,过期前 7 天可刷)
# ============================================================
@auth_bp.route('/refresh', methods=['POST'])
@require_session
def refresh():
from flask import g
sess = g.current_session
user = g.current_user
# 仅在剩余寿命 < 7 天时续期,避免每次请求都写 DB
try:
expires = datetime.fromisoformat(sess['expires_at'])
remaining = (expires - datetime.utcnow()).total_seconds()
except Exception:
remaining = 0
if remaining > 7 * 24 * 3600:
return _ok({'refreshed': False, 'expires_at': sess['expires_at']})
new_expires = (datetime.utcnow() + timedelta(days=SESSION_LIFETIME_DAYS)).isoformat()
with get_conn() as conn:
conn.execute(
"UPDATE sessions SET expires_at = ? WHERE id = ?",
(new_expires, sess['id']),
)
return _ok({'refreshed': True, 'expires_at': new_expires})
# 蓝图注册入口已定义在文件顶部 register_auth_api(app, limiter=None)
# 避免重复定义(早期版本残留已清理)
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
用户系统数据库模块 — BIZ-129 方案 C
表:
- users 用户账号(含多用户预留字段 / 登录失败锁定字段)
- sessions 服务端 session(存 token_hash,不存明文 cookie 值)
- password_reset_tokens 找回密码一次性 token
- operation_logs 操作日志(90 天保留)
约束对齐 — BIZ-127 架构侧清单:
#2 sessions.token_hash = sha256(cookie_value).hexdigest()
#5 users.tenant_id / users.status(数据层预留,UI 不写)
#6 users.failed_attempts / users.locked_until(登录失败锁定)
升级路径:旧装已存在的 `users` / `sessions` 表会被 `migrate_legacy_schema()` 增量升级,
不会丢数据。新装直接走 CREATE TABLE。
"""
import os
import sqlite3
import threading
from contextlib import contextmanager
from datetime import datetime
# 数据库路径:与项目同级 .data/lottery_auth.db
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, '.data')
DB_PATH = os.path.join(DATA_DIR, 'lottery_auth.db')
_db_lock = threading.Lock()
def _ensure_data_dir():
os.makedirs(DATA_DIR, exist_ok=True)
@contextmanager
def get_conn():
"""线程安全的 SQLite 连接。"""
_ensure_data_dir()
conn = sqlite3.connect(DB_PATH, timeout=10, isolation_level=None)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA journal_mode = WAL")
try:
yield conn
finally:
conn.close()
# ============================================================
# 表结构(BIZ-127 8 条约束对齐版)
# ============================================================
SCHEMA = [
"""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user', -- 'admin' / 'user'
is_active INTEGER NOT NULL DEFAULT 1,
tenant_id TEXT, -- #5 多用户预留
status TEXT NOT NULL DEFAULT 'active',-- #5 active/disabled
failed_attempts INTEGER NOT NULL DEFAULT 0, -- #6 失败计数
locked_until TEXT, -- #6 锁定到期时间
created_at TEXT NOT NULL,
last_login_at TEXT
)
""",
"""
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT NOT NULL UNIQUE, -- #2 sha256(cookie_value)
user_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL, -- 默认 30 天
ip TEXT,
user_agent TEXT,
revoked INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
""",
"""
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL, -- 2 小时有效
used_at TEXT, -- 使用时间(一次性)
ip TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
""",
"""
CREATE TABLE IF NOT EXISTS operation_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER, -- 可为空(如登录失败)
username TEXT, -- 冗余存储便于查询
action TEXT NOT NULL, -- login_success / login_fail / ...
ip TEXT,
user_agent TEXT,
detail TEXT, -- JSON 字符串
success INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
)
""",
"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_token_hash ON sessions(token_hash)",
"CREATE INDEX IF NOT EXISTS idx_reset_tokens_token ON password_reset_tokens(token)",
"CREATE INDEX IF NOT EXISTS idx_op_logs_user ON operation_logs(user_id)",
"CREATE INDEX IF NOT EXISTS idx_op_logs_action ON operation_logs(action)",
"CREATE INDEX IF NOT EXISTS idx_op_logs_created ON operation_logs(created_at)",
]
# ============================================================
# 数据迁移 SQL — 旧装兼容
# ============================================================
# 旧版 users 缺 tenant_id / status / failed_attempts / locked_until
# 旧版 sessions 用 session_id 明文,需 rename → token_hash 并回填 sha256
#
# 注意:SQLite ALTER TABLE 不能改 UNIQUE 约束列名,所以采用
# 1. add column token_hash_temp(无 UNIQUE)
# 2. UPDATE token_hash_temp = sha256(session_id)
# 3. drop 原 session_id 列(不支持 DROP COLUMN 的旧版 SQLite 走 fallback)
# 4. rename token_hash_temp → token_hash
# 的方式。新版 SQLite(≥3.35)支持 DROP COLUMN,本脚本两种路径都跑。
def _table_has_column(conn, table: str, column: str) -> bool:
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
return any(r['name'] == column for r in rows)
def _sqlite_supports_drop_column(conn) -> bool:
row = conn.execute("SELECT sqlite_version() AS v").fetchone()
try:
# SQLite 3.35.0 起支持 DROP COLUMN
major, minor, _ = (int(x) for x in row['v'].split('.'))
return (major, minor) >= (3, 35)
except Exception:
return False
def migrate_legacy_schema():
"""把旧装数据库升级到 BIZ-127 约束对齐版。幂等。
返回 dict 报告做了哪些迁移(便于测试断言)。
"""
report = {'migrated_users': False, 'migrated_sessions': False}
with _db_lock, get_conn() as conn:
# ---- users 表:补 4 个字段 ----
# SQLite 不支持 IF NOT EXISTS ADD COLUMN(3.35 之前),用 PRAGMA 查
if not _table_has_column(conn, 'users', 'tenant_id'):
conn.execute("ALTER TABLE users ADD COLUMN tenant_id TEXT")
report['migrated_users'] = True
if not _table_has_column(conn, 'users', 'status'):
conn.execute("ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'")
report['migrated_users'] = True
if not _table_has_column(conn, 'users', 'failed_attempts'):
conn.execute("ALTER TABLE users ADD COLUMN failed_attempts INTEGER NOT NULL DEFAULT 0")
report['migrated_users'] = True
if not _table_has_column(conn, 'users', 'locked_until'):
conn.execute("ALTER TABLE users ADD COLUMN locked_until TEXT")
report['migrated_users'] = True
# ---- sessions 表:session_id → token_hash(sha256 化)----
if _table_has_column(conn, 'sessions', 'session_id') \
and not _table_has_column(conn, 'sessions', 'token_hash'):
# 用 temp 列 + 改名方式,不依赖 DROP COLUMN
conn.execute("ALTER TABLE sessions ADD COLUMN token_hash_temp TEXT")
# 回填 sha256(session_id)
rows = conn.execute(
"SELECT id, session_id FROM sessions WHERE session_id IS NOT NULL"
).fetchall()
for r in rows:
# Python 端算 sha256(避免 SQLite 函数兼容性问题)
import hashlib
digest = hashlib.sha256(r['session_id'].encode('utf-8')).hexdigest()
conn.execute(
"UPDATE sessions SET token_hash_temp = ? WHERE id = ?",
(digest, r['id']),
)
if _sqlite_supports_drop_column(conn):
# 新版 SQLite:直接 drop 旧列 + rename temp
conn.execute("ALTER TABLE sessions DROP COLUMN session_id")
conn.execute("ALTER TABLE sessions RENAME COLUMN token_hash_temp TO token_hash")
else:
# 旧版 SQLite:建新表,复制数据,drop 旧表,rename
conn.execute("""
CREATE TABLE sessions_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
ip TEXT,
user_agent TEXT,
revoked INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
""")
conn.execute("""
INSERT INTO sessions_new
(id, token_hash, user_id, created_at, expires_at, ip, user_agent, revoked)
SELECT id, token_hash_temp, user_id, created_at, expires_at, ip, user_agent, revoked
FROM sessions
""")
conn.execute("DROP TABLE sessions")
conn.execute("ALTER TABLE sessions_new RENAME TO sessions")
report['migrated_sessions'] = True
return report
# ============================================================
# 初始化
# ============================================================
def init_db():
"""初始化所有表(幂等),并对旧装做 schema 迁移。
新装:CREATE TABLE 走完即可。
旧装:CREATE TABLE IF NOT EXISTS 跳过已存在表,迁移 SQL 补齐字段。
"""
with _db_lock, get_conn() as conn:
cur = conn.cursor()
for stmt in SCHEMA:
cur.execute(stmt)
# 迁移在 init 完成后单独跑(需要先有 users/sessions 表存在)
migrate_legacy_schema()
def now_iso():
return datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S')
if __name__ == '__main__':
init_db()
print(f"✅ 初始化完成:{DB_PATH}")
@@ -0,0 +1,187 @@
# 产品需求文档(PRD):记录-比对 中奖金额展示
> **编号**:BIZ-102(BIZ-101 子任务1)
> **负责人**:沈路明(productmanager)
> **版本**:v1.1
> **状态**:架构评审通过(条件式已落实),待设计与开发评审
> **父任务**:BIZ-101「webui 记录-比对 中奖金额展示」(CO 已批准)
> **关联系统**:双色球 WebUI(后端 Flask `app.py` + 前端 `index.html`)
---
## 1. 背景与目标
### 1.1 业务背景
当前 `POST/GET /api/records/<id>/compare` 已完成「是否中奖 + 几等奖」的比对,前端弹窗展示每注的奖项徽标与红球/蓝球命中情况,但**不展示中奖金额**。运营人员与刘总在查看比对结果时,只能看到「x 注中奖」,无法直观获知本次生成号码的**总中奖金额**与**各奖级分布金额**,不利于快速评估生成策略的价值与复盘。
### 1.2 解决的问题
- 运营/管理层查看比对结果时,缺少「中奖金额」这一核心经营指标。
- 现有比对只给出奖项枚举,无法按奖级聚合金额,复盘成本结构困难。
### 1.3 成功指标(可量化)
- [ ] 比对弹窗统计区新增「总中奖金额」与「各奖级金额分布」两项,覆盖率 100%。
- [ ] 每条中奖注在奖项徽标旁展示该注中奖金额,覆盖率 100%。
- [ ] 后端响应新增字段后,旧版前端(不消费新字段)功能不受影响,回归通过率 100%。
- [ ] 中奖金额计算口径在 UI 中对浮动奖显式标注「以官方公布为准」,避免误导。
---
## 2. 用户故事
- **作为运营人员**,我希望在比对弹窗中一眼看到「本次生成共中奖多少元、各奖级各多少元」,以便快速汇报生成效果,无需人工逐注累加。
- **作为刘总**,我希望查看每注的中奖金额与总额,以便评估号码生成策略的投入产出,并在浮动奖场景下明确知晓金额口径来源。
- **作为开发(徐聪)**,我希望后端以结构化字段(`prize_amount` / `total_prize` / `prize_summary`)一次性返回金额,前端直接消费,避免前端重复实现中奖金额计算逻辑。
---
## 3. 功能需求
### 3.1 后端 API 扩展(`app.py` `/api/records/<id>/compare`)
现有响应 `data` 已包含:`status, gen_time, compare_type, compare_rule, draw{...}, results[], total, win_count, win_tickets[]`。
每条 `results[i]` 已包含:`index, reds, blue, red_matches, blue_match, prize_level, prize_desc, is_win`。
本需求在**不改动既有字段**前提下做增量扩展:
| 功能点 | 位置 | 字段 | 类型 | 描述 | 优先级 | 验收标准 |
|--------|------|------|------|------|--------|----------|
| F-1 每注中奖金额 | `results[i]` | `prize_amount` | int(单位:元) | 该注中奖金额(元),未中奖为 `0` | P0 | 中奖注 `prize_amount` = 该奖级单注金额;未中奖注恒为 `0` |
| F-2 总中奖金额 | `data` 顶层 | `total_prize` | int(单位:元) | 所有中奖注金额合计(元) | P0 | `total_prize` = Σ `results[i].prize_amount`,与前端展示一致 |
| F-3 按奖级汇总 | `data` 顶层 | `prize_summary` | dict | 按奖项分组汇总,仅含实际出现的奖项 | P0 | key 为英文枚举(见 3.3),每项含 `{count, amount, prize_level, floating}`;未出现奖级不出现 |
| F-3.1 单注浮动标记 | `results[i]` | `floating` | bool | 该注是否属浮动奖(一/二/三等奖);固定奖或未中奖为 `false` | P1 | 浮动奖注 `floating = true`,固定奖注 `floating = false` |
**字段类型与单位**(v1.1 落实架构评审 #1):
- 所有金额字段(`prize_amount` / `total_prize` / `prize_summary[].amount`)均为 **整数**,**单位:元(非分)**。
- 量级说明:双色球一等奖历史最高单注超 1500 万元,Python int 无溢出(任意精度);前端 JS `Number` 在 `2^53 ≈ 9007 万亿` 以下安全,本场景安全。
**边界**:
- `status === 'waiting'`(等待开奖)的响应**不新增**上述字段(无开奖数据,无法计算)。
- 浮动奖(一/二/三等奖)单注金额来源见第 4 节;若取不到动态金额,使用占位值并通过 F-3.1 的 `floating: true` 提示前端「金额以官方公布为准」。
### 3.2 前端展示扩展(`index.html` `compareRecord()` 比对弹窗)
现有弹窗结构(`.compare-summary` 含 4 项:生成时间 / 比对开奖期 / 总注数 / 中奖注数)保持不变,做增量插入:
| 功能点 | 位置 | 描述 | 优先级 | 验收标准 |
|--------|------|------|--------|----------|
| F-4 统计区-总额 | `.compare-summary` 内 | 新增「总中奖金额 `<total_prize>` 元」 | P0 | 使用 `data.total_prize`;`0` 时显示「0 元」 |
| F-5 统计区-分布 | `.compare-summary` 下方 | 遍历 `prize_summary`,逐行展示「`<count>` 注 `<奖级>` 共 `<amount>` 元」 | P0 | 仅展示 `count > 0` 的奖级;每行一条 |
| F-6 每注金额 | 每注 `.compare-ticket` | 在 `prize-badge`(几等奖)旁额外显示该注金额,如「`五等奖 10元`」 | P0 | 使用 `r.prize_amount`;未中奖注不显示金额 |
**展示文案规范**:
- 统计区总额:`总中奖金额 25 元`
- 分布行:`1 注 五等奖 共 10 元` / `3 注 六等奖 共 15 元`
- 每注:`五等奖 10元`(浮动奖额外追加 `· 浮动以官方为准` 角标,角标数据源为 `r.floating === true`,见第 4 节)
**前端字段消费映射**:
- 统计区总额 ← `data.total_prize`
- 分布行每条 ← `Object.entries(data.prize_summary)` 遍历,每项含 `count` / `amount` / `prize_level`(中文显示名)/ `floating`
- 每注金额 ← `r.prize_amount`,浮动角标 ← `r.floating`
---
## 3.3 `prize_summary` 字段结构与 key 命名规范(v1.1 落实架构评审 #2)
采用**方案 A(推荐)**:`prize_summary` 的 key 使用**英文枚举**,每项内显式带中文 `prize_level` 字段供前端展示。
**理由**:
- **[常识]** JSON key 使用中文在日志检索、grep、跨语言消费、后期 i18n 时易踩坑;英文枚举是更稳定的契约。
- 前端用映射表把英文 key 翻译成中文显示名,仅多写一处常量映射,零成本。
`prize_summary` 结构示例:
```json
{
"fifth_prize": {"count": 1, "amount": 10, "prize_level": "五等奖", "floating": false},
"sixth_prize": {"count": 3, "amount": 15, "prize_level": "六等奖", "floating": false}
}
```
| key | prize_level | 类型 | 中奖条件 |
|------|------|------|----------|
| `first_prize` | 一等奖 | 浮动 | 6红+1蓝 |
| `second_prize` | 二等奖 | 浮动 | 6红+0蓝 |
| `third_prize` | 三等奖 | 浮动 | 5红+1蓝 |
| `fourth_prize` | 四等奖 | 固定 | 5红+0蓝 或 4红+1蓝 |
| `fifth_prize` | 五等奖 | 固定 | 4红+0蓝 或 3红+1蓝 |
| `sixth_prize` | 六等奖 | 固定 | 2红+1蓝 或 1红+1蓝 或 0红+1蓝 |
字段含义:
- `prize_summary[key].count` = 该奖级中奖注数
- `prize_summary[key].amount` = `count × 该奖级单注金额`(元,整数)
- `prize_summary[key].prize_level` = 中文显示名(前端直接展示)
- `prize_summary[key].floating` = 是否浮动奖(前端决定是否展示「以官方公布为准」角标)
- 仅当 `count > 0` 的奖级出现在 `prize_summary` 中(未中奖注不计入)
## 4. 中奖金额口径与浮动奖说明
### 4.1 单注奖级金额表
| 奖项 | 单注金额(元) | 类型 | 中奖条件 | 声明 |
|------|---------------|------|----------|------|
| 一等奖 | 浮动(官方公布) | 浮动 | 6红+1蓝 | [已知] 双色球一等奖为浮动奖池 |
| 二等奖 | 浮动(官方公布) | 浮动 | 6红+0蓝 | [已知] 二等奖为浮动奖池 |
| 三等奖 | 浮动(官方公布) | 浮动 | 5红+1蓝 | [已知] 三等奖为浮动奖池 |
| 四等奖 | 200 | 固定 | 5红+0蓝 或 4红+1蓝 | [已知] 中彩中心固定奖级 |
| 五等奖 | 10 | 固定 | 4红+0蓝 或 3红+1蓝 | [已知] 中彩中心固定奖级 |
| 六等奖 | 5 | 固定 | 2红+1蓝 或 1红+1蓝 或 0红+1蓝 | [已知] 中彩中心固定奖级 |
> 固定奖级(四/五/六等奖)金额由中彩中心规则固定,可直接硬编码;浮动奖(一/二/三等奖)实际金额随奖池浮动。
### 4.2 浮动奖处理规则(关键决策点)
[推论] 本迭代先采用**单注固定/占位金额**驱动展示,浮动奖金额本期以「占位值 + UI 显式标注」呈现,待数据源(开奖数据接口)能提供每期实际奖金后再切换为动态读取。
- **数据源可读取实际奖金时**:`prize_amount` 取接口返回的实际单注金额;`prize_summary` 的 `amount` 按实际金额计算。
- **数据源不可读取时(本期默认)**:浮动奖使用占位值(建议一等奖 5,000,000 / 二等奖 200,000 / 三等奖 3,000 作占位,**[猜测]** 具体占位值待刘总确认),并在 UI 该奖级旁追加角标「`浮动·以官方公布为准`」。
- **Fallback 策略**:优先实际金额 → 缺失时占位值 + 标注。
### 4.3 计算边界
- `prize_amount` 一律为非负整数(元),未中奖 = `0`。
- `total_prize` = Σ `results[i].prize_amount`,由后端聚合,前端不做二次计算。
- `prize_summary[key].amount` = `count × 该奖级单注金额`,由后端聚合。
- 每次比对请求都**重新计算**上述字段,**不持久化到数据库**(v1.1 落实架构评审兼容性补充:避免老记录因历史缺字段导致新版本回放异常)。
---
## 5. 兼容性(不影响现有比对流程)
- [ ] 新增字段为**纯增量**,旧版前端未消费新字段时比对弹窗功能不变。
- [ ] 旧版后端(无新字段)对新版前端:前端需做字段存在性判断(`data.total_prize ?? 0`),避免 `undefined` 渲染异常。
- [ ] `status === 'waiting'` 响应结构不变,不新增字段。
- [ ] 比对核心逻辑(奖项判定、命中统计)零改动,仅追加金额计算分支。
---
## 6. 验收标准
### 后端
1. 中奖记录比对响应中,`results[i]` 含 `prize_amount`(int,单位:元),且中奖注 = 对应奖级单注金额、未中奖注 = `0`。
2. `data.total_prize`(int,单位:元)= 所有中奖注 `prize_amount` 之和。
3. `data.prize_summary` 仅含 `count > 0` 的奖级;key 为英文枚举(`first_prize` ... `sixth_prize`);每项含 `{count, amount, prize_level, floating}`,且 `amount = count × 单注金额`。
4. `results[i].floating` 在浮动奖(一/二/三等奖)注上为 `true`,固定奖与未中奖注为 `false`。
5. `waiting` 状态下不返回上述字段。
6. 字段每次实时计算,不依赖持久化(老记录重新比对也能正确返回新字段)。
### 前端
7. 比对弹窗统计区显示「总中奖金额 <total_prize> 元」。
8. 弹窗展示各奖级分布行「x 注 y 等奖 共 z 元」,仅 `count > 0` 奖级;显示名取 `prize_summary[key].prize_level`。
9. 每条中奖注在奖项徽标旁显示该注金额;未中奖注不显示金额。
10. `floating: true` 的注/奖级在 UI 显式标注「以官方公布为准」。
### 回归
9. 不消费新字段的旧逻辑(命中统计、奖项徽标)行为不变。
10. 字段缺失时前端不报错(存在性兜底)。
---
## 7. 范围与边界(与 BIZ-101 的关系)
- **本卡(BIZ-102)**:仅产出 PRD + UI 原型,不写后端/前端代码,不做部署。
- **后续链路**:本卡 `in_review` → 产研交叉评审(@梁思筑 @徐聪 @苏锦绘)→ 通过后进入 BIZ-101 子任务2(开发落地)。
- 中奖金额的**具体数值**(尤其浮动奖占位值)由本 PRD 决策,落地细节在子任务2 实现。
---
## 版本历史
| 版本 | 日期 | 作者 | 说明 |
|------|------|------|------|
| v1.0 | 2026-08-30 11:57 | 沈路明 | 首版 PRD,基于现有 `app.py` compare 与 `index.html` compareRecord 结构增量设计 |
| v1.1 | 2026-08-30 14:35 | 沈路明 | 落实架构评审(梁思筑)3 点意见:① 金额字段追加 `(单位:元)` 标注与量级安全说明;② `prize_summary` key 改用英文枚举(方案 A),新增 `prize_level` 中文显示名字段;③ 浮动标记 `floating` 落到 `results[i]` 与 `prize_summary[key]` 两处粒度;④ 验收标准补充兼容性「每次实时计算」条款 |
+252
View File
@@ -0,0 +1,252 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BIZ-102 比对弹窗原型 · 中奖金额展示</title>
<style>
:root {
--text-light: #888;
--border: #e8e8e8;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: #f5f6f8;
margin: 0;
padding: 24px;
color: #333;
}
.modal-wrap {
max-width: 520px;
margin: 0 auto;
background: #fff;
border-radius: 14px;
box-shadow: 0 8px 30px rgba(0,0,0,0.12);
overflow: hidden;
}
.modal-header {
padding: 16px 20px;
font-size: 16px;
font-weight: 700;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 8px;
}
.modal-body { padding: 18px 20px; }
/* 统计区(.compare-summary) */
.compare-summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 14px;
}
.compare-summary .item {
background: #f8f9fa;
border-radius: 8px;
padding: 10px;
text-align: center;
}
.compare-summary .item .label { font-size: 12px; color: var(--text-light); margin-bottom: 4px; }
.compare-summary .item .value { font-size: 16px; font-weight: 700; }
/* 新增:总额与分布区 */
.prize-total {
grid-column: 1 / -1;
background: #eafaf0;
border: 1px solid #bdebcd;
color: #1e8e4a;
border-radius: 8px;
padding: 12px;
text-align: center;
font-size: 18px;
font-weight: 800;
}
.prize-breakdown {
grid-column: 1 / -1;
background: #fcfcfd;
border: 1px dashed #d8dde3;
border-radius: 8px;
padding: 10px 12px;
font-size: 13px;
line-height: 1.9;
}
.prize-breakdown .row { display: flex; justify-content: space-between; }
.prize-breakdown .row .lv { font-weight: 600; }
.prize-breakdown .row .amt { color: #1e8e4a; font-weight: 700; }
.prize-breakdown .floating-note {
margin-top: 6px;
font-size: 11px;
color: #c0392b;
}
/* 开奖号码 */
.draw-line {
background: #f0f0f0;
padding: 10px;
border-radius: 8px;
margin-bottom: 10px;
font-size: 13px;
}
/* 每注 */
.compare-ticket {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.compare-ticket.win { background: #f4fcf7; }
.compare-ticket .idx { color: var(--text-light); min-width: 30px; font-size: 12px; }
.ball {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px; height: 24px;
border-radius: 50%;
font-size: 12px;
color: #fff;
font-weight: 700;
}
.ball-red { background: #e74c3c; }
.ball-blue { background: #3498db; }
.prize-badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 700;
}
.prize-badge.win { background: #27ae60; color: #fff; }
.prize-badge.lose { background: #eee; color: var(--text-light); }
/* 新增:每注金额 */
.ticket-amount {
margin-left: 2px;
font-size: 12px;
font-weight: 700;
color: #1e8e4a;
}
.ticket-amount.floating::after {
content: " · 浮动以官方为准";
font-size: 10px;
color: #c0392b;
font-weight: 400;
}
.compare-ticket-info { margin-left: auto; color: var(--text-light); font-size: 12px; }
.footer-msg {
text-align: center;
padding: 14px;
font-size: 14px;
color: #27ae60;
font-weight: 700;
}
.legend { max-width: 520px; margin: 14px auto 0; font-size: 12px; color: var(--text-light); line-height: 1.8; }
</style>
</head>
<body>
<!-- ===== 比对弹窗(仅弹窗部分,便于设计师评审) ===== -->
<div class="modal-wrap">
<div class="modal-header">🎯 中奖比对</div>
<div class="modal-body">
<!-- 统计区:保留原有 4 项,新增总额 + 分布 -->
<div class="compare-summary">
<div class="item"><div class="label">生成时间</div><div class="value" style="font-size:14px;">2026-08-30 11:00:00</div></div>
<div class="item"><div class="label">比对开奖期</div><div class="value" style="font-size:14px;">2026098 · 2026-08-25</div></div>
<div class="item"><div class="label">总注数</div><div class="value">5</div></div>
<div class="item"><div class="label">中奖注数</div><div class="value" style="color:#27ae60;">4</div></div>
<!-- 新增 F-4:总中奖金额 -->
<div class="prize-total">总中奖金额 25 元</div>
<!-- 新增 F-5:各奖级分布(数据源 prize_summary,key 为英文枚举) -->
<div class="prize-breakdown">
<!-- 示例对应 prize_summary.fifth_prize -->
<div class="row"><span class="lv">1 注 五等奖</span><span class="amt">共 10 元</span></div>
<!-- 示例对应 prize_summary.sixth_prize -->
<div class="row"><span class="lv">3 注 六等奖</span><span class="amt">共 15 元</span></div>
<!-- 示例对应 prize_summary.first_prize(floating: true) -->
<div class="row"><span class="lv">1 注 一等奖 · 浮动</span><span class="amt">共 5,000,000 元*</span></div>
<div class="floating-note">* 浮动奖金额为占位/官方公布值,以官方公布为准(数据源:prize_summary[key].floating)</div>
</div>
</div>
<!-- 开奖号码 -->
<div class="draw-line">
<strong>开奖号码:</strong>
<span class="ball ball-red">03</span><span class="ball ball-red">08</span><span class="ball ball-red">14</span><span class="ball ball-red">19</span><span class="ball ball-red">22</span><span class="ball ball-red">27</span>
<span class="ball ball-blue">11</span>
</div>
<!-- 每注:F-6 在奖项徽标旁显示该注金额 -->
<div class="compare-ticket win">
<span class="idx">001</span>
<span class="ball ball-red">03</span><span class="ball ball-red">08</span><span class="ball ball-red">14</span><span class="ball ball-red">19</span><span class="ball ball-red">22</span><span class="ball ball-red">27</span>
<span class="ball ball-blue">11</span>
<span class="prize-badge win">一等奖</span>
<span class="ticket-amount floating">5,000,000元</span>
<span class="compare-ticket-info">红6 蓝✓</span>
</div>
<div class="compare-ticket win">
<span class="idx">002</span>
<span class="ball ball-red">03</span><span class="ball ball-red">08</span><span class="ball ball-red">14</span><span class="ball ball-red">19</span>
<span class="ball ball-blue">11</span>
<span class="prize-badge win">五等奖</span>
<span class="ticket-amount">10元</span>
<span class="compare-ticket-info">红4 蓝✓</span>
</div>
<div class="compare-ticket win">
<span class="idx">003</span>
<span class="ball ball-red">03</span><span class="ball ball-red">08</span>
<span class="ball ball-blue">11</span>
<span class="prize-badge win">六等奖</span>
<span class="ticket-amount">5元</span>
<span class="compare-ticket-info">红2 蓝✓</span>
</div>
<div class="compare-ticket win">
<span class="idx">004</span>
<span class="ball ball-red">08</span>
<span class="ball ball-blue">11</span>
<span class="prize-badge win">六等奖</span>
<span class="ticket-amount">5元</span>
<span class="compare-ticket-info">红1 蓝✓</span>
</div>
<div class="compare-ticket">
<span class="idx">005</span>
<span class="ball ball-red">01</span><span class="ball ball-red">02</span><span class="ball ball-red">03</span>
<span class="ball ball-blue">09</span>
<span class="prize-badge lose">未中奖</span>
<span class="compare-ticket-info">红0 蓝✗</span>
</div>
<div class="footer-msg">🎉 共 4 注中奖!</div>
</div>
</div>
<div class="legend">
原型说明(BIZ-102 / PRD v1.1):<br>
· 绿色总额条 = 新增 F-4(<code>data.total_prize</code>);虚线框 = 新增 F-5(遍历 <code>data.prize_summary</code>,key 为英文枚举 <code>first_prize</code>...<code>sixth_prize</code>,展示名取 <code>prize_level</code>);<br>
· 每注徽标后的绿色金额 = 新增 F-6(<code>results[i].prize_amount</code>);<br>
· 浮动奖(一/二/三等奖)金额旁红色「浮动以官方为准」角标,数据源 <code>results[i].floating === true</code> 与 <code>prize_summary[key].floating === true</code>(PRD 第 3.1 / 3.3 节);<br>
· 未中奖注不显示金额。原型数据为演示用占位值。<br>
· mock JSON 示例:
<pre style="background:#f8f9fa;padding:8px;border-radius:6px;font-size:11px;line-height:1.6;margin:6px 0 0;">
{
"total_prize": 25,
"prize_summary": {
"fifth_prize": {"count": 1, "amount": 10, "prize_level": "五等奖", "floating": false},
"sixth_prize": {"count": 3, "amount": 15, "prize_level": "六等奖", "floating": false}
},
"results": [
{"index": 1, "prize_level": "五等奖", "prize_amount": 10, "floating": false, "is_win": true},
{"index": 2, "prize_level": "六等奖", "prize_amount": 5, "floating": false, "is_win": true}
]
}
</pre>
</div>
</body>
</html>
+375 -11
View File
@@ -51,6 +51,83 @@
}
.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
@@ -600,6 +677,64 @@
}
.prize-badge.win { background: #27ae60; color: white; }
.prize-badge.lose { background: #f0f0f0; color: var(--text-light); }
/* PRD v1.2 BIZ-105:每注中奖金额(紧邻「元」不空格) */
.ticket-amount {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
margin-left: 4px;
background: rgba(30, 142, 74, 0.1);
color: #1e8e4a;
}
.ticket-amount.floating {
background: rgba(230, 126, 34, 0.1);
color: #e67e22;
}
/* PRD v1.2 BIZ-105:浮动奖顶部提示条 */
.variable-prize-tip {
background: #fff3e0;
color: #e67e22;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
margin-top: 10px;
line-height: 1.6;
}
/* PRD v1.2 BIZ-105:奖项汇总表 */
.prize-table-wrap {
background: #f8f9fa;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 12px;
}
.prize-table-title {
font-size: 13px;
font-weight: 600;
color: var(--text);
margin-bottom: 8px;
}
.prize-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.prize-table th, .prize-table td {
padding: 6px 4px;
text-align: center;
border-bottom: 1px solid #e8e8e8;
}
.prize-table th {
font-size: 12px;
color: var(--text-light);
font-weight: 500;
}
.prize-table tr:last-child td { border-bottom: none; }
.prize-table .col-level { text-align: left; color: var(--text); font-weight: 500; }
.prize-table .col-count { color: var(--text-light); }
.prize-table .col-amount { font-weight: 600; color: #1e8e4a; }
.prize-table .col-amount.floating { color: #e67e22; }
.compare-ticket-info {
font-size: 12px;
color: var(--text-light);
@@ -670,6 +805,24 @@
</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">请输入账号密码以继续(BIZ-129 方案 C)</div>
<input type="text" class="login-input" id="loginUsernameInput" placeholder="用户名" autocomplete="username" style="margin-bottom: 10px;">
<input type="password" class="login-input" id="loginPasswordInput" placeholder="密码" autocomplete="current-password" 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">会话存于 HttpOnly cookie(30 天),XSS 不可读。</div>
</div>
</div>
<main id="mainApp">
<!-- ============================================================
Toast
============================================================ -->
@@ -679,6 +832,7 @@ Toast
Header
============================================================ -->
<div class="header">
<button class="logout-btn" onclick="logout()" title="退出登录(清除本地令牌)">退出</button>
<h1>🎯 双色球号码生成系统</h1>
<p>基于历史数据分析 · 支持 PC 端和移动端</p>
</div>
@@ -844,6 +998,101 @@ const state = {
searchTimer: null,
};
// ============================================================
// Login Page (BIZ-129 方案 C:username + password + cookie session)
// ============================================================
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 || '请输入账号密码以继续';
}
// 清空输入框 + 聚焦用户名
const userInput = document.getElementById('loginUsernameInput');
const passInput = document.getElementById('loginPasswordInput');
if (userInput) { userInput.value = getUsername(); }
if (passInput) { passInput.value = ''; }
setTimeout(() => {
if (userInput && !userInput.value) userInput.focus();
else if (passInput) passInput.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 userInput = document.getElementById('loginUsernameInput');
const passInput = document.getElementById('loginPasswordInput');
const btn = document.getElementById('loginSubmitBtn');
const errEl = document.getElementById('loginError');
if (!userInput || !passInput || !btn) return;
const username = (userInput.value || '').trim();
const password = passInput.value || '';
if (!username || !password) {
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 {
const res = await fetch('/api/auth/login', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const j = await res.json().catch(() => ({}));
if (!res.ok || !j.success) {
const code = j.code || 'login_failed';
const msg = j.error || `登录失败(HTTP ${res.status})`;
if (errEl) {
// 锁定提示额外说明
if (code === 'account_locked') {
errEl.textContent = '⛔ ' + msg;
} else if (code === 'rate_limited') {
errEl.textContent = '⏳ ' + msg;
} else {
errEl.textContent = msg;
}
errEl.style.display = 'block';
}
btn.disabled = false;
btn.textContent = oldText;
return;
}
// 登录成功
setUsername(username);
showMainPage();
showToast('✅ 登录成功(' + (j.data.user.username || username) + ')', 'success');
// 重新加载当前页面数据
if (state.currentPage) switchPage(state.currentPage);
} catch (e) {
if (errEl) {
errEl.textContent = '网络错误:' + (e.message || '请检查连接');
errEl.style.display = 'block';
}
btn.disabled = false;
btn.textContent = oldText;
}
}
// ============================================================
// Toast
// ============================================================
@@ -881,21 +1130,84 @@ function switchPage(page) {
}
// ============================================================
// API Helper
// Auth (BIZ-129 方案 C:session cookie + username/password)
// ============================================================
// 架构决策:HttpOnly cookie 走 session,localStorage 只存 username(用于 UI 提示)
// session 本身由服务器写入 HttpOnly cookie,前端 JS 读不到——完全防 XSS。
const AUTH_STORAGE_KEY = 'lotto_username';
function getUsername() {
try {
return localStorage.getItem(AUTH_STORAGE_KEY) || '';
} catch (e) {
return '';
}
}
function setUsername(u) {
try {
if (u) localStorage.setItem(AUTH_STORAGE_KEY, u);
else localStorage.removeItem(AUTH_STORAGE_KEY);
} catch (e) { /* localStorage 不可用(隐私模式)—忽略 */ }
}
function isLoggedIn() {
// 是否登录仅看 cookie 是否存在;username 仅为 UI 优化
return document.cookie.split(';').some(c => c.trim().startsWith('lotto_session='));
}
async function logout(reason) {
// 调后端 logout 吊销 session,然后清本地状态
try {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
} catch (e) {
console.warn('logout 请求失败:', e);
}
setUsername('');
showToast(reason || '已退出登录', 'info');
showLoginPage();
}
// ============================================================
// API Helper (BIZ-129: 不再注入 Bearer,cookie 由浏览器自动带上)
// ============================================================
async function api(path, options = {}) {
const opts = { credentials: 'same-origin', ...options }; // 关键:带上 cookie
const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) };
let res;
try {
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;
res = await fetch(path, { ...opts, headers });
} catch (e) {
showToast(e.message, 'error');
showToast('网络错误:' + e.message, 'error');
throw e;
}
// 401 → 清 username + 跳登录页(cookie 由浏览器自动清除 — 如果服务器返回 Set-Cookie 过期)
if (res.status === 401) {
let msg = '未授权访问';
try {
const j = await res.json();
if (j && j.error) msg = j.error;
} catch (_) { /* 忽略 JSON 解析错误 */ }
setUsername('');
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;
}
// ============================================================
@@ -1400,14 +1712,36 @@ async function compareRecord(id) {
const draw = d.draw;
const results = d.results;
const winCount = d.win_count;
// PRD v1.2 BIZ-105:中奖金额汇总字段
const totalPrize = d.total_prize || 0;
const totalPrizeDisplay = d.total_prize_display || '0元';
const hasVariablePrize = !!d.has_variable_prize;
const prizeDisplay = d.prize_display || [];
let html = '<div class="compare-summary">';
html += `<div class="item"><div class="label">生成时间</div><div class="value" style="font-size:14px;">${d.gen_time}</div></div>`;
html += `<div class="item"><div class="label">比对开奖期</div><div class="value" style="font-size:14px;">${draw.period} · ${draw.date}</div></div>`;
html += `<div class="item"><div class="label">总注数</div><div class="value">${d.total}</div></div>`;
html += `<div class="item"><div class="label">中奖注数</div><div class="value" style="color:${winCount > 0 ? '#27ae60' : 'var(--text-light)'};">${winCount}</div></div>`;
html += `<div class="item"><div class="label">总中奖金额${hasVariablePrize ? '<span style="font-size:10px;color:#e67e22;font-weight:400;margin-left:4px;">含浮动奖</span>' : ''}</div><div class="value" style="color:${totalPrize > 0 ? '#27ae60' : 'var(--text-light)'};">${totalPrizeDisplay}</div></div>`;
html += '</div>';
// PRD v1.2 BIZ-105:按奖项分行汇总(不展示浮动奖金额)
if (prizeDisplay.length > 0) {
html += '<div class="prize-table-wrap">';
html += '<div class="prize-table-title">中奖奖项汇总</div>';
html += '<table class="prize-table"><thead><tr><th class="col-level">奖级</th><th class="col-count">中奖注数</th><th class="col-amount">单注金额</th></tr></thead><tbody>';
prizeDisplay.forEach(row => {
html += `<tr><td class="col-level">${row.level}</td><td class="col-count">${row.count} 注</td><td class="col-amount">${row.amount}</td></tr>`;
});
html += '</tbody></table></div>';
}
// PRD v1.2 BIZ-105:浮动奖提示(一/二等奖金额仅参考,不计入总金额)
if (hasVariablePrize) {
html += '<div class="variable-prize-tip">说明:一/二等奖金额为占位值,实际按奖池浮动,不计入「总中奖金额」。</div>';
}
// Show draw result
html += `<div style="margin-bottom:12px;background:#f0f0f0;padding:10px;border-radius:8px;">
<span style="font-size:13px;font-weight:600;margin-right:8px;">开奖号码:</span>
@@ -1428,12 +1762,19 @@ async function compareRecord(id) {
const prizeBadge = r.is_win
? `<span class="prize-badge win">${r.prize_level}</span>`
: `<span class="prize-badge lose">未中奖</span>`;
// PRD v1.2 BIZ-105:每注中奖金额(未中奖不展示)
let amountBadge = '';
if (r.is_win) {
const isFloating = r.prize_level === '一等奖' || r.prize_level === '二等奖';
const amountDisplay = r.prize_amount_display || `${r.prize_amount || 0}元`;
amountBadge = `<span class="ticket-amount${isFloating ? ' floating' : ''}">${amountDisplay}</span>`;
}
const matchInfo = `<span class="match-info ${r.red_matches > 0 ? 'match' : ''}">红${r.red_matches}</span> <span class="match-info ${r.blue_match ? 'match' : ''}">蓝${r.blue_match ? '✓' : '✗'}</span>`;
html += `<div class="compare-ticket${r.is_win ? ' win' : ''}">
<span style="font-size:12px;color:var(--text-light);min-width:36px;">${String(r.index).padStart(3,'0')}</span>
${redBalls}${blueBall}
${prizeBadge}
${prizeBadge}${amountBadge}
<span class="compare-ticket-info">${matchInfo} ${r.prize_desc || ''}</span>
</div>`;
}
@@ -1457,7 +1798,30 @@ async function compareRecord(id) {
// ============================================================
// Init
// ============================================================
loadStatsOverview();
async function bootstrap() {
// BIZ-129 方案 C:检查 cookie + 调 /api/auth/me 验证
if (!isLoggedIn()) {
showLoginPage();
return;
}
try {
const me = await api('/api/auth/me');
if (me && me.username) {
setUsername(me.username);
showMainPage();
loadStatsOverview();
} else {
showLoginPage();
}
} catch (e) {
// 401 已被 api() 拦截;其他错误 → 跳登录页
showLoginPage();
}
}
bootstrap();
</script>
</main>
</body>
</html>
+1
View File
@@ -4,3 +4,4 @@ numpy>=1.24
openpyxl>=3.1
requests>=2.31
beautifulsoup4>=4.12
flask-limiter>=4.0
View File
+51
View File
@@ -0,0 +1,51 @@
"""
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()
+348
View File
@@ -0,0 +1,348 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
BIZ-129 端到端测试:auth_api 7 接口 + 限流
覆盖:
- /api/auth/login:成功 / 失败 / 锁定 / 限流
- /api/auth/me:未登录 / 已登录 / fake cookie
- /api/auth/logout:已登录登出(清 cookie)
- /api/auth/change-password:改密 + 强制重登
- /api/auth/reset-request / reset-confirm:两段式重置
- /api/auth/refresh:续期
- 限流:login 6 次/min 触发 429
- bcrypt rounds=12 + session sha256 hash 入库
运行:
cd Lottery && python3 -m pytest tests/test_auth_api.py -v
"""
import os
import sys
import json
import unittest
import sqlite3
import tempfile
import time
HERE = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.dirname(HERE)
sys.path.insert(0, PROJECT_ROOT)
class AuthApiTestCase(unittest.TestCase):
"""端到端测试:使用 Flask test_client 调真实 app 加载路径。"""
@classmethod
def setUpClass(cls):
# 隔离 DB 到临时文件
cls.tmp_db = tempfile.NamedTemporaryFile(suffix='.db', delete=False)
cls.tmp_db.close()
# patch get_conn
import auth_db as ad
original_get_conn = ad.get_conn
def patched_get_conn():
conn = sqlite3.connect(cls.tmp_db.name)
conn.row_factory = sqlite3.Row
return conn
ad.get_conn = patched_get_conn
cls._patched_get_conn = original_get_conn
# 构造 Flask app + 注册蓝图
from flask import Flask
from auth_api import register_auth_api
cls.app = Flask(__name__)
cls.app.config['TESTING'] = True
register_auth_api(cls.app)
cls.client = cls.app.test_client()
# 初始化 DB schema
ad.init_db()
@classmethod
def tearDownClass(cls):
try:
os.unlink(cls.tmp_db.name)
except Exception:
pass
import auth_db as ad
ad.get_conn = cls._patched_get_conn
def setUp(self):
"""每个用例前:清表 + 重置限流计数。"""
import auth_db as ad
with ad.get_conn() as conn:
conn.execute("DELETE FROM operation_logs")
conn.execute("DELETE FROM sessions")
conn.execute("DELETE FROM password_reset_tokens")
conn.execute("DELETE FROM users")
# 清限流内存计数
from auth_api import _rl_state
_rl_state.clear()
# 清 client cookie jar(重要:避免上一用例的 cookie 影响)
self.client._cookies.clear() if hasattr(self.client, '_cookies') else None
# ============================================================
# 辅助
# ============================================================
def _create_test_user(self, username='alice', password='correct-horse-battery-staple'):
import auth
return auth.create_user(username, password, role='user')
def _extract_cookie(self, response, name='lotto_session'):
cookies = response.headers.get('Set-Cookie', '')
for part in cookies.split(','):
if name + '=' in part:
return part.split(name + '=')[1].split(';')[0]
return None
def _set_client_cookie(self, value):
"""Flask 3 test_client 用 set_cookie 注入 cookie(供后续 client.get/post 使用)。"""
self.client.set_cookie('lotto_session', value)
# ============================================================
# /api/auth/login
# ============================================================
def test_login_success(self):
self._create_test_user()
r = self.client.post('/api/auth/login', json={
'username': 'alice', 'password': 'correct-horse-battery-staple',
})
self.assertEqual(r.status_code, 200)
self.assertTrue(r.json['success'])
self.assertEqual(r.json['data']['user']['username'], 'alice')
cookie = self._extract_cookie(r)
self.assertIsNotNone(cookie, '登录成功应设置 lotto_session cookie')
self.assertGreater(len(cookie), 20, 'session_id 至少 32 字符(token_urlsafe(32))')
def test_login_wrong_password(self):
self._create_test_user()
r = self.client.post('/api/auth/login', json={
'username': 'alice', 'password': 'wrong-password',
})
self.assertEqual(r.status_code, 401)
self.assertEqual(r.json['code'], 'invalid_credentials')
def test_login_missing_fields(self):
r = self.client.post('/api/auth/login', json={'username': 'alice'})
self.assertEqual(r.status_code, 400)
self.assertEqual(r.json['code'], 'missing_credentials')
def test_login_unknown_user(self):
r = self.client.post('/api/auth/login', json={
'username': 'nobody', 'password': 'whatever',
})
self.assertEqual(r.status_code, 401)
self.assertEqual(r.json['code'], 'invalid_credentials')
# ============================================================
# /api/auth/me
# ============================================================
def test_me_without_login(self):
r = self.client.get('/api/auth/me')
self.assertEqual(r.status_code, 401)
self.assertEqual(r.json['code'], 'unauthenticated')
def test_me_with_valid_session(self):
self._create_test_user()
login_r = self.client.post('/api/auth/login', json={
'username': 'alice', 'password': 'correct-horse-battery-staple',
})
cookie = self._extract_cookie(login_r)
self._set_client_cookie(cookie)
r = self.client.get('/api/auth/me')
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json['data']['username'], 'alice')
def test_me_with_invalid_cookie(self):
self._set_client_cookie('fake-session-id-not-in-db')
r = self.client.get('/api/auth/me')
self.assertEqual(r.status_code, 401)
self.assertEqual(r.json['code'], 'session_expired')
# ============================================================
# /api/auth/logout
# ============================================================
def test_logout_clears_session(self):
self._create_test_user()
login_r = self.client.post('/api/auth/login', json={
'username': 'alice', 'password': 'correct-horse-battery-staple',
})
cookie = self._extract_cookie(login_r)
self._set_client_cookie(cookie)
# 登出
logout_r = self.client.post('/api/auth/logout')
self.assertEqual(logout_r.status_code, 200)
# 登出后用同一个 cookie 调 /me 应该 401(session 已吊销)
me_r = self.client.get('/api/auth/me')
self.assertEqual(me_r.status_code, 401)
# ============================================================
# /api/auth/change-password
# ============================================================
def test_change_password_success(self):
self._create_test_user()
login_r = self.client.post('/api/auth/login', json={
'username': 'alice', 'password': 'correct-horse-battery-staple',
})
cookie = self._extract_cookie(login_r)
self._set_client_cookie(cookie)
# 改密码
r = self.client.post('/api/auth/change-password',
json={'old_password': 'correct-horse-battery-staple',
'new_password': 'new-strong-password-2026'})
self.assertEqual(r.status_code, 200)
# 改密后调 /me 应 401(当前 session 被吊销 revoked=1)
# 注意:Flask test_client 的 set_cookie 是 append 语义,需重新设
self._set_client_cookie(cookie)
me_r = self.client.get('/api/auth/me')
self.assertEqual(me_r.status_code, 401,
'改密后原 session 应被吊销')
# 用新密码登录
login2 = self.client.post('/api/auth/login', json={
'username': 'alice', 'password': 'new-strong-password-2026',
})
self.assertEqual(login2.status_code, 200)
def test_change_password_wrong_old(self):
self._create_test_user()
login_r = self.client.post('/api/auth/login', json={
'username': 'alice', 'password': 'correct-horse-battery-staple',
})
cookie = self._extract_cookie(login_r)
self._set_client_cookie(cookie)
r = self.client.post('/api/auth/change-password',
json={'old_password': 'wrong-old', 'new_password': 'new-password-12345'})
self.assertEqual(r.status_code, 401)
self.assertEqual(r.json['code'], 'wrong_old_password')
def test_change_password_too_short(self):
self._create_test_user()
login_r = self.client.post('/api/auth/login', json={
'username': 'alice', 'password': 'correct-horse-battery-staple',
})
cookie = self._extract_cookie(login_r)
self._set_client_cookie(cookie)
r = self.client.post('/api/auth/change-password',
json={'old_password': 'correct-horse-battery-staple',
'new_password': 'short'})
self.assertEqual(r.status_code, 400)
self.assertEqual(r.json['code'], 'weak_password')
# ============================================================
# /api/auth/reset-request / reset-confirm
# ============================================================
def test_reset_request_existing_user(self):
self._create_test_user()
r = self.client.post('/api/auth/reset-request', json={'username': 'alice'})
self.assertEqual(r.status_code, 200)
self.assertTrue(r.json['success'])
self.assertEqual(r.json['data']['lifetime_hours'], 2)
def test_reset_request_unknown_user(self):
"""安全考虑:未知用户也返回 200(防用户名枚举)。"""
r = self.client.post('/api/auth/reset-request', json={'username': 'nobody'})
self.assertEqual(r.status_code, 200)
self.assertTrue(r.json['success'])
self.assertNotIn('lifetime_hours', r.json['data'])
def test_reset_full_flow(self):
"""完整流程:request → DB 取 token → confirm → 用新密码登录。"""
self._create_test_user()
# 1. 申请 reset
r1 = self.client.post('/api/auth/reset-request', json={'username': 'alice'})
self.assertEqual(r1.status_code, 200)
# 2. 模拟从邮件拿 token(生产不应在 response 返回)
import auth_db as ad
with ad.get_conn() as conn:
row = conn.execute(
"SELECT token FROM password_reset_tokens WHERE used_at IS NULL ORDER BY id DESC LIMIT 1"
).fetchone()
self.assertIsNotNone(row, '应该生成 token')
token = row['token']
# 3. 确认重置
r2 = self.client.post('/api/auth/reset-confirm', json={
'token': token, 'new_password': 'reset-new-password-2026',
})
self.assertEqual(r2.status_code, 200)
# 4. 用新密码登录
r3 = self.client.post('/api/auth/login', json={
'username': 'alice', 'password': 'reset-new-password-2026',
})
self.assertEqual(r3.status_code, 200)
def test_reset_confirm_invalid_token(self):
r = self.client.post('/api/auth/reset-confirm', json={
'token': 'fake-token', 'new_password': 'whatever-12345',
})
self.assertEqual(r.status_code, 400)
self.assertEqual(r.json['code'], 'invalid_token')
# ============================================================
# 限流
# ============================================================
def test_login_rate_limit(self):
"""6 次/分钟触发 429。"""
for i in range(5):
r = self.client.post('/api/auth/login', json={
'username': 'flood', 'password': 'flood',
})
self.assertEqual(r.status_code, 401, f'attempt {i+1} 应该是业务 401')
# 第 6 次触发限流
r6 = self.client.post('/api/auth/login', json={
'username': 'flood', 'password': 'flood',
})
self.assertEqual(r6.status_code, 429)
self.assertEqual(r6.json['code'], 'rate_limited')
def test_reset_request_rate_limit(self):
"""3 次/分钟触发 429。"""
for i in range(3):
r = self.client.post('/api/auth/reset-request', json={'username': 'nobody'})
self.assertEqual(r.status_code, 200, f'attempt {i+1} 应该是业务 200')
r4 = self.client.post('/api/auth/reset-request', json={'username': 'nobody'})
self.assertEqual(r4.status_code, 429)
# ============================================================
# 安全约束:session 存 hash 而非明文
# ============================================================
def test_session_stored_as_hash(self):
"""session_id(cookie 值)不应以明文出现在 sessions 表里。"""
import hashlib
import auth_db as ad
self._create_test_user()
login_r = self.client.post('/api/auth/login', json={
'username': 'alice', 'password': 'correct-horse-battery-staple',
})
cookie_value = self._extract_cookie(login_r)
self.assertIsNotNone(cookie_value)
# 查 sessions 表
with ad.get_conn() as conn:
row = conn.execute(
"SELECT token_hash FROM sessions WHERE user_id IN (SELECT id FROM users WHERE username = 'alice')"
).fetchone()
self.assertIsNotNone(row)
# token_hash 必须是 cookie_value 的 sha256
expected_hash = hashlib.sha256(cookie_value.encode()).hexdigest()
self.assertEqual(row['token_hash'], expected_hash)
self.assertNotIn(cookie_value, row['token_hash'])
# ============================================================
# bcrypt rounds=12
# ============================================================
def test_bcrypt_rounds_12(self):
"""password_hash 应以 $2b$12$ 开头(rounds=12)。"""
import auth_db as ad
self._create_test_user()
with ad.get_conn() as conn:
row = conn.execute(
"SELECT password_hash FROM users WHERE username = 'alice'"
).fetchone()
self.assertTrue(row['password_hash'].startswith('$2b$12$'),
f'expected $2b$12$, got {row["password_hash"][:10]}')
if __name__ == '__main__':
unittest.main()
+344
View File
@@ -0,0 +1,344 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
BIZ-105 单测:中奖金额计算与展示(PRD v1.2)
覆盖:
- 6 档奖金档(一/二/三/四/五/六)
- 未中奖
- waiting 状态(接口不返回金额字段)
- 0 注
- 全中奖
- 仅有浮动奖
- Mock 一致性(验收 #16):total_prize = 固定奖级 amount × count 求和
- 边界 case:边界数字、混合、固定 + 浮动
- format_amount 千分位 + 无空格(PRD v1.2 §3.2)
- PRIZE_AMOUNT / PRIZE_KEY_MAP 表结构(PRD v1.2 §4.3)
运行:
cd Lottery && python3 -m pytest tests/test_compare_prize.py -v
cd Lottery && python3 tests/test_compare_prize.py
"""
import os
import sys
import unittest
# 把项目目录加入 path,以便导入 app
HERE = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.dirname(HERE)
sys.path.insert(0, PROJECT_ROOT)
import app # noqa: E402
class TestConstants(unittest.TestCase):
"""PRD v1.2 §4.3:PRIZE_AMOUNT + PRIZE_KEY_MAP 双表结构验证"""
def test_prize_amount_keys(self):
"""PRIZE_AMOUNT 必须包含全部 7 个 key(含「未中奖」)"""
expected = {'一等奖', '二等奖', '三等奖', '四等奖', '五等奖', '六等奖', '未中奖'}
self.assertEqual(set(app.PRIZE_AMOUNT.keys()), expected)
def test_prize_amount_values_are_tuples(self):
"""每个 value 必须是 (amount: int, is_float: bool) 元组"""
for level, val in app.PRIZE_AMOUNT.items():
self.assertIsInstance(val, tuple, f'{level} value 必须是元组')
self.assertEqual(len(val), 2, f'{level} 元组必须是 2 元素')
amount, is_float = val
self.assertIsInstance(amount, int, f'{level} amount 必须是 int')
self.assertIsInstance(is_float, bool, f'{level} is_float 必须是 bool')
def test_variable_prize_markers(self):
"""一/二等奖必须标记为浮动奖(True)"""
self.assertTrue(app.PRIZE_AMOUNT['一等奖'][1])
self.assertTrue(app.PRIZE_AMOUNT['二等奖'][1])
def test_fixed_prize_markers(self):
"""三/四/五/六等奖 + 未中奖必须标记为固定(False)"""
for level in ('三等奖', '四等奖', '五等奖', '六等奖', '未中奖'):
self.assertFalse(app.PRIZE_AMOUNT[level][1], f'{level} 应为固定奖')
def test_fixed_prize_amounts_match_official(self):
"""三/四/五/六等奖官方金额"""
self.assertEqual(app.PRIZE_AMOUNT['三等奖'][0], 3000)
self.assertEqual(app.PRIZE_AMOUNT['四等奖'][0], 200)
self.assertEqual(app.PRIZE_AMOUNT['五等奖'][0], 10)
self.assertEqual(app.PRIZE_AMOUNT['六等奖'][0], 5)
def test_prize_key_map_covers_all_paid_levels(self):
"""PRIZE_KEY_MAP 必须覆盖 6 个有奖级(含一/二等奖)"""
expected = {'一等奖', '二等奖', '三等奖', '四等奖', '五等奖', '六等奖'}
self.assertEqual(set(app.PRIZE_KEY_MAP.keys()), expected)
def test_prize_key_map_values_unique(self):
"""PRIZE_KEY_MAP 的 value 必须唯一"""
values = list(app.PRIZE_KEY_MAP.values())
self.assertEqual(len(values), len(set(values)))
class TestFormatAmount(unittest.TestCase):
"""PRD v1.2 §3.2:千分位 + 「元」紧邻不空格"""
def test_thousands_separator(self):
self.assertEqual(app.format_amount(3000), '3,000元')
self.assertEqual(app.format_amount(5000000), '5,000,000元')
self.assertEqual(app.format_amount(200), '200元')
def test_no_space_before_yuan(self):
"""数字和「元」之间不能有空格(苏锦绘建议 #1)"""
result = app.format_amount(1234)
self.assertNotIn(' 元', result)
self.assertTrue(result.endswith('元'))
def test_zero(self):
self.assertEqual(app.format_amount(0), '0元')
def test_small_amounts(self):
self.assertEqual(app.format_amount(5), '5元')
self.assertEqual(app.format_amount(10), '10元')
def test_large_amounts(self):
self.assertEqual(app.format_amount(100000000), '100,000,000元')
class TestComputePrizeSummary(unittest.TestCase):
"""compute_prize_summary 主逻辑:覆盖 PRD 验收的所有边界 case"""
# ---------- 单奖档覆盖 ----------
def test_first_prize_only(self):
"""仅一等奖:total_prize=0(浮动),has_variable_prize=True"""
results = [{'prize_level': '一等奖'}] * 2
s = app.compute_prize_summary(results)
self.assertEqual(s['total_prize'], 0, '浮动奖不计入 total_prize')
self.assertTrue(s['has_variable_prize'])
self.assertEqual(s['prize_summary']['first']['count'], 2)
self.assertEqual(s['prize_display'], [], '浮动奖不进 prize_display')
def test_second_prize_only(self):
"""仅二等奖:同上逻辑"""
results = [{'prize_level': '二等奖'}] * 1
s = app.compute_prize_summary(results)
self.assertEqual(s['total_prize'], 0)
self.assertTrue(s['has_variable_prize'])
self.assertEqual(s['prize_summary']['second']['count'], 1)
def test_third_prize(self):
"""仅三等奖:3000 × N"""
results = [{'prize_level': '三等奖'}] * 2
s = app.compute_prize_summary(results)
self.assertEqual(s['total_prize'], 6000)
self.assertFalse(s['has_variable_prize'])
self.assertEqual(s['prize_summary']['third']['count'], 2)
self.assertEqual(s['prize_summary']['third']['amount'], 6000)
def test_fourth_prize(self):
"""仅四等奖:200 × N"""
results = [{'prize_level': '四等奖'}] * 5
s = app.compute_prize_summary(results)
self.assertEqual(s['total_prize'], 1000)
self.assertEqual(s['prize_summary']['fourth']['count'], 5)
def test_fifth_prize(self):
"""仅五等奖:10 × N"""
results = [{'prize_level': '五等奖'}] * 3
s = app.compute_prize_summary(results)
self.assertEqual(s['total_prize'], 30)
self.assertEqual(s['prize_summary']['fifth']['count'], 3)
def test_sixth_prize(self):
"""仅六等奖:5 × N"""
results = [{'prize_level': '六等奖'}] * 7
s = app.compute_prize_summary(results)
self.assertEqual(s['total_prize'], 35)
self.assertEqual(s['prize_summary']['sixth']['count'], 7)
# ---------- 未中奖 ----------
def test_no_win(self):
"""全部未中奖:total_prize=0,无 prize_summary"""
results = [{'prize_level': '未中奖'}] * 10
s = app.compute_prize_summary(results)
self.assertEqual(s['total_prize'], 0)
self.assertEqual(s['total_prize_display'], '0元')
self.assertFalse(s['has_variable_prize'])
self.assertEqual(s['prize_summary'], {})
self.assertEqual(s['prize_display'], [])
# ---------- 0 注 ----------
def test_zero_tickets(self):
"""0 注:空 results"""
s = app.compute_prize_summary([])
self.assertEqual(s['total_prize'], 0)
self.assertEqual(s['prize_summary'], {})
self.assertEqual(s['prize_display'], [])
self.assertFalse(s['has_variable_prize'])
# ---------- 全中奖 ----------
def test_all_win_levels(self):
"""全 6 档都有命中:固定奖全计,浮动奖仅标记"""
results = (
[{'prize_level': '一等奖'}] * 1 +
[{'prize_level': '二等奖'}] * 1 +
[{'prize_level': '三等奖'}] * 1 +
[{'prize_level': '四等奖'}] * 1 +
[{'prize_level': '五等奖'}] * 1 +
[{'prize_level': '六等奖'}] * 1
)
s = app.compute_prize_summary(results)
# total_prize = 3000 + 200 + 10 + 5 = 3215
self.assertEqual(s['total_prize'], 3215)
self.assertEqual(s['total_prize_display'], '3,215元')
self.assertTrue(s['has_variable_prize'])
# prize_display 应有 4 行(无浮动)
self.assertEqual(len(s['prize_display']), 4)
levels = [r['level'] for r in s['prize_display']]
self.assertNotIn('一等奖', levels)
self.assertNotIn('二等奖', levels)
# ---------- 仅浮动奖 ----------
def test_variable_prize_only(self):
"""仅命中浮动奖:total_prize=0,has_variable_prize=True"""
results = [{'prize_level': '一等奖'}] * 2 + [{'prize_level': '二等奖'}] * 1
s = app.compute_prize_summary(results)
self.assertEqual(s['total_prize'], 0)
self.assertEqual(s['total_prize_display'], '0元')
self.assertTrue(s['has_variable_prize'])
self.assertEqual(s['prize_summary']['first']['count'], 2)
self.assertEqual(s['prize_summary']['second']['count'], 1)
self.assertEqual(s['prize_display'], [])
# ---------- Mock 一致性(验收 #16) ----------
def test_mock_consistency_total_prize_matches_sum(self):
"""验收 #16:total_prize 必须等于所有固定奖级 amount × count 求和
PRD v1.2 Mock 数据笔误修正示例:
一等奖 1 注 + 五等奖 1 注 + 六等奖 1 注
一等奖浮动不计 → total_prize = 10 + 5 = 15 元
"""
results = (
[{'prize_level': '一等奖'}] * 1 +
[{'prize_level': '五等奖'}] * 1 +
[{'prize_level': '六等奖'}] * 1
)
s = app.compute_prize_summary(results)
# 手工核算固定奖
expected = 10 * 1 + 5 * 1
self.assertEqual(s['total_prize'], expected,
f'total_prize 应等于固定奖求和:{expected}')
self.assertEqual(s['total_prize'], 15)
def test_mock_prize_summary_amount_is_total_per_level(self):
"""prize_summary[x].amount 应是该奖级 count × 单注金额(汇总值)"""
results = [{'prize_level': '五等奖'}] * 5 + [{'prize_level': '六等奖'}] * 3
s = app.compute_prize_summary(results)
self.assertEqual(s['prize_summary']['fifth']['amount'], 50)
self.assertEqual(s['prize_summary']['sixth']['amount'], 15)
# total_prize 应等于各 level amount 之和(固定奖)
self.assertEqual(s['total_prize'], 65)
# ---------- 混合 + 边界 ----------
def test_mixed_with_lots_of_tickets(self):
"""大量混合注数"""
results = (
[{'prize_level': '六等奖'}] * 100 +
[{'prize_level': '五等奖'}] * 20 +
[{'prize_level': '四等奖'}] * 5 +
[{'prize_level': '三等奖'}] * 2 +
[{'prize_level': '二等奖'}] * 1
)
s = app.compute_prize_summary(results)
expected = 100 * 5 + 20 * 10 + 5 * 200 + 2 * 3000 # = 500 + 200 + 1000 + 6000 = 7700
self.assertEqual(s['total_prize'], expected)
self.assertEqual(s['total_prize_display'], '7,700元')
self.assertTrue(s['has_variable_prize'])
def test_prize_display_excludes_variable(self):
"""prize_display 一定不包含浮动奖级"""
results = [{'prize_level': '一等奖'}] * 3 + [{'prize_level': '五等奖'}] * 2
s = app.compute_prize_summary(results)
levels = {r['level'] for r in s['prize_display']}
self.assertNotIn('一等奖', levels)
self.assertNotIn('二等奖', levels)
self.assertIn('五等奖', levels)
def test_prize_display_amounts_are_formatted(self):
"""prize_display 内 amount 是千分位字符串(非 int)"""
results = [{'prize_level': '三等奖'}] * 1
s = app.compute_prize_summary(results)
self.assertEqual(len(s['prize_display']), 1)
self.assertEqual(s['prize_display'][0]['amount'], '3,000元')
def test_count_zero_excluded(self):
"""即使 prize_counts 含 count=0 也不进入 prize_summary"""
# 模拟异常:直接构造
results = [{'prize_level': '未中奖'}] * 5
s = app.compute_prize_summary(results)
# 未中奖已在 compute_prize_summary 里被显式跳过
self.assertEqual(s['prize_summary'], {})
class TestWaitingStateContract(unittest.TestCase):
"""waiting 状态:接口不返回金额字段(PRD v1.2 决策 #8)
这是契约测试:后端代码层面 waiting 分支直接 return jsonify,
不应包含 total_prize / prize_summary / prize_display / has_variable_prize。
"""
def test_waiting_branch_in_app_py(self):
"""app.py 中 waiting 分支必须不含金额字段"""
with open(os.path.join(PROJECT_ROOT, 'app.py'), 'r', encoding='utf-8') as f:
src = f.read()
# 找到 waiting 早返回
idx = src.find("compare_type == 'waiting'")
self.assertGreater(idx, 0, '未找到 waiting 分支')
# 找下一个 return jsonify 的右括号位置
ret_idx = src.find("'compare_rule': '生成时间 ≥ 开奖日 20:00", idx)
self.assertGreater(ret_idx, idx)
waiting_block = src[idx:ret_idx + 200]
# 不应包含新字段
for forbidden in ('total_prize', 'prize_summary', 'prize_display',
'has_variable_prize', 'prize_amount'):
self.assertNotIn(forbidden, waiting_block,
f'waiting 分支不应包含 {forbidden}')
class TestResponseShapeContract(unittest.TestCase):
"""响应结构契约:compared 分支必须包含所有新字段(向后兼容)"""
def test_compared_response_has_all_new_fields(self):
"""app.py 中 compared return dict 必须含 5 个新顶层字段"""
with open(os.path.join(PROJECT_ROOT, 'app.py'), 'r', encoding='utf-8') as f:
src = f.read()
# 找到 'status': 'compared' 块
idx = src.find("'status': 'compared'")
self.assertGreater(idx, 0)
# 取到下一个空行 + 下个函数(或本 return 结束)前的所有内容
# 用下一个 '}\n })' 形式判断 return jsonify 结束
ret_idx = src.find("'has_variable_prize': prize_info", idx)
self.assertGreater(ret_idx, idx)
# 取 has_variable_prize 之后 200 字符(足够覆盖 prize_display 行)
block = src[idx:ret_idx + 300]
for required in ('total_prize', 'total_prize_display',
'has_variable_prize', 'prize_summary', 'prize_display'):
self.assertIn(required, block,
f'compared 响应缺少 {required}')
def test_per_result_has_prize_amount(self):
"""每条 result 必须含 prize_amount + prize_amount_display"""
with open(os.path.join(PROJECT_ROOT, 'app.py'), 'r', encoding='utf-8') as f:
src = f.read()
idx = src.find("'prize_desc': prize_desc")
self.assertGreater(idx, 0)
block = src[idx:idx + 500]
self.assertIn('prize_amount', block)
self.assertIn('prize_amount_display', block)
if __name__ == '__main__':
unittest.main(verbosity=2)