## 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 例 + 语法检查。
682 lines
30 KiB
Go
682 lines
30 KiB
Go
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
|
||
}
|