Files
Scoreboard/server/internal/httpapi/entries_batch_test.go
T
AskaEth 45edfbeb71 fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)
## 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 例 + 语法检查。
2026-09-11 21:47:07 +08:00

1005 lines
44 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}