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