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

+106 -1
View File
@@ -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) {