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 }