Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a759d655d | |||
| fccfd775a3 | |||
| 31cabbe044 | |||
| 462adcc424 | |||
| 5d459b4505 |
@@ -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"
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# 生成记录管理(线程安全)
|
||||
# ============================================================
|
||||
@@ -197,9 +305,50 @@ def find_draw_by_gen_time(gen_time):
|
||||
# ============================================================
|
||||
# 认证装饰器(可选)
|
||||
# ============================================================
|
||||
# BIZ-109 临时放行白名单 v2(v1 修复不彻底,用户验收仍报错,升级白名单)
|
||||
#
|
||||
# 原因:BIZ-108 commit 31cabbe 启用 auth_enabled=True,但 index.html 前端
|
||||
# api() 函数 (L944) 未注入 Authorization header。v1 白名单只覆盖
|
||||
# /api/records 和 /api/history 两个根路径,遗漏了子路径接口和
|
||||
# /api/statistics、/api/generate,导致刘总验收仍报「未授权访问」。
|
||||
#
|
||||
# 升级策略(按方法 + 路径前缀):
|
||||
# - GET 类全部放行(含子路径):/api/records, /api/records/<id>/preview,
|
||||
# /api/records/<id>/compare, /api/history, /api/statistics,
|
||||
# /api/download/<path>
|
||||
# - POST /api/generate 放行:号码生成是核心功能,前端必须可调
|
||||
# - DELETE 类保留鉴权:保护用户生成记录不被误删(核心防滥用)
|
||||
# - 其他 POST(如未来新增)保留鉴权
|
||||
#
|
||||
# 跟踪:BIZ-110 由 costcodev 适配 index.html 前端后撤白名单,恢复完整鉴权。
|
||||
# 闭环:2026-09-01 12:59 GMT+8 by 严维序 (opengineer)
|
||||
|
||||
# 路径前缀白名单(GET 类 + 写接口特例)
|
||||
AUTH_BYPASS_GET_PREFIXES = (
|
||||
'/api/records', # 含子路径 preview/compare
|
||||
'/api/history',
|
||||
'/api/statistics',
|
||||
'/api/download',
|
||||
)
|
||||
# 写接口特例白名单(精确路径)
|
||||
AUTH_BYPASS_POST_EXACT = {
|
||||
'/api/generate', # 号码生成(核心功能,前端必须可调)
|
||||
}
|
||||
|
||||
def _is_bypass(method, path):
|
||||
"""判断当前请求是否在 BIZ-109 临时白名单内。"""
|
||||
if method == 'GET':
|
||||
return any(path.startswith(p) for p in AUTH_BYPASS_GET_PREFIXES)
|
||||
if method == 'POST':
|
||||
return path in AUTH_BYPASS_POST_EXACT
|
||||
return False
|
||||
|
||||
def require_auth(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
# BIZ-109 应急:白名单内请求临时放行
|
||||
if _is_bypass(request.method, request.path):
|
||||
return f(*args, **kwargs)
|
||||
if CONFIG['auth_enabled']:
|
||||
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
if token != CONFIG['api_token']:
|
||||
@@ -562,6 +711,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 +722,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 +748,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(含真实中奖金额展示)
|
||||
|
||||
+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