Files
Scoreboard/server/internal/export/medals_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

298 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 export
import (
"database/sql"
"path/filepath"
"testing"
)
// addBronzeEntry 给 seedEvent 的小项补一条第 3 名,让一块金牌之外还有银、铜。
func addBronzeEntry(t *testing.T, db *sql.DB) {
t.Helper()
// entries 对(组别,单位,运动员)唯一,第 3 名要再派一名运动员。
stmts := []string{
`INSERT INTO athletes(name, gender, team_id) VALUES ('王六', '男', 2)`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 2, 3, '103')`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
VALUES (1, 3, 3, '12.10', 12.10, 's', 'published')`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
}
// 团体奖牌明细:总表逐块列出,每个有奖牌的单位一张分表,分表末尾一行小计。
func TestExportMedalDetailXLSX(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db) // T1 第 1 名、T2 第 2 名
addBronzeEntry(t, db)
seedChannelRanks(t, db)
dst := filepath.Join(t.TempDir(), "奖牌.xlsx")
if err := ExportMedalDetailXLSX(db, dst, Options{}); err != nil {
t.Fatalf("导出团体奖牌明细: %v", err)
}
f := openXLSX(t, dst)
defer func() { _ = f.Close() }()
want := []string{"团体奖牌明细", "第一分公司奖牌明细", "第二分公司奖牌明细"}
if got := f.GetSheetList(); !equalStrings(got, want) {
t.Fatalf("sheet 列表 = %v,期望 %v", got, want)
}
master, err := f.GetRows("团体奖牌明细")
if err != nil {
t.Fatalf("读取总表: %v", err)
}
// 4 行版式 + 3 块奖牌
if len(master) != 7 {
t.Fatalf("总表行数 = %d,期望 7", len(master))
}
header := []string{"单位", "大项", "小项", "组别", "名次", "奖牌", "运动员/队"}
for i, want := range header {
if got := cell(master, 3, i); got != want {
t.Errorf("表头第 %d 列 = %q,期望 %q", i, got, want)
}
}
first := []string{"第一分公司", "田径", "100米跑", "男子", "1", "金牌", "张三"}
for i, want := range first {
if got := cell(master, 4, i); got != want {
t.Errorf("首行第 %d 列 = %q,期望 %q", i, got, want)
}
}
if got := cell(master, 5, 5); got != "银牌" {
t.Errorf("第二行奖牌 = %q,期望 银牌", got)
}
if got := cell(master, 6, 5); got != "铜牌" {
t.Errorf("第三行奖牌 = %q,期望 铜牌", got)
}
if got := cell(master, 6, 0); got != "第二分公司" {
t.Errorf("第三行单位 = %q,期望 第二分公司", got)
}
// 分表列 = 总表去掉「单位」列 + 末尾小计。
gold, err := f.GetRows("第一分公司奖牌明细")
if err != nil {
t.Fatalf("读取金牌单位分表: %v", err)
}
if len(gold) != 6 { // 4 行版式 + 1 块奖牌 + 小计
t.Fatalf("第一分公司分表行数 = %d,期望 6", len(gold))
}
if got := cell(gold, 3, 0); got != "大项" {
t.Errorf("分表表头首列 = %q,期望 大项", got)
}
if got := cell(gold, 4, 5); got != "张三" {
t.Errorf("分表首行运动员 = %q,期望 张三", got)
}
if got := cell(gold, 5, 0); got != "小计" {
t.Errorf("分表小计行首列 = %q,期望 小计", got)
}
if got := cell(gold, 5, 4); got != "金 1 / 银 0 / 铜 0" {
t.Errorf("分表小计 = %q,期望 金 1 / 银 0 / 铜 0", got)
}
silver, err := f.GetRows("第二分公司奖牌明细")
if err != nil {
t.Fatalf("读取银铜单位分表: %v", err)
}
if len(silver) != 7 { // 4 行版式 + 银、铜各 1 块 + 小计
t.Fatalf("第二分公司分表行数 = %d,期望 7", len(silver))
}
if got := cell(silver, 6, 4); got != "金 0 / 银 1 / 铜 1" {
t.Errorf("分表小计 = %q,期望 金 0 / 银 1 / 铜 1", got)
}
}
// 奖牌明细必须与团体奖牌榜逐单位对得上,包括「个人+团体」双计项目只记一枚奖牌。
func TestMedalDetailMatchesBoard(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
addBronzeEntry(t, db)
// 再造一个双计项目:同一条成绩在 athlete + team 两条通道下各产出一条名次。
stmts := []string{
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (1, 'E2', '100米跑(双计)', 'individual_team', 'auto', 'time', 'asc', 'published')`,
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (2, '男子', 1)`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
SELECT 2, 'athlete', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
SELECT 2, 'team', 'result', id FROM scoring_profiles WHERE code = 'team_top6'`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (2, 2, 1, 1, '301')`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
SELECT 2, id, 1, '11.00', 11.00, 's', 'published' FROM entries WHERE event_id = 2`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
seedChannelRanks(t, db)
wb, err := LoadMedalDetailWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取奖牌明细: %v", err)
}
board, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取团体奖牌榜: %v", err)
}
// 双计项目只贡献一枚金牌(去重成一条),不是两条。
dual := 0
total := 0
for _, s := range wb.Sheets {
for _, r := range s.Rows {
total++
if r.EventName == "100米跑(双计)" {
dual++
}
}
}
if dual != 1 {
t.Errorf("双计项目奖牌明细行数 = %d,期望 1(同一报名项只记一枚)", dual)
}
if total != 4 { // 3 块单通道奖牌 + 1 块双计奖牌
t.Errorf("奖牌明细总行数 = %d,期望 4", total)
}
for i, row := range board.Medal {
gold, silver, bronze := medalCountsOf(wb, row.TeamID)
t.Logf("单位 %d:明细 金%d/银%d/铜%d,榜单 金%d/银%d/铜%d", row.TeamID, gold, silver, bronze, row.Gold, row.Silver, row.Bronze)
if gold != row.Gold || silver != row.Silver || bronze != row.Bronze {
t.Errorf("单位 %d 奖牌明细 金%d/银%d/铜%d,榜单 金%d/银%d/铜%d(榜第 %d 名)",
row.TeamID, gold, silver, bronze, row.Gold, row.Silver, row.Bronze, i+1)
}
if gold+silver+bronze == 0 {
// 榜上有名字但没有奖牌的单位不应出现在明细里。
for _, s := range wb.Sheets {
if s.Team.ID == row.TeamID {
t.Errorf("无奖牌单位 %d 不应出分表", row.TeamID)
}
}
}
}
}
// 没有单条成绩的奖牌(团体手工 / 聚合名次,channel_ranks.result_id 为空)也要能印出
// 大项 / 小项 / 组别,并与团体奖牌榜逐单位对得上(devdocs 17 §11.3)。
func TestMedalDetailShowsSubjectOnlyMedal(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedChannelRanks(t, db)
if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
SELECT 1, 'team', 'manual', id FROM scoring_profiles WHERE code = 'team_top6'`); err != nil {
t.Fatalf("补团体手工通道: %v", err)
}
var channelID int64
if err := db.QueryRow(`SELECT id FROM event_score_channels WHERE event_id = 1 AND scope = 'team'`).Scan(&channelID); err != nil {
t.Fatalf("读通道 id: %v", err)
}
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
entry_id, result_id, athlete_id, team_id, rank, points)
VALUES (1, 1, ?, 'manual', 'team', 't:2', NULL, NULL, NULL, 2, 1, 0)`, channelID); err != nil {
t.Fatalf("造团体手工名次: %v", err)
}
wb, err := LoadMedalDetailWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取奖牌明细: %v", err)
}
board, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取团体奖牌榜: %v", err)
}
for _, row := range board.Medal {
gold, silver, bronze := medalCountsOf(wb, row.TeamID)
if gold != row.Gold || silver != row.Silver || bronze != row.Bronze {
t.Errorf("单位 %d 奖牌明细 金%d/银%d/铜%d,榜单 金%d/银%d/铜%d",
row.TeamID, gold, silver, bronze, row.Gold, row.Silver, row.Bronze)
}
}
subjectOnly := 0
for _, s := range wb.Sheets {
for _, r := range s.Rows {
if r.SportName == "" || r.EventName == "" || r.Category == "" {
t.Errorf("奖牌明细缺大项 / 小项 / 组别: %+v", r)
}
if r.Participant == "" {
subjectOnly++
}
}
}
if subjectOnly != 1 {
t.Errorf("没有成绩的奖牌行数 = %d,期望 1(团体手工名次),明细 = %+v", subjectOnly, wb.Sheets)
}
}
// 没有奖牌(甚至没有小项)时不报错,返回只有表头的总表。
func TestExportMedalDetailEmptyEvent(t *testing.T) {
db := openEventDB(t)
insertTeam(t, db, "T1", "第一分公司", "一公司", 1, true)
dst := filepath.Join(t.TempDir(), "奖牌.xlsx")
if err := ExportMedalDetailXLSX(db, dst, Options{}); err != nil {
t.Fatalf("无奖牌导出: %v", err)
}
f := openXLSX(t, dst)
defer func() { _ = f.Close() }()
if got := f.GetSheetList(); !equalStrings(got, []string{"团体奖牌明细"}) {
t.Fatalf("sheet 列表 = %v,期望只有总表", got)
}
rows, err := f.GetRows("团体奖牌明细")
if err != nil {
t.Fatalf("读取总表: %v", err)
}
if len(rows) != 4 { // 标题 / 副标题 / 空行 / 表头
t.Fatalf("总表行数 = %d,期望 4", len(rows))
}
if got := cell(rows, 3, 0); got != "单位" {
t.Errorf("表头首列 = %q,期望 单位", got)
}
}
// 已隐藏但有奖牌的单位在榜上,导出要与榜单一致地保留它。
func TestMedalDetailKeepsHiddenTeamWithMedals(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedChannelRanks(t, db)
silent := insertTeam(t, db, "H2", "被隐藏且无牌", "隐藏二", 4, false)
hideTeam(t, db, silent)
wb, err := LoadMedalDetailWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取奖牌明细: %v", err)
}
for _, s := range wb.Sheets {
if s.Team.ID == silent {
t.Fatalf("无奖牌的隐藏单位不应出分表: %+v", s.Team)
}
}
if len(wb.Sheets) != 2 {
t.Fatalf("分表数 = %d,期望 2(T1 金、T2 银)", len(wb.Sheets))
}
}
func medalCountsOf(wb MedalDetailWorkbook, teamID int64) (int, int, int) {
var gold, silver, bronze int
for _, s := range wb.Sheets {
if s.Team.ID != teamID {
continue
}
for _, r := range s.Rows {
switch r.Medal {
case medalGold:
gold++
case medalSilver:
silver++
case medalBronze:
bronze++
}
}
}
return gold, silver, bronze
}