Files
Scoreboard/server/internal/domain/channel_ranks_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

364 lines
13 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 domain
import (
"reflect"
"strconv"
"testing"
)
// 本文件守住 devdocs/17 的两条底线:
// 1. P0:同一份数据连续发布两次,流水与名次必须完全一致——团体通道绝不读
// 「athlete_id != 0 的报名项」的 results.rank(那是个人名次,读了会让同一份成绩发两次团体分);
// 2. 团体名次的来源只有 in.TeamRanks 与团体报名项(athlete_id == 0)的 results.rank 两处,
// 同一单位两边都有时以 TeamRanks 为准。
// teamPullupInput 构造 TEAM_PULLUP 场景:1 个小项、1 个组别、6 名运动员同属 A 队(1001),
// 成绩 30/29/28/27/26/25(引体向上,次数多者名次靠前),两条通道 =
// 个人通道(athlete + result + 个人表)+ 团体通道(team + manual + 团体表)。
// teamRanks 是录入员填的团体名次(可为空)。
func teamPullupInput(teamRanks []TeamManualRank) EventScoreInput {
values := []float64{30, 29, 28, 27, 26, 25}
ps := make([]participant, 0, len(values))
for i, v := range values {
ps = append(ps, participant{
entryID: int64(i + 1), athleteID: int64(101 + i), teamID: 1001,
name: "选手" + string(rune('A'+i)), value: v,
})
}
entries, results := fixture(ps)
ev := individualEvent(1)
ev.EventType = EventTypeIndividualTeam
ev.Compare = CompareDesc
return EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{
{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1},
{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisManual, ProfileID: 2},
},
Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()},
Entries: entries,
Results: results,
TeamRanks: teamRanks,
}
}
// writeBackComputedRanks 模拟 httpapi 的 applyComputedRanks:
// 只把个人通道(scope=athlete)算出的名次写回对应成绩的 results.rank。
func writeBackComputedRanks(in EventScoreInput, ranked []RankedResult) {
for _, rr := range ranked {
if rr.Scope != ScopeAthlete || rr.ResultID == 0 || rr.Rank <= 0 {
continue
}
for i := range in.Results {
if in.Results[i].ID == rr.ResultID {
in.Results[i].Rank = rr.Rank
}
}
}
}
// TestTeamPullupPublishTwiceIsIdempotent 是 P0 回归:
// 「发布 → 把个人名次写回 results.rank → 再发布一次」两次的 Ledger 与 Ranked 必须逐字段一致。
func TestTeamPullupPublishTwiceIsIdempotent(t *testing.T) {
t.Run("团体名次已录:两次发布一致,A 队总分不跳变", func(t *testing.T) {
in := teamPullupInput([]TeamManualRank{{TeamID: 1001, Rank: 1}})
first := mustScore(t, in)
writeBackComputedRanks(in, first.Ranked)
second := mustScore(t, in)
if !reflect.DeepEqual(first.Ledger, second.Ledger) {
t.Fatalf("两次发布的流水不一致:\n第一次 %+v\n第二次 %+v", first.Ledger, second.Ledger)
}
if !reflect.DeepEqual(first.Ranked, second.Ranked) {
t.Fatalf("两次发布的名次不一致:\n第一次 %+v\n第二次 %+v", first.Ranked, second.Ranked)
}
// 团体通道的分数只来自团体名次:唯一一条单位流水,没有报名项 / 成绩(EntryID=0、ResultID=0)。
var teamLedger []LedgerEntry
for _, l := range second.Ledger {
if l.AthleteID == 0 {
teamLedger = append(teamLedger, l)
}
}
if len(teamLedger) != 1 {
t.Fatalf("团体流水 = %d 条,期望 1 条:%+v", len(teamLedger), teamLedger)
}
l := teamLedger[0]
if l.TeamID != 1001 || l.EntryID != 0 || l.ResultID != 0 {
t.Fatalf("团体流水 = %+v,期望只记单位 1001 且无报名项 / 成绩", l)
}
if !nearlyEqual(l.Points, 15) {
t.Fatalf("团体名次第 1 名的分 = %v,期望 15(团体表)", l.Points)
}
if l.SourceID != 2 {
t.Fatalf("sourceID 兜底应取通道 id=2,得到 %d", l.SourceID)
}
if l.SourceType != SourceResult {
t.Fatalf("SourceType = %q,期望 %q", l.SourceType, SourceResult)
}
// 团体通道只有一条名次明细,主体是单位,绝不消费那 6 条个人名次。
var teamRanked []RankedResult
for _, rr := range second.Ranked {
if rr.ChannelID == 2 {
teamRanked = append(teamRanked, rr)
}
}
if len(teamRanked) != 1 {
t.Fatalf("团体通道名次 = %d 条,期望 1 条(只认团体名次):%+v", len(teamRanked), teamRanked)
}
if teamRanked[0].EntryID != 0 || teamRanked[0].AthleteID != 0 || teamRanked[0].SubjectKey != "t:1001" {
t.Fatalf("团体名次 = %+v,期望合成主体 t:1001", teamRanked[0])
}
if teamRanked[0].Rank != 1 {
t.Fatalf("团体名次 Rank = %d,期望 1", teamRanked[0].Rank)
}
// 个人通道照旧按成绩排名 1..6。
ranks := rankByEntry(second.Ranked)
for i := 1; i <= 6; i++ {
if ranks[int64(i)] != i {
t.Fatalf("个人名次 = %v,期望 entry%d → %d", ranks, i, i)
}
}
// A 队总分 = 个人表 10+8+6+3+2+1 = 30,加团体名次第 1 名的 15,共 45;
// 第二次发布如果团体通道又读了一遍个人名次,这里会变成 85(P0 的真机表现)。
for _, es := range []EventScore{first, second} {
if got := SumByTeam(es.Ledger, 1)[1001].Total; !nearlyEqual(got, 45) {
t.Fatalf("A 队总分 = %v,期望 45(30 个人 + 15 团体)", got)
}
}
})
t.Run("团体名次未录:个人名次绝不进团体通道", func(t *testing.T) {
in := teamPullupInput(nil)
first := mustScore(t, in)
writeBackComputedRanks(in, first.Ranked)
second := mustScore(t, in)
if !reflect.DeepEqual(first, second) {
t.Fatalf("两次发布的计分结果不一致:\n第一次 %+v\n第二次 %+v", first, second)
}
for _, rr := range second.Ranked {
if rr.ChannelID == 2 {
t.Fatalf("没录团体名次时团体通道不应产出名次:%+v", rr)
}
}
if got := SumByTeam(second.Ledger, 1)[1001].Total; !nearlyEqual(got, 30) {
t.Fatalf("A 队总分 = %v,期望只算个人通道的 30(不跳变)", got)
}
})
}
// TestTeamManualChannelCandidateSources 守住团体手工通道的候选来源。
func TestTeamManualChannelCandidateSources(t *testing.T) {
score := func(t *testing.T, in EventScoreInput) EventScore {
t.Helper()
return mustScore(t, in)
}
t.Run("只认团体报名项兜底,个人报名项的名次不进团体通道", func(t *testing.T) {
entries, results := fixture([]participant{
// 个人报名项:录入名次第 1,是个人名次,团体通道不许读。
{entryID: 1, athleteID: 101, teamID: 1001, name: "甲", rank: 1, noValue: true},
// 团体报名项(athlete_id = 0):团体名次第 2。
{entryID: 2, teamID: 1002, rank: 2, noValue: true},
})
es := score(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(2)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
Entries: entries, Results: results,
})
got := pointsByTeam(es.Ledger)
if !nearlyEqual(got[1002], 10) {
t.Fatalf("单位 1002 = %v,期望团体名次第 2 名的 10 分:%v", got[1002], got)
}
if _, ok := got[1001]; ok {
t.Fatalf("个人报名项的名次进了团体通道:%v", got)
}
if len(es.Ranked) != 1 || es.Ranked[0].TeamID != 1002 || es.Ranked[0].EntryID != 2 {
t.Fatalf("团体名次明细 = %+v,期望只有单位 1002 的团体报名项", es.Ranked)
}
})
t.Run("TeamRanks 覆盖团体报名项的兜底名次", func(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, teamID: 1001, rank: 3, noValue: true},
})
es := score(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(2)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
Entries: entries, Results: results,
TeamRanks: []TeamManualRank{{TeamID: 1001, Rank: 1}},
})
if got := pointsByTeam(es.Ledger); !nearlyEqual(got[1001], 15) {
t.Fatalf("单位 1001 = %v,期望以 TeamRanks 的第 1 名计 15 分:%v", got[1001], got)
}
if len(es.Ranked) != 1 {
t.Fatalf("同一单位应只产出一条名次,得到 %d 条:%+v", len(es.Ranked), es.Ranked)
}
if es.Ranked[0].Rank != 1 || es.Ranked[0].EntryID != 1 || es.Ranked[0].SubjectKey != "e:1" {
t.Fatalf("覆盖后的名次 = %+v,期望报名项 1 记为第 1 名", es.Ranked[0])
}
})
t.Run("rank<=0 的团体名次视为未填,退回团体报名项", func(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, teamID: 1001, rank: 2, noValue: true},
})
es := score(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(2)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
Entries: entries, Results: results,
TeamRanks: []TeamManualRank{{TeamID: 1001, Rank: 0}, {TeamID: 1002, Rank: -1}},
})
got := pointsByTeam(es.Ledger)
if !nearlyEqual(got[1001], 10) {
t.Fatalf("单位 1001 = %v,期望退回团体报名项的第 2 名(10 分):%v", got[1001], got)
}
if _, ok := got[1002]; ok {
t.Fatalf("rank<=0 的团体名次不应产出名次:%v", got)
}
if len(es.Ranked) != 1 {
t.Fatalf("名次明细 = %d 条,期望 1 条:%+v", len(es.Ranked), es.Ranked)
}
})
t.Run("并列团体名次走模板的 tieMode", func(t *testing.T) {
ranks := []TeamManualRank{{TeamID: 1001, Rank: 1}, {TeamID: 1002, Rank: 1}}
cases := []struct {
name string
tie TieMode
points float64
}{
{"顺延(默认)", TieSkip, 15},
{"平均", TieAverage, 12.5},
{"就高", TieHigher, 15},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
profile := teamTop6()
profile.TieMode = tc.tie
es := score(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(2)},
Profiles: map[int64]ScoringProfile{2: profile},
TeamRanks: ranks,
})
if len(es.Ranked) != 2 {
t.Fatalf("并列名次 = %d 条,期望 2 条:%+v", len(es.Ranked), es.Ranked)
}
got := pointsByTeam(es.Ledger)
for _, rr := range es.Ranked {
if rr.Rank != 1 {
t.Fatalf("并列名次 = %+v,期望同为第 1 名", rr)
}
}
if !nearlyEqual(got[1001], tc.points) || !nearlyEqual(got[1002], tc.points) {
t.Fatalf("并列得分 = %v,期望各 %v", got, tc.points)
}
})
}
})
}
// TestRankedSubjectKeys 覆盖计分引擎各条产出路径的 SubjectKey 口径。
func TestRankedSubjectKeys(t *testing.T) {
t.Run("result 通道按报名项", func(t *testing.T) {
entries, results := fixture(lineupValues(11, 12))
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
for _, rr := range es.Ranked {
if want := "e:" + strconv.FormatInt(rr.EntryID, 10); rr.SubjectKey != want {
t.Fatalf("SubjectKey = %q,期望 %q(%+v)", rr.SubjectKey, want, rr)
}
}
})
t.Run("团体手工名次按单位", func(t *testing.T) {
es := mustScore(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(2)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
TeamRanks: []TeamManualRank{{TeamID: 1001, Rank: 1}, {TeamID: 1002, Rank: 2}},
})
want := []string{"t:1001", "t:1002"}
if len(es.Ranked) != len(want) {
t.Fatalf("名次条数 = %d,期望 %d", len(es.Ranked), len(want))
}
for i, key := range want {
if es.Ranked[i].SubjectKey != key {
t.Fatalf("第 %d 条 SubjectKey = %q,期望 %q", i, es.Ranked[i].SubjectKey, key)
}
}
})
t.Run("聚合通道按主体", func(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 11},
{entryID: 2, athleteID: 2, teamID: 1001, value: 12},
{entryID: 3, athleteID: 3, teamID: 1002, value: 13},
})
ev := teamEvent(1)
ev.Compare = CompareAsc
es := mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{
{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisAggregate, ProfileID: 1},
{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisAggregate, ProfileID: 2},
},
Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()},
Entries: entries, Results: results,
})
for _, rr := range es.Ranked {
var want string
if rr.Scope == ScopeAthlete {
want = "a:" + strconv.FormatInt(rr.AthleteID, 10)
} else {
want = "t:" + strconv.FormatInt(rr.TeamID, 10)
}
if rr.SubjectKey != want || rr.EntryID != 0 {
t.Fatalf("聚合名次 = %+v,期望 SubjectKey %q 且 EntryID=0", rr, want)
}
}
})
}
// TestSubjectKeyOfFallback 直接覆盖 subjectKeyOf 的四层口径(含退化兜底)。
func TestSubjectKeyOfFallback(t *testing.T) {
cases := []struct {
name string
rr RankedResult
want string
}{
{"有报名项按报名项", RankedResult{EntryID: 7, AthleteID: 101, TeamID: 1001, ChannelID: 3, Scope: ScopeAthlete}, "e:7"},
{"个人主体", RankedResult{AthleteID: 101, TeamID: 1001, ChannelID: 3, Scope: ScopeAthlete}, "a:101"},
{"单位主体", RankedResult{TeamID: 1001, ChannelID: 3, Scope: ScopeTeam}, "t:1001"},
{"团体名次但没有单位", RankedResult{AthleteID: 101, ChannelID: 3, Scope: ScopeTeam}, "ch:3"},
{"全空退化通道", RankedResult{ChannelID: 5}, "ch:5"},
{"连通道都没有", RankedResult{}, "ch:0"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := subjectKeyOf(tc.rr); got != tc.want {
t.Fatalf("subjectKeyOf = %q,期望 %q", got, tc.want)
}
})
}
}