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 例 + 语法检查。
This commit is contained in:
AskaEth committed 2026-09-11 21:47:07 +08:00
1 parent 53a0cb225c
commit 45edfbeb71
115 files changed
+10104 -755

No files matched your search

@@ -0,0 +1,251 @@
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)
}
}