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:
AskaEth committed 2026-09-16 23:55:12 +08:00
1 parent a85115c96e
commit 48208f395b
30 files changed
+1394 -85

No files matched your search

+44 -16
View File
@@ -298,31 +298,40 @@ function sortManual(cands: Candidate[]): void {
}
/**
* 聚合通道:先把同一主体的成绩汇总(avg 取平均 / sum 求和),再按汇总值排名
* 聚合通道:先把同一主体的成绩汇总(avg 取平均 / sum 求和 / worst 取队内最差),再按汇总值排名
* (scoring.go scoreByAggregate)。
*
* 汇总口径来自通道的 aggregateMode(缺省 avg);平均值分母由 avgMode 决定:
* valid 只算有有效成绩的人;all 把该主体的全部报名项都算进去,没成绩的按 0 计入。
* sum 只累加有效成绩,avgMode 在这里只决定「一个有效成绩都没有的主体算不算 0 分参与排名」。
* worst 没有分母,avgMode 在这里**不起作用**:一个有效成绩都没有的主体一律不参与排名
* (与 avg 的 valid 同一条规则)——不能把「没成绩」当成 0 去比:asc 里 0 最快、desc 里 0 最差,
* 两边都会让「没人完赛」的队白拿名次。
* scope=athlete 的主体是运动员,缺 athleteId 的行按服务端口径跳过。
*/
function aggregateCandidates(
rows: readonly ScorableRow[],
channel: ScoreChannel,
avgMode: AvgMode,
compare: CompareMode,
): Candidate[] {
const sumMode = normalizeAggregateMode(channel.aggregateMode) === 'sum'
const mode = normalizeAggregateMode(channel.aggregateMode)
const sumMode = mode === 'sum'
const worstMode = mode === 'worst'
const order: number[] = []
const buckets = new Map<number, { row: ScorableRow; total: number; valid: number; registered: number }>()
const buckets = new Map<
number,
{ row: ScorableRow; total: number; valid: number; registered: number; worst: number | null }
>()
for (const row of rows) {
const subject = channel.scope === 'team' ? row.teamId : (row.athleteId ?? 0)
if (subject === 0) continue
const value = row.status === 'ok' ? row.resultValue : null
let bucket = buckets.get(subject)
if (bucket === undefined) {
// valid 口径下没有任何有效成绩的主体不参与排名(两种汇总口径都一样)。
// valid 口径下没有任何有效成绩的主体不参与排名(三种汇总口径都一样)。
if (value === null && avgMode !== 'all') continue
bucket = { row, total: 0, valid: 0, registered: 0 }
bucket = { row, total: 0, valid: 0, registered: 0, worst: null }
buckets.set(subject, bucket)
order.push(subject)
}
@@ -330,25 +339,44 @@ function aggregateCandidates(
if (value !== null) {
bucket.total += value
bucket.valid += 1
if (bucket.worst === null || worseThan(value, bucket.worst, compare)) bucket.worst = value
}
}
return order.map((subject) => {
const out: Candidate[] = []
for (const subject of order) {
const bucket = buckets.get(subject) as {
row: ScorableRow
total: number
valid: number
registered: number
worst: number | null
}
if (worstMode && bucket.worst === null) continue
const count = avgMode === 'all' ? bucket.registered : bucket.valid
const value = sumMode ? bucket.total : count > 0 ? bucket.total / count : 0
return {
row: bucket.row,
value,
rankIn: 0,
subject,
teamId: bucket.row.teamId,
}
})
const value = worstMode
? (bucket.worst as number)
: sumMode
? bucket.total
: count > 0
? bucket.total / count
: 0
out.push({ row: bucket.row, value, rankIn: 0, subject, teamId: bucket.row.teamId })
}
return out
}
/**
* 判断 a 是不是比 b 更「差」——**方向由小项的 compare 决定,这是最容易写反的一处**
* (scoring.go 的 worseValue 同款):
*
* asc (越小越好,如时间):更差 = 更大 →「队内最差」是**最后一名冲线**的那个;
* desc(越大越好,如次数):更差 = 更小 →「队内最差」是**做得最少**的那个。
*
* 写死成 min 或 max 都会让一半的项目算反(越野会取到跑得最快的人、引体向上会取到做得最多的人),
* 而两种错法读起来都很正常,所以预览与后端各有 asc / desc 的双向用例钉着。
*/
function worseThan(a: number, b: number, compare: CompareMode): boolean {
return compare === 'desc' ? a < b : a > b
}
function scoreChannel(
@@ -368,7 +396,7 @@ function scoreChannel(
let groups: TieGroup[]
if (basis === 'aggregate') {
const aggregated = aggregateCandidates(rows, channel, normalizeAvgMode(event.avgMode))
const aggregated = aggregateCandidates(rows, channel, normalizeAvgMode(event.avgMode), event.compare)
sortByValue(aggregated, event.compare)
groups = groupByValue(aggregated)
} else if (basis === 'manual') {