feat(0.5.1): 业务逻辑审查 9 条 P0 全修 + 自绘安装器 + 现场反馈四项
## 算分正确性(6 份只读审查 → 收敛成 5 个病根,均先复现失败再修) - 「人工名次」与「自动名次回写」互斥:原来同一份成绩**发布两次分数不同** (团体报名项 dual 通道 10/8/6 → 25/18/14),现在连发多次逐字节一致 - 删小项时在同一事务作废旧流水并推榜:原来分数**永久残留**在总榜、任何重算都打不到 - 规则包导入整体事务化:原来中途失败会清空名次表且补不回(奖牌全丢); 另加守卫「compare=none 的小项不得配 basis=result/aggregate」 - 末位归零只抹真正落在末位的那一档:原来跨末位的并列组会被整组抹 0 (7 人三人并列第 5 时第 5、6 名的分也一起没了) - 模板「分值表档位超出录取名次」现在拒绝保存:原来静默截断成 0 分 ## 现场反馈(主人实测报的) - 已发布成绩可改:行内「修改本条」+ 成绩格/状态格可点(退回草稿→改→重新发布本组), 与「作废」语义分开;取消路径不产生任何副作用 - 单位积分明细补「运动员 / 来源」列,并显示「已修订 ×N(时间·原因)」: 管理端、大屏覆盖层、xlsx 备注列三处同源 - 单位奖牌榜改为按奖牌独立排名(金→银→铜→单位编码),与积分榜解耦 - 历史孤儿积分流水清理(迁移 0022,只动 result/record_bonus,人工调整分不受影响) ## 新功能 - 团体项目聚合口径二选一:平均(默认)/ 总和(迁移 0023,通道编辑器可切) ## 安装器 - 自绘五页安装界面:无边框、自绘标题栏、DPI 自适应(4K@200% 实测)、 安装/卸载逻辑复用 electron-builder 宏不变;默认「为所有用户安装」并自动放行防火墙 19540 - 构建期校验:安装器素材里的版本号必须与 VERSION 一致,否则构建失败 自检:服务端 14 包 ×2 / 前端 vue-tsc + build + 742 例 ×2 / Console 20 文件 + 68 例 ×2,全绿。 端到端对拍:修前 vs 修后,纯团体 / 双计 / 普通个人三形态**逐字节一致**,仅出 bug 的双通道形态按要求改变。
This commit is contained in:
1 parent
78b14efaa9
commit
877ff15823
181 files changed
+8747
-371
No files matched your search
@@ -0,0 +1,232 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 聚合口径(通道的 aggregate_mode:avg 按成员平均 / sum 按成员总和)的引擎口径。
|
||||
//
|
||||
// 现场要在「平均」与「总和」之间临时定口径(团体引体向上这类双计项目领导还没拍板),
|
||||
// 所以这里钉住三件事:缺省必须是 avg(升级前后榜单一分不差)、人数相同时两种口径排名一致、
|
||||
// 人数不同时两者可能不同(现场不能默认它们等价)。
|
||||
|
||||
// aggScore 用指定汇总口径跑一次团体聚合通道,返回「单位 → 平均/总和值、名次、积分」。
|
||||
func aggScore(t *testing.T, ev Event, mode AggregateMode, entries []Entry, results []Result) EventScore {
|
||||
t.Helper()
|
||||
ch := teamAggregateChannel(2)
|
||||
ch.AggregateMode = mode
|
||||
return mustScore(t, EventScoreInput{
|
||||
Event: ev, Category: testCategory(),
|
||||
Channels: []ScoreChannel{ch},
|
||||
Profiles: map[int64]ScoringProfile{2: teamTop6()},
|
||||
Entries: entries, Results: results,
|
||||
})
|
||||
}
|
||||
|
||||
// TestAggregateModeEqualRosterSameRanking:各队人数相同时 sum 与 avg 的排名与积分完全一致。
|
||||
// 这是现场选口径的依据——人数整齐时选哪种都不影响榜单,人数不齐时才会分叉。
|
||||
func TestAggregateModeEqualRosterSameRanking(t *testing.T) {
|
||||
// 三队各 2 人,compare=desc(越大越好)。总和 = 平均 × 2,是同一个单调变换。
|
||||
entries, results := fixture([]participant{
|
||||
{entryID: 1, athleteID: 1, teamID: 1001, value: 18},
|
||||
{entryID: 2, athleteID: 2, teamID: 1001, value: 20},
|
||||
{entryID: 3, athleteID: 3, teamID: 1002, value: 16},
|
||||
{entryID: 4, athleteID: 4, teamID: 1002, value: 14},
|
||||
{entryID: 5, athleteID: 5, teamID: 1003, value: 12},
|
||||
{entryID: 6, athleteID: 6, teamID: 1003, value: 10},
|
||||
})
|
||||
ev := teamEvent(1)
|
||||
ev.Compare = CompareDesc
|
||||
|
||||
avg := aggScore(t, ev, AggregateAvg, entries, results)
|
||||
sum := aggScore(t, ev, AggregateSum, entries, results)
|
||||
|
||||
// 名次与积分逐队一致(录取前六名,三队都有分);汇总值本身当然不同(19 平均 / 38 总和)。
|
||||
avgOutcome := aggregateByTeam(t, avg)
|
||||
sumOutcome := aggregateByTeam(t, sum)
|
||||
for _, teamID := range []int64{1001, 1002, 1003} {
|
||||
if avgOutcome[teamID].rank != sumOutcome[teamID].rank || !nearlyEqual(avgOutcome[teamID].points, sumOutcome[teamID].points) {
|
||||
t.Fatalf("人数相同时两种口径的名次/积分应一致:单位 %d avg=%+v sum=%+v",
|
||||
teamID, avgOutcome[teamID], sumOutcome[teamID])
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(pointsByTeam(avg.Ledger), pointsByTeam(sum.Ledger)) {
|
||||
t.Fatalf("人数相同时两种口径的流水应一致:avg=%v sum=%v",
|
||||
pointsByTeam(avg.Ledger), pointsByTeam(sum.Ledger))
|
||||
}
|
||||
// 顺带钉住名次本身:1001 平均 19 / 总和 38 → 第一。
|
||||
got := aggregateByTeam(t, sum)
|
||||
if got[1001].rank != 1 || got[1002].rank != 2 || got[1003].rank != 3 {
|
||||
t.Fatalf("sum 口径名次 = %+v,期望 1001/1002/1003 依次第 1/2/3 名", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAggregateModeRosterDiffersFlipsRanking:各队人数不同时两种口径的排名会不同。
|
||||
// 1001 队 2 人(10 + 1)、1002 队 1 人(9):平均看 1002 强,总和看 1001 强。
|
||||
func TestAggregateModeRosterDiffersFlipsRanking(t *testing.T) {
|
||||
entries, results := fixture([]participant{
|
||||
{entryID: 1, athleteID: 1, teamID: 1001, value: 10},
|
||||
{entryID: 2, athleteID: 2, teamID: 1001, value: 1},
|
||||
{entryID: 3, athleteID: 3, teamID: 1002, value: 9},
|
||||
})
|
||||
ev := teamEvent(1)
|
||||
ev.Compare = CompareDesc
|
||||
|
||||
avg := aggregateByTeam(t, aggScore(t, ev, AggregateAvg, entries, results))
|
||||
sum := aggregateByTeam(t, aggScore(t, ev, AggregateSum, entries, results))
|
||||
|
||||
// 平均:1001 = 5.5 第二、1002 = 9 第一。
|
||||
if !nearlyEqual(avg[1001].value, 5.5) || avg[1001].rank != 2 || avg[1001].points != 10 {
|
||||
t.Errorf("avg:1001 应 5.5 第 2 名 10 分,实际 %+v", avg[1001])
|
||||
}
|
||||
if !nearlyEqual(avg[1002].value, 9) || avg[1002].rank != 1 || avg[1002].points != 15 {
|
||||
t.Errorf("avg:1002 应 9 第 1 名 15 分,实际 %+v", avg[1002])
|
||||
}
|
||||
// 总和:1001 = 11 第一、1002 = 9 第二。
|
||||
if !nearlyEqual(sum[1001].value, 11) || sum[1001].rank != 1 || sum[1001].points != 15 {
|
||||
t.Errorf("sum:1001 应 11 第 1 名 15 分,实际 %+v", sum[1001])
|
||||
}
|
||||
if !nearlyEqual(sum[1002].value, 9) || sum[1002].rank != 2 || sum[1002].points != 10 {
|
||||
t.Errorf("sum:1002 应 9 第 2 名 10 分,实际 %+v", sum[1002])
|
||||
}
|
||||
if avg[1001].rank == sum[1001].rank {
|
||||
t.Fatalf("人数不同时两种口径的名次不应相同:avg=%+v sum=%+v", avg, sum)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAggregateModeDefaultsToAverage:缺省(不带 aggregateMode / 老库老客户端)就是 avg,
|
||||
// 结果与显式写 avg 逐字段一致——现有数据与现有用例的行为一字不变。
|
||||
func TestAggregateModeDefaultsToAverage(t *testing.T) {
|
||||
if got := (ScoreChannel{}).aggregateMode(); got != AggregateAvg {
|
||||
t.Fatalf("零值通道的汇总口径 = %q,期望 avg", got)
|
||||
}
|
||||
// 认不出的取值也退回 avg,不是退回 sum(库里有 CHECK,但内存里的对象仍要兜住)。
|
||||
for _, bogus := range []AggregateMode{"", "average", "AVG", "SUM", "biggest"} {
|
||||
if got := (ScoreChannel{AggregateMode: bogus}).aggregateMode(); got != AggregateAvg {
|
||||
t.Errorf("取值 %q 应退回 avg,实际 %q", bogus, got)
|
||||
}
|
||||
}
|
||||
|
||||
// 与 TestAggregateAvgMode 同一个现场(compare=asc,越小越好):1001 一人有成绩 10、一人没成绩;
|
||||
// 1002 两人各 4 / 6。valid 口径下 1002 平均 5 第一(15 分)、1001 平均 10 第二(10 分)
|
||||
// ——这是升级前的既有结果。
|
||||
legacy := []struct {
|
||||
name string
|
||||
mode AvgMode
|
||||
build func() ([]Entry, []Result)
|
||||
want map[int64]aggregateOutcome
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
mode: AvgModeValid,
|
||||
build: func() ([]Entry, []Result) {
|
||||
return fixture([]participant{
|
||||
{entryID: 1, athleteID: 1, teamID: 1001, value: 10},
|
||||
{entryID: 2, athleteID: 2, teamID: 1001, noValue: true},
|
||||
{entryID: 3, athleteID: 3, teamID: 1002, value: 4},
|
||||
{entryID: 4, athleteID: 4, teamID: 1002, value: 6},
|
||||
})
|
||||
},
|
||||
want: map[int64]aggregateOutcome{
|
||||
1002: {value: 5, points: 15, rank: 1},
|
||||
1001: {value: 10, points: 10, rank: 2},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all",
|
||||
mode: AvgModeAll,
|
||||
build: func() ([]Entry, []Result) {
|
||||
return fixture([]participant{
|
||||
{entryID: 1, athleteID: 1, teamID: 1001, value: 10},
|
||||
{entryID: 2, athleteID: 2, teamID: 1001, noValue: true},
|
||||
{entryID: 3, athleteID: 3, teamID: 1002, value: 4},
|
||||
{entryID: 4, athleteID: 4, teamID: 1002, value: 6},
|
||||
})
|
||||
},
|
||||
want: map[int64]aggregateOutcome{
|
||||
1001: {value: 5, points: 15, rank: 1},
|
||||
1002: {value: 5, points: 15, rank: 1},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range legacy {
|
||||
t.Run("既有结果不变/"+tc.name, func(t *testing.T) {
|
||||
entries, results := tc.build()
|
||||
ev := teamEvent(1)
|
||||
ev.AvgMode = tc.mode
|
||||
|
||||
// defaultScore:通道不带 aggregateMode(老库数据 / 老客户端保存的通道)。
|
||||
defaultScore := aggScore(t, ev, "", entries, results)
|
||||
explicitAvg := aggScore(t, ev, AggregateAvg, entries, results)
|
||||
|
||||
// 「缺省」与「显式 avg」必须逐字段一致(连流水顺序都一致)。
|
||||
if !reflect.DeepEqual(defaultScore.Ledger, explicitAvg.Ledger) {
|
||||
t.Errorf("缺省口径的流水与显式 avg 不一致:\n缺省 %+v\n显式 %+v", defaultScore.Ledger, explicitAvg.Ledger)
|
||||
}
|
||||
if !reflect.DeepEqual(defaultScore.Ranked, explicitAvg.Ranked) {
|
||||
t.Errorf("缺省口径的名次与显式 avg 不一致:\n缺省 %+v\n显式 %+v", defaultScore.Ranked, explicitAvg.Ranked)
|
||||
}
|
||||
|
||||
got := aggregateByTeam(t, defaultScore)
|
||||
for teamID, want := range tc.want {
|
||||
have := got[teamID]
|
||||
if !nearlyEqual(have.value, want.value) || have.rank != want.rank || !nearlyEqual(have.points, want.points) {
|
||||
t.Errorf("单位 %d = %+v,期望 %+v", teamID, have, want)
|
||||
}
|
||||
}
|
||||
if got := pointsByTeam(defaultScore.Ledger); !reflect.DeepEqual(got, pointsByTeam(explicitAvg.Ledger)) {
|
||||
t.Errorf("缺省口径的单位总分与显式 avg 不一致:%v / %v", got, pointsByTeam(explicitAvg.Ledger))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAggregateModeSumWithAvgMode:sum 只累加有效成绩,avg_mode 在求和口径下只剩
|
||||
// 「一个有效成绩都没有的单位算不算 0 分参与排名」这一条作用(valid 不参与 / all 参与)。
|
||||
func TestAggregateModeSumWithAvgMode(t *testing.T) {
|
||||
build := func() ([]Entry, []Result) {
|
||||
return fixture([]participant{
|
||||
{entryID: 1, athleteID: 1, teamID: 1001, value: 10},
|
||||
{entryID: 2, athleteID: 2, teamID: 1001, value: 1},
|
||||
{entryID: 3, athleteID: 3, teamID: 1002, value: 9},
|
||||
// 1003 全队一条有效成绩都没有:valid 不参与、all 按 0 参与(与平均口径同规则)。
|
||||
{entryID: 4, athleteID: 4, teamID: 1003, noValue: true},
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("valid:没成绩的单位不成桶", func(t *testing.T) {
|
||||
entries, results := build()
|
||||
ev := teamEvent(1)
|
||||
ev.Compare = CompareDesc
|
||||
ev.AvgMode = AvgModeValid
|
||||
got := aggregateByTeam(t, aggScore(t, ev, AggregateSum, entries, results))
|
||||
if !nearlyEqual(got[1001].value, 11) || got[1001].rank != 1 {
|
||||
t.Errorf("1001 应总和 11 第 1 名,实际 %+v", got[1001])
|
||||
}
|
||||
if !nearlyEqual(got[1002].value, 9) || got[1002].rank != 2 {
|
||||
t.Errorf("1002 应总和 9 第 2 名,实际 %+v", got[1002])
|
||||
}
|
||||
if got[1003].rank != 0 || got[1003].points != 0 {
|
||||
t.Errorf("1003 全队没成绩,不应参与排名,实际 %+v", got[1003])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all:没成绩的单位按 0 参与、排在最后", func(t *testing.T) {
|
||||
entries, results := build()
|
||||
ev := teamEvent(1)
|
||||
ev.Compare = CompareDesc
|
||||
ev.AvgMode = AvgModeAll
|
||||
got := aggregateByTeam(t, aggScore(t, ev, AggregateSum, entries, results))
|
||||
// 求和:1001 = 11 第一;1002 = 9 第二;1003 = 0 第三(加 0 不改变其它队的和)。
|
||||
if !nearlyEqual(got[1001].value, 11) || got[1001].rank != 1 {
|
||||
t.Errorf("1001 应总和 11 第 1 名,实际 %+v", got[1001])
|
||||
}
|
||||
if !nearlyEqual(got[1002].value, 9) || got[1002].rank != 2 {
|
||||
t.Errorf("1002 应总和 9 第 2 名,实际 %+v", got[1002])
|
||||
}
|
||||
if !nearlyEqual(got[1003].value, 0) || got[1003].rank != 3 {
|
||||
t.Errorf("1003 应总和 0 第 3 名,实际 %+v", got[1003])
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -44,12 +44,30 @@ func teamPullupInput(teamRanks []TeamManualRank) EventScoreInput {
|
||||
}
|
||||
|
||||
// writeBackComputedRanks 模拟 httpapi 的 applyComputedRanks:
|
||||
// 只把个人通道(scope=athlete)算出的名次写回对应成绩的 results.rank。
|
||||
// 只把个人通道(scope=athlete)算出的名次写回对应成绩的 results.rank,
|
||||
// 且要给 manual 通道的「录入位」让路(工单 A-1 / A-2,与 httpapi.scoring.go 同口径):
|
||||
// 配了 [athlete,manual] 时整项不写回,配了 [team,manual] 时团体报名项(AthleteID=0)不写回。
|
||||
// 这里少一条判断,模拟出来的「第二次发布」就会跟真实系统不是一回事。
|
||||
func writeBackComputedRanks(in EventScoreInput, ranked []RankedResult) {
|
||||
athleteManual, teamManual := false, false
|
||||
for _, ch := range in.Channels {
|
||||
if ch.Basis != BasisManual {
|
||||
continue
|
||||
}
|
||||
switch ch.Scope {
|
||||
case ScopeAthlete:
|
||||
athleteManual = true
|
||||
case ScopeTeam:
|
||||
teamManual = true
|
||||
}
|
||||
}
|
||||
for _, rr := range ranked {
|
||||
if rr.Scope != ScopeAthlete || rr.ResultID == 0 || rr.Rank <= 0 {
|
||||
continue
|
||||
}
|
||||
if athleteManual || (teamManual && rr.AthleteID == 0) {
|
||||
continue
|
||||
}
|
||||
for i := range in.Results {
|
||||
if in.Results[i].ID == rr.ResultID {
|
||||
in.Results[i].Rank = rr.Rank
|
||||
@@ -58,6 +76,93 @@ func writeBackComputedRanks(in EventScoreInput, ranked []RankedResult) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTeamEntryDualChannelPublishTwiceIsIdempotent 是工单 A-1 的引擎侧回归:
|
||||
// team 类型小项 + 3 条团体报名项(athlete_id 为空)+ 双通道 [athlete/result, team/manual]。
|
||||
// 团体报名项的 results.rank 是「单位名次」的录入位,个人通道的自动名次不许写进去,
|
||||
// 否则第二次发布团体手工通道会把个人名次当成团体名次再发一遍分(10/8/6 → 25/18/14)。
|
||||
func TestTeamEntryDualChannelPublishTwiceIsIdempotent(t *testing.T) {
|
||||
entries, results := fixture([]participant{
|
||||
{entryID: 1, teamID: 1001, name: "甲队", value: 90},
|
||||
{entryID: 2, teamID: 1002, name: "乙队", value: 80},
|
||||
{entryID: 3, teamID: 1003, name: "丙队", value: 70},
|
||||
})
|
||||
ev := teamEvent(1)
|
||||
ev.Compare = CompareDesc
|
||||
in := EventScoreInput{
|
||||
Event: ev, Category: testCategory(),
|
||||
Channels: []ScoreChannel{
|
||||
{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1},
|
||||
teamManualChannel(2),
|
||||
},
|
||||
Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()},
|
||||
Entries: entries, Results: results,
|
||||
}
|
||||
|
||||
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 _, r := range in.Results {
|
||||
if r.Rank != 0 {
|
||||
t.Fatalf("团体报名项的 results.rank 被自动名次写回,得到 %+v", r)
|
||||
}
|
||||
}
|
||||
// 团体手工通道没有录入名次:两次发布都只有个人通道的 10/8/6。
|
||||
got := pointsByTeam(second.Ledger)
|
||||
for _, want := range []struct {
|
||||
teamID int64
|
||||
points float64
|
||||
}{{1001, 10}, {1002, 8}, {1003, 6}} {
|
||||
if !nearlyEqual(got[want.teamID], want.points) {
|
||||
t.Fatalf("单位 %d = %v,期望 %v(团体名次未录,不该多出团体分):%v",
|
||||
want.teamID, got[want.teamID], want.points, got)
|
||||
}
|
||||
}
|
||||
if len(second.Ranked) != 3 {
|
||||
t.Fatalf("名次明细 = %d 条,期望只有个人通道的 3 条:%+v", len(second.Ranked), second.Ranked)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAthleteManualRankNotOverwritten 是工单 A-2 的引擎侧回归:
|
||||
// [athlete,result] 与 [athlete,manual] 共存时,results.rank 是人工录入的个人名次,
|
||||
// 自动名次不许覆盖它;反复「发布 → 写回」必须得到同一份结果。
|
||||
func TestAthleteManualRankNotOverwritten(t *testing.T) {
|
||||
// 成绩 30 / 29(compare=asc:数值小的靠前 → 自动名次本该是 乙1 第 1、甲1 第 2);
|
||||
// 人工名次反着填(甲1 第 1、乙1 第 2)。
|
||||
entries, results := fixture([]participant{
|
||||
{entryID: 1, athleteID: 101, teamID: 1001, name: "甲", value: 30, rank: 1},
|
||||
{entryID: 2, athleteID: 102, teamID: 1002, name: "乙", value: 29, rank: 2},
|
||||
})
|
||||
in := EventScoreInput{
|
||||
Event: individualEvent(1), Category: testCategory(),
|
||||
Channels: []ScoreChannel{
|
||||
athleteResultChannel(1),
|
||||
{ID: 2, EventID: 1, Scope: ScopeAthlete, Basis: BasisManual, ProfileID: 1},
|
||||
},
|
||||
Profiles: map[int64]ScoringProfile{1: individualTop6()},
|
||||
Entries: entries, Results: results,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if in.Results[0].Rank != 1 || in.Results[1].Rank != 2 {
|
||||
t.Fatalf("人工名次被自动名次覆盖:甲=%d、乙=%d,期望 1 / 2", in.Results[0].Rank, in.Results[1].Rank)
|
||||
}
|
||||
// 自动通道 8/10 + 人工通道 10/8:两队各 18 分。
|
||||
got := pointsByTeam(second.Ledger)
|
||||
if !nearlyEqual(got[1001], 18) || !nearlyEqual(got[1002], 18) {
|
||||
t.Fatalf("两队应各 18 分(自动 + 人工),得到 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTeamPullupPublishTwiceIsIdempotent 是 P0 回归:
|
||||
// 「发布 → 把个人名次写回 results.rank → 再发布一次」两次的 Ledger 与 Ranked 必须逐字段一致。
|
||||
func TestTeamPullupPublishTwiceIsIdempotent(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 任务 F:修正「末位归零」的语义。
|
||||
//
|
||||
// 修复前的判据是「本组占用的名次区间是否包含末位名次」:
|
||||
//
|
||||
// startRank <= lastRank && lastRank <= startRank+size-1
|
||||
//
|
||||
// 并列组只要跨过末位就整组 0 分,于是 7 人里三人并列第 5(占 5/6/7)时,
|
||||
// 第 5 名的 2 分与第 6 名的 1 分被一起抹掉(审查报告 01 的 P1)。
|
||||
//
|
||||
// 修复后的语义:**末位那一档(名次 == lastPlace)不得分**,并列组按起始名次取分,
|
||||
// 所以只有起始名次正好落在末位档的组才归零——那种组必然只有一个人(startRank == lastPlace
|
||||
// 蕴含 size == 1,因为 lastPlace 是所有组占用名次的最大值)。区间跨过末位的并列组不归零。
|
||||
//
|
||||
// 修复前后实测对照(个人成绩通道,模板 10/8/6/3/2/1,lastPlaceZero=true):
|
||||
//
|
||||
// ① 7 人三人并列第 5(占 5/6/7):修前 [10 8 6 3 0 0 0] → 修后 [10 8 6 3 2 2 2]
|
||||
// ② 5 人独占第 5 名: 修前 [10 8 6 3 0] → 修后 [10 8 6 3 0](真末位,继续归零)
|
||||
// ③ 6 人两人并列第 5(占 5/6): 修前 [10 8 6 3 0 0] → 修后 [10 8 6 3 2 2]
|
||||
//
|
||||
// 名次(Rank)不受本修复影响:修复只改「这一组拿多少分」,不改谁排第几。
|
||||
|
||||
// scoreValueChannel 用个人成绩通道(basis=result)跑一次计分,返回 entryID 1..len(values)
|
||||
// 逐条的积分与展示名次。
|
||||
func scoreValueChannel(t *testing.T, profile ScoringProfile, values ...float64) ([]float64, []int) {
|
||||
t.Helper()
|
||||
entries, results := fixture(lineupValues(values...))
|
||||
es := mustScore(t, EventScoreInput{
|
||||
Event: individualEvent(1), Category: testCategory(),
|
||||
Channels: []ScoreChannel{athleteResultChannel(9)},
|
||||
Profiles: map[int64]ScoringProfile{9: profile},
|
||||
Entries: entries, Results: results,
|
||||
})
|
||||
pts := pointsByEntry(es.Ledger)
|
||||
ranks := rankByEntry(es.Ranked)
|
||||
out := make([]float64, len(values))
|
||||
outRanks := make([]int, len(values))
|
||||
for i := range values {
|
||||
out[i] = pts[int64(i+1)]
|
||||
outRanks[i] = ranks[int64(i+1)]
|
||||
}
|
||||
return out, outRanks
|
||||
}
|
||||
|
||||
// scoreManualTeamChannel 用团体手工名次通道(basis=manual + scope=team)跑一次计分,
|
||||
// 返回按录入顺序(单位 1001、1002 …)的积分。
|
||||
func scoreManualTeamChannel(t *testing.T, profile ScoringProfile, ranks []int) []float64 {
|
||||
t.Helper()
|
||||
teamRanks := make([]TeamManualRank, 0, len(ranks))
|
||||
for i, rk := range ranks {
|
||||
teamRanks = append(teamRanks, TeamManualRank{TeamID: int64(1001 + i), Rank: rk})
|
||||
}
|
||||
es := mustScore(t, EventScoreInput{
|
||||
Event: teamEvent(1), Category: testCategory(),
|
||||
Channels: []ScoreChannel{teamManualChannel(9)},
|
||||
Profiles: map[int64]ScoringProfile{9: profile},
|
||||
TeamRanks: teamRanks,
|
||||
})
|
||||
pts := pointsByTeam(es.Ledger)
|
||||
out := make([]float64, len(ranks))
|
||||
for i := range ranks {
|
||||
out[i] = pts[int64(1001+i)]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// scoreAggregateChannel 用团体聚合通道(basis=aggregate)跑一次计分,每个单位一条报名项,
|
||||
// 返回按单位 1001、1002 … 顺序的积分。
|
||||
func scoreAggregateChannel(t *testing.T, profile ScoringProfile, mode AvgMode, values ...float64) []float64 {
|
||||
t.Helper()
|
||||
ps := make([]participant, 0, len(values))
|
||||
for i, v := range values {
|
||||
ps = append(ps, participant{
|
||||
entryID: int64(i + 1), athleteID: int64(100 + i + 1), teamID: int64(1001 + i),
|
||||
name: "选手" + string(rune('A'+i)), value: v,
|
||||
})
|
||||
}
|
||||
entries, results := fixture(ps)
|
||||
ev := teamEvent(1)
|
||||
ev.AvgMode = mode
|
||||
es := mustScore(t, EventScoreInput{
|
||||
Event: ev, Category: testCategory(),
|
||||
Channels: []ScoreChannel{teamAggregateChannel(9)},
|
||||
Profiles: map[int64]ScoringProfile{9: profile},
|
||||
Entries: entries, Results: results,
|
||||
})
|
||||
pts := pointsByTeam(es.Ledger)
|
||||
out := make([]float64, len(values))
|
||||
for i := range values {
|
||||
out[i] = pts[int64(1001+i)]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestLastPlaceZeroOnlyZeroesLastPlaceTier 覆盖工单任务 F 要求的三种形态。
|
||||
func TestLastPlaceZeroOnlyZeroesLastPlaceTier(t *testing.T) {
|
||||
profile := individualTop6()
|
||||
profile.LastPlaceZero = true
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
values []float64
|
||||
wantPoints []float64
|
||||
wantRanks []int
|
||||
}{
|
||||
{
|
||||
// 修复前这里断言的是 [10 8 6 3 0 0 0]——整组被末位判据误伤,第 5、6 名白丢 2+1 分。
|
||||
name: "① 7 人三人并列第 5(占 5/6/7):不归零,按第 5 名给分",
|
||||
values: []float64{1, 2, 3, 4, 5, 5, 5},
|
||||
wantPoints: []float64{10, 8, 6, 3, 2, 2, 2},
|
||||
wantRanks: []int{1, 2, 3, 4, 5, 5, 5},
|
||||
},
|
||||
{
|
||||
name: "② 5 人独占第 5 名:这一档就是末位,归零",
|
||||
values: []float64{1, 2, 3, 4, 5},
|
||||
wantPoints: []float64{10, 8, 6, 3, 0},
|
||||
wantRanks: []int{1, 2, 3, 4, 5},
|
||||
},
|
||||
{
|
||||
name: "③ 6 人末位与第 5 名并列(占 5/6):起始名次 5 < 末位 6,不归零",
|
||||
values: []float64{1, 2, 3, 4, 5, 5},
|
||||
wantPoints: []float64{10, 8, 6, 3, 2, 2},
|
||||
wantRanks: []int{1, 2, 3, 4, 5, 5},
|
||||
},
|
||||
{
|
||||
name: "7 人两人并列第 6(占 6/7):不归零,按第 6 名给分",
|
||||
values: []float64{1, 2, 3, 4, 5, 6, 6},
|
||||
wantPoints: []float64{10, 8, 6, 3, 2, 1, 1},
|
||||
wantRanks: []int{1, 2, 3, 4, 5, 6, 6},
|
||||
},
|
||||
{
|
||||
name: "7 人独占第 7 名:真末位,归零",
|
||||
values: []float64{1, 2, 3, 4, 5, 6, 7},
|
||||
wantPoints: []float64{10, 8, 6, 3, 2, 1, 0},
|
||||
wantRanks: []int{1, 2, 3, 4, 5, 6, 7},
|
||||
},
|
||||
{
|
||||
name: "仅 1 人参赛:唯一参赛者就是末位,归零",
|
||||
values: []float64{1},
|
||||
wantPoints: []float64{0},
|
||||
wantRanks: []int{1},
|
||||
},
|
||||
{
|
||||
// 全场并列时起始名次是 1,没人在「末位那一档」上,所以都不归零。
|
||||
name: "7 人全场并列第 1:起始名次 1 ≠ 末位 7,不归零",
|
||||
values: []float64{1, 1, 1, 1, 1, 1, 1},
|
||||
wantPoints: []float64{10, 10, 10, 10, 10, 10, 10},
|
||||
wantRanks: []int{1, 1, 1, 1, 1, 1, 1},
|
||||
},
|
||||
{
|
||||
name: "2 人并列第 1(占 1/2):不归零,两人同分",
|
||||
values: []float64{1, 1},
|
||||
wantPoints: []float64{10, 10},
|
||||
wantRanks: []int{1, 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
points, ranks := scoreValueChannel(t, profile, tc.values...)
|
||||
assertPointsAndRanks(t, points, ranks, tc.wantPoints, tc.wantRanks)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLastPlaceZeroTeamManualAndAggregateChannels 把同一条语义钉在另外两类名次来源上:
|
||||
// 团体手工名次(篮球 / 足球等纯团体项目的主路径)与团体聚合通道共用 groupPoints,
|
||||
// 末位判据必须同样只抹末位那一档。
|
||||
func TestLastPlaceZeroTeamManualAndAggregateChannels(t *testing.T) {
|
||||
manual := teamTop6()
|
||||
manual.LastPlaceZero = true
|
||||
aggregate := teamTop6()
|
||||
aggregate.LastPlaceZero = true
|
||||
|
||||
t.Run("团体手工名次:7 队独占末位 → 第 7 名归零", func(t *testing.T) {
|
||||
got := scoreManualTeamChannel(t, manual, []int{1, 2, 3, 4, 5, 6, 7})
|
||||
assertFloatSlice(t, "各队积分", got, []float64{15, 10, 8, 4, 2, 1, 0})
|
||||
})
|
||||
|
||||
t.Run("团体手工名次:三人并列第 5(占 5/6/7)→ 不归零", func(t *testing.T) {
|
||||
got := scoreManualTeamChannel(t, manual, []int{1, 2, 3, 4, 5, 5, 5})
|
||||
assertFloatSlice(t, "各队积分", got, []float64{15, 10, 8, 4, 2, 2, 2})
|
||||
})
|
||||
|
||||
t.Run("团体手工名次:录入漏顺延 1,2,2,3,4,5,6 → 只抹被占用的末日档(第 6 名)", func(t *testing.T) {
|
||||
// lastPlace 取「所有组占用名次的最大值」:录入员按最自然的顺延理解写 1,2,2,3,4,5,6 时,
|
||||
// 最后一个队展示的是第 6 名,就是这份录入里的末位,仍应归零。
|
||||
got := scoreManualTeamChannel(t, manual, []int{1, 2, 2, 3, 4, 5, 6})
|
||||
assertFloatSlice(t, "各队积分", got, []float64{15, 10, 10, 8, 4, 2, 0})
|
||||
})
|
||||
|
||||
t.Run("团体手工名次:5 队独占第 5 名 → 归零", func(t *testing.T) {
|
||||
got := scoreManualTeamChannel(t, manual, []int{1, 2, 3, 4, 5})
|
||||
assertFloatSlice(t, "各队积分", got, []float64{15, 10, 8, 4, 0})
|
||||
})
|
||||
|
||||
t.Run("团体聚合:6 队末位与第 5 名并列(占 5/6)→ 不归零", func(t *testing.T) {
|
||||
got := scoreAggregateChannel(t, aggregate, AvgModeValid, 1, 2, 3, 4, 5, 5)
|
||||
assertFloatSlice(t, "各单位积分", got, []float64{15, 10, 8, 4, 2, 2})
|
||||
})
|
||||
|
||||
t.Run("团体聚合:5 队独占末位 → 归零", func(t *testing.T) {
|
||||
got := scoreAggregateChannel(t, aggregate, AvgModeValid, 1, 2, 3, 4, 5)
|
||||
assertFloatSlice(t, "各单位积分", got, []float64{15, 10, 8, 4, 0})
|
||||
})
|
||||
}
|
||||
|
||||
// TestDefaultConfigScoringUnchanged 是「默认配置逐字节不变」的回归:
|
||||
// lastPlaceZero=false(现场多数小项在用)时修复只动了 `if profile.LastPlaceZero` 里面的判据,
|
||||
// 这个分支根本不进,所以任何形态的结果都必须与修复前一致。这里把值直接写死。
|
||||
func TestDefaultConfigScoringUnchanged(t *testing.T) {
|
||||
profile := individualTop6()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
values []float64
|
||||
want []float64
|
||||
}{
|
||||
{"7 人三人并列第 5", []float64{1, 2, 3, 4, 5, 5, 5}, []float64{10, 8, 6, 3, 2, 2, 2}},
|
||||
{"6 人两人并列第 5", []float64{1, 2, 3, 4, 5, 5}, []float64{10, 8, 6, 3, 2, 2}},
|
||||
{"5 人独占第 5 名", []float64{1, 2, 3, 4, 5}, []float64{10, 8, 6, 3, 2}},
|
||||
{"7 人无并列", []float64{1, 2, 3, 4, 5, 6, 7}, []float64{10, 8, 6, 3, 2, 1, 0}},
|
||||
{"1 人参赛", []float64{1}, []float64{10}},
|
||||
{"2 人并列第 1", []float64{1, 1}, []float64{10, 10}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
points, _ := scoreValueChannel(t, profile, tc.values...)
|
||||
assertFloatSlice(t, "积分", points, tc.want)
|
||||
})
|
||||
}
|
||||
|
||||
manual := teamTop6()
|
||||
t.Run("团体手工名次", func(t *testing.T) {
|
||||
got := scoreManualTeamChannel(t, manual, []int{1, 2, 3, 4, 5, 6, 7})
|
||||
assertFloatSlice(t, "各队积分", got, []float64{15, 10, 8, 4, 2, 1, 0})
|
||||
})
|
||||
|
||||
t.Run("团体聚合", func(t *testing.T) {
|
||||
got := scoreAggregateChannel(t, manual, AvgModeValid, 1, 2, 3, 4, 5, 5)
|
||||
assertFloatSlice(t, "各单位积分", got, []float64{15, 10, 8, 4, 2, 2})
|
||||
})
|
||||
}
|
||||
|
||||
// defaultConfigGoldenSHA256 是 defaultConfigScoreDump 的签名,取自**修复前**的代码
|
||||
// (2026-09-13 任务 F 开工时记录)。覆盖:成绩通道 1..9 人 × 19 种成绩形态 × 三种并列口径、
|
||||
// 团体手工名次 14 种录入形态、团体聚合两种 avg_mode × 8 种成绩形态。
|
||||
// 这些形态在默认配置(lastPlaceZero=false)下一分都不该变。
|
||||
const defaultConfigGoldenSHA256 = "2c7425cb7371d584ca435403cea0ec9c03d069e6f08f6fdc21531845702b11f8"
|
||||
|
||||
// TestDefaultConfigScoringDumpUnchanged 是本任务对「lastPlaceZero=false 逐字节一致」的全量兜底:
|
||||
// 逐个形态的积分拼成一份签名与修复前对比。失败时会把完整签名打出来,方便定位是哪个形态变了。
|
||||
func TestDefaultConfigScoringDumpUnchanged(t *testing.T) {
|
||||
dump := defaultConfigScoreDump(t)
|
||||
sum := sha256.Sum256([]byte(dump))
|
||||
got := hex.EncodeToString(sum[:])
|
||||
if got != defaultConfigGoldenSHA256 {
|
||||
t.Fatalf("lastPlaceZero=false 的计分结果与修复前不一致:\n签名 = %s\n期望 = %s\n\n%s",
|
||||
got, defaultConfigGoldenSHA256, dump)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultConfigScoreDump 把默认配置下各形态的逐条积分拼成一份稳定文本。
|
||||
// 构造只依赖引擎输出,不依赖 map 遍历顺序,所以修复前后跑出来的文本可以直接比对。
|
||||
func defaultConfigScoreDump(t *testing.T) string {
|
||||
t.Helper()
|
||||
var lines []string
|
||||
add := func(name string, points []float64) {
|
||||
lines = append(lines, fmt.Sprintf("%s => %s", name, formatPoints(points)))
|
||||
}
|
||||
|
||||
valueShapes := [][]float64{
|
||||
{1}, {1, 2}, {1, 1}, {1, 2, 3}, {1, 1, 1}, {1, 2, 2},
|
||||
{1, 2, 3, 4}, {1, 2, 3, 5}, {1, 1, 2, 3}, {1, 1, 1, 1},
|
||||
{1, 2, 3, 4, 5}, {1, 2, 3, 4, 4}, {1, 2, 3, 4, 5, 5},
|
||||
{1, 1, 1, 1, 1, 1, 1}, {1, 2, 3, 4, 5, 5, 5}, {1, 2, 3, 4, 5, 6, 6},
|
||||
{1, 2, 3, 4, 5, 6, 7}, {1, 2, 3, 4, 5, 6, 7, 8}, {1, 2, 3, 4, 5, 6, 7, 8, 9},
|
||||
}
|
||||
for _, tie := range []TieMode{TieSkip, TieAverage, TieHigher} {
|
||||
for i, values := range valueShapes {
|
||||
points, _ := scoreValueChannel(t, nineProfile(tie), values...)
|
||||
add(fmt.Sprintf("result/%s/shape%02d/%s", tie, i, formatPoints(values)), points)
|
||||
}
|
||||
}
|
||||
|
||||
manualShapes := [][]int{
|
||||
{1}, {1, 2}, {1, 1}, {1, 2, 3}, {1, 1, 1}, {1, 2, 2}, {1, 2, 4},
|
||||
{1, 2, 3, 4, 5}, {1, 2, 3, 4, 4}, {1, 2, 3, 4, 5, 5},
|
||||
{1, 2, 3, 4, 5, 6, 7}, {1, 2, 2, 3, 4, 5, 6}, {1, 2, 3, 4, 5, 5, 7},
|
||||
{1, 1, 1, 1, 1, 1, 1},
|
||||
}
|
||||
for i, ranks := range manualShapes {
|
||||
got := scoreManualTeamChannel(t, teamTop6(), ranks)
|
||||
add(fmt.Sprintf("manual-team/%02d/%s", i, formatInts(ranks)), got)
|
||||
}
|
||||
|
||||
aggregateShapes := [][]float64{
|
||||
{1}, {1, 2}, {1, 1}, {1, 2, 3, 4, 5}, {1, 2, 3, 4, 5, 5},
|
||||
{1, 2, 3, 4, 5, 5, 5}, {1, 2, 3, 4, 5, 6, 7}, {1, 1, 1, 1, 1, 1, 1},
|
||||
}
|
||||
for _, mode := range []AvgMode{AvgModeValid, AvgModeAll} {
|
||||
for i, values := range aggregateShapes {
|
||||
got := scoreAggregateChannel(t, teamTop6(), mode, values...)
|
||||
add(fmt.Sprintf("aggregate/%s/%02d/%s", mode, i, formatPoints(values)), got)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
|
||||
func assertFloatSlice(t *testing.T, what string, got, want []float64) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%s 条数 = %d,期望 %d(%v)", what, len(got), len(want), got)
|
||||
}
|
||||
for i := range want {
|
||||
if !nearlyEqual(got[i], want[i]) {
|
||||
t.Errorf("%s 第 %d 项 = %v,期望 %v(完整:%v)", what, i+1, got[i], want[i], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertPointsAndRanks(t *testing.T, points []float64, ranks []int, wantPoints []float64, wantRanks []int) {
|
||||
t.Helper()
|
||||
assertFloatSlice(t, "积分", points, wantPoints)
|
||||
if len(ranks) != len(wantRanks) {
|
||||
t.Fatalf("名次条数 = %d,期望 %d(%v)", len(ranks), len(wantRanks), ranks)
|
||||
}
|
||||
for i := range wantRanks {
|
||||
if ranks[i] != wantRanks[i] {
|
||||
t.Errorf("第 %d 条名次 = %d,期望 %d(完整:%v)", i+1, ranks[i], wantRanks[i], ranks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formatPoints(fs []float64) string {
|
||||
parts := make([]string, len(fs))
|
||||
for i, f := range fs {
|
||||
parts[i] = fmt.Sprintf("%v", f)
|
||||
}
|
||||
return "[" + strings.Join(parts, ", ") + "]"
|
||||
}
|
||||
|
||||
func formatInts(is []int) string {
|
||||
parts := make([]string, len(is))
|
||||
for i, v := range is {
|
||||
parts[i] = fmt.Sprintf("%d", v)
|
||||
}
|
||||
return "[" + strings.Join(parts, ", ") + "]"
|
||||
}
|
||||
@@ -10,6 +10,18 @@ type TeamPoints struct {
|
||||
// SumByTeam 汇总各单位得分:总分 = 仅记单位的流水 + 系数 × 记到个人的流水。
|
||||
// weight <= 0 时按 1 处理,避免零值把个人分抹掉。
|
||||
// count_to_team = 0 的流水(如「个人+团体」项目的个人通道)只记在个人头上,不计入单位总分。
|
||||
//
|
||||
// 刻意不按 EventID 过滤(也不该有人来「补」这条过滤):
|
||||
// - 「event_id 为空」不等于「孤儿流水」。人工调整(source_type='adjustment')本来就允许不挂小项,
|
||||
// 有 event_id 为 NULL 的合法行(写入口 repo_misc.go 的 createAdjustment,由迁移测试钉住),
|
||||
// 一刀切过滤会把合法的调整分一起吞掉;
|
||||
// - 就算再收窄到 source_type='result',过滤也只是「让分悄悄消失」:没有作废人、没有作废原因、
|
||||
// 没有审计行。积分变动必须可追溯,所以作废只能发生在写侧(如 deleteEvent 在同一事务里
|
||||
// 按 void_reason 作废),而不是读取时假装看不见;
|
||||
// - 同一个「删掉的小项」在别处仍会被读到:单位积分明细(httpapi/repo_misc.go 的 teamBreakdown)
|
||||
// 是另一条 SQL,过滤这里只会变成「榜上无分、明细有分」的新矛盾。
|
||||
//
|
||||
// 因此本函数只认调用方给它的流水:要让它少算分,就在写侧把流水作废掉。
|
||||
func SumByTeam(entries []LedgerEntry, weight float64) map[int64]TeamPoints {
|
||||
if weight <= 0 {
|
||||
weight = 1
|
||||
@@ -33,6 +45,7 @@ func SumByTeam(entries []LedgerEntry, weight float64) map[int64]TeamPoints {
|
||||
|
||||
// SumByAthlete 汇总每个运动员的个人总分(原始分,不折算系数)。
|
||||
// 不计入单位总分的流水(count_to_team = 0)照样算在个人头上,个人榜不受影响。
|
||||
// 与 SumByTeam 同口径,同样不按 EventID 过滤(理由见 SumByTeam 的注释)。
|
||||
func SumByAthlete(entries []LedgerEntry) map[int64]float64 {
|
||||
out := make(map[int64]float64)
|
||||
for _, e := range entries {
|
||||
|
||||
@@ -365,16 +365,18 @@ func groupByManualRank(cands []candidate) []tieGroup {
|
||||
|
||||
// rankGroups 给每组分配积分并产出名次明细。
|
||||
func rankGroups(in EventScoreInput, ch ScoreChannel, profile ScoringProfile, groups []tieGroup) []RankedResult {
|
||||
lastRank := 0
|
||||
// lastPlace 是本组别被占用的最大名次档,也就是「末位」那一档:skip 口径下各组的占位
|
||||
// 连续铺满 1..N,它正好等于实际可排名人数 N。末位归零只抹这一档,见 groupPoints。
|
||||
lastPlace := 0
|
||||
for _, g := range groups {
|
||||
if r := g.startRank + len(g.members) - 1; r > lastRank {
|
||||
lastRank = r
|
||||
if r := g.startRank + len(g.members) - 1; r > lastPlace {
|
||||
lastPlace = r
|
||||
}
|
||||
}
|
||||
|
||||
var out []RankedResult
|
||||
for _, g := range groups {
|
||||
pts := groupPoints(profile, g.startRank, len(g.members), lastRank, in.Event, in.Category)
|
||||
pts := groupPoints(profile, g.startRank, len(g.members), lastPlace, in.Event, in.Category)
|
||||
for _, c := range g.members {
|
||||
out = append(out, rankedResult(in, ch, c, g.startRank, pts))
|
||||
}
|
||||
@@ -383,14 +385,18 @@ func rankGroups(in EventScoreInput, ch ScoreChannel, profile ScoringProfile, gro
|
||||
}
|
||||
|
||||
// groupPoints 计算一组并列者拿到的分。
|
||||
// 三种口径都先确定这组「占用了哪些名次」:startRank .. startRank+size-1。
|
||||
func groupPoints(profile ScoringProfile, startRank, size, lastRank int, ev Event, cat EventCategory) float64 {
|
||||
// 三种口径都先确定这组「占用了哪些名次」:startRank .. startRank+size-1;
|
||||
// lastPlace 是本组别被占用的最大名次档(末位那一档,skip 口径下等于实际可排名人数 N)。
|
||||
func groupPoints(profile ScoringProfile, startRank, size, lastPlace int, ev Event, cat EventCategory) float64 {
|
||||
if startRank <= 0 || size <= 0 {
|
||||
return 0
|
||||
}
|
||||
// lastPlaceZero 是对「最后一名」的覆盖;并列在末位的整组都按最后一名处理,
|
||||
// 否则同组出现 0 与 1 两种分数,违背「并列同分」的语义。
|
||||
if profile.LastPlaceZero && startRank <= lastRank && lastRank <= startRank+size-1 {
|
||||
// lastPlaceZero 只把「末位那一档」置零:判据是起始名次**正好落在这档**。
|
||||
// 这种组必然只有一个人(startRank == lastPlace 意味着 size == 1,因为 lastPlace 是所有组
|
||||
// 占位名次的最大值)。并列组若从更靠前的名次起步、区间跨过末位,组内多数人本可拿到录取线内的分,
|
||||
// 整组归零会把他们一起抹掉——7 人里三人并列第 5(占 5/6/7)时,第 5 名的 2 分与第 6 名的 1 分
|
||||
// 都该照发,而他们并不是「末位那一档」。
|
||||
if profile.LastPlaceZero && startRank == lastPlace {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -459,13 +465,21 @@ func rankedResult(in EventScoreInput, ch ScoreChannel, c candidate, rank int, po
|
||||
return rr
|
||||
}
|
||||
|
||||
// scoreByAggregate 实现 basis=aggregate:把同一主体(单位/运动员)在本组别的成绩取平均,
|
||||
// scoreByAggregate 实现 basis=aggregate:把同一主体(单位/运动员)在本组别的成绩汇成一个值,
|
||||
// 得到团体成绩后按项目的 compare 方向排名(并列沿用模板的 tieMode,默认顺延)。
|
||||
// 平均值分母由项目的 avg_mode 决定:
|
||||
//
|
||||
// 汇总口径(通道的 aggregate_mode,缺省 avg):
|
||||
// - avg:团体成绩 = 成员成绩的平均值;
|
||||
// - sum:团体成绩 = 成员成绩的总和。
|
||||
//
|
||||
// avg 的平均值分母由项目的 avg_mode 决定:
|
||||
// - valid:分子 = 有效成绩之和,分母 = 有有效成绩的人数;一个人都没成绩就不参与排名、不得分;
|
||||
// - all:分子 = 有效成绩之和(没成绩的按 0),分母 = 该主体的报名人数。
|
||||
// 没有报名项的主体不会成桶,自然不参与。
|
||||
//
|
||||
// sum 只累加有效成绩(没成绩的人按 0 计入和不计入是一样的和),所以 avg_mode 在这里只剩一个作用:
|
||||
// 决定「一个有效成绩都没有的单位算不算 0 分参与排名」——valid 不参与、all 参与,与 avg 完全一致。
|
||||
//
|
||||
// 弃权 / 失格(status 非 registered)的报名项既不算参与也不算进分母,与「不占名次」的既有口径一致。
|
||||
func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) {
|
||||
if in.Event.Compare == CompareNone {
|
||||
@@ -510,18 +524,24 @@ func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfil
|
||||
b.valid++
|
||||
}
|
||||
}
|
||||
sumMode := ch.aggregateMode() == AggregateSum
|
||||
allMode := in.Event.avgMode() == AvgModeAll
|
||||
agg := make([]candidate, 0, len(order))
|
||||
for _, id := range order {
|
||||
b := buckets[id]
|
||||
if b.valid == 0 && !allMode {
|
||||
// 一个人都没有效成绩的单位不参与团体排名,也不得分(两种汇总口径都一样)。
|
||||
continue
|
||||
}
|
||||
var value float64
|
||||
if in.Event.avgMode() == AvgModeAll {
|
||||
switch {
|
||||
case sumMode:
|
||||
// 求和口径:只累加有效成绩(没成绩按 0 计入与否不改变和)。
|
||||
value = b.sum
|
||||
case allMode:
|
||||
// 分母 = 报名人数:没成绩的人按 0 计入,也就是只累加有效成绩之和。
|
||||
value = b.sum / float64(b.roster)
|
||||
} else {
|
||||
if b.valid == 0 {
|
||||
// 一个人都没有效成绩的单位不参与团体排名,也不得分。
|
||||
continue
|
||||
}
|
||||
default:
|
||||
value = b.sum / float64(b.valid)
|
||||
}
|
||||
c := candidate{entry: b.entry, subject: b.subject, value: value}
|
||||
@@ -531,16 +551,17 @@ func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfil
|
||||
sortCandidatesByValue(agg, in.Event.Compare)
|
||||
|
||||
groups := groupByValue(agg)
|
||||
lastRank := 0
|
||||
// 与 rankGroups 同一个口径:lastPlace = 被占用的最大名次档(聚合结果同样是 skip 占位)。
|
||||
lastPlace := 0
|
||||
for _, g := range groups {
|
||||
if r := g.startRank + len(g.members) - 1; r > lastRank {
|
||||
lastRank = r
|
||||
if r := g.startRank + len(g.members) - 1; r > lastPlace {
|
||||
lastPlace = r
|
||||
}
|
||||
}
|
||||
|
||||
var out []RankedResult
|
||||
for _, g := range groups {
|
||||
pts := groupPoints(profile, g.startRank, len(g.members), lastRank, in.Event, in.Category)
|
||||
pts := groupPoints(profile, g.startRank, len(g.members), lastPlace, in.Event, in.Category)
|
||||
for _, c := range g.members {
|
||||
rr := RankedResult{
|
||||
EventID: in.Event.ID,
|
||||
|
||||
@@ -31,7 +31,8 @@ func TestGroupPointsTieModes(t *testing.T) {
|
||||
{"skip 超出录取名次", nineProfile(TieSkip), 9, 1, 9, 0},
|
||||
{"空模板口径按 skip", ScoringProfile{AwardRanks: 8, Rules: nineProfile(TieSkip).Rules}, 2, 1, 8, 7},
|
||||
{"lastPlaceZero 抹掉最后一名", ScoringProfile{AwardRanks: 8, LastPlaceZero: true, Rules: nineProfile(TieSkip).Rules}, 1, 1, 1, 0},
|
||||
{"lastPlaceZero 抹掉并列末位整组", ScoringProfile{AwardRanks: 8, LastPlaceZero: true, Rules: nineProfile(TieSkip).Rules}, 2, 2, 3, 0},
|
||||
// 末位档是 3(三人参赛),这组从第 2 名起步、区间跨过末位 → 只吞末位那一档,不整组归零。
|
||||
{"lastPlaceZero 不牵连跨末位的并列组", ScoringProfile{AwardRanks: 8, LastPlaceZero: true, Rules: nineProfile(TieSkip).Rules}, 2, 2, 3, 7},
|
||||
{"权重相乘", nineProfile(TieSkip), 1, 1, 8, 9},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
|
||||
@@ -19,6 +19,13 @@ var (
|
||||
defaultAthleteTieBreak = []string{TieBreakPoints, TieBreakGold, TieBreakSilver, TieBreakBronze, TieBreakName}
|
||||
)
|
||||
|
||||
// 单位奖牌榜的排序口径(主人 2026-09-13 拍板):金牌 → 银牌 → 铜牌 → 单位编码。
|
||||
//
|
||||
// 它**故意不读** StandingsInput.TieBreak:那套键是给总积分榜与个人榜的,
|
||||
// 规则包里写的 `points,gold,...` 一进来,奖牌榜就又变回"总分优先",
|
||||
// 会出现"金牌 6 枚的单位排在金牌 2 枚的后面"(06 报告 P1④)。
|
||||
var defaultMedalTieBreak = []string{TieBreakGold, TieBreakSilver, TieBreakBronze, TieBreakTeamCode}
|
||||
|
||||
// StandingsInput 是排名聚合的输入:流水 + 名次明细 + 单位/运动员基础信息。
|
||||
type StandingsInput struct {
|
||||
Teams []Team
|
||||
@@ -27,6 +34,7 @@ type StandingsInput struct {
|
||||
Ranked []RankedResult
|
||||
// TieBreak 是排序键顺序(如 points/gold/silver/bronze/teamCode/name);
|
||||
// 空表示用默认口径。配置里的键优先,其余按默认顺序补齐,保证结果全序。
|
||||
// **只作用于总积分榜与个人榜**:单位奖牌榜固定按金→银→铜→单位编码排(defaultMedalTieBreak)。
|
||||
TieBreak []string
|
||||
// IndividualToTeamWeight 个人积分折算进单位总分的系数;<=0 时按 1 处理。
|
||||
IndividualToTeamWeight float64
|
||||
@@ -61,6 +69,19 @@ func medalEligible(rr RankedResult) bool {
|
||||
// BuildTeamStandings 生成总积分榜。
|
||||
// 总分 = Σ(仅记单位的流水) + 系数 × Σ(记到个人的流水);排序按 in.TieBreak,缺省为总分 → 金 → 银 → 铜 → 单位编码。
|
||||
func BuildTeamStandings(in StandingsInput) []StandingsRow {
|
||||
rows := buildTeamRows(in)
|
||||
sort.SliceStable(rows, func(i, j int) bool { return lessTeamRow(rows[i], rows[j], in.TieBreak) })
|
||||
for i := range rows {
|
||||
rows[i].Rank = i + 1
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// buildTeamRows 汇总出「每个单位一行」的原始数据(含兜底补行),不排序、不填名次。
|
||||
//
|
||||
// 单位总分榜与单位奖牌榜的行集合必须完全一致(同一批单位、同样兜底),
|
||||
// 差别只在排序与名次;两条路都从这里出行,避免哪天只给一个榜补了兜底行。
|
||||
func buildTeamRows(in StandingsInput) []StandingsRow {
|
||||
weight := effectiveWeight(in.IndividualToTeamWeight)
|
||||
sums := SumByTeam(in.Ledger, weight)
|
||||
medals := teamMedals(in)
|
||||
@@ -87,22 +108,19 @@ func BuildTeamStandings(in StandingsInput) []StandingsRow {
|
||||
rows = append(rows, buildTeamRow(Team{ID: id}, sums[id], m))
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(rows, func(i, j int) bool { return lessTeamRow(rows[i], rows[j], in.TieBreak) })
|
||||
for i := range rows {
|
||||
rows[i].Rank = i + 1
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// BuildMedalStandings 生成单位奖牌榜。
|
||||
// 排序口径与总积分榜一致(总分优先),只是列不同,保证两块榜的单位顺序不会打架。
|
||||
//
|
||||
// 行集合与总积分榜相同(同一份输入、同一套兜底),但**名次与顺序自成一套**:
|
||||
// 金 → 银 → 铜 → 单位编码,与总分无关(主人 2026-09-13 拍板)。
|
||||
// 因此绝不能复用总积分榜的 Rank——那正是「金牌多的排在金牌少的后面」的来源。
|
||||
func BuildMedalStandings(in StandingsInput) []MedalRow {
|
||||
teamRows := BuildTeamStandings(in)
|
||||
out := make([]MedalRow, len(teamRows))
|
||||
for i, r := range teamRows {
|
||||
out[i] = MedalRow{
|
||||
Rank: r.Rank,
|
||||
teamRows := buildTeamRows(in)
|
||||
out := make([]MedalRow, 0, len(teamRows))
|
||||
for _, r := range teamRows {
|
||||
out = append(out, MedalRow{
|
||||
TeamID: r.TeamID,
|
||||
Code: r.Code,
|
||||
Name: r.Name,
|
||||
@@ -112,11 +130,42 @@ func BuildMedalStandings(in StandingsInput) []MedalRow {
|
||||
Silver: r.Silver,
|
||||
Bronze: r.Bronze,
|
||||
Total: r.Gold + r.Silver + r.Bronze,
|
||||
}
|
||||
})
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return lessMedalRow(out[i], out[j]) })
|
||||
for i := range out {
|
||||
out[i].Rank = i + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lessMedalRow 是单位奖牌榜的排序:金 DESC → 银 DESC → 铜 DESC → 单位编码 ASC。
|
||||
// 只用 defaultMedalTieBreak 这几个键(不掺 StandingsInput.TieBreak),最后用单位 ID 兜底,
|
||||
// 保证任意两行都有确定先后(结果全序)。
|
||||
func lessMedalRow(a, b MedalRow) bool {
|
||||
for _, key := range defaultMedalTieBreak {
|
||||
switch key {
|
||||
case TieBreakGold:
|
||||
if a.Gold != b.Gold {
|
||||
return a.Gold > b.Gold
|
||||
}
|
||||
case TieBreakSilver:
|
||||
if a.Silver != b.Silver {
|
||||
return a.Silver > b.Silver
|
||||
}
|
||||
case TieBreakBronze:
|
||||
if a.Bronze != b.Bronze {
|
||||
return a.Bronze > b.Bronze
|
||||
}
|
||||
case TieBreakTeamCode:
|
||||
if a.Code != b.Code {
|
||||
return a.Code < b.Code
|
||||
}
|
||||
}
|
||||
}
|
||||
return a.TeamID < b.TeamID
|
||||
}
|
||||
|
||||
// BuildAthleteStandings 生成个人积分奖牌榜。
|
||||
// 排序按 in.TieBreak,缺省为总分 → 金 → 银 → 铜 → 姓名;个人榜的奖牌只统计个人项目(团体项目奖牌归团体榜)。
|
||||
func BuildAthleteStandings(in StandingsInput) []AthleteStandingsRow {
|
||||
|
||||
@@ -147,6 +147,94 @@ func TestMedalStandings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// medalRanked 造一条能拿奖牌的名次明细:一个小项一条,避免被「同一主体只记一枚」去重掉。
|
||||
func medalRanked(teamID, eventID int64, rank int) RankedResult {
|
||||
rr := mkRanked(teamID, ScopeTeam, rank)
|
||||
rr.EventID = eventID
|
||||
rr.CategoryID = 1
|
||||
return rr
|
||||
}
|
||||
|
||||
// medalPodium 给一个单位凑出金/银/铜若干枚(各占一个小项),firstEventID 用来错开小项。
|
||||
func medalPodium(teamID, firstEventID int64, gold, silver, bronze int) []RankedResult {
|
||||
out := make([]RankedResult, 0, gold+silver+bronze)
|
||||
next := firstEventID
|
||||
for i := 0; i < gold; i++ {
|
||||
out = append(out, medalRanked(teamID, next, 1))
|
||||
next++
|
||||
}
|
||||
for i := 0; i < silver; i++ {
|
||||
out = append(out, medalRanked(teamID, next, 2))
|
||||
next++
|
||||
}
|
||||
for i := 0; i < bronze; i++ {
|
||||
out = append(out, medalRanked(teamID, next, 3))
|
||||
next++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestMedalStandingsIndependentOfPointsBoard 是 06 报告 P1④ 的回归用例(主人 2026-09-13 拍板):
|
||||
// 单位1 金牌 6 枚但只有 10 分,单位2 金牌 2 枚却拿了 30 分。
|
||||
// 修前:单位奖牌榜直接沿用总积分榜的顺序与名次,单位1 排在单位2 后面(观众看到"金牌多的排后面")。
|
||||
// 修后:奖牌榜按金→银→铜排,单位1 榜首;总积分榜仍按总分排,单位2 榜首——两个榜解耦。
|
||||
func TestMedalStandingsIndependentOfPointsBoard(t *testing.T) {
|
||||
ledger := []LedgerEntry{ledgerFor(1, 10), ledgerFor(2, 30)}
|
||||
ranked := append(medalPodium(1, 1, 6, 0, 0), medalPodium(2, 101, 2, 0, 0)...)
|
||||
in := StandingsInput{Teams: testTeams(1, 2), Ledger: ledger, Ranked: ranked}
|
||||
|
||||
points := BuildTeamStandings(in)
|
||||
if len(points) != 2 || points[0].TeamID != 2 || points[0].Rank != 1 || points[1].TeamID != 1 || points[1].Rank != 2 {
|
||||
t.Fatalf("总积分榜必须仍按总分排(单位2 30 分在前,名次 1):%+v", points)
|
||||
}
|
||||
|
||||
medal := BuildMedalStandings(in)
|
||||
if len(medal) != 2 {
|
||||
t.Fatalf("奖牌榜行数 = %d,期望 2", len(medal))
|
||||
}
|
||||
if medal[0].TeamID != 1 || medal[0].Rank != 1 || medal[0].Gold != 6 {
|
||||
t.Fatalf("奖牌榜榜首应为金牌 6 枚的单位1 且名次 1:%+v", medal)
|
||||
}
|
||||
if medal[1].TeamID != 2 || medal[1].Rank != 2 || medal[1].Gold != 2 {
|
||||
t.Fatalf("奖牌榜次席应为金牌 2 枚的单位2 且名次 2:%+v", medal)
|
||||
}
|
||||
// 名次重排了,但总分列仍照实带出:单位2 在奖牌榜上依然是 30 分。
|
||||
if !nearlyEqual(medal[1].Points, 30) {
|
||||
t.Fatalf("奖牌榜的总分列 = %v,期望 30", medal[1].Points)
|
||||
}
|
||||
|
||||
// 规则包的 tie_break 以 points 打头,也不能把奖牌榜拉回总分口径。
|
||||
configured := in
|
||||
configured.TieBreak = []string{TieBreakPoints, TieBreakGold, TieBreakSilver, TieBreakBronze, TieBreakTeamCode}
|
||||
if got := BuildMedalStandings(configured); got[0].TeamID != 1 || got[0].Rank != 1 {
|
||||
t.Fatalf("tie_break=points,… 时奖牌榜榜首 = 单位 %d,期望仍为金牌多的单位1:%+v", got[0].TeamID, got)
|
||||
}
|
||||
}
|
||||
|
||||
// 奖牌相同时才比下一级:银 → 铜 → 单位编码,名次始终是 1..n 的全序。
|
||||
func TestMedalStandingsSortChain(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ranked []RankedResult
|
||||
want int64
|
||||
}{
|
||||
{"金相同比银", append(medalPodium(1, 1, 1, 0, 0), medalPodium(2, 101, 1, 1, 0)...), 2},
|
||||
{"金、银相同比铜", append(medalPodium(1, 1, 1, 1, 0), medalPodium(2, 101, 1, 1, 1)...), 2},
|
||||
{"奖牌全同按单位编码 A < B", append(medalPodium(1, 1, 1, 0, 0), medalPodium(2, 101, 1, 0, 0)...), 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rows := BuildMedalStandings(StandingsInput{Teams: testTeams(1, 2), Ranked: tc.ranked})
|
||||
if rows[0].TeamID != tc.want {
|
||||
t.Fatalf("榜首 = 单位 %d,期望 %d:%+v", rows[0].TeamID, tc.want, rows)
|
||||
}
|
||||
if rows[0].Rank != 1 || rows[1].Rank != 2 {
|
||||
t.Fatalf("名次 = %d/%d,期望 1/2", rows[0].Rank, rows[1].Rank)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAthleteStandings(t *testing.T) {
|
||||
ledger := []LedgerEntry{
|
||||
ledgerForAthlete(1001, 1, 10),
|
||||
|
||||
@@ -35,6 +35,17 @@ const (
|
||||
AvgModeAll AvgMode = "all"
|
||||
)
|
||||
|
||||
// AggregateMode 聚合通道(basis=aggregate)的汇总口径:把同一主体的成绩汇成一个值的方式。
|
||||
// 与 AvgMode(平均值内部的分母口径)是两个维度,可以组合,见 scoreByAggregate。
|
||||
type AggregateMode string
|
||||
|
||||
const (
|
||||
// AggregateAvg 按主体成员成绩的平均值排名(缺省口径,与升级前行为一致)。
|
||||
AggregateAvg AggregateMode = "avg"
|
||||
// AggregateSum 按主体成员成绩的总和排名。
|
||||
AggregateSum AggregateMode = "sum"
|
||||
)
|
||||
|
||||
// ResultType 成绩的物理量类型,决定比较与展示方式。
|
||||
type ResultType string
|
||||
|
||||
@@ -229,7 +240,8 @@ type EventCategory struct {
|
||||
|
||||
// ScoreChannel 计分通道 = 「排谁的名次」+「名次从哪来」+「用哪张积分表」。
|
||||
// 一个项目可配 0..N 个通道;0 个表示只排名不计分。
|
||||
// basis=aggregate 按同一主体成绩的平均值汇总,分母由项目的 avg_mode 决定。
|
||||
// basis=aggregate 把同一主体的成绩汇总成一个值再排名:汇总口径由本通道的 aggregate_mode 决定
|
||||
// (avg 平均 / sum 求和),平均值内部的分母口径由项目的 avg_mode 决定。
|
||||
type ScoreChannel struct {
|
||||
ID int64 `json:"id"`
|
||||
EventID int64 `json:"eventId"`
|
||||
@@ -237,6 +249,9 @@ type ScoreChannel struct {
|
||||
Basis Basis `json:"basis"`
|
||||
ProfileID int64 `json:"profileId"`
|
||||
SortOrder int `json:"sortOrder,omitempty"`
|
||||
// AggregateMode 是 basis=aggregate 通道的汇总口径;空值按缺省(avg)处理,
|
||||
// 只对 aggregate 通道有意义,其它通道即使带着该字段也不参与计算。
|
||||
AggregateMode AggregateMode `json:"aggregateMode,omitempty"`
|
||||
// CountToTeam 决定该通道产出的流水是否计入单位总分;nil 表示按缺省(计入)处理。
|
||||
// 「个人+团体」项目的个人通道置 false:分只记在个人头上,不进单位总分。
|
||||
CountToTeam *bool `json:"countToTeam,omitempty"`
|
||||
@@ -462,6 +477,15 @@ func (c ScoreChannel) countToTeam() bool {
|
||||
return c.CountToTeam == nil || *c.CountToTeam
|
||||
}
|
||||
|
||||
// aggregateMode 未配置或配错时按 avg 处理(与库里的默认值一致):
|
||||
// 缺字段的老数据、老客户端保存的通道都维持「按成员平均」的既有口径。
|
||||
func (c ScoreChannel) aggregateMode() AggregateMode {
|
||||
if c.AggregateMode == AggregateSum {
|
||||
return AggregateSum
|
||||
}
|
||||
return AggregateAvg
|
||||
}
|
||||
|
||||
// CountsToMedals 未配置时按 true 处理:只有显式关闭的通道才不发奖牌。
|
||||
func (c ScoreChannel) CountsToMedals() bool {
|
||||
return c.CountToMedals == nil || *c.CountToMedals
|
||||
|
||||
Reference in new issue
Block a user