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,363 @@
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)
}
})
}
}