Files
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

682 lines
30 KiB
Go
Raw Permalink 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 (
"fmt"
"net/http"
"strings"
"testing"
)
// 本文件钉住设计 17 的服务端行为:团体手工名次入口、channel_ranks 派生名次、
// 改配置即重算,以及 P0(同一份数据发布两次结果必须一致)。
// ---------- 造数与读数助手 ----------
// profileIDByCode 按编码取积分模板 id。
func profileIDByCode(t *testing.T, env *testEnv, code string) int64 {
t.Helper()
var id int64
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = ?`, code).Scan(&id); err != nil {
t.Fatalf("读积分模板 %s: %v", code, err)
}
return id
}
// addTeamManualChannel 给某小项加一条「团体手工名次」通道,返回通道 id。
func addTeamManualChannel(t *testing.T, env *testEnv, eventID int64) int64 {
t.Helper()
return env.insert(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'team', 'manual', ?, 9)`, eventID, profileIDByCode(t, env, "team_top6"))
}
// ledgerActiveDump 把未作废流水导出成文本:比条数与分值更严格,重复 / 丢失 / 顺序变化都能看出来。
func ledgerActiveDump(t *testing.T, env *testEnv) string {
t.Helper()
return tableDump(t, env, `SELECT team_id, COALESCE(entry_id, 0) AS entry_id, COALESCE(result_id, 0) AS result_id,
source_type, source_id, points, count_to_team
FROM score_ledger WHERE voided = 0
ORDER BY team_id, entry_id, result_id, source_type, source_id, points`)
}
// channelRanksDump 导出通道名次明细(不含自增 id 与时间戳,便于发布前后逐字节比较)。
func channelRanksDump(t *testing.T, env *testEnv) string {
t.Helper()
return tableDump(t, env, `SELECT event_id, category_id, channel_id, basis, scope, subject_key,
COALESCE(entry_id, 0) AS entry_id, COALESCE(result_id, 0) AS result_id,
COALESCE(athlete_id, 0) AS athlete_id, COALESCE(team_id, 0) AS team_id, rank, points
FROM channel_ranks ORDER BY event_id, category_id, channel_id, subject_key`)
}
// standingsChangeRows 统计 change_log 里推给大屏的榜单版本条数。
func standingsChangeRows(t *testing.T, env *testEnv) int {
t.Helper()
return countRows(t, env, `SELECT COUNT(*) FROM change_log WHERE entity_type = 'standings'`)
}
// activeLedgerTotal 统计未作废流水的分值合计。
func activeLedgerTotal(t *testing.T, env *testEnv) float64 {
t.Helper()
var total float64
if err := env.ev.QueryRow(`SELECT COALESCE(SUM(points), 0) FROM score_ledger WHERE voided = 0`).Scan(&total); err != nil {
t.Fatalf("统计流水合计: %v", err)
}
return total
}
// putTeamRanks 调团体名次写接口。
func putTeamRanks(t *testing.T, env *testEnv, client *http.Client, eventID, categoryID int64, ranks []map[string]any) (int, envelope) {
t.Helper()
return env.do(client, http.MethodPut,
fmt.Sprintf("/api/events/%d/categories/%d/team-ranks", eventID, categoryID),
map[string]any{"scope": "team", "basis": "manual", "ranks": ranks}, true)
}
// getTeamRanksRaw 调团体名次读接口,返回原始响应体(用于断言空数组等结构)。
func getTeamRanksRaw(t *testing.T, env *testEnv, client *http.Client, eventID, categoryID int64) (int, envelope) {
t.Helper()
return env.do(client, http.MethodGet,
fmt.Sprintf("/api/events/%d/categories/%d/team-ranks", eventID, categoryID), nil, false)
}
// publishEvent 只调发布接口(不重新存草稿):成绩已发布后再存草稿会被 409 拦下,
// 而「同一份数据发布两次」要验证的正是重复发布本身。
func publishEvent(t *testing.T, env *testEnv, client *http.Client, eventID int64, tag string) {
t.Helper()
status, env2 := env.do(client, http.MethodPost, "/api/results/publish",
map[string]any{"eventId": eventID, "requestId": tag + "-publish"}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
}
// seedTeamEvent 建一个纯团体小项:一条团体报名项(athlete_id 为空)+ 一条按成绩排名的团体通道。
func seedTeamEvent(t *testing.T, env *testEnv, ids seedIDs) (eventID, categoryID, entryID int64) {
t.Helper()
eventID = env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
compare, scored, multiplier, status)
VALUES (?, 'T4X100', '4x100 米接力', 'team', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport)
categoryID = env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '男子', 1, 0)`, eventID)
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'team', 'result', ?, 0)`, eventID, profileIDByCode(t, env, "team_top6"))
entryID = env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, NULL, 'registered')`, eventID, categoryID, ids.teamA)
return eventID, categoryID, entryID
}
// ---------- 团体名次读写 ----------
// 团体手工名次走接口写入后:名次落 channel_manual_ranks、按单位算分、大屏榜单被推送。
func TestTeamManualRanksWriteRecomputesAndPushesStandings(t *testing.T) {
env, ids, client := recorderWithSeed(t)
addTeamManualChannel(t, env, ids.event)
// 录入前:有通道、有单位清单,但没有任何名次。
status, env2 := getTeamRanksRaw(t, env, client, ids.event, ids.category)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读团体名次应 200,得到 %d %+v", status, env2.Error)
}
view := decodeData[map[string]any](t, env2)
if len(view["channels"].([]any)) != 1 || len(view["teams"].([]any)) != 2 || len(view["ranks"].([]any)) != 0 {
t.Fatalf("录入前的视图应为「1 通道 + 2 单位 + 0 名次」,得到 %+v", view)
}
before := standingsChangeRows(t, env)
status, env2 = putTeamRanks(t, env, client, ids.event, ids.category, []map[string]any{
{"teamId": ids.teamA, "rank": 1},
{"teamId": ids.teamB, "rank": 2},
})
if status != http.StatusOK || !env2.OK {
t.Fatalf("写团体名次应 200,得到 %d %+v", status, env2.Error)
}
filled := decodeData[map[string]any](t, env2)
if len(filled["ranks"].([]any)) != 2 {
t.Fatalf("写后应回填两条名次,得到 %+v", filled["ranks"])
}
if filled["categoryId"] != nil {
t.Fatalf("按组别路径取时响应不该带 categoryId,得到 %+v", filled["categoryId"])
}
// 团体名次只记单位:流水的 entry_id / result_id 都为空(「无报名项归属」那一类)。
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 0`); n != 2 {
t.Fatalf("团体名次应产出 2 条流水,得到 %d 条", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger
WHERE voided = 0 AND (entry_id IS NOT NULL OR result_id IS NOT NULL)`); n != 0 {
t.Fatalf("团体名次流水不该挂报名项/成绩,得到 %d 条", n)
}
// 名次与流水同源:A 第 1 名 15 分、B 第 2 名 10 分。
if got := teamPoints(t, env, client, ids.teamA); got != 15 {
t.Fatalf("A 单位总分应为 15,得到 %v", got)
}
if got := teamPoints(t, env, client, ids.teamB); got != 10 {
t.Fatalf("B 单位总分应为 10,得到 %v", got)
}
// channel_ranks 是榜单读取来源:主体是单位,名次与分值同引擎口径。
for _, want := range []struct {
teamID int64
rank int
points float64
}{{ids.teamA, 1, 15}, {ids.teamB, 2, 10}} {
n := countRows(t, env, `SELECT COUNT(*) FROM channel_ranks
WHERE subject_key = ? AND scope = 'team' AND basis = 'manual'
AND team_id = ? AND rank = ? AND points = ?`,
fmt.Sprintf("t:%d", want.teamID), want.teamID, want.rank, want.points)
if n != 1 {
t.Fatalf("channel_ranks 应有一行 team_id=%d rank=%d points=%v,得到 %d 行:%s",
want.teamID, want.rank, want.points, n, channelRanksDump(t, env))
}
}
// 大屏:写名次必须推榜单,否则界面说保存了、榜单还是旧分。
if after := standingsChangeRows(t, env); after <= before {
t.Fatalf("写团体名次后应推榜单:之前 %d 条,之后 %d 条", before, after)
}
// rank=0 表示清空该单位的记录:B 回到无分。
status, env2 = putTeamRanks(t, env, client, ids.event, ids.category, []map[string]any{
{"teamId": ids.teamB, "rank": 0},
})
if status != http.StatusOK || !env2.OK {
t.Fatalf("清空 B 的名次应 200,得到 %d %+v", status, env2.Error)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM channel_manual_ranks WHERE team_id = ?`, ids.teamB); n != 0 {
t.Fatalf("rank=0 应删掉该单位的记录,得到 %d 条", n)
}
if got := teamPoints(t, env, client, ids.teamB); got != 0 {
t.Fatalf("清空后 B 应无分,得到 %v", got)
}
if got := teamPoints(t, env, client, ids.teamA); got != 15 {
t.Fatalf("清空 B 不该动 A,得到 %v", got)
}
}
// 按组别重算只动该组别:团体名次的流水是「无报名项归属」那一类,
// 按组别重算时若只作废不补算,另一组的名次分就会凭空消失(设计 17 §十一.2)。
func TestTeamManualRanksCategoryRecomputeKeepsOtherGroups(t *testing.T) {
env, ids, client := recorderWithSeed(t)
addTeamManualChannel(t, env, ids.event)
otherCat := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '女子', 1, 1)`, ids.event)
// 两个组别各录一轮团体名次(名次只在组别内有效,A/B 的先后可以不同)。
if status, env2 := putTeamRanks(t, env, client, ids.event, ids.category, []map[string]any{
{"teamId": ids.teamA, "rank": 1},
{"teamId": ids.teamB, "rank": 2},
}); status != http.StatusOK {
t.Fatalf("写男子组名次应 200,得到 %d %+v", status, env2.Error)
}
if status, env2 := putTeamRanks(t, env, client, ids.event, otherCat, []map[string]any{
{"teamId": ids.teamA, "rank": 2},
{"teamId": ids.teamB, "rank": 1},
}); status != http.StatusOK {
t.Fatalf("写女子组名次应 200,得到 %d %+v", status, env2.Error)
}
// A = 15 + 10、B = 10 + 15;四类「无报名项归属」流水都在。
if total := activeLedgerTotal(t, env); total != 50 {
t.Fatalf("两组名次分合计应为 50,得到 %v", total)
}
ledgerBefore := ledgerActiveDump(t, env)
ranksBefore := channelRanksDump(t, env)
// 只重写男子组:女子组的团体名次分必须原样保留(既不丢也不翻倍)。
status, env2 := putTeamRanks(t, env, client, ids.event, ids.category, []map[string]any{
{"teamId": ids.teamA, "rank": 1},
{"teamId": ids.teamB, "rank": 2},
})
if status != http.StatusOK || !env2.OK {
t.Fatalf("重写男子组名次应 200,得到 %d %+v", status, env2.Error)
}
if after := ledgerActiveDump(t, env); after != ledgerBefore {
t.Fatalf("按组别重算不该动其它组别的名次分:\n之前 %s\n之后 %s", ledgerBefore, after)
}
if after := channelRanksDump(t, env); after != ranksBefore {
t.Fatalf("按组别重算不该动其它组别的名次:\n之前 %s\n之后 %s", ranksBefore, after)
}
if total := activeLedgerTotal(t, env); total != 50 {
t.Fatalf("重算后合计应仍为 50,得到 %v", total)
}
}
// P0 回归:同一份数据连发两次,流水与名次必须逐字节一致。
func TestPublishTwiceKeepsLedgerAndRanksIdentical(t *testing.T) {
env, ids, client := recorderWithSeed(t)
addTeamManualChannel(t, env, ids.event)
// 团体名次先录好:双通道项目(个人按成绩 + 团体按录入名次)。
if status, env2 := putTeamRanks(t, env, client, ids.event, ids.category, []map[string]any{
{"teamId": ids.teamA, "rank": 1},
{"teamId": ids.teamB, "rank": 2},
}); status != http.StatusOK {
t.Fatalf("写团体名次应 200,得到 %d %+v", status, env2.Error)
}
publishResults(t, env, client, ids.event, "p1",
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"},
)
// 个人通道 10 / 8 分;团体手工通道读 channel_manual_ranks,不受 results.rank 影响。
if total := activeLedgerTotal(t, env); total != 10+8+15+10 {
t.Fatalf("首次发布后合计应为 43(10+8+15+10),得到 %v", total)
}
ledgerFirst := ledgerActiveDump(t, env)
ranksFirst := channelRanksDump(t, env)
// channel_ranks 每个通道每个主体一行:个人 2 行 + 团体 2 行。
if n := countRows(t, env, `SELECT COUNT(*) FROM channel_ranks`); n != 4 {
t.Fatalf("channel_ranks 应有 4 行(个人 2 + 团体 2),得到 %d", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM channel_ranks WHERE category_id = ?`, ids.category); n != 4 {
t.Fatalf("该组别应有 4 行名次,得到 %d", n)
}
// 换一个 requestId,确保第二次是真执行而不是幂等回放(成绩已发布,不再重存草稿)。
publishEvent(t, env, client, ids.event, "p2")
if total := activeLedgerTotal(t, env); total != 43 {
t.Fatalf("第二次发布后合计应仍为 43(不得翻倍),得到 %v", total)
}
if after := ledgerActiveDump(t, env); after != ledgerFirst {
t.Fatalf("同一份数据发布两次流水应完全一致:\n第一次 %s\n第二次 %s", ledgerFirst, after)
}
if after := channelRanksDump(t, env); after != ranksFirst {
t.Fatalf("同一份数据发布两次名次应完全一致:\n第一次 %s\n第二次 %s", ranksFirst, after)
}
}
// 团体通道的名次不进 results.rank:个人名次照写,团体报名项的名次留在 channel_ranks。
func TestApplyComputedRanksLeavesTeamRanksAlone(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, categoryID, teamEntry := seedTeamEvent(t, env, ids)
// 个人小项:两条个人成绩,rank 应写回 results。
publishResults(t, env, client, ids.event, "ind",
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"},
)
// 纯团体小项:团体报名项(athlete_id 为空)按成绩排名。
publishResults(t, env, client, eventID, "team",
map[string]any{"entryId": teamEntry, "resultValue": 45.20, "resultText": "45.20", "unit": "s", "status": "ok"},
)
var rankA, rankB, teamRank any
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, ids.entryA).Scan(&rankA); err != nil {
t.Fatalf("读个人名次: %v", err)
}
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, ids.entryB).Scan(&rankB); err != nil {
t.Fatalf("读个人名次: %v", err)
}
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, teamEntry).Scan(&teamRank); err != nil {
t.Fatalf("读团体名次: %v", err)
}
if rankA != int64(1) || rankB != int64(2) {
t.Fatalf("个人名次应回写 results.rank(1 / 2),得到 %v / %v", rankA, rankB)
}
if teamRank != nil {
t.Fatalf("团体名次不该写进 results.rank,得到 %v", teamRank)
}
// 但团体名次必须落在 channel_ranks,奖牌榜才看得到。
if n := countRows(t, env, `SELECT COUNT(*) FROM channel_ranks
WHERE event_id = ? AND category_id = ? AND scope = 'team' AND rank = 1`, eventID, categoryID); n != 1 {
t.Fatalf("团体名次应落 channel_ranks 一行 rank=1,得到 %d 行", n)
}
if got := teamPoints(t, env, client, ids.teamA); got != 15+10 {
t.Fatalf("A 应拿团体 15 分与个人 10 分,得到 %v", got)
}
}
// ---------- 改配置即重算 ----------
// 整体替换通道会重建通道 id(channel_ranks 随之级联清空):必须重算重建 + 推榜单。
func TestReplaceChannelsRecomputesAndPushesStandings(t *testing.T) {
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "cfg",
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"},
)
ledgerBefore := ledgerActiveDump(t, env)
before := standingsChangeRows(t, env)
status, env2 := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/channels", ids.event),
map[string]any{"channels": []map[string]any{
{"scope": "athlete", "basis": "result", "profileId": profileIDByCode(t, env, "individual_top6"), "sortOrder": 0},
}}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("替换通道应 200,得到 %d %+v", status, env2.Error)
}
if after := ledgerActiveDump(t, env); after != ledgerBefore {
t.Fatalf("同配置替换通道后流水应一致:\n之前 %s\n之后 %s", ledgerBefore, after)
}
// 通道 id 变了、旧名次被级联删掉;不重算就会是空榜。
if n := countRows(t, env, `SELECT COUNT(*) FROM channel_ranks WHERE event_id = ?`, ids.event); n != 2 {
t.Fatalf("替换通道后应重建 2 行名次,得到 %d", n)
}
if after := standingsChangeRows(t, env); after <= before {
t.Fatalf("替换通道应推榜单:之前 %d,之后 %d", before, after)
}
}
// updateEvent 只在 multiplier / avg_mode / scored / award_medals 真的变了时重算并推榜。
func TestUpdateEventRecomputesOnlyOnScoringFieldChange(t *testing.T) {
type tc struct {
name string
mutate func(body map[string]any)
check func(t *testing.T, env *testEnv, ids seedIDs, client *http.Client)
}
baseBody := func(ids seedIDs) map[string]any {
return map[string]any{
"sportId": ids.sport, "code": "M100", "name": "100米跑",
"eventType": "individual", "resultMode": "auto", "resultType": "time",
"compare": "asc", "multiplier": 1, "status": "ongoing", "avgMode": "valid",
}
}
cases := []tc{
{
name: "multiplier 真变",
mutate: func(b map[string]any) { b["multiplier"] = 2 },
check: func(t *testing.T, env *testEnv, ids seedIDs, client *http.Client) {
if total := activeLedgerTotal(t, env); total != 20+16 {
t.Fatalf("权重翻倍后合计应为 36,得到 %v", total)
}
},
},
{
name: "avg_mode 真变",
mutate: func(b map[string]any) { b["avgMode"] = "all" },
check: func(t *testing.T, env *testEnv, ids seedIDs, client *http.Client) {
// 个人按成绩的通道不受平均值口径影响:分值不变,但流水被重写了一遍。
if total := activeLedgerTotal(t, env); total != 18 {
t.Fatalf("改平均值口径不该改个人通道分值,得到 %v", total)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 1`); n == 0 {
t.Fatalf("改平均值口径应触发重算(应有作废流水)")
}
},
},
{
name: "scored 真变",
mutate: func(b map[string]any) { b["scored"] = false },
check: func(t *testing.T, env *testEnv, ids seedIDs, client *http.Client) {
if total := activeLedgerTotal(t, env); total != 0 {
t.Fatalf("关掉计分后应无流水,得到 %v", total)
}
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
t.Fatalf("关掉计分后榜单应清零,得到 %v", got)
}
},
},
{
name: "award_medals 真变",
mutate: func(b map[string]any) { b["awardMedals"] = false },
check: func(t *testing.T, env *testEnv, ids seedIDs, client *http.Client) {
if total := activeLedgerTotal(t, env); total != 18 {
t.Fatalf("关掉奖牌不该动积分,得到 %v", total)
}
if gold := teamGold(t, env, client, ids.teamA); gold != 0 {
t.Fatalf("关掉奖牌结算后 A 不该有金牌,得到 %d", gold)
}
},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "evt",
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 gold := teamGold(t, env, client, ids.teamA); gold != 1 {
t.Fatalf("前置条件:A 应有 1 金,得到 %d", gold)
}
before := standingsChangeRows(t, env)
body := baseBody(ids)
c.mutate(body)
status, env2 := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", ids.event), body, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("更新小项应 200,得到 %d %+v", status, env2.Error)
}
if after := standingsChangeRows(t, env); after <= before {
t.Fatalf("改算分字段应推榜单:之前 %d,之后 %d", before, after)
}
c.check(t, env, ids, client)
})
}
// 只改名不该触发重算:既不重写流水,也不推榜单(重算会白作废一遍、还多一次推送)。
t.Run("只改名不重算", func(t *testing.T) {
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "noop",
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"},
)
before := standingsChangeRows(t, env)
ledgerBefore := ledgerActiveDump(t, env)
body := baseBody(ids)
body["name"] = "100米跑(改名)"
status, env2 := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", ids.event), body, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("更新小项应 200,得到 %d %+v", status, env2.Error)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 1`); n != 0 {
t.Fatalf("只改名不该重算,得到 %d 条作废流水", n)
}
if after := ledgerActiveDump(t, env); after != ledgerBefore {
t.Fatalf("只改名不该动流水:\n之前 %s\n之后 %s", ledgerBefore, after)
}
if after := standingsChangeRows(t, env); after != before {
t.Fatalf("只改名不该推榜单:之前 %d,之后 %d", before, after)
}
})
}
// 积分模板分值变更要全量重算(模板是算分公式的输入)。
func TestUpdateProfileRecomputesAllEvents(t *testing.T) {
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "prof",
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 total := activeLedgerTotal(t, env); total != 18 {
t.Fatalf("前置条件:合计应为 18,得到 %v", total)
}
before := standingsChangeRows(t, env)
profileID := profileIDByCode(t, env, "individual_top6")
status, env2 := env.do(client, http.MethodGet, fmt.Sprintf("/api/scoring-profiles/%d", profileID), nil, false)
if status != http.StatusOK {
t.Fatalf("读积分模板应 200,得到 %d", status)
}
body := decodeData[map[string]any](t, env2)
body["rules"] = []map[string]any{
{"rank": 1, "points": 20},
{"rank": 2, "points": 16},
{"rank": 3, "points": 6},
{"rank": 4, "points": 3},
{"rank": 5, "points": 2},
{"rank": 6, "points": 1},
}
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/scoring-profiles/%d", profileID), body, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("更新积分模板应 200,得到 %d %+v", status, env2.Error)
}
if total := activeLedgerTotal(t, env); total != 20+16 {
t.Fatalf("模板改分后应全量重算为 36,得到 %v", total)
}
if after := standingsChangeRows(t, env); after <= before {
t.Fatalf("模板改分应推榜单:之前 %d,之后 %d", before, after)
}
}
// ---------- 鉴权与校验 ----------
func TestTeamRanksAuthAndValidation(t *testing.T) {
env, ids, client := recorderWithSeed(t)
addTeamManualChannel(t, env, ids.event)
// guest 只读:读 200。
guest, _ := env.client()
if status, _ := getTeamRanksRaw(t, env, guest, ids.event, ids.category); status != http.StatusOK {
t.Fatalf("guest 读团体名次应 200,得到 %d", status)
}
// guest 写被拦(与其它写路由同口径:未登录 → 401 AUTH_REQUIRED,不是 200)。
// 未登录请求由 RequireWrite 处理,不带 CSRF 也无需 CSRF。
status, env2 := env.do(guest, http.MethodPut,
fmt.Sprintf("/api/events/%d/categories/%d/team-ranks", ids.event, ids.category),
map[string]any{"scope": "team", "basis": "manual", "ranks": []map[string]any{{"teamId": ids.teamA, "rank": 1}}},
false)
if status != http.StatusUnauthorized || env2.Error == nil || env2.Error.Code != "AUTH_REQUIRED" {
t.Fatalf("guest 写团体名次应 401 AUTH_REQUIRED,得到 %d %+v", status, env2.Error)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM channel_manual_ranks`); n != 0 {
t.Fatalf("被拦下的写请求不该落库,得到 %d 条", n)
}
// recorder 可写。
if status, env2 := putTeamRanks(t, env, client, ids.event, ids.category,
[]map[string]any{{"teamId": ids.teamA, "rank": 1}}); status != http.StatusOK {
t.Fatalf("recorder 写团体名次应 200,得到 %d %+v", status, env2.Error)
}
// 校验失败一律 400 + 标准错误体(不是 500,也不能静默成功)。
checkBadRequest := func(name string, wantCode any, ranks []map[string]any, scope string) {
t.Helper()
status, resp := env.do(client, http.MethodPut,
fmt.Sprintf("/api/events/%d/categories/%d/team-ranks", ids.event, ids.category),
map[string]any{"scope": scope, "basis": "manual", "ranks": ranks}, true)
if status != http.StatusBadRequest {
t.Fatalf("%s 应 400,得到 %d %+v", name, status, resp.Error)
}
if resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" || resp.Error.Message == "" {
t.Fatalf("%s 应返回 VALIDATION_FAILED 标准错误体,得到 %+v", name, resp.Error)
}
if wantCode != nil && !containsDetail(resp.Error.Detail, wantCode) {
t.Fatalf("%s 的 detail 应指向 %v,得到 %s", name, wantCode, resp.Error.Detail)
}
}
checkBadRequest("rank 为负", nil, []map[string]any{{"teamId": ids.teamA, "rank": -1}}, "team")
checkBadRequest("teamId 不存在", float64(999999), []map[string]any{{"teamId": 999999, "rank": 1}}, "team")
// 已假删除的单位也不能写。
env.exec(t, `UPDATE teams SET deleted_at = datetime('now'), active = 0 WHERE id = ?`, ids.teamB)
checkBadRequest("teamId 已假删除", float64(ids.teamB), []map[string]any{{"teamId": ids.teamB, "rank": 1}}, "team")
// scope/basis 与该小项的通道对不上。
checkBadRequest("scope 不匹配", "athlete", []map[string]any{{"teamId": ids.teamA, "rank": 1}}, "athlete")
}
// 单位积分下钻的名次列也要读 channel_ranks:团体名次没有 results.rank 可读(设计 17 §11.3)。
func TestTeamBreakdownShowsChannelRanks(t *testing.T) {
env, ids, client := recorderWithSeed(t)
addTeamManualChannel(t, env, ids.event)
publishResults(t, env, client, ids.event, "bd",
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 := putTeamRanks(t, env, client, ids.event, ids.category, []map[string]any{
{"teamId": ids.teamA, "rank": 1},
{"teamId": ids.teamB, "rank": 2},
}); status != http.StatusOK {
t.Fatalf("写团体名次应 200,得到 %d %+v", status, env2.Error)
}
status, env2 := env.do(client, http.MethodGet,
fmt.Sprintf("/api/standings/teams/%d/breakdown", ids.teamA), nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读单位积分明细应 200,得到 %d %+v", status, env2.Error)
}
breakdown := decodeData[map[string]any](t, env2)
ranks := make(map[float64]any)
for _, raw := range breakdown["items"].([]any) {
item := raw.(map[string]any)
ranks[item["points"].(float64)] = item["rank"]
}
// 个人流水(10 分)与团体名次流水(15 分)都要能显示名次:A 两项都是第 1 名。
if ranks[10] != float64(1) {
t.Fatalf("个人流水应显示名次 1,得到 %+v", ranks)
}
if ranks[15] != float64(1) {
t.Fatalf("团体名次流水应显示名次 1(读 channel_ranks),得到 %+v", ranks)
}
}
// ---------- 录入页载荷 ----------
// 成绩录入接口随行下发团体名次:有团体手工通道的组别才出现在 teamRanks 里。
func TestEntryBoardCarriesTeamRanks(t *testing.T) {
env, ids, client := recorderWithSeed(t)
otherCat := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '女子', 1, 1)`, ids.event)
// 没有团体手工通道时:teamRanks 是空数组,不是 null / 不是缺字段。
board := getEntryBoard(t, env, client, ids.event)
if board.TeamRanks == nil || len(board.TeamRanks) != 0 {
t.Fatalf("无团体手工通道时 teamRanks 应为空数组,得到 %+v", board.TeamRanks)
}
if len(board.Entries) != 2 {
t.Fatalf("报名行应照旧下发 2 条,得到 %d", len(board.Entries))
}
addTeamManualChannel(t, env, ids.event)
if status, env2 := putTeamRanks(t, env, client, ids.event, otherCat, []map[string]any{
{"teamId": ids.teamA, "rank": 3},
}); status != http.StatusOK {
t.Fatalf("写女子组名次应 200,得到 %d %+v", status, env2.Error)
}
board = getEntryBoard(t, env, client, ids.event)
if len(board.TeamRanks) != 2 {
t.Fatalf("两个组别都应随行下发团体名次视图,得到 %+v", board.TeamRanks)
}
byCategory := make(map[float64]map[string]any, len(board.TeamRanks))
for _, view := range board.TeamRanks {
id, _ := view["categoryId"].(float64)
byCategory[id] = view
}
other := byCategory[float64(otherCat)]
if other == nil {
t.Fatalf("随行视图应带 categoryId 供录入页按组别取用,得到 %+v", board.TeamRanks)
}
if len(other["channels"].([]any)) != 1 || len(other["teams"].([]any)) != 2 || len(other["ranks"].([]any)) != 1 {
t.Fatalf("女子组视图应为「1 通道 + 2 单位 + 1 名次」,得到 %+v", other)
}
if v := byCategory[float64(ids.category)]; v == nil || len(v["ranks"].([]any)) != 0 {
t.Fatalf("男子组视图应存在但没有名次,得到 %+v", byCategory)
}
}
// containsDetail 判断错误 detail 里是否出现某个值(detail 可能是数字或字符串数组)。
func containsDetail(detail []byte, want any) bool {
s := string(detail)
switch v := want.(type) {
case string:
return strings.Contains(s, v)
case float64:
return strings.Contains(s, fmt.Sprintf("%d", int64(v)))
default:
return strings.Contains(s, fmt.Sprint(v))
}
}
// teamGold 从榜单读某单位的金牌数。
func teamGold(t *testing.T, env *testEnv, client *http.Client, teamID int64) int {
t.Helper()
status, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读积分榜应 200,得到 %d %+v", status, env2.Error)
}
for _, row := range decodeData[[]map[string]any](t, env2) {
if row["teamId"] == float64(teamID) {
gold, _ := row["gold"].(float64)
return int(gold)
}
}
return 0
}