Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf4d100e14 | |||
| e0c95a9fac | |||
| 2a759d655d | |||
| fccfd775a3 | |||
| 31cabbe044 | |||
| 462adcc424 | |||
| 5d459b4505 | |||
| e4e66f3826 | |||
| 4bfb9964db | |||
| 93295db357 | |||
| 52bee5f2e5 |
@@ -31,6 +31,100 @@ from lottery import DoubleColorBallGenerator
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# ============================================================
|
||||
# 中奖金额常量表(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,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 配置
|
||||
# ============================================================
|
||||
@@ -40,8 +134,8 @@ CONFIG = {
|
||||
'history_file': os.path.join(BASE_DIR, '双色球历史数据.xlsx'),
|
||||
'lottery_output_dir': os.path.join(BASE_DIR, 'lottery'),
|
||||
'records_file': os.path.join(BASE_DIR, '.generation_records.json'),
|
||||
'api_token': os.environ.get('LOTTO_API_TOKEN', 'lotto2026'),
|
||||
'auth_enabled': False,
|
||||
'api_token': os.environ.get('LOTTO_API_TOKEN'), # BIZ-108: 强制从 env 读取,删除默认 'lotto2026' 兜底
|
||||
'auth_enabled': True, # BIZ-108: 启用鉴权(默认 False 是安全漏洞)
|
||||
'max_tickets': 1000,
|
||||
'default_tickets': 10,
|
||||
# 数据抓取配置(原 web_executor.py 功能)
|
||||
@@ -50,6 +144,20 @@ CONFIG = {
|
||||
'fetch_timeout': 300, # 抓取超时秒数
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# BIZ-108 安全加固:启动期强制校验 LOTTO_API_TOKEN
|
||||
# ============================================================
|
||||
# 规则:auth_enabled=True 时,token 必须存在且长度 ≥ 32
|
||||
# 缺失或过短 → 启动失败(fail-fast),避免静默使用默认 token
|
||||
_API_TOKEN = CONFIG['api_token']
|
||||
if CONFIG['auth_enabled']:
|
||||
if not _API_TOKEN or len(_API_TOKEN) < 32:
|
||||
raise RuntimeError(
|
||||
"[BIZ-108] LOTTO_API_TOKEN 缺失或长度 < 32 字符。"
|
||||
"请在 /etc/lotto/env 配置强随机 token(建议 64 字符)。"
|
||||
"生成命令: openssl rand -base64 48 | tr -d '\\n=' | cut -c1-64"
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# 生成记录管理(线程安全)
|
||||
# ============================================================
|
||||
@@ -117,93 +225,90 @@ def load_history_dataframe():
|
||||
return _load_history(CONFIG['history_file'])
|
||||
|
||||
|
||||
def load_latest_draw():
|
||||
"""加载最近一期开奖数据,返回 dict 或 None。"""
|
||||
def _row_to_draw(row):
|
||||
"""将 DataFrame 行转换为开奖数据 dict(公共函数)。
|
||||
|
||||
供 find_draw_by_gen_time 及其他需要解析开奖记录的函数统一调用。
|
||||
"""
|
||||
s = str(row.get('号码', '')).strip()
|
||||
reds, blue = parse_number_string(s)
|
||||
draw_date_str = str(row.get('开奖时间', '')).strip()
|
||||
draw_date = None
|
||||
try:
|
||||
draw_date = datetime.strptime(draw_date_str[:10], '%Y-%m-%d')
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
dt = pd.to_datetime(draw_date_str, errors='coerce')
|
||||
if pd.notna(dt):
|
||||
draw_date = dt.to_pydatetime()
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
'date': draw_date_str,
|
||||
'date_obj': draw_date,
|
||||
'period': str(row.get('期数', '')).strip(),
|
||||
'reds': reds,
|
||||
'blue': blue,
|
||||
'raw': {k: str(row.get(k, '')).strip() for k in ['开奖时间', '期数', '号码', '和值特征', '奇偶比', '大小比', '跨度']}
|
||||
}
|
||||
|
||||
|
||||
def find_draw_by_gen_time(gen_time):
|
||||
"""根据生成时间找到归属的开奖期。
|
||||
|
||||
比对规则(窗口归属模型):
|
||||
- 每个开奖日 D 有一个归属窗口 (D_prev 20:00, D 20:00)
|
||||
- 生成时间 T 落在哪个窗口内 → 与该开奖日 D 比对
|
||||
- 按开奖日升序遍历,找到第一个 D 使得 T < D 20:00
|
||||
- 如果 T 同日且 T < 20:00 → 同日比对(规则 2.1),compare_type='same_day'
|
||||
- 如果 T 在 D 之前某日 → 仍归属 D(窗口归属),compare_type='cross_day'
|
||||
- 如果 T 同日且 T ≥ 20:00 → 归属下一个开奖日(规则 2.2)
|
||||
- 如果 T 超过所有开奖日的 20:00 → 等待下一期,compare_type='waiting'
|
||||
|
||||
返回 (draw_dict, compare_type) 或 (None, 'error')
|
||||
"""
|
||||
try:
|
||||
df = load_history_dataframe()
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
return None, 'no_data'
|
||||
|
||||
# 解析日期,按开奖时间降序找最近一期
|
||||
df['_dt'] = pd.to_datetime(df['开奖时间'], errors='coerce')
|
||||
df = df.sort_values('_dt', ascending=False).reset_index(drop=True)
|
||||
df = df.dropna(subset=['_dt']).sort_values('_dt', ascending=True).reset_index(drop=True)
|
||||
|
||||
if df.empty:
|
||||
return None
|
||||
return None, 'no_data'
|
||||
|
||||
row = df.iloc[0]
|
||||
s = str(row.get('号码', '')).strip()
|
||||
reds, blue = parse_number_string(s)
|
||||
# 遍历所有开奖日(升序),找到第一个 D 使得 gen_time < D 20:00
|
||||
for i in range(len(df)):
|
||||
draw_date = df.iloc[i]['_dt']
|
||||
draw_8pm = draw_date.replace(hour=20, minute=0, second=0, microsecond=0)
|
||||
|
||||
draw_date_str = str(row.get('开奖时间', '')).strip()
|
||||
draw_date = None
|
||||
try:
|
||||
draw_date = datetime.strptime(draw_date_str[:10], '%Y-%m-%d')
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
dt = pd.to_datetime(draw_date_str, errors='coerce')
|
||||
if pd.notna(dt):
|
||||
draw_date = dt.to_pydatetime()
|
||||
except Exception:
|
||||
pass
|
||||
if gen_time < draw_8pm:
|
||||
# gen_time 落在 D 的窗口内 → 与 D 比对
|
||||
draw = _row_to_draw(df.iloc[i])
|
||||
|
||||
return {
|
||||
'date': draw_date_str,
|
||||
'date_obj': draw_date,
|
||||
'period': str(row.get('期数', '')).strip(),
|
||||
'reds': reds,
|
||||
'blue': blue,
|
||||
'raw': {k: str(row.get(k, '')).strip() for k in ['开奖时间', '期数', '号码', '和值特征', '奇偶比', '大小比', '跨度']}
|
||||
}
|
||||
# 判断是同日还是跨日窗口归属
|
||||
if gen_time.date() == draw_date.date():
|
||||
compare_type = 'same_day' # 同日,20:00 前
|
||||
else:
|
||||
compare_type = 'cross_day' # 跨日窗口归属
|
||||
|
||||
return draw, compare_type
|
||||
|
||||
# gen_time 超过所有开奖日的 20:00 → 等待下一期
|
||||
last_draw = _row_to_draw(df.iloc[-1])
|
||||
return last_draw, 'waiting'
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
|
||||
def find_next_draw_after(date_obj):
|
||||
"""查找给定日期之后最近一期的开奖数据,返回 dict 或 None。"""
|
||||
try:
|
||||
df = load_history_dataframe()
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
|
||||
df['_dt'] = pd.to_datetime(df['开奖时间'], errors='coerce')
|
||||
# 找开奖日期 > date_obj 的最近一期
|
||||
future = df[df['_dt'] > date_obj].sort_values('_dt', ascending=True)
|
||||
if future.empty:
|
||||
return None
|
||||
|
||||
row = future.iloc[0]
|
||||
s = str(row.get('号码', '')).strip()
|
||||
reds, blue = parse_number_string(s)
|
||||
|
||||
draw_date_str = str(row.get('开奖时间', '')).strip()
|
||||
draw_date = None
|
||||
try:
|
||||
draw_date = datetime.strptime(draw_date_str[:10], '%Y-%m-%d')
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
dt = pd.to_datetime(draw_date_str, errors='coerce')
|
||||
if pd.notna(dt):
|
||||
draw_date = dt.to_pydatetime()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
'date': draw_date_str,
|
||||
'date_obj': draw_date,
|
||||
'period': str(row.get('期数', '')).strip(),
|
||||
'reds': reds,
|
||||
'blue': blue,
|
||||
'raw': {k: str(row.get(k, '')).strip() for k in ['开奖时间', '期数', '号码', '和值特征', '奇偶比', '大小比', '跨度']}
|
||||
}
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return None
|
||||
return None, 'error'
|
||||
|
||||
# ============================================================
|
||||
# 认证装饰器(可选)
|
||||
# 认证装饰器(严格鉴权 — BIZ-110 闭环后恢复)
|
||||
# ============================================================
|
||||
# 2026-09-02 by 严维序 (opengineer)
|
||||
# BIZ-110 前端鉴权适配完成后,撤回 BIZ-109 临时白名单(AUTH_BYPASS_* / _is_bypass)。
|
||||
# index.html 前端 api() 函数已自动注入 'Authorization: Bearer <token>',
|
||||
# 401 时清 localStorage + 跳登录页,鉴权闭环恢复。
|
||||
def require_auth(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
@@ -510,48 +615,29 @@ def api_compare_record(record_id):
|
||||
if not tickets:
|
||||
return jsonify({'success': False, 'error': '无号码数据可比对'}), 400
|
||||
|
||||
# 加载历史开奖数据,找最近一期
|
||||
latest_draw = load_latest_draw()
|
||||
if not latest_draw or not latest_draw.get('reds'):
|
||||
# 根据生成时间找到归属的开奖期
|
||||
# 规则:生成时间 < 开奖日 20:00 → 与当日开奖比对;>= 20:00 → 与下次开奖日比对
|
||||
draw_to_compare, compare_type = find_draw_by_gen_time(gen_time)
|
||||
|
||||
if draw_to_compare is None:
|
||||
return jsonify({'success': False, 'error': '无法加载开奖数据'}), 500
|
||||
|
||||
# 确定比对的开奖期
|
||||
# 规则:生成时间 < 开奖日 20:00 → 与当日开奖比对;>= 20:00 → 与下次开奖日比对
|
||||
draw_to_compare = None
|
||||
compare_type = ''
|
||||
if not draw_to_compare.get('reds'):
|
||||
return jsonify({'success': False, 'error': '开奖数据号码解析失败'}), 500
|
||||
|
||||
latest_date = latest_draw.get('date_obj')
|
||||
if latest_date:
|
||||
# 当日 20:00 的时间点
|
||||
draw_day_8pm = latest_date.replace(hour=20, minute=0, second=0, microsecond=0)
|
||||
|
||||
if gen_time < draw_day_8pm:
|
||||
# 生成时间在开奖日 20:00 之前 → 与当日(最近一期)比对
|
||||
draw_to_compare = latest_draw
|
||||
compare_type = 'current'
|
||||
else:
|
||||
# 生成时间在开奖日 20:00 之后 → 与下次开奖日比对
|
||||
next_draw = find_next_draw_after(latest_date)
|
||||
if next_draw:
|
||||
draw_to_compare = next_draw
|
||||
compare_type = 'next'
|
||||
else:
|
||||
# 暂无下次开奖数据,返回等待状态
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'status': 'waiting',
|
||||
'message': f'生成时间 {gen_time_str} 在最近开奖日 {latest_draw["date"]} 20:00 之后,下次开奖尚未举行,请等待开奖后再次比对。',
|
||||
'gen_time': gen_time_str,
|
||||
'latest_draw_date': latest_draw['date'],
|
||||
'latest_draw_period': latest_draw['period'],
|
||||
'compare_rule': '生成时间 ≥ 开奖日 20:00 → 等待下次开奖日比对'
|
||||
}
|
||||
})
|
||||
else:
|
||||
# 无法解析开奖日期,直接用最近一期
|
||||
draw_to_compare = latest_draw
|
||||
compare_type = 'current_fallback'
|
||||
if compare_type == 'waiting':
|
||||
# 生成时间超过所有已记录开奖日 → 等待下一期
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'status': 'waiting',
|
||||
'message': f'生成时间 {gen_time_str} 超过最近开奖日 {draw_to_compare["date"]} 20:00,下次开奖尚未举行,请等待开奖后再次比对。',
|
||||
'gen_time': gen_time_str,
|
||||
'latest_draw_date': draw_to_compare['date'],
|
||||
'latest_draw_period': draw_to_compare['period'],
|
||||
'compare_rule': '生成时间 ≥ 开奖日 20:00 → 等待下次开奖日比对'
|
||||
}
|
||||
})
|
||||
|
||||
# 执行比对
|
||||
draw_reds = set(draw_to_compare['reds'])
|
||||
@@ -588,6 +674,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'],
|
||||
@@ -596,18 +685,23 @@ 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': {
|
||||
'status': 'compared',
|
||||
'gen_time': gen_time_str,
|
||||
'compare_type': compare_type,
|
||||
'compare_rule': '生成时间 < 开奖日 20:00 → 与当日开奖比对' if compare_type == 'current' else '生成时间 ≥ 开奖日 20:00 → 与下次开奖日比对',
|
||||
'compare_rule': f'生成时间 {gen_time_str} 归属开奖期 {draw_to_compare["period"]}({draw_to_compare["date"]})',
|
||||
'draw': {
|
||||
'date': draw_to_compare['date'],
|
||||
'period': draw_to_compare['period'],
|
||||
@@ -617,7 +711,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:
|
||||
|
||||
@@ -224,3 +224,83 @@ curl http://127.0.0.1:8085/api/status
|
||||
---
|
||||
|
||||
> 部署人:严维序 (opengineer) | 2026-07-04
|
||||
|
||||
---
|
||||
|
||||
## BIZ-105 部署记录 — 2026-08-31
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 部署时间 | 2026-08-31 05:30 (Asia/Shanghai) |
|
||||
| 部署人员 | 严维序 (opengineer) |
|
||||
| 任务来源 | COO 陆怀瑾 sessions_send(父 BIZ-106 子任务3) |
|
||||
| 代码版本 | commit 5d459b450525063ecdbbfb5694aff30aba5fb0bf |
|
||||
| 分支 | biz-105-impl-prize-display |
|
||||
| PRD | BIZ-102 v1.1 + 中奖金额展示 v1.2 §4.3 |
|
||||
| 改动范围 | app.py +110 / index.html +89 / 新增 tests/test_compare_prize.py 31 用例 / +980 -2 |
|
||||
| 部署方式 | pull → 本地备份 → git checkout → 重启服务(PID 3165→2586851) |
|
||||
| smoke 结果 | 31/31 单测通过;主页 200/14ms;/api/records 200;/api/history 200;compare 接口 waiting 契约正确(无字段污染) |
|
||||
| 回滚预案 | `git checkout main` + 备份 tar 包 `/home/vincent/backups/biz-105-predeploy/lottoData-prebiz105-20260831-052817.tar.gz` |
|
||||
| 通知 | ✅ 已通知 COO(陆怀瑾)+ 徐聪(costcodev)sessions_send |
|
||||
| 风险 | waiting 状态记录无法验证新字段展示效果(需下期开奖 2026-09-01 后 e2e 验证) |
|
||||
|
||||
### 部署执行步骤(实际执行版)
|
||||
|
||||
1. **备份**:tar 打包 app.py/index.html/lottery.py/... → `/home/vincent/backups/biz-105-predeploy/`
|
||||
2. **stash 本地修改**:`git stash push -m "biz-105-predeploy-local-changes-20260831"`
|
||||
3. **checkout 分支**:`git checkout biz-105-impl-prize-display`(HEAD = 5d459b4)
|
||||
4. **停止服务**:`kill 3165`(SIGTERM 优雅停止,3s 内)
|
||||
5. **启动新版本**:`nohup ./.venv/bin/python3 ./app.py > /tmp/lotto-biz105-YYYYMMDD-HHMMSS.log 2>&1 &`
|
||||
6. **smoke 验证**:31 单测 + HTTP 200 三接口 + waiting 契约
|
||||
7. **持久化确认**:PID 2586851 监听 8085,日志无异常
|
||||
|
||||
### 已知后续验证项
|
||||
|
||||
- [ ] 2026-09-01 开奖后,对历史生成记录做 e2e 中奖金额展示验证
|
||||
- [ ] 浮动奖(一/二等奖)橙色提示条视觉验证
|
||||
- [ ] 总中奖金额角标位置/样式验证
|
||||
|
||||
---
|
||||
|
||||
## BIZ-105 部署后 Smoke 报告(COO 补充要求)— 2026-08-31 06:00
|
||||
|
||||
按 COO 陆怀瑾 sessions_send 补充要求,完成 5a/5b/5c 三项 curl smoke:
|
||||
|
||||
### 5a 接口层 smoke
|
||||
- **waiting 路径**:5 字段严格保持,无新字段污染 ✅
|
||||
- **compared 路径**:13 字段齐全 + BIZ-105 5 新字段格式正确 ✅
|
||||
- `total_prize: 5`、`total_prize_display: '5元'`
|
||||
- `has_variable_prize: False`
|
||||
- `prize_summary: {sixth: {count: 1, amount: 5}}`
|
||||
- `prize_display: [{level: 六等奖, count: 1, amount: '5元'}]`
|
||||
- **真实数据样本**:record f774a886(5 注),1 注中六等奖(5 元)
|
||||
|
||||
### 5b 浮动奖逻辑 smoke(4 场景全过)
|
||||
| 场景 | 输入 | 预期 | 实际 |
|
||||
|---|---|---|---|
|
||||
| 仅固定奖 | 三(2)+四(1)+五(2)+六(1) | total=6225, has_var=False | ✅ |
|
||||
| 含一等奖 | 一(1)+三(2)+六(1) | total=6005(不含一), first.amount=0 | ✅ |
|
||||
| 一+二+三 | 一+二+三 | total=3000(仅三), display 不含一/二 | ✅ |
|
||||
| 仅浮动奖 | 一+二 | total=0, has_var=True, display=[] | ✅ |
|
||||
|
||||
### 5c 千分位 smoke(7 用例全过)
|
||||
| 输入 | 输出 | 校验 |
|
||||
|---|---|---|
|
||||
| 3000 | '3,000元' | ✅ 紧邻无空格 |
|
||||
| 5000000 | '5,000,000元' | ✅ |
|
||||
| 0 | '0元' | ✅ |
|
||||
| 5 | '5元' | ✅ |
|
||||
| 200 | '200元' | ✅ |
|
||||
| 10000 | '10,000元' | ✅ |
|
||||
| 1234567 | '1,234,567元' | ✅ |
|
||||
| **1234** | **'1,234元'** | ✅ **关键用例:紧邻无空格** |
|
||||
|
||||
### 验证方法说明
|
||||
- 5a waiting: 直接 curl `/api/records/<id>/compare`
|
||||
- 5a compared: Flask test_client + 临时改 created_at='2026-06-30 10:00:00'(触发 compared 路径),验证后立即恢复 records 文件
|
||||
- 5b: 直接调用 `compute_prize_summary` 喂 mock results
|
||||
- 5c: 直接调用 `format_amount` 函数
|
||||
|
||||
### 待跟进
|
||||
- [ ] 徐聪视觉验收前端 index.html(橙色浮动奖角标 + badge + 汇总表样式)
|
||||
- [ ] 2026-09-01 开奖后真实数据 e2e(含真实中奖金额展示)
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
# BIZ-127 完整用户系统架构设计 v1.0
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| **版本** | v1.0 |
|
||||
| **作者** | 梁思筑(architect) |
|
||||
| **日期** | 2026-09-04 |
|
||||
| **关联卡片** | BIZ-127(架构)→ BIZ-129(实施) |
|
||||
| **父卡片** | BIZ-109(P0 修复闭环) |
|
||||
| **关联 PRD** | BIZ-111(产品 PRD) |
|
||||
| **存储位置** | docs/BIZ-127_完整用户系统架构_v1.0.md |
|
||||
| **同步位置** | 思源知识库 `/standards/完整用户系统架构 v1.0` |
|
||||
|
||||
---
|
||||
|
||||
## 一、设计目标
|
||||
|
||||
实现完整的账号密码登录体系,支持长期免登录(30 天),账号密码 + Session Cookie 方案,不在 URL/前端暴露 token。
|
||||
|
||||
---
|
||||
|
||||
## 二、数据模型
|
||||
|
||||
**完整 SQL DDL** 见 `docs/auth-schema.sql`(9.7KB)。
|
||||
|
||||
包含 5 张表:
|
||||
1. **users** —— 用户表(bcrypt 密码哈希、登录失败计数、锁定时间戳、多租户/角色预留)
|
||||
2. **sessions** —— 服务端会话(DB 存,非 JWT;存 session_hash、password_epoch 用于改密失效)
|
||||
3. **password_reset_tokens** —— 密码重置 token(一次性、1 小时过期、限速 3 次/小时)
|
||||
4. **operation_logs** —— 操作日志(90 天保留、多租户索引预留)
|
||||
5. **auth_settings** —— 全局鉴权配置(bcrypt cost、session 寿命、锁定参数)
|
||||
|
||||
### 关键决策
|
||||
|
||||
| 决策点 | 选择 | 理由 |
|
||||
|--------|------|------|
|
||||
| Session 存储 | **DB 存** | 可控失效、改密立即踢人;JWT 不适合内网场景 |
|
||||
| 重置 token 存储 | **DB 存** | 一次性、可控、可限速;JWT 自包含不可撤销 |
|
||||
| 密码哈希 | **bcrypt cost=12** | 行业标准,登录耗时 ~250ms,安全/性能平衡 |
|
||||
| 多用户扩展 | **数据层预留 + UI 不暴露** | 见下文"多用户扩展决策" |
|
||||
|
||||
---
|
||||
|
||||
## 三、鉴权中间件流程图
|
||||
|
||||
```
|
||||
请求进入
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ 日志中间件 │ ← 记录 method/path/IP/UA
|
||||
│ (log_request) │
|
||||
└──────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ 鉴权中间件 │ ← 优先读 cookie,fallback Bearer
|
||||
│ (authenticate) │ Bearer 仅用于内部服务调用
|
||||
└──────────┬───────────┘
|
||||
│
|
||||
│ 判断:path 是否在白名单?
|
||||
│
|
||||
┌─────┴──────┐
|
||||
│ │
|
||||
YES (白名单) NO
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌──────────────────────┐
|
||||
│ │ require_auth 装饰器 │ ← 验证 session_id
|
||||
│ │ - 查 sessions 表 │ 校验:
|
||||
│ │ - 校验 session_hash│ 1. session 存在
|
||||
│ │ - 校验 expires_at │ 2. 未撤销
|
||||
│ │ - 校验密码 epoch │ 3. 未过期
|
||||
│ │ - 续期(如活跃) │ 4. password_epoch 匹配
|
||||
│ └──────────┬───────────┘
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌──────────────────────┐
|
||||
│ │ 业务中间件 │
|
||||
│ │ (业务逻辑处理) │
|
||||
│ └──────────┬───────────┘
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌──────────────────────┐
|
||||
│ │ 日志中间件(出口) │ ← 记录 status/duration/user_id
|
||||
│ └──────────────────────┘
|
||||
│
|
||||
└──▶ 业务中间件(白名单接口无需鉴权)
|
||||
```
|
||||
|
||||
### 5 个鉴权端点
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/login` | POST | 账号密码 → 校验 → 创建 session → 设置 cookie |
|
||||
| `/api/logout` | POST | 清 cookie → 撤销 session(revoked_at=now) |
|
||||
| `/api/password-reset/request` | POST | 生成 token → 邮件/日志通知(内网暂用日志) |
|
||||
| `/api/password-reset/confirm` | POST | 校验 token → 改密码 → 撤销该用户所有 session |
|
||||
|
||||
### 装饰器改造
|
||||
|
||||
```python
|
||||
# 现状
|
||||
@require_auth
|
||||
def api_records(): ...
|
||||
|
||||
# 改造后(伪代码,BIZ-129 实施)
|
||||
@require_auth # 内部自动读 cookie 或 Bearer
|
||||
def api_records(): ...
|
||||
|
||||
# 新增装饰器
|
||||
@require_admin # role == 'admin' 才允许
|
||||
def admin_only_api(): ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、Cookie 安全策略
|
||||
|
||||
| 项 | 值 | 决策依据 |
|
||||
|---|----|---------|
|
||||
| **名称** | `lotto_session` | 业务前缀,避免与其它服务冲突 |
|
||||
| **httpOnly** | `true` | ✅ 阻止 XSS 读取 cookie |
|
||||
| **secure** | `true`(生产) / `false`(内网 HTTP 调试) | 内网暂不强制 HTTPS,但配置项保留 |
|
||||
| **sameSite** | `Lax` | ✅ 防 CSRF;`Strict` 会破坏外链跳转登录 |
|
||||
| **path** | `/` | 全站生效 |
|
||||
| **maxAge** | `30 * 24 * 3600` 秒(30 天) | 满足"长期免登录"诉求 |
|
||||
| **domain** | 当前域(不设) | 内网单域,不跨子域 |
|
||||
|
||||
### 防 CSRF 决策
|
||||
|
||||
**不单独引入 CSRF token**,理由:
|
||||
- `SameSite=Lax` 已防 95% CSRF(POST 表单跨站不携带 cookie)
|
||||
- 关键操作(删除、改密码)二次校验(要求输入当前密码或确认)
|
||||
- 如未来扩展第三方嵌入或 iframe 调用,再加 CSRF token
|
||||
|
||||
### Session 续期策略
|
||||
|
||||
- 用户每次活跃请求,若 `expires_at - now < 24h`,则续期 30 天
|
||||
- `last_seen_at` 用于清理过期 session
|
||||
|
||||
---
|
||||
|
||||
## 五、多用户扩展决策(4 个判断点)
|
||||
|
||||
| 判断点 | 是否预留 | 决策 |
|
||||
|--------|---------|------|
|
||||
| 1. 数据库 `users.tenant_id` / `role` 字段 | **✅ 预留** | 已建字段,索引就绪,单租户默认 1 |
|
||||
| 2. API 路径 `/api/admin/users/*` | **❌ 不预留** | 过度设计;未来需要时再加,无需现在占坑 |
|
||||
| 3. 前端「多用户切换」入口 | **❌ 不预留** | UI 保持单用户登录,多用户场景届时再迭代 |
|
||||
| 4. 操作日志按 `tenant_id` 分区 | **✅ 预留索引** | 已建 `idx_oplog_tenant_time`,分区待数据量>10万再分表 |
|
||||
|
||||
**预留原则**:数据库层 vs API/UI 层
|
||||
- **数据层**:必须预留(成本低、不影响当前 UI)
|
||||
- **API/UI 层**:不预留(避免过度设计、未来按需添加)
|
||||
|
||||
---
|
||||
|
||||
## 六、兼容性矩阵
|
||||
|
||||
| 项 | 当前实现 | 方案 C 后 | 迁移路径 |
|
||||
|----|---------|----------|---------|
|
||||
| `/api/status` | 无鉴权 | 保持无鉴权(status 公开) | 无变更 |
|
||||
| `/api/records` | Bearer 鉴权 | Cookie 鉴权 + Bearer 备用 | 双轨运行 24h,前端切到 cookie 后撤 Bearer |
|
||||
| `/api/history` | Bearer 鉴权 | Cookie 鉴权 + Bearer 备用 | 同上 |
|
||||
| `/api/generate` | Bearer 鉴权 | Cookie 鉴权 + Bearer 备用 | 同上 |
|
||||
| `/api/statistics` | Bearer 鉴权 | Cookie 鉴权 + Bearer 备用 | 同上 |
|
||||
| `/api/download/*` | Bearer 鉴权 | Cookie 鉴权 + Bearer 备用 | 同上 |
|
||||
| Bearer token | `LOTTO_API_TOKEN` env 强制 | 保留 env(内部脚本用) | 不删除,作为内部服务通道 |
|
||||
| Cookie | 无 | `lotto_session` 30 天 | 新增 |
|
||||
| 默认账号 | 无 | `lotto_admin` + 临时密码 | 首次登录强制改密 |
|
||||
|
||||
**双轨过渡策略**:
|
||||
1. 上线 Cookie 鉴权后,前端自动改用 cookie
|
||||
2. Bearer 通道保留 24h 给内部脚本迁移
|
||||
3. 24h 后仅允许 Bearer 用于 `/api/internal/*`(如有)
|
||||
4. 监控 `/api/records` 等接口的鉴权失败日志,确认为 0 后撤 Bearer
|
||||
|
||||
---
|
||||
|
||||
## 七、性能 + 安全边界(5 项决策)
|
||||
|
||||
| 项 | 决策 | 理由 |
|
||||
|----|------|------|
|
||||
| 1. bcrypt cost=12 登录耗时 | **目标 < 500ms(实测 ~250ms)** | Ubuntu 22.04 + bcrypt cost=12 单次 ~250ms,远低于 500ms 上限 |
|
||||
| 2. Session 失效机制(改密) | **改密码后该用户所有 session 立即失效** | session 表 `password_epoch` 字段;登录时快照,改密后 `password_changed_at` 更新,session 自动失效 |
|
||||
| 3. 找回密码 token 速率限制 | **每用户 3 次/小时** | 防爆破;超限返回 429 |
|
||||
| 4. 登录失败锁定 | **5 次失败锁定 15 分钟** | `users.failed_attempts` + `locked_until`;自动解锁 |
|
||||
| 5. 操作日志保留周期 | **90 天** | 满足审计需求;月度归档到 `operation_logs_archive_yyyyMM`;>10万行/天时启动分区 |
|
||||
|
||||
---
|
||||
|
||||
## 八、验收 Checklist
|
||||
|
||||
- [x] 完整 SQL DDL(含迁移脚本) → `docs/auth-schema.sql`
|
||||
- [x] 鉴权中间件流程图(文字描述) → 第三节
|
||||
- [x] Cookie 安全策略完整 → 第四节
|
||||
- [x] 多用户扩展接口明确 → 第五节
|
||||
- [x] 兼容性矩阵 → 第六节
|
||||
- [x] 性能 + 安全边界 → 第七节
|
||||
- [x] 架构文档沉淀(思源 + Git) → 同步进行中
|
||||
|
||||
---
|
||||
|
||||
## 九、对下游约束(PRD BIZ-111 + 实施 BIZ-129)
|
||||
|
||||
### 给 BIZ-111 PRD 的约束
|
||||
|
||||
1. **登录页**必须有:用户名、密码、"记住我"勾选(默认勾选=30天免登录)
|
||||
2. **找回密码流程**:输入用户名 → 后台生成 token → 通过日志/邮件通知(内网暂用日志)
|
||||
3. **首次登录强制改密**:检测 `password_changed_at == created_at` 时,强制跳改密页
|
||||
4. **UI 不暴露**多用户切换、注册入口(保持单账号)
|
||||
|
||||
### 给 BIZ-129 实施的约束
|
||||
|
||||
1. **必须用 bcrypt cost=12**,不要用 sha256/md5/pbkdf2
|
||||
2. **session_id 必须 32 字节 URL-safe base64**(`secrets.token_urlsafe(32)`)
|
||||
3. **session_hash = sha256(session_id + server_secret)**,secret 存 env
|
||||
4. **不改 LOTTO_API_TOKEN 流程**,保留 Bearer 作为内部通道
|
||||
5. **/api/status 必须保持公开**,不能误加鉴权
|
||||
6. **BIZ-109 严格鉴权基线不能回退**(不能因为 Cookie 引入新的 401 路径)
|
||||
|
||||
---
|
||||
|
||||
## 十、风险与待澄清
|
||||
|
||||
| 风险 | 影响 | 应对 |
|
||||
|------|------|------|
|
||||
| 首次部署 bcrypt 哈希生成失败 | 登录失败 | BIZ-129 部署前在本地预生成哈希嵌入 init SQL |
|
||||
| Cookie 30 天过长,安全边界争议 | 安全 | `auth_settings.session_max_age_sec` 运行时可调 |
|
||||
| 多用户预留字段被误用 | 数据污染 | 在 BIZ-129 代码层加 `tenant_id == 1` 断言 |
|
||||
| 操作日志 90 天后未归档 | 磁盘膨胀 | BIZ-129 实施时同时交付归档脚本 |
|
||||
|
||||
---
|
||||
|
||||
## 十一、变更历史
|
||||
|
||||
| 版本 | 日期 | 变更 | 作者 |
|
||||
|------|------|------|------|
|
||||
| v1.0 | 2026-09-04 | 初版(BIZ-127 架构交付) | 梁思筑 |
|
||||
@@ -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]` 两处粒度;④ 验收标准补充兼容性「每次实时计算」条款 |
|
||||
@@ -0,0 +1,170 @@
|
||||
-- ============================================================
|
||||
-- BIZ-127 方案 C 完整用户系统 —— 数据模型 SQL DDL
|
||||
-- ============================================================
|
||||
-- 设计人:梁思筑(architect)| 2026-09-04
|
||||
-- 适用版本:lottoData v2.0(Flask + SQLite)
|
||||
-- 数据库:SQLite 3.x(与现有 lottery.db 同库同文件,复用 DB 连接)
|
||||
-- 兼容性:当前 SQLite 文件存储(.generation_records.json)将迁移至本 schema
|
||||
-- 字符集:UTF-8
|
||||
-- 加密策略:bcrypt(cost=12) 密码哈希、session 哈希、不存任何明文凭证
|
||||
-- ============================================================
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 1. users —— 用户表
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL, -- bcrypt cost=12 哈希
|
||||
email TEXT, -- 可选,找回密码用
|
||||
role TEXT NOT NULL DEFAULT 'user' -- 预留:admin / user
|
||||
CHECK (role IN ('user', 'admin')),
|
||||
status TEXT NOT NULL DEFAULT 'active' -- active / locked / disabled
|
||||
CHECK (status IN ('active', 'locked', 'disabled')),
|
||||
tenant_id INTEGER NOT NULL DEFAULT 1, -- 多租户预留,单租户默认 1
|
||||
failed_attempts INTEGER NOT NULL DEFAULT 0, -- 登录失败计数(达到 5 锁定)
|
||||
locked_until INTEGER, -- Unix 时间戳,NULL=未锁
|
||||
password_changed_at INTEGER NOT NULL, -- 用于旧 session 失效判断
|
||||
last_login_at INTEGER, -- 最近登录时间
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
-- 多用户扩展索引
|
||||
CONSTRAINT idx_users_username UNIQUE (username),
|
||||
CONSTRAINT idx_users_tenant UNIQUE (username, tenant_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_role_status ON users(role, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 2. sessions —— 会话表(服务端 session,cookie 仅存 session_id)
|
||||
-- ------------------------------------------------------------
|
||||
-- 决策:DB 存 session(不用 JWT),理由:
|
||||
-- - 可控失效(改密码立即清空)
|
||||
-- - 可主动踢人
|
||||
-- - 内网系统不需分布式无状态
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY, -- 32 字节 URL-safe base64
|
||||
user_id INTEGER NOT NULL,
|
||||
session_hash TEXT NOT NULL, -- sha256(session_id + server_secret)
|
||||
user_agent TEXT, -- 浏览器/客户端 UA
|
||||
ip_address TEXT, -- 登录 IP(内网)
|
||||
password_epoch INTEGER NOT NULL, -- 登录时 users.password_changed_at 快照
|
||||
expires_at INTEGER NOT NULL, -- Unix 时间戳(30 天)
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen_at INTEGER NOT NULL, -- 用于过期清理
|
||||
revoked_at INTEGER, -- NULL=有效,非 NULL=已撤销
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_active ON sessions(user_id, revoked_at, expires_at);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 3. password_reset_tokens —— 密码重置 token 表
|
||||
-- ------------------------------------------------------------
|
||||
-- 决策:DB 存一次性 token(不用 JWT 自包含),理由:
|
||||
-- - 一次性、可控、可撤销
|
||||
-- - 速率限制基于 DB 计数
|
||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
token_hash TEXT PRIMARY KEY, -- sha256(明文 token)
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL, -- 1 小时过期
|
||||
used_at INTEGER, -- NULL=未用
|
||||
ip_address TEXT, -- 请求来源 IP
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pwd_reset_user ON password_reset_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pwd_reset_expires ON password_reset_tokens(expires_at);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 4. operation_logs —— 操作日志表
|
||||
-- ------------------------------------------------------------
|
||||
-- 用途:审计、追踪、异常告警
|
||||
-- 保留周期:90 天(按月度手动归档)
|
||||
-- 未来扩展:按 tenant_id 分区(本次单租户,分区预留)
|
||||
CREATE TABLE IF NOT EXISTS operation_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER, -- 公开操作可 NULL
|
||||
tenant_id INTEGER NOT NULL DEFAULT 1, -- 多租户预留
|
||||
username TEXT, -- 冗余存储,避免 JOIN
|
||||
action TEXT NOT NULL, -- login / logout / generate / delete / reset_request ...
|
||||
resource_type TEXT, -- record / config / user ...
|
||||
resource_id TEXT, -- 关联 ID
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'success' -- success / failure
|
||||
CHECK (status IN ('success', 'failure')),
|
||||
detail TEXT, -- JSON 字符串,存额外上下文
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oplog_user_time ON operation_logs(user_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_oplog_tenant_time ON operation_logs(tenant_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_oplog_action ON operation_logs(action, created_at);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 5. auth_settings —— 全局鉴权配置(单行配置表)
|
||||
-- ------------------------------------------------------------
|
||||
-- 用途:运行时调整鉴权策略,不重启服务
|
||||
CREATE TABLE IF NOT EXISTS auth_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1), -- 仅允许 id=1
|
||||
bcrypt_cost INTEGER NOT NULL DEFAULT 12, -- 12-14
|
||||
session_max_age_sec INTEGER NOT NULL DEFAULT 2592000, -- 30 天
|
||||
session_renew_sec INTEGER NOT NULL DEFAULT 86400, -- 24h 内活跃则续期
|
||||
max_failed_attempts INTEGER NOT NULL DEFAULT 5,
|
||||
lock_duration_sec INTEGER NOT NULL DEFAULT 900, -- 15 分钟
|
||||
pwd_reset_ttl_sec INTEGER NOT NULL DEFAULT 3600, -- 1 小时
|
||||
pwd_reset_rate_limit INTEGER NOT NULL DEFAULT 3, -- 每用户每小时最多 3 次
|
||||
log_retention_days INTEGER NOT NULL DEFAULT 90,
|
||||
maintenance_mode INTEGER NOT NULL DEFAULT 0, -- 0=正常 1=维护
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- 初始化默认配置(仅在表为空时插入)
|
||||
INSERT OR IGNORE INTO auth_settings (id, updated_at)
|
||||
VALUES (1, strftime('%s', 'now'));
|
||||
|
||||
-- ============================================================
|
||||
-- 二、迁移脚本(从当前 JSON 文件存储 + Bearer-only 模式)
|
||||
-- ============================================================
|
||||
-- 迁移目标:
|
||||
-- 1. 初始化 users 表,创建默认管理员账号
|
||||
-- 2. 现有 .generation_records.json 数据保留,不迁移(无 user_id 字段)
|
||||
-- 3. operation_logs 表从空开始
|
||||
-- ============================================================
|
||||
|
||||
-- 迁移步骤 1:创建默认管理员账号
|
||||
-- 默认用户名:lotto_admin
|
||||
-- 默认密码:BIZ-127-Lottery-2026(首次登录后强制修改)
|
||||
-- 警告:此密码必须在首次登录后立即修改(通过 BIZ-129 强制流程)
|
||||
INSERT OR IGNORE INTO users (
|
||||
username, password_hash, email, role, status,
|
||||
tenant_id, failed_attempts, password_changed_at,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
'lotto_admin',
|
||||
-- bcrypt('BIZ-127-Lottery-2026', cost=12)
|
||||
-- 由 BIZ-129 实施时用 Python bcrypt 生成,SQL 仅作占位
|
||||
'__BCRYPT_PLACEHOLDER__',
|
||||
NULL, 'admin', 'active', 1, 0,
|
||||
strftime('%s', 'now'), strftime('%s', 'now'), strftime('%s', 'now')
|
||||
);
|
||||
|
||||
-- 迁移步骤 2:保留 .generation_records.json 数据
|
||||
-- 策略:不迁移到 operation_logs(records 是业务数据,不是审计日志)
|
||||
-- 备注:BIZ-129 需在 add_record() 中写入 operation_logs(action='generate')
|
||||
|
||||
-- 迁移步骤 3:环境变量兼容
|
||||
-- 保留 LOTTO_API_TOKEN(Bearer 备用通道,仅供内部服务调用)
|
||||
-- 用户系统上线后,外部前端用 cookie,内部脚本用 Bearer
|
||||
|
||||
-- ============================================================
|
||||
-- 三、清理脚本(可选,生产环境 90 天后执行)
|
||||
-- ============================================================
|
||||
-- DELETE FROM sessions WHERE expires_at < strftime('%s', 'now');
|
||||
-- DELETE FROM password_reset_tokens WHERE expires_at < strftime('%s', 'now');
|
||||
-- DELETE FROM operation_logs WHERE created_at < strftime('%s', 'now', '-90 days');
|
||||
@@ -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>
|
||||
+88
-1
@@ -600,6 +600,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);
|
||||
@@ -1400,14 +1458,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 +1508,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>`;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user