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,68 @@
-- 通道名次与奖牌通道(devdocs/17 第三节的实现)。
--
-- 为什么要有这一版:
-- 1) P0 根因是 results.rank 一列同时承担「个人名次」「手工录入位置」「成绩表读取来源」
-- 三种角色。双通道项目(个人通道 + 团体手工通道)里,发布把个人名次写回 results.rank
-- 之后,团体通道又把同一列当成团体名次再发一次团体分,同一份数据连发两次总分跳变。
-- 本版把引擎产出的名次单独落一张 channel_ranks(按通道各自成行),并把 results.rank
-- 的语义收窄为「个人名次」——团体名次改由 channel_manual_ranks 按单位录入。
-- 2) 奖牌口径要能从「哪些通道算奖牌、个人奖牌算不算进单位榜」按通道控制,
-- 于是 events 加 award_medals、event_score_channels 加 count_to_medals /
-- count_to_team_medals,三列默认 1 = 与升级前行为完全一致(老库升级不改变既有榜单)。
-- 3) channel_ranks 是发布/重算时按 channel_id 全量重建的派生数据(先删后插),
-- 唯一索引 (channel_id, category_id, subject_key) 保证同一通道同一组别内一个主体一行。
-- 4) channel_manual_ranks 故意不挂 channel_id 外键:规则包导入会删掉并重建通道(id 变化),
-- 若挂外键会把录入员填好的团体名次一并级联删掉,改用 (小项, 组别, 单位, scope, basis) 定位。
--
-- 所有 DDL 都是「加列」或「新建表」,不动任何既有数据行,升级可重复执行(IF NOT EXISTS)。
-- 1) 小项级奖牌结算开关:默认 1(与升级前行为一致:scored=1 的小项前三名发牌)
ALTER TABLE events ADD COLUMN award_medals INTEGER NOT NULL DEFAULT 1
CHECK (award_medals IN (0, 1));
-- 2) 通道级奖牌开关
ALTER TABLE event_score_channels ADD COLUMN count_to_medals INTEGER NOT NULL DEFAULT 1
CHECK (count_to_medals IN (0, 1));
ALTER TABLE event_score_channels ADD COLUMN count_to_team_medals INTEGER NOT NULL DEFAULT 1
CHECK (count_to_team_medals IN (0, 1));
-- 3) 通道名次(引擎产出,发布时按 channel_id 全量重建)
CREATE TABLE IF NOT EXISTS channel_ranks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
category_id INTEGER NOT NULL REFERENCES event_categories(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES event_score_channels(id) ON DELETE CASCADE,
basis TEXT NOT NULL,
scope TEXT NOT NULL CHECK (scope IN ('athlete', 'team')),
subject_key TEXT NOT NULL, -- 'e:<entryId>' / 'a:<athleteId>' / 't:<teamId>'
entry_id INTEGER REFERENCES entries(id) ON DELETE SET NULL,
result_id INTEGER REFERENCES results(id) ON DELETE SET NULL,
athlete_id INTEGER REFERENCES athletes(id) ON DELETE SET NULL,
team_id INTEGER REFERENCES teams(id) ON DELETE SET NULL,
rank INTEGER NOT NULL CHECK (rank >= 1),
points REAL NOT NULL DEFAULT 0, -- 未乘个人→单位系数,与 RankedResult.Points 同口径
result_value REAL,
status TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_ranks_unique
ON channel_ranks(channel_id, category_id, subject_key);
CREATE INDEX IF NOT EXISTS idx_channel_ranks_event ON channel_ranks(event_id, category_id);
-- 4) 按单位录入的通道手工名次(dual 项目的团体手工通道用)
-- 故意不建到 channel_id 的外键:规则包导入会删掉并重建通道(id 变化),
-- 若挂外键会把录入员填好的团体名次一并级联删掉。用 (小项, 组别, 单位, scope, basis) 定位。
CREATE TABLE IF NOT EXISTS channel_manual_ranks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
category_id INTEGER NOT NULL REFERENCES event_categories(id) ON DELETE CASCADE,
team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
scope TEXT NOT NULL CHECK (scope IN ('athlete', 'team')),
basis TEXT NOT NULL,
rank INTEGER NOT NULL CHECK (rank >= 1),
updated_by INTEGER,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (event_id, category_id, team_id, scope, basis)
);
CREATE INDEX IF NOT EXISTS idx_channel_manual_ranks_event
ON channel_manual_ranks(event_id, category_id);
+263
View File
@@ -616,3 +616,266 @@ func TestMigration0019AddsHeatForExistingEntries(t *testing.T) {
t.Fatalf("清 heat: %v", err)
}
}
// tableColumns 返回 PRAGMA table_info 的列名集合,用于比对迁移新建表的结构。
func tableColumns(t *testing.T, db *sql.DB, table string) map[string]bool {
t.Helper()
rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
if err != nil {
t.Fatalf("读表结构 %s: %v", table, err)
}
defer func() { _ = rows.Close() }()
cols := map[string]bool{}
for rows.Next() {
var (
cid int
name string
ctype string
notNull int
dflt sql.NullString
pk int
)
if err := rows.Scan(&cid, &name, &ctype, &notNull, &dflt, &pk); err != nil {
t.Fatalf("扫描表结构 %s: %v", table, err)
}
cols[name] = true
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历表结构 %s: %v", table, err)
}
return cols
}
// extraColumns 返回实际列里多出来的部分,仅用于列数不符时的报错信息。
func extraColumns(got map[string]bool, want []string) []string {
known := make(map[string]bool, len(want))
for _, c := range want {
known[c] = true
}
var extra []string
for c := range got {
if !known[c] {
extra = append(extra, c)
}
}
sort.Strings(extra)
return extra
}
// ---------------------------------------------------------------------------
// 0020 迁移:events 加 award_medals,event_score_channels 加 count_to_medals /
// count_to_team_medals(三列默认 1,与升级前行为一致),新建 channel_ranks 与
// channel_manual_ranks。本用例守住「老数据不丢 + 默认值正确 + 新表结构 / 唯一约束 /
// CHECK 生效 + 迁移幂等」这几条底线。
// ---------------------------------------------------------------------------
// 老库升级:applyBefore 构造 0020 之前的老库,塞入老数据后走真实升级路径补跑 0020。
func TestMigration0020ChannelRanksAndMedals(t *testing.T) {
db := openEventDB(t)
applyBefore(t, db, "0020")
stmts := []string{
`INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`,
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`,
`INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id) VALUES (1, 'athlete', 'result', 1)`,
`INSERT INTO entries(event_id, category_id, team_id) VALUES (1, 1, 1)`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, state)
VALUES (1, 1, 1, '11.58', 11.58, 'published')`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
// 升级前不该有 0020 的列与表。
var probe sql.NullInt64
if err := db.QueryRow(`SELECT award_medals FROM events`).Scan(&probe); err == nil {
t.Fatal("升级前不该有 events.award_medals 列")
}
for _, table := range []string{"channel_ranks", "channel_manual_ranks"} {
var n int
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&n); err != nil {
t.Fatalf("查表 %s: %v", table, err)
}
if n != 0 {
t.Fatalf("升级前不该有 %s 表", table)
}
}
// 走真实升级路径;ADD COLUMN 只登记一次,重放会报 duplicate column。
for i := 1; i <= 2; i++ {
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("第 %d 次迁移: %v", i, err)
}
}
if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want {
t.Errorf("迁移登记数 = %d,期望 %d", got, want)
}
// 老数据一行不少,关键字段原样保留。
for table, want := range map[string]int{"events": 1, "event_score_channels": 1, "results": 1} {
if got := countRows(t, db, table); got != want {
t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want)
}
}
var (
code string
status string
rank int
state string
channel int
)
if err := db.QueryRow(`SELECT code, status FROM events WHERE id = 1`).Scan(&code, &status); err != nil {
t.Fatalf("读 events: %v", err)
}
if code != "E1" || status != "published" {
t.Errorf("老小项被改动: code=%q status=%q", code, status)
}
if err := db.QueryRow(`SELECT rank, state FROM results WHERE entry_id = 1`).Scan(&rank, &state); err != nil {
t.Fatalf("读成绩: %v", err)
}
if rank != 1 || state != "published" {
t.Errorf("老成绩被改动: rank=%d state=%q", rank, state)
}
var scope, basis string
if err := db.QueryRow(`SELECT id, scope, basis FROM event_score_channels WHERE event_id = 1`).
Scan(&channel, &scope, &basis); err != nil {
t.Fatalf("读通道: %v", err)
}
if scope != "athlete" || basis != "result" {
t.Errorf("老通道被改动: scope=%q basis=%q", scope, basis)
}
// 三个新列在老行上取默认值 1(与升级前「算奖牌、计入总分与单位奖牌」一致)。
var awardMedals int
if err := db.QueryRow(`SELECT award_medals FROM events WHERE id = 1`).Scan(&awardMedals); err != nil {
t.Fatalf("读 events.award_medals: %v", err)
}
if awardMedals != 1 {
t.Errorf("老小项 award_medals = %d,期望 1", awardMedals)
}
var countToMedals, countToTeamMedals int
if err := db.QueryRow(`SELECT count_to_medals, count_to_team_medals FROM event_score_channels WHERE event_id = 1`).
Scan(&countToMedals, &countToTeamMedals); err != nil {
t.Fatalf("读 event_score_channels 奖牌开关: %v", err)
}
if countToMedals != 1 || countToTeamMedals != 1 {
t.Errorf("老通道奖牌开关 = (%d, %d),期望 (1, 1)", countToMedals, countToTeamMedals)
}
// 新列可显式置 0;CHECK 拒绝 0/1 之外的取值。
if _, err := db.Exec(`UPDATE events SET award_medals = 0 WHERE id = 1`); err != nil {
t.Fatalf("写 award_medals=0: %v", err)
}
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_medals = 0, count_to_team_medals = 0 WHERE event_id = 1`); err != nil {
t.Fatalf("写通道奖牌开关 0: %v", err)
}
if _, err := db.Exec(`UPDATE events SET award_medals = 2 WHERE id = 1`); err == nil {
t.Error("award_medals=2 应被 CHECK 拒绝")
}
if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type, award_medals)
VALUES (1, 'E2', '非法开关', 'individual', 2)`); err == nil {
t.Error("插入 award_medals=2 应被 CHECK 拒绝")
}
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_medals = 2 WHERE event_id = 1`); err == nil {
t.Error("count_to_medals=2 应被 CHECK 拒绝")
}
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_team_medals = 2 WHERE event_id = 1`); err == nil {
t.Error("count_to_team_medals=2 应被 CHECK 拒绝")
}
// 两张新表存在,列集合与迁移文件一致(少列、多列都会在这里暴露)。
for _, tc := range []struct {
table string
want []string
}{
{"channel_ranks", []string{
"id", "event_id", "category_id", "channel_id", "basis", "scope", "subject_key",
"entry_id", "result_id", "athlete_id", "team_id", "rank", "points",
"result_value", "status", "updated_at",
}},
{"channel_manual_ranks", []string{
"id", "event_id", "category_id", "team_id", "scope", "basis", "rank",
"updated_by", "updated_at",
}},
} {
got := tableColumns(t, db, tc.table)
if len(got) == 0 {
t.Errorf("%s 表不存在", tc.table)
continue
}
for _, col := range tc.want {
if !got[col] {
t.Errorf("%s 缺列 %s", tc.table, col)
}
}
if len(got) != len(tc.want) {
t.Errorf("%s 列数 = %d,期望 %d(多余列: %v)", tc.table, len(got), len(tc.want), extraColumns(got, tc.want))
}
}
// channel_ranks 的唯一索引:同一通道 / 组别 / 主体只能一行。
var indexCount int
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_channel_ranks_unique'`).Scan(&indexCount); err != nil {
t.Fatalf("查唯一索引: %v", err)
}
if indexCount != 1 {
t.Errorf("channel_ranks 唯一索引 idx_channel_ranks_unique 缺失")
}
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, entry_id, result_id, rank, points)
VALUES (1, 1, 1, 'result', 'athlete', 'e:1', 1, 1, 1, 10)`); err != nil {
t.Fatalf("写入 channel_ranks: %v", err)
}
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, entry_id, result_id, rank, points)
VALUES (1, 1, 1, 'result', 'athlete', 'e:1', 1, 1, 2, 8)`); err == nil {
t.Error("channel_ranks 重复 (channel_id, category_id, subject_key) 应被唯一索引拒绝")
}
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, entry_id, result_id, rank, points)
VALUES (1, 1, 1, 'result', 'athlete', 'e:2', 1, 1, 2, 8)`); err != nil {
t.Errorf("channel_ranks 不同 subject_key 应可写入: %v", err)
}
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, rank)
VALUES (1, 1, 1, 'result', 'bogus', 'x:1', 1)`); err == nil {
t.Error("channel_ranks.scope='bogus' 应被 CHECK 拒绝")
}
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, rank)
VALUES (1, 1, 1, 'result', 'athlete', 'x:1', 0)`); err == nil {
t.Error("channel_ranks.rank=0 应被 CHECK 拒绝")
}
// channel_manual_ranks 的 UNIQUE(event_id, category_id, team_id, scope, basis)。
if _, err := db.Exec(`INSERT INTO channel_manual_ranks(event_id, category_id, team_id, scope, basis, rank)
VALUES (1, 1, 1, 'team', 'manual', 1)`); err != nil {
t.Fatalf("写入 channel_manual_ranks: %v", err)
}
if _, err := db.Exec(`INSERT INTO channel_manual_ranks(event_id, category_id, team_id, scope, basis, rank)
VALUES (1, 1, 1, 'team', 'manual', 2)`); err == nil {
t.Error("channel_manual_ranks 重复 (event_id, category_id, team_id, scope, basis) 应被唯一约束拒绝")
}
if _, err := db.Exec(`INSERT INTO channel_manual_ranks(event_id, category_id, team_id, scope, basis, rank)
VALUES (1, 1, 1, 'team', 'manual_alt', 2)`); err != nil {
t.Errorf("channel_manual_ranks 不同 basis 应可写入: %v", err)
}
if _, err := db.Exec(`INSERT INTO channel_manual_ranks(event_id, category_id, team_id, scope, basis, rank)
VALUES (1, 1, 1, 'team', 'manual0', 0)`); err == nil {
t.Error("channel_manual_ranks.rank=0 应被 CHECK 拒绝")
}
// 迁移可重复执行:再跑一次 MigrateEvent 不报错、新表与老表行数不变。
countsBefore := []int{countRows(t, db, "channel_ranks"), countRows(t, db, "channel_manual_ranks"), countRows(t, db, "events")}
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("重复迁移: %v", err)
}
countsAfter := []int{countRows(t, db, "channel_ranks"), countRows(t, db, "channel_manual_ranks"), countRows(t, db, "events")}
for i, name := range []string{"channel_ranks", "channel_manual_ranks", "events"} {
if countsBefore[i] != countsAfter[i] {
t.Errorf("重复迁移 %s 行数 %d → %d", name, countsBefore[i], countsAfter[i])
}
}
}