## P0:双通道项目重复发布跳分(45→85→收敛) results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」, 团体手工通道把个人名次当团体名次又按团体表发一遍分。 - 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建) 与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键, 避免规则包重导时级联删掉录入数据) - results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回; 团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底, 个人报名项的名次绝不进团体通道 - writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked / realtime.loadRanked 三份同源 SQL 一起改读新表 - 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证) ## 奖牌结算开关与奖牌通道(P1(5),新功能) - events.award_medals + 通道 count_to_medals / count_to_team_medals (迁移默认 1,升级不改变既有榜单) - 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位) - 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算 - 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、 录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks) ## 身份口径改回证件号(P1(15),推翻第三十六轮) - 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard - 同表重复证件号 = 同一人报多个组别(notices 提示,不报错); 只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate) - 补齐后端缺失但前端/文档已在依赖的三处契约 ## 其余 P1 - 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径, 被限流的请求挂起并由心跳兜底补发,不丢刷新) - 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 / 积分模板 awardRanks → 400 或 404,不再 500 - 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、 报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价) - 配置改动触发重算:通道 / 小项参数 / 积分模板 - 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算 (服务对外监听之前完成,避免升级后奖牌榜全 0) - 示例数据模式下破坏性操作被阻断,不再假成功 - Console:修复「只剩一块屏」后屏幕监听器被永久摘掉 - 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常 ## 文档 - docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、 常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、 部署指南、上线检查清单 - 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步) 自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build; Console 35 例 + 语法检查。
1005 lines
44 KiB
Go
1005 lines
44 KiB
Go
package httpapi_test
|
||
|
||
import (
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"reflect"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// batchItem / batchSummary / batchResult 是 POST /api/entries/batch 的响应契约,
|
||
// 用例直接按前端会收到的 JSON 字段来断言,避免服务端字段改名后测试还绿着。
|
||
type batchItem struct {
|
||
ID int64 `json:"id"`
|
||
OK bool `json:"ok"`
|
||
Error string `json:"error"`
|
||
Skipped bool `json:"skipped"`
|
||
Note string `json:"note"`
|
||
}
|
||
|
||
type batchSummary struct {
|
||
OK int `json:"ok"`
|
||
Failed int `json:"failed"`
|
||
Skipped int `json:"skipped"`
|
||
Changed int `json:"changed"`
|
||
RemovedEntries int `json:"removedEntries"`
|
||
RemovedResults int `json:"removedResults"`
|
||
RemovedLedger int `json:"removedLedger"`
|
||
RemovedBreaks int `json:"removedBreaks"`
|
||
RecomputedEvent []int64 `json:"recomputedEvents"`
|
||
}
|
||
|
||
type batchResult struct {
|
||
Items []batchItem `json:"items"`
|
||
Summary batchSummary `json:"summary"`
|
||
Raw map[string]any `json:"-"`
|
||
}
|
||
|
||
// entryStatus 读报名项当前状态。
|
||
func entryStatus(t *testing.T, env *testEnv, entryID int64) string {
|
||
t.Helper()
|
||
var status string
|
||
if err := env.ev.QueryRow(`SELECT status FROM entries WHERE id = ?`, entryID).Scan(&status); err != nil {
|
||
t.Fatalf("读报名项状态: %v", err)
|
||
}
|
||
return status
|
||
}
|
||
|
||
// addTeamAthleteEntry 在既有小项 / 组别下补一个单位 + 运动员 + 报名项。
|
||
func addTeamAthleteEntry(t *testing.T, env *testEnv, ids seedIDs, code, name string, athleteName string) (int64, int64) {
|
||
t.Helper()
|
||
teamID := env.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES (?, ?, ?, 9, 1)`,
|
||
code, name, name)
|
||
athleteID := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`,
|
||
athleteName, teamID)
|
||
entryID := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, teamID, athleteID)
|
||
return teamID, entryID
|
||
}
|
||
|
||
// 批量退赛后别人被重算、被退赛的人不再占名次;恢复后榜单回到原状。
|
||
func TestEntryBatchScratchAndRestore(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
teamC, entryC := addTeamAthleteEntry(t, env, ids, "C", "第三分公司", "王超")
|
||
publishResults(t, env, client, ids.event, "batch",
|
||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||
map[string]any{"entryId": entryC, "resultValue": 12.10, "resultText": "12.10", "unit": "s", "status": "ok"},
|
||
)
|
||
|
||
// 前置:10 / 8 / 6 分。
|
||
if got := activeLedgerPoints(t, env, ids.entryB); !equalFloats(got, []float64{8}) {
|
||
t.Fatalf("前置条件:B 应是 8 分,得到 %v", got)
|
||
}
|
||
if got := teamPoints(t, env, client, teamC); got != 6 {
|
||
t.Fatalf("前置条件:C 单位总分应是 6,得到 %v", got)
|
||
}
|
||
|
||
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||
"action": "scratch", "ids": []int64{ids.entryB, entryC}, "reason": "临时请假",
|
||
}, true)
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("批量退赛应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[batchResult](t, env2)
|
||
if len(result.Items) != 2 || !result.Items[0].OK || !result.Items[1].OK {
|
||
t.Fatalf("应有 2 条逐条成功,得到 %+v", result.Items)
|
||
}
|
||
if result.Summary.OK != 2 || result.Summary.Failed != 0 || result.Summary.Changed != 2 {
|
||
t.Fatalf("汇总应是 2 成功 / 0 失败,得到 %+v", result.Summary)
|
||
}
|
||
|
||
if got := entryStatus(t, env, ids.entryB); got != "scratched" {
|
||
t.Fatalf("B 应已退赛,得到 %s", got)
|
||
}
|
||
if got := entryStatus(t, env, entryC); got != "scratched" {
|
||
t.Fatalf("C 应已退赛,得到 %s", got)
|
||
}
|
||
// 退赛的行不再有有效流水,旧流水保留为「已作废」的证据。
|
||
if got := activeLedgerPoints(t, env, ids.entryB); len(got) != 0 {
|
||
t.Fatalf("B 退赛后不应再有有效流水,得到 %v", got)
|
||
}
|
||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ? AND voided = 1`, ids.entryB); n != 1 {
|
||
t.Fatalf("B 的旧流水应被作废,得到 %d 条", n)
|
||
}
|
||
// 别人被重算:A 仍是第一 10 分,C 退赛不占名次。
|
||
if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) {
|
||
t.Fatalf("A 应保持 10 分,得到 %v", got)
|
||
}
|
||
if got := teamPoints(t, env, client, teamC); got != 0 {
|
||
t.Fatalf("C 单位总分应归零,得到 %v", got)
|
||
}
|
||
// 退赛不占名次:已发布成绩的 rank 必须清掉,否则奖牌统计还会给他记一块金牌。
|
||
for _, id := range []int64{ids.entryB, entryC} {
|
||
var rank sql.NullInt64
|
||
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, id).Scan(&rank); err != nil {
|
||
t.Fatalf("读退赛行的名次: %v", err)
|
||
}
|
||
if rank.Valid {
|
||
t.Fatalf("退赛的报名项不应再占名次,entry %d 的 rank=%d", id, rank.Int64)
|
||
}
|
||
}
|
||
if n := countRows(t, env,
|
||
`SELECT COUNT(*) FROM results r JOIN entries e ON e.id = r.entry_id
|
||
WHERE r.state = 'published' AND r.rank IS NOT NULL AND e.status <> 'registered'`); n != 0 {
|
||
t.Fatalf("非正常参赛的已发布成绩不该带名次,得到 %d 条", n)
|
||
}
|
||
|
||
// 恢复:同一端点 restore,只有 scratched 会被改回 registered。
|
||
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||
"action": "restore", "ids": []int64{ids.entryB, entryC},
|
||
}, true)
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("批量恢复应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
if got := entryStatus(t, env, ids.entryB); got != "registered" {
|
||
t.Fatalf("B 应恢复为 registered,得到 %s", got)
|
||
}
|
||
if got := activeLedgerPoints(t, env, ids.entryB); !equalFloats(got, []float64{8}) {
|
||
t.Fatalf("B 恢复后应重算回 8 分,得到 %v", got)
|
||
}
|
||
if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{6}) {
|
||
t.Fatalf("C 恢复后应重算回 6 分,得到 %v", got)
|
||
}
|
||
if got := teamPoints(t, env, client, ids.teamB); got != 8 {
|
||
t.Fatalf("B 单位总分应恢复为 8,得到 %v", got)
|
||
}
|
||
if got := teamPoints(t, env, client, teamC); got != 6 {
|
||
t.Fatalf("C 单位总分应恢复为 6,得到 %v", got)
|
||
}
|
||
// 恢复后名次写回:B 回到第 2 名(A 第一、C 第三)。
|
||
var restoredRank sql.NullInt64
|
||
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, ids.entryB).Scan(&restoredRank); err != nil {
|
||
t.Fatalf("读恢复后的名次: %v", err)
|
||
}
|
||
if !restoredRank.Valid || restoredRank.Int64 != 2 {
|
||
t.Fatalf("B 恢复后应重算回第 2 名,得到 %+v", restoredRank)
|
||
}
|
||
assertNoForeignKeyViolation(t, env)
|
||
}
|
||
|
||
// 已是 dq 的行不能被退赛覆盖;不存在的 id 逐条报错;整批不失败。
|
||
func TestEntryBatchScratchSkipsDQAndReportsMissing(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
env.exec(t, `UPDATE entries SET status = 'dq' WHERE id = ?`, ids.entryB)
|
||
|
||
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||
"action": "scratch", "ids": []int64{ids.entryA, ids.entryB, int64(999999)},
|
||
}, true)
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("混选批量退赛应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[batchResult](t, env2)
|
||
if len(result.Items) != 3 {
|
||
t.Fatalf("3 个 id 应有 3 条逐条结果,得到 %+v", result.Items)
|
||
}
|
||
if !result.Items[0].OK || result.Items[0].Skipped {
|
||
t.Fatalf("A 应正常退赛,得到 %+v", result.Items[0])
|
||
}
|
||
if !result.Items[1].OK || !result.Items[1].Skipped || !strings.Contains(result.Items[1].Note, "dq") {
|
||
t.Fatalf("B 应被跳过并说明是 dq,得到 %+v", result.Items[1])
|
||
}
|
||
if result.Items[2].OK || result.Items[2].Error != "报名项不存在" {
|
||
t.Fatalf("不存在的 id 应逐条报错,得到 %+v", result.Items[2])
|
||
}
|
||
if result.Summary.OK != 2 || result.Summary.Failed != 1 || result.Summary.Skipped != 1 || result.Summary.Changed != 1 {
|
||
t.Fatalf("汇总应是 2 成功(含 1 跳过)/ 1 失败,得到 %+v", result.Summary)
|
||
}
|
||
if got := entryStatus(t, env, ids.entryA); got != "scratched" {
|
||
t.Fatalf("A 应已退赛,得到 %s", got)
|
||
}
|
||
if got := entryStatus(t, env, ids.entryB); got != "dq" {
|
||
t.Fatalf("B 的 dq 判定不应被覆盖,得到 %s", got)
|
||
}
|
||
assertNoForeignKeyViolation(t, env)
|
||
}
|
||
|
||
// 移出报名:报名、成绩、流水全部按 entry_id 清干净(不留 entry_id 置空的空壳流水),
|
||
// 留下的人按新名次重算。
|
||
func TestEntryBatchRemovePurgesLedgerByEntryID(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
teamC, entryC := addTeamAthleteEntry(t, env, ids, "C", "第三分公司", "王超")
|
||
publishResults(t, env, client, ids.event, "remove",
|
||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||
map[string]any{"entryId": entryC, "resultValue": 12.10, "resultText": "12.10", "unit": "s", "status": "ok"},
|
||
)
|
||
// 破纪录登记也要跟着消失,否则会留下一条挂不上报名项的记录。
|
||
env.exec(t, `INSERT INTO record_breaks (event_id, entry_id, level, result_value, bonus_points, state)
|
||
VALUES (?, ?, 'meet', 11.92, 5, 'pending')`, ids.event, ids.entryB)
|
||
|
||
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||
"action": "remove", "ids": []int64{ids.entryB},
|
||
}, true)
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("移出报名应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[batchResult](t, env2)
|
||
if result.Summary.RemovedEntries != 1 || result.Summary.RemovedResults != 1 ||
|
||
result.Summary.RemovedLedger != 1 || result.Summary.RemovedBreaks != 1 {
|
||
t.Fatalf("汇总应带回删除规模,得到 %+v", result.Summary)
|
||
}
|
||
|
||
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryB); n != 0 {
|
||
t.Fatalf("报名项应被物理删除,得到 %d 条", n)
|
||
}
|
||
if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ?`, ids.entryB); n != 0 {
|
||
t.Fatalf("成绩应被删除,得到 %d 条", n)
|
||
}
|
||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ?`, ids.entryB); n != 0 {
|
||
t.Fatalf("流水应按 entry_id 清干净,得到 %d 条", n)
|
||
}
|
||
if n := countRows(t, env, `SELECT COUNT(*) FROM record_breaks WHERE entry_id = ?`, ids.entryB); n != 0 {
|
||
t.Fatalf("破纪录登记应被删除,得到 %d 条", n)
|
||
}
|
||
// 关键证据:删完之后不能留下 entry_id 被 SET NULL 置空的成绩流水。
|
||
if n := countRows(t, env,
|
||
`SELECT COUNT(*) FROM score_ledger WHERE entry_id IS NULL AND result_id IS NULL AND source_type = 'result'`); n != 0 {
|
||
t.Fatalf("不应留下 entry_id 被置空的空壳流水,得到 %d 条", n)
|
||
}
|
||
// 留下的人被重算:C 从第三 6 分升到第二 8 分。
|
||
if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{8}) {
|
||
t.Fatalf("C 应随新名次重算成 8 分,得到 %v", got)
|
||
}
|
||
if got := teamPoints(t, env, client, teamC); got != 8 {
|
||
t.Fatalf("C 单位总分应重算成 8,得到 %v", got)
|
||
}
|
||
if got := teamPoints(t, env, client, ids.teamB); got != 0 {
|
||
t.Fatalf("被移出的单位总分应归零,得到 %v", got)
|
||
}
|
||
assertNoForeignKeyViolation(t, env)
|
||
}
|
||
|
||
// 跨小项混选允许,但要按 (小项, 组别) 分组各算各的。
|
||
func TestEntryBatchScratchAcrossEvents(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
event2 := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
|
||
compare, scored, multiplier, status)
|
||
VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport)
|
||
category2 := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||
VALUES (?, '男子', 1, 0)`, event2)
|
||
var profileID int64
|
||
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil {
|
||
t.Fatalf("读取积分模板: %v", err)
|
||
}
|
||
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
|
||
VALUES (?, 'athlete', 'result', ?, 0)`, event2, profileID)
|
||
entry2 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||
VALUES (?, ?, ?, ?, 'registered')`, event2, category2, ids.teamA, ids.athleteA)
|
||
publishResults(t, env, client, ids.event, "cross1",
|
||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"})
|
||
publishResults(t, env, client, event2, "cross2",
|
||
map[string]any{"entryId": entry2, "resultValue": 23.10, "resultText": "23.10", "unit": "s", "status": "ok"})
|
||
|
||
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||
"action": "scratch", "ids": []int64{ids.entryA, entry2},
|
||
}, true)
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("跨小项批量退赛应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[batchResult](t, env2)
|
||
if len(result.Summary.RecomputedEvent) != 2 ||
|
||
result.Summary.RecomputedEvent[0] != ids.event || result.Summary.RecomputedEvent[1] != event2 {
|
||
t.Fatalf("两个小项都应重算,得到 %+v", result.Summary.RecomputedEvent)
|
||
}
|
||
if got := entryStatus(t, env, ids.entryA); got != "scratched" {
|
||
t.Fatalf("小项一的报名应已退赛,得到 %s", got)
|
||
}
|
||
if got := entryStatus(t, env, entry2); got != "scratched" {
|
||
t.Fatalf("小项二的报名应已退赛,得到 %s", got)
|
||
}
|
||
}
|
||
|
||
// 边界与权限:空 ids、重复 ids、超上限、非法 action、guest 写。
|
||
func TestEntryBatchValidation(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
|
||
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch",
|
||
map[string]any{"action": "scratch", "ids": []int64{}}, true)
|
||
if status != http.StatusBadRequest || env2.Error == nil {
|
||
t.Fatalf("空 ids 应 400,得到 %d %+v", status, env2.Error)
|
||
}
|
||
|
||
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch",
|
||
map[string]any{"action": "nuke", "ids": []int64{ids.entryA}}, true)
|
||
if status != http.StatusBadRequest {
|
||
t.Fatalf("非法 action 应 400,得到 %d %+v", status, env2.Error)
|
||
}
|
||
|
||
// 重复 id 去重后只回报一条。
|
||
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch",
|
||
map[string]any{"action": "scratch", "ids": []int64{ids.entryA, ids.entryA}}, true)
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("带重复 id 的请求应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
dup := decodeData[batchResult](t, env2)
|
||
if len(dup.Items) != 1 || dup.Summary.Changed != 1 {
|
||
t.Fatalf("重复 id 应去重成一条,得到 %+v", dup)
|
||
}
|
||
|
||
// 超过上限要在动手之前拒绝。
|
||
big := make([]int64, 501)
|
||
for i := range big {
|
||
big[i] = int64(i + 1)
|
||
}
|
||
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch",
|
||
map[string]any{"action": "remove", "ids": big}, true)
|
||
if status != http.StatusBadRequest || env2.Error == nil || !strings.Contains(env2.Error.Message, "分批") {
|
||
t.Fatalf("超过上限应 400 并要求分批,得到 %d %+v", status, env2.Error)
|
||
}
|
||
|
||
// guest(未登录)不能写。
|
||
guest, _ := env.client()
|
||
status, env2 = env.do(guest, http.MethodPost, "/api/entries/batch",
|
||
map[string]any{"action": "remove", "ids": []int64{ids.entryA}}, false)
|
||
if status != http.StatusUnauthorized {
|
||
t.Fatalf("未登录批量操作应 401,得到 %d %+v", status, env2.Error)
|
||
}
|
||
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryA); n != 1 {
|
||
t.Fatalf("guest 不应删掉任何报名项,得到 %d 条", n)
|
||
}
|
||
}
|
||
|
||
// waitBatchAudit 取批量操作的审计 after_json:批量动作没有单个实体 id
|
||
// (entity_id 落库为 NULL),所以按 action 找,和 waitAudit 的单实体口径区分开。
|
||
func waitBatchAudit(t *testing.T, env *testEnv, action string) string {
|
||
t.Helper()
|
||
for i := 0; i < 100; i++ {
|
||
var after sql.NullString
|
||
err := env.ev.QueryRow(`SELECT after_json FROM audit_logs WHERE action = ? AND entity_id IS NULL`,
|
||
action).Scan(&after)
|
||
switch {
|
||
case err == nil:
|
||
return after.String
|
||
case errors.Is(err, sql.ErrNoRows):
|
||
time.Sleep(10 * time.Millisecond)
|
||
default:
|
||
t.Fatalf("查询审计: %v", err)
|
||
}
|
||
}
|
||
t.Fatalf("等不到 %s 的审计记录", action)
|
||
return ""
|
||
}
|
||
|
||
// 审计要带 ids 与 summary,三个动作各一条。
|
||
func TestEntryBatchAudit(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
|
||
for _, action := range []string{"scratch", "restore", "remove"} {
|
||
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||
"action": action, "ids": []int64{ids.entryA}, "reason": "实机验证",
|
||
}, true)
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("%s 应 200,得到 %d %+v", action, status, env2.Error)
|
||
}
|
||
after := waitBatchAudit(t, env, "entry."+action)
|
||
for _, want := range []string{fmt.Sprintf(`"ids":[%d]`, ids.entryA), `"summary"`, `"实机验证"`} {
|
||
if !strings.Contains(after, want) {
|
||
t.Fatalf("%s 审计应含 %s,得到 %s", action, want, after)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 保障:remove 之后 results 表里的 rank 不能留下指向已删报名项的悬挂行。
|
||
func TestEntryBatchRemoveKeepsOtherResultsIntact(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
publishResults(t, env, client, ids.event, "keep",
|
||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||
)
|
||
if status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||
"action": "remove", "ids": []int64{ids.entryA},
|
||
}, true); status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("移出报名应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
|
||
var (
|
||
rank sql.NullInt64
|
||
state string
|
||
)
|
||
if err := env.ev.QueryRow(`SELECT rank, state FROM results WHERE entry_id = ?`, ids.entryB).
|
||
Scan(&rank, &state); err != nil {
|
||
t.Fatalf("读 B 的成绩: %v", err)
|
||
}
|
||
if !rank.Valid || rank.Int64 != 1 || state != "published" {
|
||
t.Fatalf("B 的名次应重算为 1 且保持发布态,得到 rank=%+v state=%s", rank, state)
|
||
}
|
||
}
|
||
|
||
// ---------- 延迟结算 vs 实时结算:口径一致 ----------
|
||
|
||
// ranksOf 读每个报名项当前写回 results 的名次;成绩行不存在(被移出)按「无名次」处理。
|
||
func ranksOf(t *testing.T, env *testEnv, entryIDs []int64) map[int64]int {
|
||
t.Helper()
|
||
out := make(map[int64]int, len(entryIDs))
|
||
for _, id := range entryIDs {
|
||
var rank sql.NullInt64
|
||
err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, id).Scan(&rank)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
continue
|
||
}
|
||
if err != nil {
|
||
t.Fatalf("读名次: %v", err)
|
||
}
|
||
if rank.Valid {
|
||
out[id] = int(rank.Int64)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ledgerRowsOf 把有效流水 dump 成可比文本;only 非空时只保留这些报名项的流水
|
||
// (用于确认别的组别没有被牵连)。
|
||
func ledgerRowsOf(t *testing.T, env *testEnv, eventID int64, only []int64) []string {
|
||
t.Helper()
|
||
var allowed map[int64]bool
|
||
if only != nil {
|
||
allowed = make(map[int64]bool, len(only))
|
||
for _, id := range only {
|
||
allowed[id] = true
|
||
}
|
||
}
|
||
rows, err := env.ev.Query(
|
||
`SELECT entry_id, team_id, source_type, points FROM score_ledger
|
||
WHERE event_id = ? AND voided = 0 ORDER BY entry_id, source_type, points`, eventID)
|
||
if err != nil {
|
||
t.Fatalf("读流水: %v", err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
out := make([]string, 0)
|
||
for rows.Next() {
|
||
var (
|
||
entryID, teamID sql.NullInt64
|
||
source string
|
||
points float64
|
||
)
|
||
if err := rows.Scan(&entryID, &teamID, &source, &points); err != nil {
|
||
t.Fatalf("扫描流水: %v", err)
|
||
}
|
||
if allowed != nil && !allowed[entryID.Int64] {
|
||
continue
|
||
}
|
||
out = append(out, fmt.Sprintf("%v/%v/%s/%v", entryID.Int64, teamID.Int64, source, points))
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
t.Fatalf("遍历流水: %v", err)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// batchSnapshot 是一次批量操作前后的可比状态:名次、有效流水、榜单原始字节。
|
||
type batchSnapshot struct {
|
||
ranks map[int64]int
|
||
ledger []string
|
||
standings string
|
||
}
|
||
|
||
func readBatchSnapshot(t *testing.T, env *testEnv, client *http.Client, eventID int64, entryIDs []int64) batchSnapshot {
|
||
t.Helper()
|
||
return batchSnapshot{
|
||
ranks: ranksOf(t, env, entryIDs),
|
||
ledger: ledgerRowsOf(t, env, eventID, nil),
|
||
standings: rawData(t, env, client, "/api/standings"),
|
||
}
|
||
}
|
||
|
||
// standingsPushes 数榜单推送次数:每条都是 action=replace 的整张榜单快照。
|
||
func standingsPushes(t *testing.T, env *testEnv) int {
|
||
t.Helper()
|
||
return countRows(t, env,
|
||
`SELECT COUNT(*) FROM change_log WHERE entity_type = 'standings' AND action = 'replace'`)
|
||
}
|
||
|
||
// batchRun 是「同一串操作在某种结算模式下」的结果,两次跑之间只差 live_on_publish 一个开关。
|
||
type batchRun struct {
|
||
entries []int64 // 8 条报名项,顺序与 seedBatch 一致
|
||
teamA, teamB int64
|
||
before batchSnapshot
|
||
after batchSnapshot
|
||
pushesBefore int
|
||
pushesAfter int
|
||
}
|
||
|
||
// runScenario 用同一串操作跑一遍场景,act 是本次被考察的那个「回滚类」动作:
|
||
// 8 人报名 → 只录完前 4 条并发布 →(withSettle 时)立即结算 → act。
|
||
//
|
||
// live 只控制 standings.live_on_publish 一个开关,其它输入完全一样,
|
||
// 所以「实时 vs 延迟」两次跑出来的快照差异只可能来自结算模式。
|
||
// withSettle 区分两种现场:已经人工结算过一次(延迟模式下的复现前置)与延迟窗口内一次都没结算过。
|
||
func runScenario(t *testing.T, live, withSettle bool,
|
||
act func(t *testing.T, env *testEnv, client *http.Client, ids seedIDs, entries []int64)) batchRun {
|
||
t.Helper()
|
||
env := newTestEnv(t)
|
||
ids := env.seed(t)
|
||
entries := seedBatch(t, env, ids, 8)
|
||
setLiveOnPublish(t, env, live)
|
||
env.createUser("recorder", "recorder-pass", "recorder")
|
||
client, _ := env.client()
|
||
env.login(client, "recorder", "recorder-pass")
|
||
|
||
times := make([]float64, 8)
|
||
for i := range times {
|
||
times[i] = 12.0 + float64(i)*0.1
|
||
}
|
||
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 4, 0, times),
|
||
}, true); status != http.StatusOK {
|
||
t.Fatalf("保存草稿应 200,得到 %d %+v", status, e.Error)
|
||
}
|
||
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||
"eventId": ids.event, "categoryId": ids.category,
|
||
}, true); status != http.StatusOK {
|
||
t.Fatalf("发布应 200,得到 %d %+v", status, e.Error)
|
||
}
|
||
if withSettle {
|
||
if status, e := env.do(client, http.MethodPost, "/api/results/settle", map[string]any{
|
||
"eventId": ids.event, "categoryId": ids.category, "requestId": "settle-before-act",
|
||
}, true); status != http.StatusOK {
|
||
t.Fatalf("立即结算应 200,得到 %d %+v", status, e.Error)
|
||
}
|
||
}
|
||
|
||
run := batchRun{
|
||
entries: entries,
|
||
teamA: ids.teamA,
|
||
teamB: ids.teamB,
|
||
before: readBatchSnapshot(t, env, client, ids.event, entries),
|
||
pushesBefore: standingsPushes(t, env),
|
||
}
|
||
act(t, env, client, ids, entries)
|
||
run.after = readBatchSnapshot(t, env, client, ids.event, entries)
|
||
run.pushesAfter = standingsPushes(t, env)
|
||
assertNoForeignKeyViolation(t, env)
|
||
return run
|
||
}
|
||
|
||
// runBatchScenario 跑一遍批量动作(scratch / restore / remove)。
|
||
func runBatchScenario(t *testing.T, live bool, action string, target int, withSettle bool) batchRun {
|
||
t.Helper()
|
||
return runScenario(t, live, withSettle,
|
||
func(t *testing.T, env *testEnv, client *http.Client, ids seedIDs, entries []int64) {
|
||
t.Helper()
|
||
if status, e := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||
"action": action, "ids": []int64{entries[target]}, "reason": "结算模式一致用例",
|
||
}, true); status != http.StatusOK || !e.OK {
|
||
t.Fatalf("批量 %s 应 200,得到 %d %+v", action, status, e.Error)
|
||
}
|
||
})
|
||
}
|
||
|
||
// voidResultOf 按报名项找到它的成绩行并调作废接口(前端同一条 POST /api/results/{id}/void)。
|
||
func voidResultOf(t *testing.T, env *testEnv, client *http.Client, entryID int64) {
|
||
t.Helper()
|
||
var resultID int64
|
||
if err := env.ev.QueryRow(`SELECT id FROM results WHERE entry_id = ?`, entryID).Scan(&resultID); err != nil {
|
||
t.Fatalf("读成绩 id: %v", err)
|
||
}
|
||
if status, e := env.do(client, http.MethodPost,
|
||
fmt.Sprintf("/api/results/%d/void", resultID), map[string]any{"reason": "结算模式一致用例"}, true); status != http.StatusOK {
|
||
t.Fatalf("作废成绩应 200,得到 %d %+v", status, e.Error)
|
||
}
|
||
}
|
||
|
||
// runVoidScenario 跑一遍「作废单条成绩」,targets 是要作废的报名项(可为多条,用于「整组作废」边界)。
|
||
func runVoidScenario(t *testing.T, live bool, withSettle bool, targets ...int) batchRun {
|
||
t.Helper()
|
||
return runScenario(t, live, withSettle,
|
||
func(t *testing.T, env *testEnv, client *http.Client, ids seedIDs, entries []int64) {
|
||
t.Helper()
|
||
for _, target := range targets {
|
||
voidResultOf(t, env, client, entries[target])
|
||
}
|
||
})
|
||
}
|
||
|
||
// entryLedgerRow 拼一条流水的可比文本,期望值按报名项与单位 id 现算,不写死数字。
|
||
func entryLedgerRow(entryID, teamID int64, points float64) string {
|
||
return fmt.Sprintf("%d/%d/result/%v", entryID, teamID, points)
|
||
}
|
||
|
||
// assertSameBefore 断言批量动作发生之前,两种结算模式手里的数据已经一致。
|
||
func assertSameBefore(t *testing.T, label string, live, deferred batchRun) {
|
||
t.Helper()
|
||
if !reflect.DeepEqual(live.before.ranks, deferred.before.ranks) {
|
||
t.Fatalf("%s:操作前的名次就不一致:实时 %v vs 延迟 %v", label, live.before.ranks, deferred.before.ranks)
|
||
}
|
||
if !reflect.DeepEqual(live.before.ledger, deferred.before.ledger) {
|
||
t.Fatalf("%s:操作前的流水就不一致:实时 %v vs 延迟 %v", label, live.before.ledger, deferred.before.ledger)
|
||
}
|
||
if live.before.standings != deferred.before.standings {
|
||
t.Fatalf("%s:操作前的榜单不一致:\n实时 %s\n延迟 %s", label, live.before.standings, deferred.before.standings)
|
||
}
|
||
}
|
||
|
||
// assertSameAfter 断言两种结算模式得到的是同一份数据:名次、有效流水、榜单原始字节。
|
||
// 这是「同一份数据不管实时结算还是延迟结算,最终榜单必须一模一样」的落点。
|
||
func assertSameAfter(t *testing.T, label string, live, deferred batchRun) {
|
||
t.Helper()
|
||
if !reflect.DeepEqual(live.after.ranks, deferred.after.ranks) {
|
||
t.Fatalf("%s:名次不一致:实时 %v vs 延迟 %v", label, live.after.ranks, deferred.after.ranks)
|
||
}
|
||
if !reflect.DeepEqual(live.after.ledger, deferred.after.ledger) {
|
||
t.Fatalf("%s:流水不一致:实时 %v vs 延迟 %v", label, live.after.ledger, deferred.after.ledger)
|
||
}
|
||
if live.after.standings != deferred.after.standings {
|
||
t.Fatalf("%s:榜单不一致:\n实时 %s\n延迟 %s", label, live.after.standings, deferred.after.standings)
|
||
}
|
||
}
|
||
|
||
// 延迟结算 + 批量退赛:退赛的人必须当场丢掉名次与分数、同组别人按新名次重算,
|
||
// 结果与实时结算逐字节一致(报告模块7 [1])。
|
||
// 两种现场都跑:已经人工结算过一次(报告里的复现前置),以及延迟窗口内一次都没结算过。
|
||
func TestDeferredBatchScratchMatchesRealTime(t *testing.T) {
|
||
check := func(t *testing.T, live, deferred batchRun, compareBefore bool) {
|
||
t.Helper()
|
||
if compareBefore {
|
||
assertSameBefore(t, "批量退赛", live, deferred)
|
||
}
|
||
assertSameAfter(t, "批量退赛", live, deferred)
|
||
|
||
// 前置:前四名 10/8/6/3 已经在实时结算那侧落了账。
|
||
if len(live.before.ledger) != 4 {
|
||
t.Fatalf("前置:应已结算 4 条流水,得到 %v", live.before.ledger)
|
||
}
|
||
// 退赛的第 1 名:名次被清、流水被作废;剩下 3 人顺推成第 1/2/3 名 10/8/6 分。
|
||
after := deferred.after
|
||
if _, ok := after.ranks[live.entries[0]]; ok {
|
||
t.Fatalf("退赛者不应再占名次,得到 %v", after.ranks)
|
||
}
|
||
wantRanks := map[int64]int{
|
||
live.entries[1]: 1, live.entries[2]: 2, live.entries[3]: 3,
|
||
}
|
||
for id, rank := range wantRanks {
|
||
if after.ranks[id] != rank {
|
||
t.Fatalf("报名项 %d 应第 %d 名,得到 %v", id, rank, after.ranks)
|
||
}
|
||
}
|
||
wantLedger := []string{
|
||
entryLedgerRow(live.entries[1], live.teamB, 10),
|
||
entryLedgerRow(live.entries[2], live.teamA, 8),
|
||
entryLedgerRow(live.entries[3], live.teamB, 6),
|
||
}
|
||
if !reflect.DeepEqual(after.ledger, wantLedger) {
|
||
t.Fatalf("退赛后流水应重算成 %v,得到 %v", wantLedger, after.ledger)
|
||
}
|
||
// 延迟结算下这个动作也必须推榜单:否则大屏还挂着「退赛者仍拿金牌」的旧画面。
|
||
if got := deferred.pushesAfter - deferred.pushesBefore; got != 1 {
|
||
t.Fatalf("退赛应推 1 次榜单(standings replace),得到 %d", got)
|
||
}
|
||
if got := live.pushesAfter - live.pushesBefore; got != 1 {
|
||
t.Fatalf("实时结算下退赛也应推 1 次榜单,得到 %d", got)
|
||
}
|
||
}
|
||
|
||
t.Run("现场已人工结算过一次", func(t *testing.T) {
|
||
check(t,
|
||
runBatchScenario(t, true, "scratch", 0, true),
|
||
runBatchScenario(t, false, "scratch", 0, true), true)
|
||
})
|
||
t.Run("延迟窗口内一次都没结算过", func(t *testing.T) {
|
||
// 这一档下延迟侧操作前本就还没有流水/名次,只比操作之后的最终数据。
|
||
check(t,
|
||
runBatchScenario(t, true, "scratch", 0, false),
|
||
runBatchScenario(t, false, "scratch", 0, false), false)
|
||
})
|
||
}
|
||
|
||
// 延迟结算 + 批量移出报名:流水被正确作废/重建、名次更新、榜单被推送,
|
||
// 且与实时结算逐字节一致(报告模块7 [10])。
|
||
func TestDeferredBatchRemoveMatchesRealTime(t *testing.T) {
|
||
check := func(t *testing.T, live, deferred batchRun, compareBefore bool) {
|
||
t.Helper()
|
||
if compareBefore {
|
||
assertSameBefore(t, "批量移出报名", live, deferred)
|
||
}
|
||
assertSameAfter(t, "批量移出报名", live, deferred)
|
||
|
||
after := deferred.after
|
||
// 被移出的人:报名项与成绩都没了(ranks 里没有它),流水一条不剩。
|
||
if _, ok := after.ranks[live.entries[1]]; ok {
|
||
t.Fatalf("被移出的报名项不该还有名次,得到 %v", after.ranks)
|
||
}
|
||
wantRanks := map[int64]int{
|
||
live.entries[0]: 1, live.entries[2]: 2, live.entries[3]: 3,
|
||
}
|
||
for id, rank := range wantRanks {
|
||
if after.ranks[id] != rank {
|
||
t.Fatalf("报名项 %d 应第 %d 名,得到 %v", id, rank, after.ranks)
|
||
}
|
||
}
|
||
// 原来 10/8/6/3:移出第 2 名后剩下 10/8/6(第 3、4 名各升一位)。
|
||
wantLedger := []string{
|
||
entryLedgerRow(live.entries[0], live.teamA, 10),
|
||
entryLedgerRow(live.entries[2], live.teamA, 8),
|
||
entryLedgerRow(live.entries[3], live.teamB, 6),
|
||
}
|
||
if !reflect.DeepEqual(after.ledger, wantLedger) {
|
||
t.Fatalf("移出后流水应重建为 %v,得到 %v", wantLedger, after.ledger)
|
||
}
|
||
if got := deferred.pushesAfter - deferred.pushesBefore; got != 1 {
|
||
t.Fatalf("移出报名应推 1 次榜单(standings replace),得到 %d", got)
|
||
}
|
||
}
|
||
|
||
t.Run("现场已人工结算过一次", func(t *testing.T) {
|
||
check(t,
|
||
runBatchScenario(t, true, "remove", 1, true),
|
||
runBatchScenario(t, false, "remove", 1, true), true)
|
||
})
|
||
t.Run("延迟窗口内一次都没结算过", func(t *testing.T) {
|
||
check(t,
|
||
runBatchScenario(t, true, "remove", 1, false),
|
||
runBatchScenario(t, false, "remove", 1, false), false)
|
||
})
|
||
}
|
||
|
||
// 延迟结算 + 作废单条成绩:作废的人当场退出名次与流水、同组别人按新名次重算、榜单被推送,
|
||
// 结果与实时结算逐字节一致(报告模块7 [2])。
|
||
// 作废与撤销发布、批量退赛/移出同属回滚动作,改的是「谁还算数」,不受延迟结算约束。
|
||
func TestDeferredVoidMatchesRealTime(t *testing.T) {
|
||
check := func(t *testing.T, live, deferred batchRun, compareBefore bool, wantLedger []string, wantRanks map[int64]int) {
|
||
t.Helper()
|
||
if compareBefore {
|
||
assertSameBefore(t, "作废成绩", live, deferred)
|
||
}
|
||
assertSameAfter(t, "作废成绩", live, deferred)
|
||
|
||
after := deferred.after
|
||
if !reflect.DeepEqual(after.ledger, wantLedger) {
|
||
t.Fatalf("作废后流水应为 %v,得到 %v", wantLedger, after.ledger)
|
||
}
|
||
if !reflect.DeepEqual(after.ranks, wantRanks) {
|
||
t.Fatalf("作废后名次应为 %v,得到 %v", wantRanks, after.ranks)
|
||
}
|
||
}
|
||
|
||
t.Run("作废的是最后一名", func(t *testing.T) {
|
||
// 前四名 10/8/6/3,作废第 4 名:别人名次不动,只剩前三名的 10/8/6,总分 27 → 24。
|
||
for _, withSettle := range []bool{true, false} {
|
||
live := runVoidScenario(t, true, withSettle, 3)
|
||
deferred := runVoidScenario(t, false, withSettle, 3)
|
||
// 期望值里的 entry/team id 按现跑的 env 现算,不写死数字。
|
||
check(t, live, deferred, withSettle,
|
||
[]string{
|
||
entryLedgerRow(live.entries[0], live.teamA, 10),
|
||
entryLedgerRow(live.entries[1], live.teamB, 8),
|
||
entryLedgerRow(live.entries[2], live.teamA, 6),
|
||
},
|
||
map[int64]int{live.entries[0]: 1, live.entries[1]: 2, live.entries[2]: 3})
|
||
// 被作废的人不再占名次(与批量退赛同口径:作废行也让出 results.rank)。
|
||
if _, ok := deferred.after.ranks[live.entries[3]]; ok {
|
||
t.Fatalf("被作废的成绩不该再占名次,得到 %v", deferred.after.ranks)
|
||
}
|
||
if got := deferred.pushesAfter - deferred.pushesBefore; got != 1 {
|
||
t.Fatalf("作废成绩应推 1 次榜单(standings replace),得到 %d", got)
|
||
}
|
||
}
|
||
})
|
||
|
||
t.Run("作废后整组只剩无效成绩", func(t *testing.T) {
|
||
// 四条已发布成绩全部作废:整组没有可排名的成绩,流水清空、名次全空、榜单归零。
|
||
for _, withSettle := range []bool{true, false} {
|
||
live := runVoidScenario(t, true, withSettle, 0, 1, 2, 3)
|
||
deferred := runVoidScenario(t, false, withSettle, 0, 1, 2, 3)
|
||
check(t, live, deferred, withSettle, []string{}, map[int64]int{})
|
||
if got := deferred.pushesAfter - deferred.pushesBefore; got != 4 {
|
||
t.Fatalf("每次作废都应推榜单,4 次应推 4 次,得到 %d", got)
|
||
}
|
||
if deferred.pushesAfter-deferred.pushesBefore != live.pushesAfter-live.pushesBefore {
|
||
t.Fatalf("实时与延迟的推榜次数应一致:%d vs %d",
|
||
live.pushesAfter-live.pushesBefore, deferred.pushesAfter-deferred.pushesBefore)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// 作废立刻重算 ≠ 提前结算:延迟窗口内作废一条成绩后,这一组仍是「未结算」,
|
||
// 录入页照旧提示「榜单还没反映这批」,其它批次照样等录完才结算。
|
||
// 这条钉住 scoring.go 里那句「作废不写 state,不会把未录完的组提前变成已结算」。
|
||
func TestDeferredVoidKeepsGroupUnsettled(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
ids := env.seed(t)
|
||
entries := seedBatch(t, env, ids, 8)
|
||
setLiveOnPublish(t, env, false)
|
||
env.createUser("recorder", "recorder-pass", "recorder")
|
||
client, _ := env.client()
|
||
env.login(client, "recorder", "recorder-pass")
|
||
|
||
times := make([]float64, 8)
|
||
for i := range times {
|
||
times[i] = 12.0 + float64(i)*0.1
|
||
}
|
||
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 4, 0, times),
|
||
}, true)
|
||
env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||
"eventId": ids.event, "categoryId": ids.category,
|
||
}, true)
|
||
// 发布 4/8 且没结算:这一组在延迟窗口里,流水为空。
|
||
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 0, ids.event)
|
||
|
||
voidResultOf(t, env, client, entries[0])
|
||
|
||
// 作废当场生效:剩下 3 条已发布成绩算出 10/8/6,名次写回,榜单被推。
|
||
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 3, ids.event)
|
||
if got := rankOfEntry(t, env, entries[1]); got != 1 {
|
||
t.Fatalf("作废冠军后第 2 名应升到第 1 名,得到 %d", got)
|
||
}
|
||
if got := standingsTotal(t, env, client); got != 24 {
|
||
t.Fatalf("作废后榜单总分应为 10+8+6=24,得到 %v", got)
|
||
}
|
||
// 但这一组还没录完(8 人里只录出 4 条,其中 1 条作废),仍应留在未结算清单里。
|
||
pending := readSettlement(t, env, client, ids.event, ids.category)
|
||
if len(pending.Groups) != 1 || pending.Groups[0].Required != 8 || pending.Groups[0].Published != 4 {
|
||
t.Fatalf("作废不该把未录完的组变成已结算,得到 %+v", pending.Groups)
|
||
}
|
||
}
|
||
|
||
// 反例保护:批量动作只重算被勾选报名项所在的 (小项, 组别),
|
||
// 同小项另一个组别的名次与流水必须原样不动。
|
||
func TestDeferredBatchScratchKeepsOtherCategoriesIntact(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
setLiveOnPublish(t, env, false)
|
||
|
||
// 同小项的第二个组别:自己一条报名 + 一条已发布成绩。
|
||
category2 := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||
VALUES (?, '女子', 1, 1)`, ids.event)
|
||
entryC := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||
VALUES (?, ?, ?, ?, 'registered')`, ids.event, category2, ids.teamA, ids.athleteA)
|
||
publishResults(t, env, client, ids.event, "cat2",
|
||
map[string]any{"entryId": entryC, "resultValue": 13.20, "resultText": "13.20", "unit": "s", "status": "ok"},
|
||
)
|
||
if status, e := env.do(client, http.MethodPost, "/api/results/settle", map[string]any{
|
||
"eventId": ids.event, "categoryId": category2, "requestId": "settle-cat2",
|
||
}, true); status != http.StatusOK {
|
||
t.Fatalf("结算组别二应 200,得到 %d %+v", status, e.Error)
|
||
}
|
||
|
||
// 组别一:录一条已发布成绩,但不结算(延迟窗口内)。
|
||
publishResults(t, env, client, ids.event, "cat1",
|
||
map[string]any{"entryId": ids.entryA, "resultValue": 12.00, "resultText": "12.00", "unit": "s", "status": "ok"},
|
||
)
|
||
|
||
ranksBefore := ranksOf(t, env, []int64{entryC})
|
||
ledgerBefore := ledgerRowsOf(t, env, ids.event, []int64{entryC})
|
||
if ranksBefore[entryC] != 1 || !reflect.DeepEqual(ledgerBefore, []string{entryLedgerRow(entryC, ids.teamA, 10)}) {
|
||
t.Fatalf("前置:组别二应是第 1 名 10 分,得到 ranks=%v ledger=%v", ranksBefore, ledgerBefore)
|
||
}
|
||
standingsBefore := rawData(t, env, client, "/api/standings")
|
||
|
||
if status, e := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||
"action": "scratch", "ids": []int64{ids.entryA},
|
||
}, true); status != http.StatusOK || !e.OK {
|
||
t.Fatalf("批量退赛应 200,得到 %d %+v", status, e.Error)
|
||
}
|
||
|
||
if got := ranksOf(t, env, []int64{entryC}); !reflect.DeepEqual(got, ranksBefore) {
|
||
t.Fatalf("别的组别的名次不该被改动:%v → %v", ranksBefore, got)
|
||
}
|
||
if got := ledgerRowsOf(t, env, ids.event, []int64{entryC}); !reflect.DeepEqual(got, ledgerBefore) {
|
||
t.Fatalf("别的组别的流水不该被改动:%v → %v", ledgerBefore, got)
|
||
}
|
||
if got := entryStatus(t, env, entryC); got != "registered" {
|
||
t.Fatalf("别的组别的报名状态不该被改动,得到 %s", got)
|
||
}
|
||
// 组别一的成绩还没结算、退赛后更不该有分,所以整张榜单的内容一格都不该动。
|
||
if got := rawData(t, env, client, "/api/standings"); got != standingsBefore {
|
||
t.Fatalf("别的组别没被牵连时榜单内容不该变:\n之前 %s\n之后 %s", standingsBefore, got)
|
||
}
|
||
}
|
||
|
||
// ---------- 破纪录加分跟随状态 ----------
|
||
|
||
// 打开破纪录加分并配一档「赛会纪录 +2 分」,用于端到端验证加分随状态出现 / 消失(P1-6)。
|
||
func enableRecordBonus(t *testing.T, env *testEnv) {
|
||
t.Helper()
|
||
env.exec(t, `INSERT INTO settings (key, value) VALUES ('record_bonus.enabled', 'true')
|
||
ON CONFLICT(key) DO UPDATE SET value = 'true'`)
|
||
env.exec(t, `INSERT INTO settings (key, value) VALUES ('record_bonus.levels',
|
||
'[{"code":"meet","name":"赛会纪录","points":2}]')
|
||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
|
||
}
|
||
|
||
// activeRecordBonusPoints 取某报名项名下的有效破纪录加分(无则 0)。
|
||
func activeRecordBonusPoints(t *testing.T, env *testEnv, entryID int64) float64 {
|
||
t.Helper()
|
||
var points sql.NullFloat64
|
||
if err := env.ev.QueryRow(
|
||
`SELECT SUM(points) FROM score_ledger
|
||
WHERE entry_id = ? AND source_type = 'record_bonus' AND voided = 0`, entryID).Scan(&points); err != nil {
|
||
t.Fatalf("统计破纪录加分: %v", err)
|
||
}
|
||
return points.Float64
|
||
}
|
||
|
||
// 退赛 / 恢复 / 作废成绩都不能让加分留在榜上:加分与名次分同生同灭(P1-6)。
|
||
func TestRecordBonusFollowsStatusEndToEnd(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
enableRecordBonus(t, env)
|
||
publishResults(t, env, client, ids.event, "bonus",
|
||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||
)
|
||
|
||
// 登记并确认「赛会纪录」:确认会当场重算,加分流水随之出现。
|
||
status, env2 := env.do(client, http.MethodPost, "/api/record-breaks", map[string]any{
|
||
"eventId": ids.event, "entryId": ids.entryA, "level": "meet",
|
||
"resultValue": 11.58, "resultText": "11.58",
|
||
}, true)
|
||
if status != http.StatusOK {
|
||
t.Fatalf("登记破纪录应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
breakID := int64(decodeData[map[string]any](t, env2)["id"].(float64))
|
||
if status, env2 = env.do(client, http.MethodPost,
|
||
fmt.Sprintf("/api/record-breaks/%d/approve", breakID), nil, true); status != http.StatusOK {
|
||
t.Fatalf("确认破纪录应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 2 {
|
||
t.Fatalf("确认后应记 2 分加分,得到 %v", got)
|
||
}
|
||
if got := teamPoints(t, env, client, ids.teamA); got != 12 {
|
||
t.Fatalf("确认后单位总分应是 10+2,得到 %v", got)
|
||
}
|
||
|
||
// 退赛 → 加分与名次一起消失。
|
||
if status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||
"action": "scratch", "ids": []int64{ids.entryA}, "reason": "破纪录加分口径用例",
|
||
}, true); status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("批量退赛应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 0 {
|
||
t.Fatalf("退赛后不该再有破纪录加分,得到 %v", got)
|
||
}
|
||
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
|
||
t.Fatalf("退赛后该单位总分应归零,得到 %v", got)
|
||
}
|
||
|
||
// 恢复 → 同一个登记把加分还回来(登记是现场事实,不能被一次性作废)。
|
||
if status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||
"action": "restore", "ids": []int64{ids.entryA},
|
||
}, true); status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("批量恢复应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 2 {
|
||
t.Fatalf("恢复后应把加分还回来,得到 %v", got)
|
||
}
|
||
if got := teamPoints(t, env, client, ids.teamA); got != 12 {
|
||
t.Fatalf("恢复后单位总分应回到 12,得到 %v", got)
|
||
}
|
||
|
||
// 作废成绩(results.status = withdrawn)→ 加分同样消失。
|
||
var resultID int64
|
||
if err := env.ev.QueryRow(`SELECT id FROM results WHERE entry_id = ?`, ids.entryA).Scan(&resultID); err != nil {
|
||
t.Fatalf("读成绩 id: %v", err)
|
||
}
|
||
if status, env2 := env.do(client, http.MethodPost,
|
||
fmt.Sprintf("/api/results/%d/void", resultID), map[string]any{"reason": "破纪录加分口径用例"}, true); status != http.StatusOK {
|
||
t.Fatalf("作废成绩应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 0 {
|
||
t.Fatalf("成绩作废后不该再有破纪录加分,得到 %v", got)
|
||
}
|
||
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
|
||
t.Fatalf("成绩作废后该单位总分应归零,得到 %v", got)
|
||
}
|
||
|
||
// 把成绩改回有效再发布 → 加分回来(与名次同一判据)。
|
||
env.exec(t, `UPDATE results SET status = 'ok' WHERE id = ?`, resultID)
|
||
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||
"eventId": ids.event, "requestId": "bonus-republish",
|
||
}, true); status != http.StatusOK {
|
||
t.Fatalf("重新发布应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 2 {
|
||
t.Fatalf("成绩改回有效后应把加分还回来,得到 %v", got)
|
||
}
|
||
if got := teamPoints(t, env, client, ids.teamA); got != 12 {
|
||
t.Fatalf("成绩改回有效后单位总分应回到 12,得到 %v", got)
|
||
}
|
||
}
|