Files
Scoreboard/server/internal/httpapi/rules_handlers_test.go
T
AskaEth 13f1f03cd0 fix: 修掉十路审查发现的 9 个 P0 与一批 P1(数据正确性 / 越权 / 现场可用性)
P0(9 条全修):
- 多通道项目奖牌重复计数:domain 层按 (EventID,CategoryID,EntryID/Subject) 去重,
  通道优先级 athlete→team→id
- 「只排名不计分」项目(scored:false)不再进奖牌榜,REST/WS/导出口径统一
- aggregate 流水 entry_id 恒 NULL 导致重算后分数翻倍:作废范围改按 event+category 定位
- 已发布成绩仍可被「存草稿」覆盖并清空 rank:服务端 409 STATE_INVALID + 前端禁编辑
- voidResult / 规则导入重算后名次不回写:接住 Ranked 调 applyComputedRanks
- 赛事包覆盖导入可能「旧项目已删、新项目没成」:先改名成功再清理,失败回滚
- 导入赛事包可覆盖正在运行的赛事目录:name==当前项目直接 400
- Console spawn 失败后守护器永久卡死:清 child/pid + 调度重启 + stop 超时兜底
- /ws 快照向非 admin 下发 admin-only 配置:按角色裁剪 settings

P1(计分口径 / 契约 / 现场):
- standings.tieBreak 三处接线;profiles[].recordBonusMode 生效;破纪录校验
  RecordEligible 与 entry 归属;破纪录登记校验已发布成绩
- export.loadRanked 与主路径对齐;ListBackups reason 解析;CLI backup 前缀修正
  (原来只增不减会撑爆磁盘);单位积分明细/导出统一权重折算;停用单位展示
- 规则导入原子化(重算并入同一事务)+ 返回 compatible/affectedEventCount/
  recalculatedLedgerRows;登录审计 user_id 修正
- WS 策略性断开用 Close(code,reason);HelloMessage 补 page/instanceId;
  实例注册表清理;settings/单位/项目写操作补 change_log 触发大屏刷新
- 大屏:全量快照不再被版本守卫丢弃;个人积分榜刷新;公告返回页/轮播;行数截断;
  REST 竞态;1008 停止重连;握手超时;时区补 Z;401 仅 AUTH_REQUIRED 才登出
- 管理端:published 行禁编辑;saveDraft 补 unit(1:06.33 显示);前端预览改用服务端
  points;空成绩行不再恒「已修改」;修订详情按 id 拉取;切项目请求序号与 dirty 保护;
  发布后刷新项目状态;NAV/路由按角色过滤;recorder 不再打 admin-only 接口
- Console:大屏窗口销毁后重建;大屏 URL 复用 host 归一化;backup.bat 改纯 ASCII
  (已在 Meowhome 实跑验证)

自检:服务端 gofmt/build/vet 通过、12 包测试全绿;前端 vue-tsc + build 通过、
node --test 24/24;console check-syntax 12 文件通过。
2026-09-09 22:16:58 +08:00

