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

252 lines
8.9 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 (
"bytes"
"context"
"io"
"log/slog"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
)
// 本文件只覆盖「老库升级自愈」:迁移 0020 只建 channel_ranks、不回填,
// 升级后到下一次重算之间奖牌榜会全 0,启动自愈必须在监听端口前把它补回来。
// healResult 与 httpapi.HealResult 对应;测试里用别名只是为了让断言读起来短一点。
type healResult = httpapi.HealResult
// healLogger 造一个把日志写进 buffer 的 logger:自愈触发与否既有返回值、也有日志,
// 两个都断言,避免「返回值对了但根本没打日志」或反之。
func healLogger(buf *bytes.Buffer) *slog.Logger {
return slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
}
// runHeal 以 httpapi.Options 最小形态调用自愈(只有 Event 是必需的)。
func runHeal(t *testing.T, env *testEnv, logs *bytes.Buffer) healResult {
t.Helper()
res, err := httpapi.HealChannelRanksOnce(context.Background(),
httpapi.Options{Logger: healLogger(logs), Event: env.ev})
if err != nil {
t.Fatalf("自愈失败: %v", err)
}
return res
}
// healStanding 是 GET /api/standings 里本文件关心的字段。
type healStanding struct {
TeamID int64 `json:"teamId"`
Gold int `json:"gold"`
Silver int `json:"silver"`
Bronze int `json:"bronze"`
}
// medalsByTeam 读当前奖牌榜(只读 channel_ranks 的那张),返回「单位 → 金银铜」。
func medalsByTeam(t *testing.T, env *testEnv) map[int64]healStanding {
t.Helper()
client, _ := env.client()
status, resp := env.do(client, http.MethodGet, "/api/standings", nil, false)
if status != http.StatusOK || !resp.OK {
t.Fatalf("读奖牌榜应 200,得到 %d %+v", status, resp.Error)
}
out := map[int64]healStanding{}
for _, row := range decodeData[[]healStanding](t, resp) {
out[row.TeamID] = row
}
return out
}
func channelRankCount(t *testing.T, env *testEnv) int {
t.Helper()
var n int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM channel_ranks`).Scan(&n); err != nil {
t.Fatalf("统计 channel_ranks: %v", err)
}
return n
}
func activeLedgerCount(t *testing.T, env *testEnv) int {
t.Helper()
var n int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM score_ledger WHERE voided = 0`).Scan(&n); err != nil {
t.Fatalf("统计流水: %v", err)
}
return n
}
// healSeedAndPublish 造出「老库」的原料:两个单位各一名运动员跑 100 米,发布后 A 金 B 银。
// 不复用 rules_handlers_test.go 的 publishTwoResults,避免测试之间互相牵制。
func healSeedAndPublish(t *testing.T, env *testEnv, ids seedIDs) {
t.Helper()
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"requestId": "heal-draft-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 || !resp.OK {
t.Fatalf("保存草稿应 200,得到 %d %+v", status, resp.Error)
}
status, resp = env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
"requestId": "heal-publish-1",
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("发布应 200,得到 %d %+v", status, resp.Error)
}
}
// simulateOldDatabase 把库打回迁移 0020 刚跑完的样子:已发布成绩与 results.rank 都在,
// 但 channel_ranks 是空的。此时奖牌榜必然全 0,正是升级当天会出的那个故障。
func simulateOldDatabase(t *testing.T, env *testEnv) {
t.Helper()
if n := channelRankCount(t, env); n == 0 {
t.Fatalf("发布后 channel_ranks 应非空,否则这条用例没在模拟老库")
}
env.exec(t, `DELETE FROM channel_ranks`)
}
func TestHealChannelRanksRebuildsMedals(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
healSeedAndPublish(t, env, ids)
// 发布后的正常形态:A 金、B 银。
before := medalsByTeam(t, env)
if before[ids.teamA].Gold != 1 || before[ids.teamB].Silver != 1 {
t.Fatalf("发布后应 A 金 B 银,得到 %+v", before)
}
simulateOldDatabase(t, env)
// 先确认故障确实复现:只读 channel_ranks 的奖牌榜整块归零。
if broken := medalsByTeam(t, env); broken[ids.teamA].Gold != 0 || broken[ids.teamB].Silver != 0 {
t.Fatalf("channel_ranks 清空后奖牌榜应为 0(故障复现),得到 %+v", broken)
}
var logs bytes.Buffer
res := runHeal(t, env, &logs)
if !res.Triggered {
t.Fatalf("老库形态应触发自愈,得到 %+v", res)
}
if res.EventCount != 1 {
t.Fatalf("影响的小项数应为 1,得到 %d", res.EventCount)
}
if res.LedgerRows <= 0 {
t.Fatalf("重算应写回流水,得到 %d", res.LedgerRows)
}
if n := channelRankCount(t, env); n == 0 {
t.Fatalf("自愈后 channel_ranks 应非空")
}
after := medalsByTeam(t, env)
if after[ids.teamA].Gold != 1 || after[ids.teamB].Silver != 1 {
t.Fatalf("自愈后奖牌榜应恢复 A 金 B 银,得到 %+v", after)
}
logText := logs.String()
if !strings.Contains(logText, "检测到通道名次为空但已有已发布成绩,正在执行一次性全量重算") {
t.Fatalf("触发时应有一条中文 INFO,实际日志:\n%s", logText)
}
if !strings.Contains(logText, "一次性全量重算完成") {
t.Fatalf("完成时应有一条中文 INFO,实际日志:\n%s", logText)
}
}
// channel_ranks 非空 = 名次已经建过,哪怕是老库也不该再动:不能触发重算。
func TestHealChannelRanksSkipsWhenRanksPresent(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
healSeedAndPublish(t, env, ids)
before := channelRankCount(t, env)
beforeLedger := activeLedgerCount(t, env)
var logs bytes.Buffer
res := runHeal(t, env, &logs)
if res.Triggered {
t.Fatalf("channel_ranks 非空不应触发,得到 %+v", res)
}
if n := channelRankCount(t, env); n != before {
t.Fatalf("不应改动 channel_ranks:before=%d after=%d", before, n)
}
if n := activeLedgerCount(t, env); n != beforeLedger {
t.Fatalf("不应改动流水:before=%d after=%d", beforeLedger, n)
}
if strings.Contains(logs.String(), "一次性全量重算") {
t.Fatalf("未触发时不应有重算日志,实际日志:\n%s", logs.String())
}
}
// 没有「已发布且名次为正」的成绩时(全新库、只有草稿的库)都不该触发。
func TestHealChannelRanksSkipsWithoutPublishedRanks(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
// 全新库:channel_ranks 空,但没有已发布成绩。
var logs bytes.Buffer
if res := runHeal(t, env, &logs); res.Triggered {
t.Fatalf("全新库不应触发,得到 %+v", res)
}
// 只有草稿、且草稿连名次都填了:state=draft 仍不算「已发布」。
env.exec(t,
`INSERT INTO results (event_id, entry_id, result_value, result_text, unit, status, state, rank)
VALUES (?, ?, 11.58, '11.58', 's', 'ok', 'draft', 1)`, ids.event, ids.entryA)
logs.Reset()
if res := runHeal(t, env, &logs); res.Triggered {
t.Fatalf("只有草稿不应触发,得到 %+v", res)
}
if logs.Len() != 0 {
t.Fatalf("未触发时不应有日志,实际:\n%s", logs.String())
}
}
// 两次启动(两次调用)结果一致:第一次补上名次,第二次因表非空而跳过,数据不翻倍。
func TestHealChannelRanksIsIdempotent(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
healSeedAndPublish(t, env, ids)
simulateOldDatabase(t, env)
var firstLogs bytes.Buffer
first := runHeal(t, env, &firstLogs)
if !first.Triggered {
t.Fatalf("第一次应触发,得到 %+v", first)
}
ranksAfterFirst := channelRankCount(t, env)
ledgerAfterFirst := activeLedgerCount(t, env)
medalsAfterFirst := medalsByTeam(t, env)
var secondLogs bytes.Buffer
second := runHeal(t, env, &secondLogs)
if second.Triggered {
t.Fatalf("第二次不应再触发,得到 %+v", second)
}
if n := channelRankCount(t, env); n != ranksAfterFirst {
t.Fatalf("两次调用后 channel_ranks 应一致:%d → %d", ranksAfterFirst, n)
}
if n := activeLedgerCount(t, env); n != ledgerAfterFirst {
t.Fatalf("两次调用后流水应一致(不翻倍):%d → %d", ledgerAfterFirst, n)
}
if after := medalsByTeam(t, env); after[ids.teamA] != medalsAfterFirst[ids.teamA] ||
after[ids.teamB] != medalsAfterFirst[ids.teamB] {
t.Fatalf("两次调用后奖牌榜应一致:%+v → %+v", medalsAfterFirst, after)
}
// 再换一个 logger 调一次(模拟重启后新建 logger 的进程),结论必须一样。
discard := slog.New(slog.NewTextHandler(io.Discard, nil))
if res, err := httpapi.HealChannelRanksOnce(context.Background(),
httpapi.Options{Logger: discard, Event: env.ev}); err != nil || res.Triggered {
t.Fatalf("第三次调用应跳过:res=%+v err=%v", res, err)
}
}