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

+42 -21
View File
@@ -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,