244 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 (
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)
// decodeRaw 解析 doRaw 返回的完整响应体({ok,data} 信封)。
func decodeRaw[T any](t *testing.T, raw []byte) T {
t.Helper()
var env envelope
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatalf("解析响应: %v (%s)", err, raw)
}
return decodeData[T](t, env)
}
// exportRulesYAML 取当前规则包(导出与导入共用同一序列化,保证往返可解析)。
func (e *testEnv) exportRulesYAML(t *testing.T, client *http.Client) string {
t.Helper()
resp, err := client.Get(e.server.URL + "/api/rules/export")
if err != nil {
t.Fatalf("导出规则包: %v", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != http.StatusOK {
t.Fatalf("导出规则包失败 status=%d err=%v", resp.StatusCode, err)
}
return string(body)
}
// publishTwoResults 录入两条成绩并发布,返回两条流水的分值(按单位)。
func (e *testEnv) publishTwoResults(t *testing.T, client *http.Client, ids seedIDs) {
t.Helper()
status, env2 := e.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"requestId": "draft-rules-1",
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("保存草稿应 200,得到 %d %+v", status, env2.Error)
}
status, env2 = e.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
"requestId": "publish-rules-1",
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
}
// ledgerPointsByTeam 读取当前有效流水的「单位 → 分值」。
func (e *testEnv) ledgerPointsByTeam(t *testing.T) map[int64]float64 {
t.Helper()
rows, err := e.ev.Query(`SELECT team_id, points FROM score_ledger WHERE voided = 0 AND source_type = 'result'`)
if err != nil {
t.Fatalf("查询流水: %v", err)
}
defer func() { _ = rows.Close() }()
out := map[int64]float64{}
for rows.Next() {
var team int64
var points float64
if err := rows.Scan(&team, &points); err != nil {
t.Fatalf("扫描流水: %v", err)
}
out[team] = points
}
return out
}
// 规则导入后必须回写名次:分值一变,results.rank 也要跟着新规则重算(P0-5)。
func TestRulesImportRewritesRanks(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
env.publishTwoResults(t, client, ids)
// 模拟名次过期/写坏,导入规则包后应被重算覆盖。
env.exec(t, `UPDATE results SET rank = 99 WHERE event_id = ?`, ids.event)
pack := env.exportRulesYAML(t, client)
status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(pack), "application/yaml", true)
if status != http.StatusOK {
t.Fatalf("导入规则包应 200,得到 %d %s", status, raw)
}
rows, err := env.ev.Query(`SELECT entry_id, rank FROM results WHERE event_id = ? ORDER BY entry_id`, ids.event)
if err != nil {
t.Fatalf("查询名次: %v", err)
}
defer func() { _ = rows.Close() }()
ranks := map[int64]int64{}
for rows.Next() {
var entryID, rank int64
if err := rows.Scan(&entryID, &rank); err != nil {
t.Fatalf("扫描名次: %v", err)
}
ranks[entryID] = rank
}
if ranks[ids.entryA] != 1 || ranks[ids.entryB] != 2 {
t.Fatalf("导入后名次应重算为 A=1 B=2,得到 %+v", ranks)
}
}
// 预览与落地响应必须带上 compatible / affectedEventCount / recalculatedLedgerRows(P1-11)。
func TestRulesImportReturnsContractFields(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
env.publishTwoResults(t, client, ids)
// 把 M100 的计分通道换成团队模板:制造一个「影响 1 个小项」的差异。
pack := env.exportRulesYAML(t, client)
changed := strings.Replace(pack, "profile: individual_top6", "profile: team_top6", 1)
if changed == pack {
t.Fatalf("规则包里没有可替换的计分通道,导出内容:\n%s", pack)
}
// 只改模板分值:引用了该模板的小项同样算「被影响」。
pointsChanged := strings.Replace(pack, "points: 10", "points: 11", 1)
if pointsChanged == pack {
t.Fatalf("规则包里没有可替换的分值,导出内容:\n%s", pack)
}
status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import?preview=1", []byte(pointsChanged), "application/yaml", true)
if status != http.StatusOK {
t.Fatalf("预览应 200,得到 %d %s", status, raw)
}
pointsPreview := decodeRaw[map[string]any](t, raw)
if count, _ := pointsPreview["affectedEventCount"].(float64); count != 1 {
t.Fatalf("只改模板分值时 affectedEventCount 应为 1,得到 %+v", pointsPreview["affectedEventCount"])
}
status, raw = env.doRaw(client, http.MethodPost, "/api/rules/import?preview=1", []byte(changed), "application/yaml", true)
if status != http.StatusOK {
t.Fatalf("预览应 200,得到 %d %s", status, raw)
}
preview := decodeRaw[map[string]any](t, raw)
if preview["compatible"] != true {
t.Fatalf("预览 compatible 应为 true,得到 %+v", preview["compatible"])
}
if count, _ := preview["affectedEventCount"].(float64); count != 1 {
t.Fatalf("预览 affectedEventCount 应为 1,得到 %+v", preview["affectedEventCount"])
}
if channels, _ := preview["channels"].([]any); len(channels) != 1 {
t.Fatalf("预览 channels 应含 1 个小项,得到 %+v", preview["channels"])
}
status, raw = env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(changed), "application/yaml", true)
if status != http.StatusOK {
t.Fatalf("导入应 200,得到 %d %s", status, raw)
}
applied := decodeRaw[map[string]any](t, raw)
if applied["compatible"] != true {
t.Fatalf("导入结果 compatible 应为 true,得到 %+v", applied["compatible"])
}
if count, _ := applied["affectedEventCount"].(float64); count != 1 {
t.Fatalf("导入结果 affectedEventCount 应为 1,得到 %+v", applied["affectedEventCount"])
}
if rows, _ := applied["recalculatedLedgerRows"].(float64); rows != 2 {
t.Fatalf("导入结果 recalculatedLedgerRows 应为 2,得到 %+v", applied["recalculatedLedgerRows"])
}
// 落地响应也要带差异明细,否则前端会把预览覆盖成「无差异」。
if channels, _ := applied["channels"].([]any); len(channels) != 1 {
t.Fatalf("导入结果 channels 应含 1 个小项,得到 %+v", applied["channels"])
}
// 换成团队模板后分值应变成 15/10,证明重算确实落库。
points := env.ledgerPointsByTeam(t)
if points[ids.teamA] != 15 || points[ids.teamB] != 10 {
t.Fatalf("导入后流水分值应为 A=15 B=10,得到 %+v", points)
}
}
// 重算失败必须显式报错,且整个重算在同一事务里回滚,不能留下「新规则 + 部分新积分」(P1-12)。
func TestRulesImportRecomputeFailureRollsBackAndReports(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
env.publishTwoResults(t, client, ids)
var profileID int64
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil {
t.Fatalf("读取积分模板: %v", err)
}
// 第二个项目 compare=none 却配了 basis=result 通道,重算到这里必然报错。
// 项目编码排在 M100 之后,保证替换到的第一个通道属于 M100。
broken := env.insert(t,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (?, 'Z_BROKEN', '损坏项目', 'individual', 'auto', 'time', 'none', 1, 1, 'ongoing')`, ids.sport)
brokenCat := env.insert(t,
`INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, broken)
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'athlete', 'result', ?, 0)`, broken, profileID)
brokenEntry := env.insert(t,
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, broken, brokenCat, ids.teamA, ids.athleteA)
env.insert(t, `INSERT INTO results (event_id, entry_id, result_value, result_text, unit, status, state, rank)
VALUES (?, ?, 11.0, '11.00', 's', 'ok', 'published', 1)`, broken, brokenEntry)
before := env.ledgerPointsByTeam(t)
pack := env.exportRulesYAML(t, client)
changed := strings.Replace(pack, "profile: individual_top6", "profile: team_top6", 1)
if changed == pack {
t.Fatalf("规则包里没有可替换的计分通道")
}
status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(changed), "application/yaml", true)
if status != http.StatusInternalServerError {
t.Fatalf("重算失败应 500,得到 %d %s", status, raw)
}
if !strings.Contains(string(raw), "积分重算失败") {
t.Fatalf("错误信息应说明重算失败,得到 %s", raw)
}
// 规则本身已经落库(Apply 先提交),但第一个项目的重算必须随第二个项目一起回滚。
var code string
if err := env.ev.QueryRow(`SELECT p.code FROM event_score_channels c
JOIN scoring_profiles p ON p.id = c.profile_id
WHERE c.event_id = ?`, ids.event).Scan(&code); err != nil {
t.Fatalf("查询导入后的计分通道: %v", err)
}
if code != "team_top6" {
t.Fatalf("规则应已落库(通道改为 team_top6),得到 %q", code)
}
after := env.ledgerPointsByTeam(t)
if after[ids.teamA] != before[ids.teamA] || after[ids.teamB] != before[ids.teamB] {
t.Fatalf("重算失败应整体回滚,流水不应变化:before=%+v after=%+v", before, after)
}
}