feat(scoring): 新增「队内最差(最后一名冲线)」聚合口径;版本 dev_0.5.11
## 需求(主人原话)
> 记分通道团体名次的名次来源缺一个模式,添加一个「按队内最低分」。
> 有一个特殊项目是取团队的最后一人的成绩(最后一名冲线)来排名的。
## 口径(这里最容易写反,所以定死)
新增 `aggregate_mode = 'worst'`。**不叫 `min`/`max`** —— 它的方向是**派生**的,叫 `min` 会让
「越大越好」的那半项目被写错。**跟着项目的比较方向自动取**:
| 项目 `compare` | 含义 | 队内最差 = |
| --- | --- | --- |
| `asc`(越小越好,如时间) | 行军 / 越野 | **max** = 最后一名冲线的那个人 |
| `desc`(越大越好,如次数) | 引体向上 | **min** = 做得最少的那个 |
方向判据**各只有一处**:Go 的 `domain.worseValue`、前端预览的 `worseThan`,
两边都有 asc/desc 双向用例钉着。规则包里写 `min`/`max` 会被校验**拦下并报错**。
## 改动
- **迁移 `0027`**:SQLite 改不了已有的 `CHECK`,按官方做法**重建 `event_score_channels`**,
取值放开到 `avg/sum/worst`;⚠️ 重建时**先把子表 `channel_ranks` 寄存到临时表再原样灌回**
(父表被 DROP 会级联清空名次 —— 这一步漏了就会把已发布的名次全丢)。
- **引擎**:`scoreByAggregate` 支持第三种口径。
- **接口 / 规则包**:读、写、校验、快照、导出五处都认第三个取值。
- **界面**:「聚合口径」由**两个按钮变三个**(平均 / 总和 / **队内最低分**),
提示文案写明两种方向,不丢一个 `worst` 让人猜。
- **前端预览同步**(`web/src/admin/api/scoring.ts`)—— 前一版刚修完「预览与后端算分不一致」,
这次不允许再开一个新口子。
- 文档:`docs/使用手册.md`、`docs/升级说明.md`、`devdocs/18` 各补一处。
## 两条边界(都是刻意的)
- **弃权 / 失格不算参与,且它的成绩不代表队伍**:队里 5 / 6 / 弃赛(0) → 队内最差是 **5**,不是 0。
否则谁都可以挂一个弃赛的人在队里垫底。
- **一个有效成绩都没有的主体一律不参与排名**(`avg_mode` 在这一口径下不起作用)。
若按 0 参与:`asc` 里 0 是全场最快、`desc` 里 0 是全场最差 —— 两边都会让没完赛的队白拿名次。
## 验证
- **老口径逐字节不变**:新增 golden `TestAggregateModesLegacyUnchanged`(`avg` / `sum` 结果与改动前一致)。
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **953 例** 0 fail / Console 96 例 0 fail。
- 迁移用例覆盖:老行原样、子表名次一行不少、id 不漂、非法值仍拒、幂等、级联删除仍在。
## 版本
`dev_0.5.10` → **`dev_0.5.11`**(含数据迁移 `0027`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
This commit is contained in:
1 parent
a85115c96e
commit
48208f395b
30 files changed
+1394
-85
No files matched your search
@@ -591,7 +591,10 @@ func rankedResult(in EventScoreInput, ch ScoreChannel, c candidate, rank int, po
|
||||
//
|
||||
// 汇总口径(通道的 aggregate_mode,缺省 avg):
|
||||
// - avg:团体成绩 = 成员成绩的平均值;
|
||||
// - sum:团体成绩 = 成员成绩的总和。
|
||||
// - sum:团体成绩 = 成员成绩的总和;
|
||||
// - worst:团体成绩 = 成员成绩里**最差的那一份**。方向随项目的 compare 自动取,见 worseValue:
|
||||
// compare=asc(越小越好,如时间)→ 取最大,即**最后一名冲线**的那个人;
|
||||
// compare=desc(越大越好,如次数)→ 取最小,即**做得最少**的那个。
|
||||
//
|
||||
// avg 的平均值分母由项目的 avg_mode 决定:
|
||||
// - valid:分子 = 有效成绩之和,分母 = 有有效成绩的人数;一个人都没成绩就不参与排名、不得分;
|
||||
@@ -601,7 +604,14 @@ func rankedResult(in EventScoreInput, ch ScoreChannel, c candidate, rank int, po
|
||||
// sum 只累加有效成绩(没成绩的人按 0 计入和不计入是一样的和),所以 avg_mode 在这里只剩一个作用:
|
||||
// 决定「一个有效成绩都没有的单位算不算 0 分参与排名」——valid 不参与、all 参与,与 avg 完全一致。
|
||||
//
|
||||
// 弃权 / 失格(status 非 registered)的报名项既不算参与也不算进分母,与「不占名次」的既有口径一致。
|
||||
// worst 没有分母,「没成绩按 0 计入」对它也就无从谈起,所以 avg_mode 在这个口径下不起任何作用:
|
||||
// 一个有效成绩都没有的主体一律不参与排名、不得分(与 avg 的 valid 口径同一条规则)。
|
||||
// **绝不能**改成「按 0 参与」:0 在 asc 项目里是全场最快、在 desc 项目里是全场最差,
|
||||
// 无论哪一边,都会让一支没人完赛(或全队都还没录成绩)的队伍白拿名次与分。
|
||||
//
|
||||
// 弃权 / 失格(status 非 registered)的报名项既不算参与也不算进分母,与「不占名次」的既有口径一致;
|
||||
// 在 worst 下这条尤其要紧:队里有人弃赛时,队伍成绩由**实际出赛的人**里最差的那个决定,
|
||||
// 而不是由「没来的人」决定(否则谁都想在队里挂一个弃赛的人来垫底)。
|
||||
func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) {
|
||||
if in.Event.Compare == CompareNone {
|
||||
return nil, ErrCompareRequired
|
||||
@@ -613,11 +623,13 @@ func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfil
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
subject int64
|
||||
entry Entry
|
||||
sum float64 // 有效成绩之和
|
||||
valid int // 有有效成绩的人数(valid 口径的分母)
|
||||
roster int // 报名人数(all 口径的分母)
|
||||
subject int64
|
||||
entry Entry
|
||||
sum float64 // 有效成绩之和
|
||||
valid int // 有有效成绩的人数(valid 口径的分母)
|
||||
roster int // 报名人数(all 口径的分母)
|
||||
worst float64 // 队内最差的那一份有效成绩(worst 口径)
|
||||
hasWorst bool // 是否已经取到过一份有效成绩
|
||||
}
|
||||
order := make([]int64, 0)
|
||||
buckets := make(map[int64]*bucket)
|
||||
@@ -643,20 +655,32 @@ func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfil
|
||||
if v, ok := validResultValue(r, found, in.Options); ok {
|
||||
b.sum += v
|
||||
b.valid++
|
||||
if !b.hasWorst || worseValue(v, b.worst, in.Event.Compare) {
|
||||
b.worst = v
|
||||
b.hasWorst = true
|
||||
}
|
||||
}
|
||||
}
|
||||
sumMode := ch.aggregateMode() == AggregateSum
|
||||
mode := ch.aggregateMode()
|
||||
allMode := in.Event.avgMode() == AvgModeAll
|
||||
agg := make([]candidate, 0, len(order))
|
||||
for _, id := range order {
|
||||
b := buckets[id]
|
||||
if b.valid == 0 && !allMode {
|
||||
// 一个人都没有效成绩的单位不参与团体排名,也不得分(两种汇总口径都一样)。
|
||||
switch {
|
||||
case mode == AggregateWorst:
|
||||
// 队内最差:一个有效成绩都没有的主体不参与排名(worst 没有分母,avg_mode 在这里不适用)。
|
||||
if !b.hasWorst {
|
||||
continue
|
||||
}
|
||||
case b.valid == 0 && !allMode:
|
||||
// 一个人都没有效成绩的单位不参与团体排名,也不得分(avg 与 sum 都一样)。
|
||||
continue
|
||||
}
|
||||
var value float64
|
||||
switch {
|
||||
case sumMode:
|
||||
case mode == AggregateWorst:
|
||||
value = b.worst
|
||||
case mode == AggregateSum:
|
||||
// 求和口径:只累加有效成绩(没成绩按 0 计入与否不改变和)。
|
||||
value = b.sum
|
||||
case allMode:
|
||||
@@ -734,6 +758,21 @@ func nearlyEqual(a, b float64) bool {
|
||||
return math.Abs(a-b) <= rankEpsilon
|
||||
}
|
||||
|
||||
// worseValue 判断 a 是不是比 b 更「差」——**方向由项目的 compare 决定,这是本口径最容易写反的一处**:
|
||||
//
|
||||
// compare=asc (越小越好,如时间):更差 = 更大 → 队内最差是**最后一名冲线**的那个人;
|
||||
// compare=desc(越大越好,如次数):更差 = 更小 → 队内最差是**做得最少**的那个人。
|
||||
//
|
||||
// 写死成 min 或 max 都会让一半的项目算反:越野这类时间项目会把「跑得最快的人」当成全队成绩、
|
||||
// 引体向上这类次数项目会把「做得最多的人」当成全队成绩。两种错法在代码上都读得通
|
||||
// (`v < worst` 看着很正常),所以用 asc / desc 双向用例钉住,别凭直觉改。
|
||||
func worseValue(a, b float64, compare CompareMode) bool {
|
||||
if compare == CompareDesc {
|
||||
return a < b
|
||||
}
|
||||
return a > b
|
||||
}
|
||||
|
||||
// validResultValue 判断一条成绩是否算「有效成绩」:成绩存在、已发布(或允许草稿)、
|
||||
// 状态正常,且有可比较的数值。聚合通道按 avg_mode 决定没成绩的人算不算进分母。
|
||||
func validResultValue(r Result, found bool, opts ScoreOptions) (float64, bool) {
|
||||
|
||||
Reference in new issue
Block a user