Files
Scoreboard/server/internal/httpapi/entry_heat_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

269 lines
10 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 (
"fmt"
"net/http"
"strings"
"testing"
)
// importHeaderHeat 是带批次列的 12 列表头(与 ImportSection 模板一致)。
const importHeaderHeat = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,heat,status,member_names"
// 批量设置批次:成功写库、GET 下发、再传 null 清除;重复提交幂等。
func TestSetEntriesHeatSetsAndClears(t *testing.T) {
env, ids, client := recorderWithSeed(t)
status, env2 := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
"entryIds": []int64{ids.entryA, ids.entryB},
"heat": 2,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("设置批次应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["updated"] != float64(2) || result["heat"] != float64(2) {
t.Fatalf("批量设置结果不对: %+v", result)
}
var heat int
if err := env.ev.QueryRow(`SELECT heat FROM entries WHERE id = ?`, ids.entryA).Scan(&heat); err != nil {
t.Fatalf("读 entries.heat: %v", err)
}
if heat != 2 {
t.Fatalf("entryA 的批次应为 2,得到 %d", heat)
}
// 下发路径也要带上 heat:录入页靠 GET /api/events/{id}/entries 拿批次。
status, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d/entries", ids.event), nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读报名列表应 200,得到 %d %+v", status, env2.Error)
}
items := getEntryBoard(t, env, client, ids.event).Entries
if len(items) != 2 {
t.Fatalf("报名列表应有 2 条,得到 %d", len(items))
}
for _, item := range items {
if item["heat"] != float64(2) {
t.Fatalf("报名列表应下发 heat=2,得到 %+v", item["heat"])
}
}
// 幂等:同一批再设一次批次号,结果一致。
status, env2 = env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
"entryIds": []int64{ids.entryA, ids.entryB},
"heat": 2,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("重复设置同一批次应 200,得到 %d %+v", status, env2.Error)
}
if again := decodeData[map[string]any](t, env2); again["updated"] != float64(2) {
t.Fatalf("重复设置应同样更新 2 条,得到 %+v", again)
}
// 传 null 清除为「未分批」。
status, env2 = env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
"entryIds": []int64{ids.entryA, ids.entryB},
"heat": nil,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("清除批次应 200,得到 %d %+v", status, env2.Error)
}
if cleared := decodeData[map[string]any](t, env2); cleared["heat"] != nil {
t.Fatalf("清除后 heat 应为 null,得到 %+v", cleared["heat"])
}
var nullHeat any
if err := env.ev.QueryRow(`SELECT heat FROM entries WHERE id = ?`, ids.entryA).Scan(&nullHeat); err != nil {
t.Fatalf("读 entries.heat: %v", err)
}
if nullHeat != nil {
t.Fatalf("清除后 entries.heat 应为 NULL,得到 %v", nullHeat)
}
}
// 跨小项批量设置一定是选错行:必须拒绝,且一条都不能改。
func TestSetEntriesHeatRejectsCrossEvent(t *testing.T) {
env, ids, client := recorderWithSeed(t)
otherEvent := 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)
otherCategory := env.insert(t,
`INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, otherEvent)
otherEntry := env.insert(t,
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`,
otherEvent, otherCategory, ids.teamB, ids.athB)
status, env2 := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
"entryIds": []int64{ids.entryA, otherEntry},
"heat": 1,
}, true)
if status != http.StatusBadRequest {
t.Fatalf("跨小项设置批次应 400,得到 %d %+v", status, env2.Error)
}
assertNoHeat(t, env, ids.entryA)
assertNoHeat(t, env, otherEntry)
}
// entryIds 为空 / 全部非法、以及 heat 越界都必须被拒。
func TestSetEntriesHeatValidatesInput(t *testing.T) {
env, ids, client := recorderWithSeed(t)
cases := []struct {
name string
body map[string]any
}{
{"entryIds 为空", map[string]any{"entryIds": []int64{}, "heat": 1}},
{"entryIds 缺失", map[string]any{"heat": 1}},
{"entryIds 全是非法 id", map[string]any{"entryIds": []int64{0, -3}, "heat": 1}},
{"heat 为 0", map[string]any{"entryIds": []int64{ids.entryA}, "heat": 0}},
{"heat 为负", map[string]any{"entryIds": []int64{ids.entryA}, "heat": -1}},
{"heat 超上限", map[string]any{"entryIds": []int64{ids.entryA}, "heat": 100000}},
{"报名项不存在", map[string]any{"entryIds": []int64{ids.entryA, 999999}, "heat": 1}},
}
for _, tc := range cases {
status, env2 := env.do(client, http.MethodPut, "/api/entries/heat", tc.body, true)
if status != http.StatusBadRequest {
t.Errorf("%s 应 400,得到 %d %+v", tc.name, status, env2.Error)
continue
}
if env2.Error == nil || env2.Error.Code != "VALIDATION_FAILED" {
t.Errorf("%s 应返回 VALIDATION_FAILED,得到 %+v", tc.name, env2.Error)
}
}
assertNoHeat(t, env, ids.entryA)
}
// 权限:guest 未登录 401;recorder 放行;批次写操作进审计。
func TestSetEntriesHeatPermissionAndAudit(t *testing.T) {
env, ids, _ := recorderWithSeed(t)
guest, _ := env.client()
status, env2 := env.do(guest, http.MethodPut, "/api/entries/heat", map[string]any{
"entryIds": []int64{ids.entryA}, "heat": 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)
}
assertNoHeat(t, env, ids.entryA)
recorder, _ := env.client()
env.login(recorder, "recorder", "recorder-pass")
status, env2 = env.do(recorder, http.MethodPut, "/api/entries/heat", map[string]any{
"entryIds": []int64{ids.entryA}, "heat": 3,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("recorder 设置批次应 200,得到 %d %+v", status, env2.Error)
}
var (
action, after string
)
if err := env.ev.QueryRow(
`SELECT action, after_json FROM audit_logs WHERE action = 'entry.heat' ORDER BY id DESC LIMIT 1`).
Scan(&action, &after); err != nil {
t.Fatalf("查批次审计: %v", err)
}
if !strings.Contains(after, `"heat":3`) {
t.Fatalf("审计里应记下批次号 3,得到 %s", after)
}
}
func assertNoHeat(t *testing.T, env *testEnv, entryID int64) {
t.Helper()
var heat any
if err := env.ev.QueryRow(`SELECT heat FROM entries WHERE id = ?`, entryID).Scan(&heat); err != nil {
t.Fatalf("读 entries.heat: %v", err)
}
if heat != nil {
t.Fatalf("entry %d 的批次应保持未分批,得到 %v", entryID, heat)
}
}
// 导入带 heat 列:正常值入库、留空 = 未分批、非法值走行级拦截且其余行照常导入。
func TestImportEntriesWithHeatColumn(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeaderHeat + "\n" +
"M100,男子,A,张三,110101199001011234,男,,,,1,registered,\n" +
"M100,男子,A,李强,110101199001011235,男,,,,2,registered,\n" +
"M100,男子,B,王芳,110101199001011236,女,,,,3,registered,\n" +
"M100,男子,A,赵六,110101199001011237,男,,,,abc,registered,\n" +
"M100,男子,B,孙七,110101199001011238,男,,,,0,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["created"] != float64(3) || result["skipped"] != float64(2) {
t.Fatalf("应新建 3 条、跳过 2 条,得到 %+v", result)
}
errs, _ := result["errors"].([]any)
if len(errs) != 2 {
t.Fatalf("应有 2 条行级错误,得到 %+v", errs)
}
for _, raw := range errs {
text, _ := raw.(string)
if !strings.Contains(text, "heat") {
t.Errorf("行级错误应说明 heat 非法,得到 %q", text)
}
}
// 正常值入库;heat 非法的两行被拦,不影响同一份表里的其余行。
for idCard, want := range map[string]int64{
"110101199001011234": 1,
"110101199001011235": 2,
"110101199001011236": 3,
} {
var heat int64
if err := env.ev.QueryRow(
`SELECT e.heat FROM entries e JOIN athletes a ON a.id = e.athlete_id WHERE a.id_card = ?`, idCard).
Scan(&heat); err != nil {
t.Fatalf("读 %s 的报名批次: %v", idCard, err)
}
if heat != want {
t.Errorf("%s 的批次应为 %d,得到 %d", idCard, want, heat)
}
}
// 非法值的两行没有落库。
assertCount(t, env, `SELECT COUNT(*) FROM athletes WHERE id_card IN ('110101199001011237','110101199001011238')`, 0)
// 表头没有 heat 列时(老模板)照常导入,批次为未分批。
status, env2 = env.postImport(t, client, "/api/entries/import", importHeader+
"\nM100,男子,A,周八,110101199001011239,男,,,,registered,\n", "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("无 heat 列的老模板应照常导入,得到 %d %+v", status, env2.Error)
}
var heat any
if err := env.ev.QueryRow(
`SELECT e.heat FROM entries e JOIN athletes a ON a.id = e.athlete_id WHERE a.id_card = '110101199001011239'`).
Scan(&heat); err != nil {
t.Fatalf("读老模板导入的报名批次: %v", err)
}
if heat != nil {
t.Fatalf("无 heat 列时应为未分批,得到 %v", heat)
}
}
// 导入留空 = 未分批:显式带 heat 列但留空,写入 NULL。
func TestImportEntriesHeatBlankMeansUnbatched(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeaderHeat + "\nM100,男子,A,张三,110101199001011234,男,,,,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
if result := decodeData[map[string]any](t, env2); result["created"] != float64(1) {
t.Fatalf("应新建 1 条,得到 %+v", result)
}
var heat any
if err := env.ev.QueryRow(
`SELECT e.heat FROM entries e JOIN athletes a ON a.id = e.athlete_id WHERE a.id_card = '110101199001011234'`).
Scan(&heat); err != nil {
t.Fatalf("读报名批次: %v", err)
}
if heat != nil {
t.Fatalf("留空应为未分批(NULL),得到 %v", heat)
}
}