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:
AskaEth committed 2026-09-13 15:20:18 +08:00
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])
}
})
}