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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user