Files
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

430 lines
16 KiB
Go
Raw Permalink 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 "testing"
func bonusConfig(mode RecordBonusMode) RecordBonusConfig {
return RecordBonusConfig{
Enabled: true, Mode: mode,
Levels: []RecordLevel{
{Code: "meet", Name: "赛会纪录", Points: 2},
{Code: "city", Name: "市级纪录", Points: 5},
{Code: "province", Name: "省级纪录", Points: 8},
},
}
}
func TestRecordBonusDisabled(t *testing.T) {
cfg := bonusConfig(RecordBonusMax)
cfg.Enabled = false
got := RecordBonusPoints(cfg, []RecordBreak{{EntryID: 1, Level: "meet", State: RecordBreakApproved}})
if got != nil {
t.Fatalf("未启用时 = %+v,期望 nil", got)
}
}
func TestRecordBonusStateFilter(t *testing.T) {
cases := []struct {
name string
state RecordBreakState
want float64
}{
{"已通过计入", RecordBreakApproved, 2},
{"待审核不计入", RecordBreakPending, 0},
{"已驳回不计入", RecordBreakRejected, 0},
{"状态空值不计入", "", 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := RecordBonusPoints(bonusConfig(RecordBonusMax), []RecordBreak{{EntryID: 1, Level: "meet", State: tc.state}})
if tc.want == 0 {
if len(got) != 0 {
t.Fatalf("结果 = %+v,期望空", got)
}
return
}
if len(got) != 1 || !nearlyEqual(got[0].Points, tc.want) {
t.Fatalf("结果 = %+v,期望 %v 分", got, tc.want)
}
})
}
}
func TestRecordBonusUnknownLevel(t *testing.T) {
got := RecordBonusPoints(bonusConfig(RecordBonusMax), []RecordBreak{{EntryID: 1, Level: "galaxy", State: RecordBreakApproved}})
if len(got) != 0 {
t.Fatalf("未配置档位 = %+v,期望空(不能凭空加分)", got)
}
}
func TestRecordBonusModes(t *testing.T) {
breaks := []RecordBreak{
{ID: 1, EntryID: 1, Level: "meet", State: RecordBreakApproved},
{ID: 2, EntryID: 1, Level: "city", State: RecordBreakApproved},
{ID: 3, EntryID: 1, Level: "province", State: RecordBreakApproved},
}
cases := []struct {
name string
mode RecordBonusMode
want float64
}{
{"max 取最高档", RecordBonusMax, 8},
{"sum 累加", RecordBonusSum, 15},
{"模式空值按 max", "", 8},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := RecordBonusPoints(bonusConfig(tc.mode), breaks)
if len(got) != 1 || !nearlyEqual(got[0].Points, tc.want) {
t.Fatalf("结果 = %+v,期望 %v", got, tc.want)
}
})
}
}
func TestRecordBonusGroupsByEntry(t *testing.T) {
breaks := []RecordBreak{
{ID: 1, EntryID: 2, Level: "meet", State: RecordBreakApproved},
{ID: 2, EntryID: 1, Level: "city", State: RecordBreakApproved},
{ID: 3, EntryID: 1, Level: "meet", State: RecordBreakApproved},
}
got := RecordBonusPoints(bonusConfig(RecordBonusMax), breaks)
if len(got) != 2 {
t.Fatalf("条数 = %d,期望 2(按报名项分组)", len(got))
}
if got[0].EntryID != 1 || got[1].EntryID != 2 {
t.Fatalf("顺序 = %+v,期望按 EntryID 升序", got)
}
if !nearlyEqual(got[0].Points, 5) || !nearlyEqual(got[1].Points, 2) {
t.Fatalf("分值 = %v/%v,期望 5/2", got[0].Points, got[1].Points)
}
}
func TestRecordBonusDedupLevel(t *testing.T) {
breaks := []RecordBreak{
{ID: 1, EntryID: 1, Level: "meet", State: RecordBreakApproved},
{ID: 2, EntryID: 1, Level: "meet", State: RecordBreakApproved},
}
got := RecordBonusPoints(bonusConfig(RecordBonusSum), breaks)
if len(got) != 1 || !nearlyEqual(got[0].Points, 2) {
t.Fatalf("重复档位 = %+v,期望只计一次 2 分", got)
}
if len(got[0].Levels) != 1 {
t.Fatalf("档位列表 = %v,期望去重", got[0].Levels)
}
}
func TestRecordBonusNoBreaks(t *testing.T) {
if got := RecordBonusPoints(bonusConfig(RecordBonusMax), nil); got != nil {
t.Fatalf("无破纪录登记 = %+v,期望 nil", got)
}
}
func TestRecordBonusLedger(t *testing.T) {
entries, results := fixture(lineup(2))
base := EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
RecordBreaks: []RecordBreak{{
ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001,
Level: "meet", State: RecordBreakApproved,
}},
}
t.Run("未启用不产生加分流水", func(t *testing.T) {
cfg := bonusConfig(RecordBonusMax)
cfg.Enabled = false
base.RecordBonus = &cfg
es := mustScore(t, base)
for _, e := range es.Ledger {
if e.SourceType == SourceRecordBonus {
t.Fatalf("不应有加分流水: %+v", e)
}
}
})
t.Run("启用后归属到人", func(t *testing.T) {
cfg := bonusConfig(RecordBonusMax)
base.RecordBonus = &cfg
es := mustScore(t, base)
var found bool
for _, e := range es.Ledger {
if e.SourceType != SourceRecordBonus {
continue
}
found = true
if e.TeamID != 1001 || e.AthleteID != 101 || !nearlyEqual(e.Points, 2) {
t.Fatalf("加分流水 = %+v,期望 1001/101 记 2 分", e)
}
}
if !found {
t.Fatal("未产生加分流水")
}
})
}
// recordBonusPointsOf 统计一次计分产出的破纪录加分总和。
func recordBonusPointsOf(t *testing.T, in EventScoreInput) float64 {
t.Helper()
var got float64
for _, e := range mustScore(t, in).Ledger {
if e.SourceType == SourceRecordBonus {
got += e.Points
}
}
return got
}
// TestRecordBonusRequiresEligibleEvent 验证只有「可破纪录」的项目才产生加分(P1-5)。
func TestRecordBonusRequiresEligibleEvent(t *testing.T) {
entries, results := fixture(lineup(1))
cfg := bonusConfig(RecordBonusMax)
in := EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
RecordBonus: &cfg,
RecordBreaks: []RecordBreak{{
ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001,
Level: "meet", State: RecordBreakApproved,
}},
}
in.Event.RecordEligible = false
if got := recordBonusPointsOf(t, in); got != 0 {
t.Fatalf("不可破纪录项目的加分 = %v,期望 0", got)
}
in.Event.RecordEligible = true
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 2) {
t.Fatalf("可破纪录项目的加分 = %v,期望 2", got)
}
}
// TestRecordBonusUsesHitChannelProfileMode 验证 max/sum 口径取「命中通道」所属模板的配置,
// 而不是全局 cfg.Mode(P1-3)。
func TestRecordBonusUsesHitChannelProfileMode(t *testing.T) {
entries, results := fixture(lineup(1))
athleteProfile := individualTop6()
athleteProfile.RecordBonusMode = RecordBonusSum
teamProfile := teamTop6()
teamProfile.RecordBonusMode = RecordBonusMax
cfg := bonusConfig(RecordBonusMax)
in := EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
// 团体通道排在前面、通道 id 更小,个人通道仍应优先命中。
Channels: []ScoreChannel{
{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisResult, ProfileID: 2},
{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1},
},
Profiles: map[int64]ScoringProfile{1: athleteProfile, 2: teamProfile},
Entries: entries, Results: results,
RecordBonus: &cfg,
RecordBreaks: []RecordBreak{
{ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "meet", State: RecordBreakApproved},
{ID: 2, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "city", State: RecordBreakApproved},
},
}
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 7) {
t.Fatalf("加分 = %v,期望 7(个人通道 sum:2+5)", got)
}
}
// TestRecordBonusProfileModeOverridesGlobal 验证模板口径优先于全局口径。
func TestRecordBonusProfileModeOverridesGlobal(t *testing.T) {
entries, results := fixture(lineup(1))
profile := individualTop6()
profile.RecordBonusMode = RecordBonusMax
cfg := bonusConfig(RecordBonusSum)
in := EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: profile},
Entries: entries, Results: results,
RecordBonus: &cfg,
RecordBreaks: []RecordBreak{
{ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "meet", State: RecordBreakApproved},
{ID: 2, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "city", State: RecordBreakApproved},
},
}
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 5) {
t.Fatalf("加分 = %v,期望 5(模板 max 优先于全局 sum)", got)
}
}
// TestRecordBonusTeamEntryUsesTeamChannelMode 验证没有运动员的报名项命中团体通道的模板口径。
func TestRecordBonusTeamEntryUsesTeamChannelMode(t *testing.T) {
entries, results := fixture([]participant{{entryID: 1, teamID: 1001, value: 1, rank: 1}})
athleteProfile := individualTop6()
athleteProfile.RecordBonusMode = RecordBonusMax
teamProfile := teamTop6()
teamProfile.RecordBonusMode = RecordBonusSum
cfg := bonusConfig(RecordBonusMax)
in := EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{
{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1},
{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisResult, ProfileID: 2},
},
Profiles: map[int64]ScoringProfile{1: athleteProfile, 2: teamProfile},
Entries: entries, Results: results,
RecordBonus: &cfg,
RecordBreaks: []RecordBreak{
{ID: 1, EventID: 1, EntryID: 1, TeamID: 1001, Level: "meet", State: RecordBreakApproved},
{ID: 2, EventID: 1, EntryID: 1, TeamID: 1001, Level: "city", State: RecordBreakApproved},
},
}
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 7) {
t.Fatalf("加分 = %v,期望 7(团体通道 sum:2+5)", got)
}
}
// TestRecordBonusLedgerFiltersByCategory 验证按「小项+组别」过滤:
// 上层逐组别调用 ScoreEvent,别的组别的破纪录登记不能在本组别重复记一次加分。
func TestRecordBonusLedgerFiltersByCategory(t *testing.T) {
entries, results := fixture(lineup(2))
entries[1].CategoryID = 2 // entry2 属于另一个组别
cfg := bonusConfig(RecordBonusMax)
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
RecordBonus: &cfg,
RecordBreaks: []RecordBreak{
{ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "meet", State: RecordBreakApproved},
{ID: 2, EventID: 1, EntryID: 2, AthleteID: 102, TeamID: 1002, Level: "meet", State: RecordBreakApproved},
},
})
var thisCategory, otherCategory int
for _, e := range es.Ledger {
if e.SourceType != SourceRecordBonus {
continue
}
switch e.EntryID {
case 1:
thisCategory++
case 2:
otherCategory++
}
}
if thisCategory != 1 {
t.Fatalf("本组别加分流水 = %d 条,期望 1", thisCategory)
}
if otherCategory != 0 {
t.Fatalf("别的组别的加分不应记入本组别,实际 %d 条", otherCategory)
}
}
// bonusLedgerOfEntry 取某报名项名下两种流水的分值:成绩分与破纪录加分。
func bonusLedgerOfEntry(es EventScore, entryID int64) (rankPoints, bonusPoints float64) {
for _, l := range es.Ledger {
if l.EntryID != entryID {
continue
}
switch l.SourceType {
case SourceResult:
rankPoints += l.Points
case SourceRecordBonus:
bonusPoints += l.Points
}
}
return rankPoints, bonusPoints
}
// TestRecordBonusFollowsRankableStatus 验证破纪录加分与名次分同一口径(P1-6):
// 报名项退赛 / 弃权 / 失格,或成绩被作废 / 未发布时都不该再有加分流水。
//
// 除了逐条钉期望值,还断言「名次分有没有,破纪录加分就有没有」——两条通道是同一条成绩的
// 两种产出,判据必须同源(EntryStatus.rankable / ResultStatus.rankable),
// 各写一套就会出现「退赛的人拿不到名次分、却继续拿加分」。
func TestRecordBonusFollowsRankableStatus(t *testing.T) {
cases := []struct {
name string
mutate func(entries *[]Entry, results *[]Result)
want bool
}{
{"正常参赛且成绩有效", func(*[]Entry, *[]Result) {}, true},
{"报名项退赛", func(entries *[]Entry, _ *[]Result) { (*entries)[0].Status = EntryStatusScratched }, false},
{"报名项失格", func(entries *[]Entry, _ *[]Result) { (*entries)[0].Status = EntryStatusDQ }, false},
{"报名项未出场", func(entries *[]Entry, _ *[]Result) { (*entries)[0].Status = EntryStatusDNS }, false},
{"成绩作废", func(_ *[]Entry, results *[]Result) { (*results)[0].Status = ResultStatusWithdrawn }, false},
{"成绩失格", func(_ *[]Entry, results *[]Result) { (*results)[0].Status = ResultStatusDQ }, false},
{"成绩未出场", func(_ *[]Entry, results *[]Result) { (*results)[0].Status = ResultStatusDNS }, false},
{"成绩未完赛", func(_ *[]Entry, results *[]Result) { (*results)[0].Status = ResultStatusDNF }, false},
{"成绩仍是草稿", func(_ *[]Entry, results *[]Result) { (*results)[0].State = ResultStateDraft }, false},
{"没有成绩行", func(_ *[]Entry, results *[]Result) { *results = nil }, false},
{"状态留空按正常处理", func(entries *[]Entry, results *[]Result) {
(*entries)[0].Status = ""
(*results)[0].Status = ""
}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
entries, results := fixture(lineup(1))
tc.mutate(&entries, &results)
cfg := bonusConfig(RecordBonusMax)
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
RecordBonus: &cfg,
RecordBreaks: []RecordBreak{{
ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001,
Level: "meet", State: RecordBreakApproved,
}},
})
rankPoints, bonusPoints := bonusLedgerOfEntry(es, 1)
if tc.want && !nearlyEqual(bonusPoints, 2) {
t.Fatalf("加分 = %v,期望 2", bonusPoints)
}
if !tc.want && bonusPoints != 0 {
t.Fatalf("不该产生破纪录加分,得到 %v", bonusPoints)
}
if got := rankPoints > 0; got != (bonusPoints > 0) {
t.Fatalf("成绩分与破纪录加分必须同生同灭,得到 成绩分=%v 加分=%v", rankPoints, bonusPoints)
}
})
}
}
// TestRecordBonusComesBackAfterRestore 验证状态判据是「看当前状态」而不是「一次性作废」:
// 退赛 / 作废后再改回正常,同一个登记还要能把加分还回来(现场纠正误判就靠这条)。
func TestRecordBonusComesBackAfterRestore(t *testing.T) {
entries, results := fixture(lineup(1))
cfg := bonusConfig(RecordBonusMax)
in := EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
RecordBonus: &cfg,
RecordBreaks: []RecordBreak{{
ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001,
Level: "meet", State: RecordBreakApproved,
}},
}
entries[0].Status = EntryStatusScratched
if got := recordBonusPointsOf(t, in); got != 0 {
t.Fatalf("退赛后的加分 = %v,期望 0", got)
}
entries[0].Status = EntryStatusRegistered
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 2) {
t.Fatalf("恢复参赛后的加分 = %v,期望 2", got)
}
results[0].Status = ResultStatusWithdrawn
if got := recordBonusPointsOf(t, in); got != 0 {
t.Fatalf("成绩作废后的加分 = %v,期望 0", got)
}
results[0].Status = ResultStatusOK
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 2) {
t.Fatalf("成绩改回有效后的加分 = %v,期望 2", got)
}
}