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

+1 -1
View File
@@ -1 +1 @@
dev_0.5.10
dev_0.5.11
Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

+1 -1
View File
@@ -1 +1 @@
0.5.10
0.5.11
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scoreboard-console",
"productName": "Scoreboard Console",
"version": "0.5.10",
"version": "0.5.11",
"private": true,
"author": "AskaEth",
"description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏",
+10 -4
View File
@@ -80,7 +80,7 @@
2. 每个小项带状态徽标:**已发布** 或 **待录入**;
3. 多个组别时,表格上方出现组别页签,切换组别分别录入;
4. 组别页签旁边有**批次下拉**(`全部` / `第 1 批` / `第 2 批` / … / `未分批`),默认 `全部`(`未分批` 只在确实还有未分批的行时才出现)。选某一批后表格只显示那一批的人,方便录入员**只在自己这一批里找名字**,不用在一大堆人里翻;
5. 配了「按队内平均」通道的小项(例如「个人+团体」项目的团体通道),工具栏下方会出现 **「平均值口径」单选**:`只算有效成绩`(没有有效成绩的人不进分母)或 `全部报名人员都计入`(没成绩的按 0 计入平均)。点选后立即保存到该小项,不需要另外点保存;这是小项级设置,该小项的所有组别共用。
5. 配了「按队内平均」通道的小项(例如「个人+团体」项目的团体通道),工具栏下方会出现 **「平均值口径」单选**:`只算有效成绩`(没有有效成绩的人不进分母)或 `全部报名人员都计入`(没成绩的按 0 计入平均)。点选后立即保存到该小项,不需要另外点保存;这是小项级设置,该小项的所有组别共用。⚠️ 该小项的聚合口径选了 **「队内最低分」** 时这个单选**对它不起作用**:那种口径取的是队内最差的一份成绩,一个人都没有效成绩的单位一律不参与排名(见 10.4 第 5 条)。
**批次录入进度条**:工具栏正下方有一条横跨整行的进度条,显示当前筛选范围的录入进度,例如 `第 2 批 已录 5/8 · 3 条有错`(有错的行既不算「已录」,也会在发布时被防呆拦下,所以要单独报出来);**全部录完时文字变成「已录完 8/8」、进度条变绿**。分母就是当前筛选范围的行数——选了某一批就只数这一批。它和发布防呆、行上的状态徽标用的是**同一套判定**,不会出现「进度条说录完了、发布却说缺人」。
@@ -701,11 +701,17 @@
2. 点「新增通道」逐条配置,同一「名次范围 + 名次来源」只能有一个通道;
3. 点「保存全部」整体替换该小项的通道配置;
4. 标记为「不计分」的小项,通道可以保存但不参与积分计算;
5. 「名次来源」选「按队内平均」时,把同一个单位(或个人)在该组别的成绩**汇总成一个值**后排名,并列按积分模板的并列口径处理(默认顺延)。汇总口径在该行的**「聚合口径」列**上选,两个按钮点一下即切换:
5. 「名次来源」选「按队内平均」时,把同一个单位(或个人)在该组别的成绩**汇总成一个值**后排名,并列按积分模板的并列口径处理(默认顺延)。汇总口径在该行的**「聚合口径」列**上选,三个按钮点一下即切换:
- **平均**(默认):团体成绩 = 该单位成员成绩的**平均值**。分母取录入页选的「平均值口径」——`只算有效成绩`时是本单位有有效成绩的条数(一个人都没有效成绩的单位不参与团体排名、不得分),`全部报名人员都计入`时是本单位在该组别的报名条数(没成绩的按 0 计入);
- **总和**:团体成绩 = 该单位成员成绩的**总和**(没成绩的人按 0 加与不加都一样)。
- **各队人数相同时,两种口径排出来的名次完全相同**;人数不同时可能不同(这时按你们要的口径选,默认是「平均」)。改完点「保存全部」生效,该小项立即重算;
- 报名表里 `status` 不是 `registered` 的报名项(弃赛 / 取消)既不算参与,也不进分母;
- **队内最低分**:团体成绩 = 该单位成员里**最差的那一份**成绩——**取的是谁,由小项的「比较方式」决定**:
- 比较方式 = **升序**(时间这类**越小越好**的项目,例如越野 / 行军):取**跑得最慢的那个人**,也就是**最后一名冲线**的那个人的成绩;
- 比较方式 = **降序**(次数这类**越大越好**的项目,例如引体向上):取**做得最少的那个**。
现场用途:赛会规则写「取团队最后一人的成绩」的项目就是它。⚠️ **别按字面理解成"取最小的那个"**——时间项目里"最小"是最快的人,正好是反的;
- **各队人数相同时,平均与总和排出来的名次完全相同**;人数不同时可能不同(这时按你们要的口径选,默认是「平均」)。「队内最低分」与它们都可能不同(它只看队里最差的那一份,与人数无关)。改完点「保存全部」生效,该小项立即重算;
- 报名表里 `status` 不是 `registered` 的报名项(弃赛 / 取消)既不算参与,也不进分母;**「队内最低分」下弃赛的人的成绩也不代表队伍**(弃赛那一格填得再差也不算),队伍成绩只在实际出赛的人里取;
- **「队内最低分」下,一个有效成绩都没有的单位不参与团体排名、也不得分**(没人冲线就没有"最后一名冲线"可言;这一条与「平均值口径」选哪个无关);
- 不是「按队内平均」的通道这一列显示 `—`(对它没有意义);
6. 每条通道有一个「计入单位总分」开关。**新建通道时的默认值按小项类型给**:**普通个人项目 / 团体项目默认勾选**;**「个人 + 团体」项目的个人通道默认不勾**(这类项目的个人分本来就只记个人榜、不并入单位总分)。关掉后该通道的分只记在个人头上,不进单位总分——「个人+团体」项目的个人通道就靠它做到只记个人;**所以现场要留意自己那一行的实际勾选状态**;
7. 每条通道还有一个**「奖牌通道」**列,里面是两个开关(都默认打开):
+40 -1
View File
@@ -6,10 +6,49 @@
---
## dev_0.5.9(当前版本)
## dev_0.5.11
### 一句话
**这不是一次「数据迁移级」的变更:没有任何一行已有数据被改动,升级后不用重配、不用重新导入规则包**,已有的名次、积分、榜单与导出件**一个字不变**。只是给「按队内平均」的通道**多了一个可选的汇总口径**——**「队内最低分」**(队内最差的那一份成绩):越野 / 行军这类**取团队最后一人的成绩(最后一名冲线)**的项目现在能直接配出来,不用手工录名次。
(库里有一条结构迁移 `0027`,它只做一件事:把「聚合口径」这一列允许的取值从 `avg / sum` 放开到 `avg / sum / worst`。老通道的值原样保留、默认仍是「平均」,所以升级后榜单与你上次看到的一模一样。)
### ① 新能力:团体成绩可以按「队内最低分」算
- **在哪配**:「基础数据 → 计分通道」,名次来源是「按队内平均」的那一行,**「聚合口径」列**现在有**三个按钮**:**平均 / 总和 / 队内最低分**。点「队内最低分」→ 点「保存全部」生效,该小项立即重算;
- **它算的是什么**:团体成绩 = 该单位成员里**最差的那一份**成绩。⚠️ **取的是谁,由小项的「比较方式」决定**,这是最容易搞反的地方:
| 小项的「比较方式」 | 项目例子 | 「队内最低分」取的是 |
| --- | --- | --- |
| **升序**(越小越好) | 越野 / 行军(时间) | **跑得最慢的那个人**——也就是**最后一名冲线**的那个人的成绩 |
| **降序**(越大越好) | 引体向上(次数) | **做得最少的那个** |
- 所以它**不是**「取最小的那一格」:时间项目里"最小"是最快的人,正好反了。界面上按钮旁边鼠标停一下就有这句提示;
- **弃赛 / 失格的人不算**:队伍成绩只在实际出赛的人里取最差的那一份(弃赛那一格填得再差也不代表队伍);
- **一个有效成绩都没有的单位不参与团体排名、也不得分**(没人冲线就没有"最后一名冲线"可言)。这一条**与「平均值口径」选哪个无关**——「队内最低分」没有"分母"这回事;
- 它和已有的两个口径**互不影响**:不改口径的通道仍然按「平均」(默认)跑,**升级后榜单不会变**。
### ② 规则包里也能写
规则包的通道上写 `aggregateMode: worst` 即可(不写 = `avg`,与以前一致)。**不认识的值会被拦下报错**,不会静默按「平均」处理——特别地,**`min` / `max` 不是合法取值**:方向由小项的「比较方式」决定,写死成一个方向的项目一半会算反,所以要写 `worst`。
### ③ 升级后 5 分钟核对
1. 打开「基础数据 → 计分通道」,选中一个名次来源是「按队内平均」的小项:**「聚合口径」那一列应该是三个按钮,默认停在「平均」**;
2. 随便找一个小项,什么都不改点一次「保存全部」,然后看单位总分榜 / 名次表:**与升级前一致**(老口径一字不变);
3. 要试新口径的话:挑一个**时间类(比较方式 = 升序)**的小项,把它的团体通道切成「队内最低分」并保存,榜单上该单位的团体成绩应等于**该单位里最慢那个人的成绩**(不是平均、也不是最快那个);改用「平均」再保存一次,应立刻退回原来的名次;
4. 其余照旧:**不需要重新导入规则包,也不需要重配任何东西**。
### ④ 详细说明在哪
《使用手册》10.4 计分通道 → 第 5 条(聚合口径三个按钮与两种方向的对照)。
---
## dev_0.5.9
### 一句话
**没有数据迁移,升级后不用重配任何东西**:只改**提示与模板上的说明**。以前在团体项目的报名表里把队员姓名填进「姓名」列,导入时只回一句「填了队伍 / 队员名单,但这一行有运动员姓名,按个人报名处理」——**没告诉你该怎么改**,于是现场反复换填法重试。现在这两条提示会**直接写出两步动作**(要报团体就把姓名列留空、号码布填到「队员名单」列;确实是个人报名就把那一格删掉),下载的模板里**「姓名」与「队员名单」两列表头也各挂了一条说明**(鼠标停上去或点中该列的格子就能看到)。**导入表、模板结构、已有数据一个字都没动。**
### ① 两条导入提示改成「能照着改」的话
@@ -0,0 +1,167 @@
package domain
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"testing"
)
// 本文件是「聚合通道两个既有口径(avg / sum)逐字节不变」的硬门槛回归(本需求:新增 worst 口径)。
//
// 为什么要有它:worst 的取值分支就长在 scoreByAggregate 里,而那一段同时算了 avg 与 sum
// (同一个桶、同一段 `switch`)。改错一处(例如把 sum 的累加挪进新分支、或把「没成绩不参与排名」
// 的判断顺手改成对所有口径生效),现场看到的不是崩溃,而是**榜单悄悄变样**——所以钉住签名。
//
// 签名文本只写「谁拿到什么值、什么名次、多少分」这些事实,不写结构体形状:
// 汇总值按 %v 打印、流水只打事实列,将来给 RankedResult 加字段不会把这份签名带跑偏。
//
// 签名取自 **worst 实现之前**的代码,实现之后必须一字不差(同一棵树、同一组输入)。
const aggregateModesGoldenSHA256 = "572e64a7b03ea79205ca4150fb5312ddab4e4202ec7151d02a5acf0351cf482e"
// TestAggregateModesLegacyUnchanged 跑遍 avg / sum 两个老口径 × avg_mode(valid/all) × 几种现场形态,
// 把结果拼成一份稳定文本并比对签名。
func TestAggregateModesLegacyUnchanged(t *testing.T) {
dump := aggregateModesDump(t)
// 防空转:每种形态都必须真的留下痕迹,否则签名只是一份空文本。
for _, marker := range []string{
"case=roster-differs mode=unset avg=valid",
"case=roster-differs mode=avg avg=valid",
"case=roster-differs mode=sum avg=valid",
"case=roster-differs mode=avg avg=all",
"case=roster-differs mode=sum avg=all",
"case=missing-value ",
"case=non-registered ",
"case=tie ",
"ledger ",
"ranked ",
} {
if !strings.Contains(dump, marker) {
t.Fatalf("签名里缺少形态 %q,对拍没有覆盖到它:\n%s", marker, dump)
}
}
// worst 是本次新增的口径,绝不能出现在这份「老口径」签名里(出现了说明老口径串了口径)。
if strings.Contains(dump, string(AggregateWorst)) {
t.Fatalf("老口径的签名里出现了 worst:\n%s", dump)
}
sum := sha256.Sum256([]byte(dump))
got := hex.EncodeToString(sum[:])
if got != aggregateModesGoldenSHA256 {
t.Fatalf("聚合通道的老口径(avg / sum)变了:\n签名 = %s\n期望 = %s\n\n%s",
got, aggregateModesGoldenSHA256, dump)
}
}
// aggregateModesDump 把「几个现场形态 × 两个老口径 × 两种 avg_mode」的引擎产出拼成一份稳定文本。
//
// 稳定性来源:流水与名次按切片顺序打印(引擎的输出顺序本身是确定的),
// 同一组输入每次跑出来的文本完全一样(没有 map 遍历、没有并发)。
func aggregateModesDump(t *testing.T) string {
t.Helper()
var lines []string
appendScore := func(name string, es EventScore) {
for _, rr := range es.Ranked {
value := "nil"
if rr.ResultValue != nil {
value = fmt.Sprintf("%v", *rr.ResultValue)
}
lines = append(lines, fmt.Sprintf(
"%s ranked key=%s scope=%s basis=%s team=%d athlete=%d rank=%d points=%v value=%s shares=%s",
name, rr.SubjectKey, rr.Scope, rr.Basis, rr.TeamID, rr.AthleteID, rr.Rank, rr.Points, value, formatShares(rr.Shares)))
}
for _, l := range es.Ledger {
lines = append(lines, fmt.Sprintf(
"%s ledger team=%d athlete=%d entry=%d result=%d source=%s/%d points=%v countToTeam=%v voided=%v",
name, l.TeamID, l.AthleteID, l.EntryID, l.ResultID, l.SourceType, l.SourceID,
l.Points, l.CountToTeam, l.Voided))
}
}
// 三种现场形态,各自钉住 scoreByAggregate 里的一段:
// roster-differs:各队人数不同 —— avg 与 sum 的名次会分叉(现场分辨口径就看这一组);
// missing-value :有人没成绩 —— avg 的 valid/all 分母不同,sum 只累加有效成绩;
// non-registered:弃权 / 失格 —— 既不算参与也不进分母;
// tie :汇总值相同 —— 走并列与顺延。
cases := []struct {
name string
compare CompareMode
build func() ([]Entry, []Result)
}{
{
name: "roster-differs",
compare: CompareDesc,
build: func() ([]Entry, []Result) {
return fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 10},
{entryID: 2, athleteID: 2, teamID: 1001, value: 1},
{entryID: 3, athleteID: 3, teamID: 1002, value: 9},
{entryID: 4, athleteID: 4, teamID: 1003, value: 7},
{entryID: 5, athleteID: 5, teamID: 1003, value: 8},
})
},
},
{
name: "missing-value",
compare: CompareAsc,
build: func() ([]Entry, []Result) {
return fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 10},
{entryID: 2, athleteID: 2, teamID: 1001, noValue: true},
{entryID: 3, athleteID: 3, teamID: 1002, value: 4},
{entryID: 4, athleteID: 4, teamID: 1002, value: 6},
{entryID: 5, athleteID: 5, teamID: 1003, noValue: true},
})
},
},
{
name: "non-registered",
compare: CompareAsc,
build: func() ([]Entry, []Result) {
return fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 10},
{entryID: 2, athleteID: 2, teamID: 1001, value: 12, entryStatus: EntryStatusScratched},
{entryID: 3, athleteID: 3, teamID: 1002, value: 11, status: ResultStatusDQ},
{entryID: 4, athleteID: 4, teamID: 1002, value: 30},
})
},
},
{
name: "tie",
compare: CompareAsc,
build: func() ([]Entry, []Result) {
return fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 5},
{entryID: 2, athleteID: 2, teamID: 1001, value: 5},
{entryID: 3, athleteID: 3, teamID: 1002, value: 5},
{entryID: 4, athleteID: 4, teamID: 1003, value: 9},
})
},
},
}
for _, tc := range cases {
for _, mode := range []struct {
name string
mode AggregateMode
}{
// 缺省(老库老客户端保存的通道根本不带这一列)与显式 avg 必须给出同一份结果。
{"unset", ""},
{"avg", AggregateAvg},
{"sum", AggregateSum},
} {
for _, avgMode := range []AvgMode{AvgModeValid, AvgModeAll} {
entries, results := tc.build()
ev := teamEvent(2) // 权重 2:证明汇总值还要乘项目权重
ev.Compare = tc.compare
ev.AvgMode = avgMode
name := fmt.Sprintf("case=%s mode=%s avg=%s", tc.name, mode.name, avgMode)
appendScore(name, aggScore(t, ev, mode.mode, entries, results))
}
}
}
return strings.Join(lines, "\n") + "\n"
}
@@ -0,0 +1,268 @@
package domain
import (
"reflect"
"testing"
)
// 聚合口径的第三个取值 worst(按队内最差排名)。
//
// 现场:有的项目按「团队最后一人的成绩」排名——越野 / 行军这类**最后一名冲线**的项目,
// 以及引体向上这类「按做得最少的那个算」的项目。同一个「队内最差」,方向变了算法就反过来:
//
// compare=asc (越小越好,如时间):队内最差 = 最大 → 最后一名冲线的那个人;
// compare=desc(越大越好,如次数):队内最差 = 最小 → 做得最少的那个。
//
// 这一条是本需求最容易写反的地方,下面每组用例都同时用 asc 与 desc 跑一遍:
// 哪一边写成固定的 min / max,另一边立刻红。
// worstScore 用 worst 口径跑一次团体聚合通道。
func worstScore(t *testing.T, ev Event, entries []Entry, results []Result) EventScore {
t.Helper()
return aggScore(t, ev, AggregateWorst, entries, results)
}
// TestAggregateWorstTakesSlowestWhenAsc:compare=asc(时间,越小越好)时取**最大**的那份成绩
// —— 队里最后一名冲线的人是全队的成绩。
func TestAggregateWorstTakesSlowestWhenAsc(t *testing.T) {
entries, results := fixture([]participant{
// 1001:12 / 15 / 20 分钟,最后一名的 20 分钟代表全队。
{entryID: 1, athleteID: 1, teamID: 1001, value: 12},
{entryID: 2, athleteID: 2, teamID: 1001, value: 15},
{entryID: 3, athleteID: 3, teamID: 1001, value: 20},
// 1002:13 / 14 分钟,最慢的 14 分钟代表全队。
{entryID: 4, athleteID: 4, teamID: 1002, value: 13},
{entryID: 5, athleteID: 5, teamID: 1002, value: 14},
// 1003:单人 30 分钟。
{entryID: 6, athleteID: 6, teamID: 1003, value: 30},
})
ev := teamEvent(1)
ev.Compare = CompareAsc
got := aggregateByTeam(t, worstScore(t, ev, entries, results))
// 1002(14)快于 1001(20)快于 1003(30)。
if !nearlyEqual(got[1002].value, 14) || got[1002].rank != 1 || !nearlyEqual(got[1002].points, 15) {
t.Errorf("1002 应取队内最慢的 14 分钟、第 1 名 15 分,实际 %+v", got[1002])
}
if !nearlyEqual(got[1001].value, 20) || got[1001].rank != 2 || !nearlyEqual(got[1001].points, 10) {
t.Errorf("1001 应取队内最慢的 20 分钟、第 2 名 10 分,实际 %+v", got[1001])
}
if !nearlyEqual(got[1003].value, 30) || got[1003].rank != 3 || !nearlyEqual(got[1003].points, 8) {
t.Errorf("1003 应取 30 分钟、第 3 名 8 分,实际 %+v", got[1003])
}
// 与 avg 不同口径不同名次,证明这里真的取了「最差」而不是「平均」:
// 平均下来 1001 = 15.67、1002 = 13.5、1003 = 30 —— 前三名不变,所以换一组数据再看(见下一个用例)。
// 这一组只钉住「取的是哪个值」。
avg := aggregateByTeam(t, aggScore(t, ev, AggregateAvg, entries, results))
if nearlyEqual(avg[1001].value, got[1001].value) {
t.Fatalf("worst 与 avg 取到同一个值(%v),说明没取队内最差", avg[1001].value)
}
}
// TestAggregateWorstTakesFewestWhenDesc:compare=desc(次数,越大越好)时取**最小**的那份成绩
// —— 队里做得最少的那个是全队的成绩。这一组同时钉住「与 avg 的名次真的会互换」。
func TestAggregateWorstTakesFewestWhenDesc(t *testing.T) {
entries, results := fixture([]participant{
// 1001:20 个 + 1 个 —— 队内最差是 1 个(平均 10.5)。
{entryID: 1, athleteID: 1, teamID: 1001, value: 20},
{entryID: 2, athleteID: 2, teamID: 1001, value: 1},
// 1002:5 个 + 6 个 —— 队内最差是 5 个(平均 5.5)。
{entryID: 3, athleteID: 3, teamID: 1002, value: 5},
{entryID: 4, athleteID: 4, teamID: 1002, value: 6},
})
ev := teamEvent(1)
ev.Compare = CompareDesc
got := aggregateByTeam(t, worstScore(t, ev, entries, results))
// 最差成绩:1002 的 5 个 > 1001 的 1 个 → 1002 第一。
if !nearlyEqual(got[1002].value, 5) || got[1002].rank != 1 || !nearlyEqual(got[1002].points, 15) {
t.Errorf("1002 应取队内最少的 5 个、第 1 名 15 分,实际 %+v", got[1002])
}
if !nearlyEqual(got[1001].value, 1) || got[1001].rank != 2 || !nearlyEqual(got[1001].points, 10) {
t.Errorf("1001 应取队内最少的 1 个、第 2 名 10 分,实际 %+v", got[1001])
}
// 平均口径下名次恰好相反(1001 平均 10.5 > 1002 平均 5.5)——两种口径不能互相顶替。
avg := aggregateByTeam(t, aggScore(t, ev, AggregateAvg, entries, results))
if avg[1001].rank != 1 || avg[1002].rank != 2 {
t.Fatalf("avg 口径下应 1001 第一,实际 %+v", avg)
}
if got[1001].rank == avg[1001].rank {
t.Fatalf("worst 与 avg 的名次应互换:worst=%+v avg=%+v", got, avg)
}
}
// TestAggregateWorstTie:两支队伍的「最差成绩」相同即并列,并列口径沿用积分模板的 tieMode(默认顺延)。
func TestAggregateWorstTie(t *testing.T) {
entries, results := fixture([]participant{
// 1001:10 / 20 —— 最慢 20。
{entryID: 1, athleteID: 1, teamID: 1001, value: 10},
{entryID: 2, athleteID: 2, teamID: 1001, value: 20},
// 1002:单人 20 —— 同样是最慢 20,并列。
{entryID: 3, athleteID: 3, teamID: 1002, value: 20},
// 1003:25。
{entryID: 4, athleteID: 4, teamID: 1003, value: 25},
})
ev := teamEvent(1)
ev.Compare = CompareAsc
got := aggregateByTeam(t, worstScore(t, ev, entries, results))
if got[1001].rank != 1 || got[1002].rank != 1 {
t.Errorf("最差成绩相同的两队应并列第 1,实际 1001=%+v 1002=%+v", got[1001], got[1002])
}
if !nearlyEqual(got[1001].points, 15) || !nearlyEqual(got[1002].points, 15) {
t.Errorf("并列第 1 的两队各得 15 分,实际 %v / %v", got[1001].points, got[1002].points)
}
// 并列顺延:第三名那队拿第 3 名那份分(8),不是第 2 名(10)。
if got[1003].rank != 3 || !nearlyEqual(got[1003].points, 8) {
t.Errorf("并列之后应顺延到第 3 名 8 分,实际 %+v", got[1003])
}
}
// TestAggregateWorstSkipsSubjectWithoutAnyValidResult:一个有效成绩都没有的主体不参与排名、不得分,
// 与 avg 的 valid 口径同一条规则 —— 没人冲线就没有「最后一名冲线」可言。
//
// 关键是**不能**把「没成绩」当成 0 去比:asc 项目里 0 是最快的,会让一支没人完赛的队伍白拿第一;
// desc 项目里 0 又是最差的,同样荒谬。avg_mode=all 也照样不参与(worst 没有分母可用)。
func TestAggregateWorstSkipsSubjectWithoutAnyValidResult(t *testing.T) {
build := func() ([]Entry, []Result) {
return fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 12},
{entryID: 2, athleteID: 2, teamID: 1001, value: 20},
// 1002 全队一条有效成绩都没有(都还没录)。
{entryID: 3, athleteID: 3, teamID: 1002, noValue: true},
{entryID: 4, athleteID: 4, teamID: 1002, noValue: true},
})
}
for _, tc := range []struct {
name string
compare CompareMode
avgMode AvgMode
}{
{"asc + valid", CompareAsc, AvgModeValid},
{"asc + all", CompareAsc, AvgModeAll},
{"desc + valid", CompareDesc, AvgModeValid},
{"desc + all", CompareDesc, AvgModeAll},
} {
t.Run(tc.name, func(t *testing.T) {
entries, results := build()
ev := teamEvent(1)
ev.Compare = tc.compare
ev.AvgMode = tc.avgMode
got := aggregateByTeam(t, worstScore(t, ev, entries, results))
if got[1002].rank != 0 || got[1002].points != 0 {
t.Fatalf("全队没有有效成绩的 1002 不应参与排名,实际 %+v", got[1002])
}
// 1001 独自参与,拿第 1 名的分(不是被「0 分的空队」挤到第 2 名)。
if got[1001].rank != 1 || !nearlyEqual(got[1001].points, 15) {
t.Fatalf("1001 应第 1 名 15 分,实际 %+v", got[1001])
}
})
}
}
// TestAggregateWorstIgnoresNonRegisteredEntries:弃权 / 失格(status 非 registered)的报名项
// 既不算参与、也不算成绩 —— 它的成绩再差也不能代表队伍。
//
// 现场的含义:一支队伍有人弃赛,队伍的成绩由**实际出赛的人**里最差的那个决定,
// 而不是由「没来的人」决定(否则弃赛反而是最差成绩,谁都愿意报一个弃赛的人垫底)。
func TestAggregateWorstIgnoresNonRegisteredEntries(t *testing.T) {
entries, results := fixture([]participant{
// 1001:一人 30 分钟正常完赛,另一人 50 分钟但弃赛 —— 队伍成绩取 30。
{entryID: 1, athleteID: 1, teamID: 1001, value: 30},
{entryID: 2, athleteID: 2, teamID: 1001, value: 50, entryStatus: EntryStatusScratched},
// 1002:一人 40 分钟完赛,另一人失格 —— 队伍成绩取 40。
{entryID: 3, athleteID: 3, teamID: 1002, value: 40},
{entryID: 4, athleteID: 4, teamID: 1002, value: 99, entryStatus: EntryStatusDQ},
})
ev := teamEvent(1)
ev.Compare = CompareAsc
got := aggregateByTeam(t, worstScore(t, ev, entries, results))
if !nearlyEqual(got[1001].value, 30) || got[1001].rank != 1 {
t.Errorf("1001 应取实际出赛里最慢的 30 分钟、第 1 名,实际 %+v", got[1001])
}
if !nearlyEqual(got[1002].value, 40) || got[1002].rank != 2 {
t.Errorf("1002 应取实际出赛的 40 分钟、第 2 名,实际 %+v", got[1002])
}
}
// TestAggregateWorstAgainstAvgAndSum:同一组数据下三种口径各给一个值,互不相等 ——
// 现场切换口径时看得见差别(也说明 worst 不是 avg / sum 的另一种写法)。
func TestAggregateWorstAgainstAvgAndSum(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 10},
{entryID: 2, athleteID: 2, teamID: 1001, value: 30},
{entryID: 3, athleteID: 3, teamID: 1002, value: 20},
{entryID: 4, athleteID: 4, teamID: 1002, value: 21},
})
ev := teamEvent(1)
ev.Compare = CompareAsc
worst := aggregateByTeam(t, worstScore(t, ev, entries, results))
avg := aggregateByTeam(t, aggScore(t, ev, AggregateAvg, entries, results))
sum := aggregateByTeam(t, aggScore(t, ev, AggregateSum, entries, results))
// worst:1002(21)快于 1001(30);
// avg:1002(20.5)快于 1001(20)—— 等等,平均下 1001 是 20、1002 是 20.5,所以 avg 里 1001 第一;
// sum:1001 40 > 1002 41,所以 sum 里 1002 第一。
if worst[1002].rank != 1 || worst[1001].rank != 2 {
t.Errorf("worst 下应 1002 第 1,实际 %+v", worst)
}
if avg[1001].rank != 1 || avg[1002].rank != 2 {
t.Errorf("avg 下应 1001 第 1(20 < 20.5),实际 %+v", avg)
}
if worst[1001].value == avg[1001].value || worst[1001].value == sum[1001].value {
t.Errorf("三种口径的汇总值不该相同:worst=%v avg=%v sum=%v",
worst[1001].value, avg[1001].value, sum[1001].value)
}
}
// TestAggregateModeWorstNormalization:worst 是合法取值,认不出的取值仍退回 avg。
func TestAggregateModeWorstNormalization(t *testing.T) {
if got := (ScoreChannel{AggregateMode: AggregateWorst}).aggregateMode(); got != AggregateWorst {
t.Fatalf("worst 应原样保留,实际 %q", got)
}
if got := (ScoreChannel{}).aggregateMode(); got != AggregateAvg {
t.Fatalf("零值通道的汇总口径 = %q,期望 avg", got)
}
for _, bogus := range []AggregateMode{"", "worse", "WORST", "min", "max", "biggest"} {
if got := (ScoreChannel{AggregateMode: bogus}).aggregateMode(); got != AggregateAvg {
t.Errorf("取值 %q 应退回 avg,实际 %q", bogus, got)
}
}
}
// TestAggregateWorstLedgerAndRankedShape:worst 口径产出的流水与名次明细形状与既有口径一致
// (EntryID=0 的聚合名次、TeamID 是主体、Shares 独占),免得读侧(榜单 / 导出)按别的形状解析。
func TestAggregateWorstLedgerAndRankedShape(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 12},
{entryID: 2, athleteID: 2, teamID: 1002, value: 20},
})
ev := teamEvent(1)
ev.Compare = CompareAsc
es := worstScore(t, ev, entries, results)
if len(es.Ranked) != 2 {
t.Fatalf("应产出 2 条名次明细,实际 %+v", es.Ranked)
}
for _, rr := range es.Ranked {
if rr.EntryID != 0 || rr.ResultID != 0 {
t.Errorf("聚合名次不应挂报名项 / 成绩:%+v", rr)
}
if rr.Scope != ScopeTeam || rr.Basis != BasisAggregate {
t.Errorf("名次明细的 scope/basis 应照通道来:%+v", rr)
}
if !reflect.DeepEqual(rr.Shares, wholeShare(rr.TeamID)) {
t.Errorf("聚合名次按主体单位独占份额,实际 %+v", rr.Shares)
}
}
got := pointsByTeam(es.Ledger)
if !nearlyEqual(got[1001], 15) || !nearlyEqual(got[1002], 10) {
t.Fatalf("单位流水 = %v,期望 1001=15 1002=10", got)
}
}
+50 -11
View File
@@ -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) {
+26 -1
View File
@@ -44,6 +44,12 @@ const (
AggregateAvg AggregateMode = "avg"
// AggregateSum 按主体成员成绩的总和排名。
AggregateSum AggregateMode = "sum"
// AggregateWorst 按主体成员成绩里**最差的那一份**排名(队内最差 / 最后一名冲线)。
//
// 刻意不叫 min / max:它到底是取最小还是最大**取决于项目的比较方向**
// (asc 越小越好 → 取最大,即最后一名冲线;desc 越大越好 → 取最小,即做得最少的那个),
// 叫 min 或 max 都会让后来人把其中一半的项目写反,见 scoreByAggregate 的说明。
AggregateWorst AggregateMode = "worst"
)
// ResultType 成绩的物理量类型,决定比较与展示方式。
@@ -241,7 +247,7 @@ type EventCategory struct {
// ScoreChannel 计分通道 = 「排谁的名次」+「名次从哪来」+「用哪张积分表」。
// 一个项目可配 0..N 个通道;0 个表示只排名不计分。
// basis=aggregate 把同一主体的成绩汇总成一个值再排名:汇总口径由本通道的 aggregate_mode 决定
// (avg 平均 / sum 求和),平均值内部的分母口径由项目的 avg_mode 决定。
// (avg 平均 / sum 求和 / worst 队内最差),平均值内部的分母口径由项目的 avg_mode 决定。
type ScoreChannel struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
@@ -519,9 +525,28 @@ func (c ScoreChannel) aggregateMode() AggregateMode {
if c.AggregateMode == AggregateSum {
return AggregateSum
}
if c.AggregateMode == AggregateWorst {
return AggregateWorst
}
return AggregateAvg
}
// AggregateModeOf 把库里 / 请求体里的汇总口径字符串还原成引擎口径;认不出的取值退回缺省。
//
// 缺省(avg)刻意返回**零值**而不是 "avg":这一列在通道响应体与导出的规则包里都按 omitempty 下发,
// 填上 "avg" 会让老数据的响应体与导出文件凭空多出一个字段(老库 / 老客户端的对拍当场对不上)。
// 三个读路径(REST 列表、计分读通道、规则包导出)都走它,免得各写一份、漏掉新取值。
func AggregateModeOf(stored string) AggregateMode {
switch AggregateMode(stored) {
case AggregateSum:
return AggregateSum
case AggregateWorst:
return AggregateWorst
default:
return ""
}
}
// CountsToMedals 未配置时按 true 处理:只有显式关闭的通道才不发奖牌。
func (c ScoreChannel) CountsToMedals() bool {
return c.CountToMedals == nil || *c.CountToMedals
@@ -0,0 +1,164 @@
package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
// 聚合口径第三个取值 worst(按队内最差排名)的 REST 契约与端到端效果。
//
// 契约:
// - 请求体写 worst 能存下、读回来还是 worst;
// - 认不出的取值仍 400(新增取值不能顺带把校验放松成「什么都收」)。
//
// 效果(越野这类 compare=asc 的项目,按最后一名冲线排名):
// - 缺省 avg 与 worst 的名次**必须不同**——这组数据是特意挑的,两者分叉,串了口径当场看得出来;
// - 落库的 channel_ranks.result_value 就是队内最差的那一份成绩(不是平均、不是最快);
// - 全队一条有效成绩都没有(这里用「没录」实现)→ 不参与排名、不得分。
func TestChannelAggregateModeWorst(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
var teamProfile int64
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&teamProfile); err != nil {
t.Fatalf("读取团体积分模板: %v", err)
}
// 5 公里越野:compare=asc(时间,越小越好)——最后一名冲线的队伍排前面。
ev := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (?, 'MARCH', '5 公里越野', 'team', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport)
cat := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, ev)
athA1 := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('甲', '男', ?, 1)`, ids.teamA)
athA2 := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('乙', '男', ?, 1)`, ids.teamA)
athB1 := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('丙', '男', ?, 1)`, ids.teamB)
athB2 := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('丁', '男', ?, 1)`, ids.teamB)
// 第三个单位:全队一条有效成绩都不录,用来钉住「没有效成绩不参与排名」。
teamC := env.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES ('C', '第三分公司', '三司', 2, 1)`)
athC1 := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('戊', '男', ?, 1)`, teamC)
entryA1 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`, ev, cat, ids.teamA, athA1)
entryA2 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`, ev, cat, ids.teamA, athA2)
entryB1 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`, ev, cat, ids.teamB, athB1)
entryB2 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`, ev, cat, ids.teamB, athB2)
env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`, ev, cat, teamC, athC1)
chans := fmt.Sprintf("/api/events/%d/channels", ev)
setMode := func(mode string) {
t.Helper()
channel := map[string]any{"scope": "team", "basis": "aggregate", "profileId": teamProfile, "sortOrder": 1}
if mode != "" {
channel["aggregateMode"] = mode
}
status, resp := env.do(client, http.MethodPut, chans, map[string]any{"channels": []map[string]any{channel}}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("写通道(aggregateMode=%q)应 200,得到 %d %+v", mode, status, resp.Error)
}
}
teamLedger := func() map[int64]float64 {
t.Helper()
rows, err := env.ev.Query(`SELECT team_id, SUM(points) FROM score_ledger
WHERE voided = 0 AND event_id = ? GROUP BY team_id`, ev)
if err != nil {
t.Fatalf("统计单位流水: %v", err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64]float64)
for rows.Next() {
var teamID int64
var points float64
if err := rows.Scan(&teamID, &points); err != nil {
t.Fatalf("扫描流水: %v", err)
}
out[teamID] = points
}
return out
}
// 落库的团体成绩:聚合名次不挂报名项,主体就是单位(subject_key = 't:<单位>')。
channelRankValue := func(teamID int64) (float64, int, bool) {
t.Helper()
var value float64
var rank int
err := env.ev.QueryRow(`SELECT result_value, rank FROM channel_ranks
WHERE event_id = ? AND subject_key = ?`, ev, fmt.Sprintf("t:%d", teamID)).Scan(&value, &rank)
if err != nil {
return 0, 0, false
}
return value, rank, true
}
// A 队 12 + 20 分钟、B 队 18 + 19 分钟、C 队(还没录成绩)。
status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ev,
"results": []map[string]any{
{"entryId": entryA1, "resultValue": 12, "unit": "分", "status": "ok"},
{"entryId": entryA2, "resultValue": 20, "unit": "分", "status": "ok"},
{"entryId": entryB1, "resultValue": 18, "unit": "分", "status": "ok"},
{"entryId": entryB2, "resultValue": 19, "unit": "分", "status": "ok"},
},
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("保存草稿应 200,得到 %d %+v", status, resp.Error)
}
// 1) 缺省 avg:A 队平均 16 快于 B 队 18.5 —— A 第一(15 分)、B 第二(10 分)。
setMode("")
if status, resp = env.do(client, http.MethodPost, "/api/results/publish", map[string]any{"eventId": ev}, true); status != http.StatusOK || !resp.OK {
t.Fatalf("发布应 200,得到 %d %+v", status, resp.Error)
}
if got := teamLedger(); got[ids.teamA] != 15 || got[ids.teamB] != 10 {
t.Fatalf("avg 口径下单位流水 = %+v,期望 A=15 B=10", got)
}
if value, _, ok := channelRankValue(ids.teamA); !ok || value != 16 {
t.Fatalf("avg 口径下 A 队落库成绩 = %v,期望平均 16", value)
}
// 2) 现场切成「按队内最低分」:A 队最慢 20 分钟、B 队最慢 19 分钟 —— B 第一、A 第二,与平均口径正好互换。
setMode("worst")
if got := teamLedger(); got[ids.teamB] != 15 || got[ids.teamA] != 10 {
t.Fatalf("worst 口径下单位流水 = %+v,期望 B=15 A=10(按最后一名冲线)", got)
}
if value, rank, ok := channelRankValue(ids.teamB); !ok || value != 19 || rank != 1 {
t.Fatalf("worst 口径下 B 队落库成绩 = %v 第 %d 名,期望 19 第 1 名", value, rank)
}
if value, rank, ok := channelRankValue(ids.teamA); !ok || value != 20 || rank != 2 {
t.Fatalf("worst 口径下 A 队落库成绩 = %v 第 %d 名,期望 20 第 2 名", value, rank)
}
// 读回来还是 worst(缺省值才不下发,非缺省值必须下发)。
status, resp = env.do(client, http.MethodGet, chans, nil, false)
if status != http.StatusOK || !resp.OK {
t.Fatalf("读通道列表应 200,得到 %d %+v", status, resp.Error)
}
if got := decodeData[[]map[string]any](t, resp); got[0]["aggregateMode"] != "worst" {
t.Fatalf("通道列表应带 aggregateMode=worst,得到 %+v", got[0])
}
var stored string
if err := env.ev.QueryRow(`SELECT aggregate_mode FROM event_score_channels WHERE event_id = ?`, ev).Scan(&stored); err != nil {
t.Fatalf("读取落库的聚合口径: %v", err)
}
if stored != "worst" {
t.Fatalf("落库的聚合口径 = %q,期望 worst", stored)
}
// 3) 全队没有有效成绩的 C 队不参与排名、不得分(worst 没有分母,avg_mode 在这里不适用)。
if _, _, ok := channelRankValue(teamC); ok {
t.Fatal("一条有效成绩都没有的 C 队不应出现在团体名次里")
}
if got := teamLedger(); got[teamC] != 0 {
t.Fatalf("C 队不应得分,实际流水 %+v", got)
}
// 4) 认不出的取值仍 400,且不把已配好的 worst 改坏。
status, resp = env.do(client, http.MethodPut, chans, map[string]any{
"channels": []map[string]any{
{"scope": "team", "basis": "aggregate", "profileId": teamProfile, "sortOrder": 1, "aggregateMode": "min"},
},
}, true)
if status != http.StatusBadRequest || resp.Error == nil || !strings.Contains(resp.Error.Message, "aggregateMode") {
t.Fatalf("非法聚合口径应 400 并点明 aggregateMode,得到 %d %+v", status, resp.Error)
}
if got := teamLedger(); got[ids.teamB] != 15 || got[ids.teamA] != 10 {
t.Fatalf("被拒的请求不应改动已发布的榜,实际 %+v", got)
}
}
+3 -2
View File
@@ -56,9 +56,10 @@ type channelDTO struct {
Basis string `json:"basis"`
ProfileID int64 `json:"profileId"`
SortOrder int `json:"sortOrder"`
// AggregateMode 是 basis=aggregate 通道的汇总口径:avg 按成员平均 / sum 按成员总和。
// AggregateMode 是 basis=aggregate 通道的汇总口径:avg 按成员平均 / sum 按成员总和 /
// worst 按队内最差(方向随小项的 compare 走:asc 取最后一名冲线、desc 取做得最少的那个)。
// 请求体里缺省(空串)按 avg 处理,不带该字段的旧客户端保存通道时口径不变。
// 响应里只下发非缺省值(sum);缺省(avg)不下发,老数据的响应体因此一个字节都不变。
// 响应里只下发非缺省值(sum / worst);缺省(avg)不下发,老数据的响应体因此一个字节都不变。
AggregateMode string `json:"aggregateMode,omitempty"`
// CountToTeam 决定该通道产出的流水是否计入单位总分;请求体里缺省(null)按 true 处理,
// 「个人+团体」项目的个人通道传 false。响应里始终是具体值。
+6 -8
View File
@@ -670,10 +670,8 @@ func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO,
&countToMedals, &countToTeamMedals); err != nil {
return nil, internal(err)
}
// 只下发非缺省口径(sum):avg 是缺省值,老数据的响应体因此一个字节都不变。
if aggregateMode == string(domain.AggregateSum) {
c.AggregateMode = aggregateMode
}
// 只下发非缺省口径(sum / worst):avg 是缺省值,老数据的响应体因此一个字节都不变。
c.AggregateMode = string(domain.AggregateModeOf(aggregateMode))
c.CountToTeam = boolPtr(countToTeam != 0)
c.CountToMedals = boolPtr(countToMedals != 0)
c.CountToTeamMedals = boolPtr(countToTeamMedals != 0)
@@ -697,8 +695,8 @@ func channelFlag(v *bool) bool {
// channelAggregateMode 读请求体里的聚合口径:缺省(空串)落成 avg,
// 保证不带该字段的旧客户端保存通道时口径不变(与库里的默认值一致)。
func channelAggregateMode(c channelDTO) string {
if c.AggregateMode == string(domain.AggregateSum) {
return string(domain.AggregateSum)
if mode := domain.AggregateModeOf(c.AggregateMode); mode != "" {
return string(mode)
}
return string(domain.AggregateAvg)
}
@@ -748,9 +746,9 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
return nil, badRequest("通道 basis 非法", i)
}
switch c.AggregateMode {
case "", string(domain.AggregateAvg), string(domain.AggregateSum):
case "", string(domain.AggregateAvg), string(domain.AggregateSum), string(domain.AggregateWorst):
default:
return nil, badRequest("通道 aggregateMode 只能是 avg/sum", i)
return nil, badRequest("通道 aggregateMode 只能是 avg/sum/worst", i)
}
if c.ProfileID == 0 {
return nil, badRequest("通道 profileId 必填", i)
+3 -4
View File
@@ -107,10 +107,9 @@ func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.Scor
&countToMedals, &countToTeamMedals); err != nil {
return nil, internal(err)
}
// 汇总口径只认 sum:avg(含缺字段的老库)保持零值,由引擎按缺省 avg 处理。
if aggregateMode == string(domain.AggregateSum) {
c.AggregateMode = domain.AggregateSum
}
// 汇总口径只认非缺省的取值(sum / worst):avg(含缺字段的老库)保持零值,
// 由引擎按缺省 avg 处理,响应体里也不多出这个字段。
c.AggregateMode = domain.AggregateModeOf(aggregateMode)
// 只把显式关闭的通道落成指针,缺省时保持 nil(等同计入单位总分 / 发奖牌)。
if countToTeam == 0 {
c.CountToTeam = boolPtr(false)
+30 -5
View File
@@ -9,12 +9,13 @@ import (
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// 聚合口径(basis=aggregate 的 avg / sum)在规则包这条路上的口径:
// 解析认 avg/sum、缺省即 avg;导入落库补成具体值;导出只写非缺省值(sum),
// 聚合口径(basis=aggregate 的 avg / sum / worst)在规则包这条路上的口径:
// 解析认 avg/sum/worst、缺省即 avg;导入落库补成具体值;导出只写非缺省值(sum / worst),
// 规则包里显式写 avg 与不写等价,不该被当成一次「通道改动」。
//
// 现场背景:团体引体向上这类双计项目的团体通道,领导还没定按平均还是按总和,
// 两种都要能在规则包里表达,且默认仍是升级前的平均值口径。
// 两种都要能在规则包里表达,且默认仍是升级前的平均值口径;越野 / 行军这类
// 「取团队最后一人的成绩」的项目要能写 worst。
const aggregateModePackYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo-rules, name: 示例规则包 }
@@ -42,15 +43,20 @@ eventOverrides:
name: 团体引体向上(平均,显式写缺省值)
channels:
- { scope: team, basis: aggregate, profile: team_top6, aggregateMode: avg }
- eventCode: EVT_WORST
name: 5 公里越野(队内最差)
channels:
- { scope: team, basis: aggregate, profile: team_top6, aggregateMode: worst }
`
// seedAggregateEvents 建两个可排名的团体小项(compare 必须有方向,否则 aggregate 通道用不了)。
// seedAggregateEvents 建三个可排名的团体小项(compare 必须有方向,否则 aggregate 通道用不了)。
func seedAggregateEvents(t *testing.T, db *sql.DB) {
t.Helper()
sportID := insertID(t, db, `INSERT INTO sports (code, name) VALUES ('demo-sport', '示例大项')`)
for _, ev := range []struct{ code, name string }{
{"EVT_SUM", "团体引体向上(总和)"},
{"EVT_AVG", "团体引体向上(平均,显式写缺省值)"},
{"EVT_WORST", "5 公里越野(队内最差)"},
} {
if _, err := db.Exec(`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier)
VALUES (?, ?, ?, 'individual_team', 'auto', 'count', 'desc', 1, 1)`, sportID, ev.code, ev.name); err != nil {
@@ -76,6 +82,10 @@ func TestParseAggregateMode(t *testing.T) {
if got := overrides["EVT_AVG"].AggregatesBy(); got != domain.AggregateAvg {
t.Errorf("aggregateMode: avg 应解析成 avg,实际 %q", got)
}
// worst = 按队内最差排名(方向随小项的 compare 走),取值本身不带方向,别读成 min。
if got := overrides["EVT_WORST"].AggregatesBy(); got != domain.AggregateWorst {
t.Errorf("aggregateMode: worst 应解析成 worst,实际 %q", got)
}
// 普通通道(basis=result)不写该字段时同样是缺省 avg,不影响任何行为。
tmpl := pack.ChannelTemplates[0].Channels[0]
if tmpl.AggregateMode != "" || tmpl.AggregatesBy() != domain.AggregateAvg {
@@ -89,6 +99,15 @@ func TestParseAggregateMode(t *testing.T) {
} else if !strings.Contains(err.Error(), "aggregateMode") {
t.Errorf("报错应点明 aggregateMode 字段,实际 %v", err)
}
// 「取最小」不能当取值写:worst 的方向由小项的 compare 决定,写 min 的项目一半会算反,
// 所以 min / max 都必须是**非法取值**(报错让人改用 worst),而不是被静默当成某个方向。
for _, wrong := range []string{"min", "max"} {
bad := strings.Replace(aggregateModePackYAML, "aggregateMode: worst", "aggregateMode: "+wrong, 1)
if _, err := Parse([]byte(bad)); !errors.Is(err, ErrValidation) {
t.Errorf("aggregateMode: %s 应报校验错误(改用 worst),实际 %v", wrong, err)
}
}
}
// TestApplyAndExportAggregateMode 守落库与导出:
@@ -121,6 +140,9 @@ func TestApplyAndExportAggregateMode(t *testing.T) {
if got := modeOf("EVT_AVG"); got != string(domain.AggregateAvg) {
t.Errorf("EVT_AVG 落库 = %q,期望 avg", got)
}
if got := modeOf("EVT_WORST"); got != string(domain.AggregateWorst) {
t.Errorf("EVT_WORST 落库 = %q,期望 worst", got)
}
exported, err := Export(db, Meta{Code: "demo", Name: "导出"})
if err != nil {
@@ -130,10 +152,13 @@ func TestApplyAndExportAggregateMode(t *testing.T) {
for _, ov := range exported.EventOverrides {
exportedMode[ov.EventCode] = (*ov.Channels)[0].AggregateMode
}
// sum 是非缺省口径,必须写进导出文件,否则「导出 → 导入」会把总和口径丢成平均。
// sum / worst 都是非缺省口径,必须写进导出文件,否则「导出 → 导入」会把它们丢成平均。
if exportedMode["EVT_SUM"] != string(domain.AggregateSum) {
t.Errorf("EVT_SUM 导出 = %q,期望 sum", exportedMode["EVT_SUM"])
}
if exportedMode["EVT_WORST"] != string(domain.AggregateWorst) {
t.Errorf("EVT_WORST 导出 = %q,期望 worst", exportedMode["EVT_WORST"])
}
// avg 是缺省值,导出文件里不出现,往返比对不会凭空多出差异。
if exportedMode["EVT_AVG"] != "" {
t.Errorf("EVT_AVG 导出 = %q,期望省略(avg 是缺省值)", exportedMode["EVT_AVG"])
+1 -3
View File
@@ -298,9 +298,7 @@ func loadEvents(q queryer, profileByID map[int64]string) ([]EventInfo, error) {
Profile: code,
}
// 只把非缺省的口径写进导出文件:avg 是缺省值,写了会让「导出 → 导入」凭空多出差异。
if aggregateMode == string(domain.AggregateSum) {
ch.AggregateMode = string(domain.AggregateSum)
}
ch.AggregateMode = string(domain.AggregateModeOf(aggregateMode))
// 只把显式关闭的通道写成 false:导出文件里不出现缺省值,往返比对才不会凭空多出差异。
if countToTeam == 0 {
ch.CountToTeam = BoolPtr(false)
+5 -4
View File
@@ -97,12 +97,13 @@ type Rule struct {
// Channel 是计分通道:排谁的名次 + 名次从哪来 + 用哪张积分表。
// basis=aggregate 把同一主体的成绩汇总成一个值再排名:汇总口径由 AggregateMode 决定
// (avg 平均 / sum 求和),平均值内部的分母口径由项目的 avg_mode 决定。
// (avg 平均 / sum 求和 / worst 队内最差),平均值内部的分母口径由项目的 avg_mode 决定。
type Channel struct {
Scope domain.Scope `yaml:"scope" json:"scope"`
Basis domain.Basis `yaml:"basis" json:"basis"`
Profile string `yaml:"profile" json:"profile"`
// AggregateMode 是 basis=aggregate 通道的汇总口径:avg 按成员平均(缺省)/ sum 按成员总和。
// AggregateMode 是 basis=aggregate 通道的汇总口径:avg 按成员平均(缺省)/ sum 按成员总和 /
// worst 按队内最差(方向随小项的 compare 走:asc 取最后一名冲线、desc 取做得最少的那个)。
// 只对 aggregate 通道有意义;缺省(不写)等价于 avg,与库里的默认值一致。
AggregateMode string `yaml:"aggregateMode,omitempty" json:"aggregateMode,omitempty"`
// CountToTeam 决定该通道产出的流水是否计入单位总分;缺省 true。
@@ -118,8 +119,8 @@ type Channel struct {
// AggregatesBy 返回补齐缺省值后的汇总口径:未配置或写 avg 时按 avg(与库里的默认值一致)。
func (c Channel) AggregatesBy() domain.AggregateMode {
if c.AggregateMode == string(domain.AggregateSum) {
return domain.AggregateSum
if mode := domain.AggregateModeOf(c.AggregateMode); mode != "" {
return mode
}
return domain.AggregateAvg
}
+3 -3
View File
@@ -295,10 +295,10 @@ func validateChannels(l *issueList, path string, channels []Channel, profiles ma
} else if !profiles[ch.Profile] {
l.addf(cpath+".profile", "引用了不存在的积分模板 %q", ch.Profile)
}
// 汇总口径只有 avg / sum 两个取值;不写 = avg(缺省),拼错的取值必须报错而不是被当成 avg。
// 汇总口径只有 avg / sum / worst 三个取值;不写 = avg(缺省),拼错的取值必须报错而不是被当成 avg。
if ch.AggregateMode != "" &&
ch.AggregateMode != string(domain.AggregateAvg) && ch.AggregateMode != string(domain.AggregateSum) {
l.addf(cpath+".aggregateMode", "只能是 avg / sum,当前 %q", ch.AggregateMode)
ch.AggregateMode != string(domain.AggregateAvg) && domain.AggregateModeOf(ch.AggregateMode) == "" {
l.addf(cpath+".aggregateMode", "只能是 avg / sum / worst,当前 %q", ch.AggregateMode)
}
// 落库有 UNIQUE(event_id, scope, basis) 约束,这里先挡住,避免导入时才炸。
key := string(ch.Scope) + "/" + string(ch.Basis)
@@ -0,0 +1,64 @@
-- 聚合通道(basis=aggregate)的汇总口径新增第三个取值 worst:**按队内最差排名**。
--
-- 现场:有的项目取「团队最后一人的成绩」来排名 —— 越野 / 行军这类**最后一名冲线**的项目,
-- 以及引体向上这类「按做得最少的那个算」的项目。同一个「队内最差」,方向随项目的 compare 自动取
-- (asc 取最大 = 最后一名冲线;desc 取最小 = 做得最少的那个),见 domain/scoring.go 的 worseValue。
--
-- 为什么叫 worst 而不是 min / max:它到底是取最小还是取最大取决于项目的比较方向,
-- 叫 min 或 max 会让后来人把其中一半的项目写反(那份成绩的语义是「队里最差的那一份」,
-- 而不是「最小的那一份」)。
--
-- 这一列在 0023 上是 `CHECK (aggregate_mode IN ('avg', 'sum'))`,SQLite **改不了已存在的 CHECK**,
-- 只能按官方推荐的做法重建表(写法与 0012 / 0017 的重建一致):建新表 → 拷数据 → 删旧表 → 改名。
--
-- 重建父表有一处**必须处理干净**的连带影响:channel_ranks.channel_id 是指向本表的
-- `ON DELETE CASCADE` 外键,删掉父表会让子表的名次**被级联清空** —— 那些名次是现场已经发布过的
-- 榜(谁第几名、多少分),丢了不会报错,只是榜单静默变空,而且没人会想到要重新发布一遍。
-- 所以下面先把子表整体寄存在临时表里,重建完再原样灌回(id、列值一字不改,外键与唯一索引照旧)。
-- 临时表在迁移事务结束的同一个连接内使用,迁移跑完即随连接消失,不在库里留残迹。
--
-- 老数据零变化:每条老通道的 aggregate_mode 原样搬过去(默认仍是 'avg'),其它列一列不改,
-- id 也原样保留(channel_ranks 的外键挂在它上面);升级前后榜单与上一版一字不差。
PRAGMA defer_foreign_keys = ON;
-- 1) 子表名次先寄存(DROP 父表会级联把它们清掉)。
CREATE TEMP TABLE channel_ranks_keep AS SELECT * FROM channel_ranks;
-- 2) 新表:只放开 aggregate_mode 的取值,其它列定义与 0004 + 0017 + 0020 + 0023 逐字一致。
CREATE TABLE event_score_channels_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL REFERENCES events(id),
scope TEXT NOT NULL CHECK (scope IN ('athlete', 'team')),
basis TEXT NOT NULL CHECK (basis IN ('result', 'manual', 'aggregate')),
profile_id INTEGER NOT NULL REFERENCES scoring_profiles(id),
sort_order INTEGER NOT NULL DEFAULT 0,
count_to_team INTEGER NOT NULL DEFAULT 1 CHECK (count_to_team IN (0, 1)),
count_to_medals INTEGER NOT NULL DEFAULT 1 CHECK (count_to_medals IN (0, 1)),
count_to_team_medals INTEGER NOT NULL DEFAULT 1 CHECK (count_to_team_medals IN (0, 1)),
aggregate_mode TEXT NOT NULL DEFAULT 'avg'
CHECK (aggregate_mode IN ('avg', 'sum', 'worst')),
UNIQUE (event_id, scope, basis)
);
INSERT INTO event_score_channels_new (id, event_id, scope, basis, profile_id, sort_order,
count_to_team, count_to_medals, count_to_team_medals, aggregate_mode)
SELECT id, event_id, scope, basis, profile_id, sort_order,
count_to_team, count_to_medals, count_to_team_medals, aggregate_mode
FROM event_score_channels;
-- 3) 换名(父表被 DROP 时子表名次被级联清空,下面原样灌回)。
DROP TABLE event_score_channels;
ALTER TABLE event_score_channels_new RENAME TO event_score_channels;
CREATE INDEX IF NOT EXISTS idx_event_score_channels_event_id ON event_score_channels(event_id);
-- 4) 名次原样回填(含 id 与 updated_at;AUTOINCREMENT 的号也会随之接上)。
INSERT INTO channel_ranks (id, event_id, category_id, channel_id, basis, scope, subject_key,
entry_id, result_id, athlete_id, team_id, rank, points, result_value,
status, updated_at)
SELECT id, event_id, category_id, channel_id, basis, scope, subject_key,
entry_id, result_id, athlete_id, team_id, rank, points, result_value,
status, updated_at
FROM channel_ranks_keep;
DROP TABLE channel_ranks_keep;
@@ -0,0 +1,218 @@
package event
import (
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// 0027:聚合通道的汇总口径放开到第三个取值 worst(按队内最差排名)。
//
// 五条底线:
// - 老数据零变化:每条老通道的 aggregate_mode 原样保留(0023 的默认 avg 与后配的 sum),
// 其它列一列不改、行数一行不少;
// - **子表 channel_ranks 的名次一行不少**(这条迁移要重建父表,父表被 DROP 时会级联清空子表,
// 必须原样灌回);
// - id 不能漂:老通道的 id 是 channel_ranks 的外键,换号就等于把名次全删了;
// - 取值放开到 avg / sum / worst,拼错的仍被 CHECK 挡下;
// - 幂等:连跑两次不报错、不重复登记。
func TestMigration0027ChannelAggregateModeWorst(t *testing.T) {
db := openEventDB(t)
applyBefore(t, db, "0027") // 回到「升级前」:库结构到 0026 为止
stmts := []string{
`INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`,
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
// 升序项目(时间,越小越好):最后一名冲线要用它。
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (1, 'MARCH', '5 公里越野', 'team', 'auto', 'time', 'asc', 1, 1, 'ongoing')`,
`INSERT INTO event_categories(event_id, name, multiplier, sort_order) VALUES (1, '男子', 1, 0)`,
// 三条老通道:缺省 avg、显式 sum、以及一条非聚合通道(它也有这列,同样要原样留着)。
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, aggregate_mode)
VALUES (1, 'team', 'aggregate', 1, 1, 'avg')`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, aggregate_mode)
VALUES (1, 'athlete', 'aggregate', 1, 2, 'sum')`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order)
VALUES (1, 'team', 'manual', 1, 3)`,
// 子表里两条名次:重建父表时必须原样回来(挂的是通道 2)。
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, rank, points, result_value)
VALUES (1, 1, 2, 'aggregate', 'team', 't:1', 1, 15, 12.5)`,
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, rank, points, result_value)
VALUES (1, 1, 2, 'aggregate', 'team', 't:2', 2, 10, 13)`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造老库数据 %q: %v", s, err)
}
}
// 升级前:worst 这一取值还不该被接受。
if _, err := db.Exec(`UPDATE event_score_channels SET aggregate_mode = 'worst' WHERE id = 1`); err == nil {
t.Fatal("升级前不应接受 aggregate_mode = 'worst'")
}
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("升级赛事库: %v", err)
}
// 老行逐列原样(id 尤其不能漂:channel_ranks 的外键挂在它上面)。
want := []struct {
id int64
scope string
basis string
mode string
sortOrd int
}{
{1, "team", "aggregate", "avg", 1},
{2, "athlete", "aggregate", "sum", 2},
{3, "team", "manual", "avg", 3},
}
rows, err := db.Query(`SELECT id, scope, basis, aggregate_mode, sort_order FROM event_score_channels ORDER BY id`)
if err != nil {
t.Fatalf("读取升级后的通道: %v", err)
}
var seen []struct {
id int64
scope string
basis string
mode string
sortOrd int
}
for rows.Next() {
var got struct {
id int64
scope string
basis string
mode string
sortOrd int
}
if err := rows.Scan(&got.id, &got.scope, &got.basis, &got.mode, &got.sortOrd); err != nil {
_ = rows.Close()
t.Fatalf("扫描通道: %v", err)
}
seen = append(seen, got)
}
if err := rows.Err(); err != nil {
_ = rows.Close()
t.Fatalf("遍历通道: %v", err)
}
_ = rows.Close()
if len(seen) != len(want) {
t.Fatalf("通道数 = %d,期望 %d:%+v", len(seen), len(want), seen)
}
for i := range want {
if seen[i] != want[i] {
t.Fatalf("第 %d 条通道 = %+v,期望 %+v", i+1, seen[i], want[i])
}
}
// 子表名次一行不少、内容一字不差。
var ranks int
if err := db.QueryRow(`SELECT COUNT(*) FROM channel_ranks`).Scan(&ranks); err != nil {
t.Fatalf("统计通道名次: %v", err)
}
if ranks != 2 {
t.Fatalf("channel_ranks 行数 = %d,期望 2(重建父表时被级联清空了?)", ranks)
}
var subjectKey, basis, scope string
var rank int
var points, resultValue float64
if err := db.QueryRow(`SELECT subject_key, basis, scope, rank, points, result_value FROM channel_ranks
WHERE channel_id = 2 AND rank = 1`).
Scan(&subjectKey, &basis, &scope, &rank, &points, &resultValue); err != nil {
t.Fatalf("读取保留下来的通道名次: %v", err)
}
if subjectKey != "t:1" || basis != "aggregate" || scope != "team" || rank != 1 || points != 15 || resultValue != 12.5 {
t.Fatalf("通道名次被改动了:%s/%s/%s rank=%d points=%v value=%v", subjectKey, basis, scope, rank, points, resultValue)
}
// 外键仍然指着同一张表:级联删除还要照旧生效。
var channelRanksDDL string
if err := db.QueryRow(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'channel_ranks'`).Scan(&channelRanksDDL); err != nil {
t.Fatalf("读取 channel_ranks 定义: %v", err)
}
if !strings.Contains(channelRanksDDL, "REFERENCES event_score_channels(id) ON DELETE CASCADE") {
t.Fatalf("channel_ranks 对通道的外键定义被改动了:\n%s", channelRanksDDL)
}
// 取值放开:worst 能写进去,拼错的仍被挡下。
if _, err := db.Exec(`UPDATE event_score_channels SET aggregate_mode = 'worst' WHERE id = 1`); err != nil {
t.Fatalf("升级后应能写入 aggregate_mode = 'worst': %v", err)
}
if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, aggregate_mode)
VALUES (1, 'athlete', 'manual', 1, 4, 'biggest')`); err == nil {
t.Fatal("非法 aggregate_mode 应被 CHECK 拒绝")
}
// 新增通道时自增号接着往下走(重建表不能把 id 序列重置成 1,那会与既有名次撞号)。
if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order)
VALUES (1, 'athlete', 'manual', 1, 4)`); err != nil {
t.Fatalf("新增通道: %v", err)
}
var maxID int64
if err := db.QueryRow(`SELECT MAX(id) FROM event_score_channels`).Scan(&maxID); err != nil {
t.Fatalf("读取通道最大 id: %v", err)
}
if maxID != 4 {
t.Fatalf("新增通道的 id = %d,期望 4(自增号应接着老数据往下走)", maxID)
}
// 幂等:再跑一次不报错、登记仍只有一条。
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("重复升级应无副作用: %v", err)
}
var registered, total int
if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE name = '0027_channel_aggregate_mode_worst.sql'`).Scan(&registered); err != nil {
t.Fatalf("查询迁移登记: %v", err)
}
if registered != 1 {
t.Fatalf("迁移登记数 = %d,期望 1", registered)
}
if err := db.QueryRow(`SELECT COUNT(*) FROM event_score_channels`).Scan(&total); err != nil {
t.Fatalf("统计通道: %v", err)
}
if total != 4 {
t.Fatalf("重复升级后通道数 = %d,期望 4", total)
}
}
// 重建父表最容易出的事故是「外键与级联被改坏」:空子表也要能照常写名次、照常级联删除。
func TestMigration0027ForeignKeyStillCascades(t *testing.T) {
db := openEventDB(t)
applyBefore(t, db, "0027")
stmts := []string{
`INSERT INTO sports(code, name) VALUES ('SP9', '测试')`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES ((SELECT id FROM sports WHERE code = 'SP9'), 'X', 'x', 'team', 'auto', 'time', 'asc', 1, 1, 'ongoing')`,
`INSERT INTO event_categories(event_id, name, multiplier, sort_order) VALUES (1, '男子', 1, 0)`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, aggregate_mode)
VALUES (1, 'team', 'aggregate', 1, 1, 'avg')`,
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, rank, points)
VALUES (1, 1, 1, 'aggregate', 'team', 't:1', 1, 15)`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造老库数据 %q: %v", s, err)
}
}
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("升级赛事库: %v", err)
}
// 新口径的通道能照常落库。
if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, aggregate_mode)
VALUES (1, 'athlete', 'aggregate', 1, 2, 'worst')`); err != nil {
t.Fatalf("写入 worst 通道: %v", err)
}
// 级联删除照旧:删掉通道 1,挂它的名次跟着没。
if _, err := db.Exec(`DELETE FROM event_score_channels WHERE id = 1`); err != nil {
t.Fatalf("删除通道: %v", err)
}
var left int
if err := db.QueryRow(`SELECT COUNT(*) FROM channel_ranks WHERE channel_id = 1`).Scan(&left); err != nil {
t.Fatalf("统计级联后的名次: %v", err)
}
if left != 0 {
t.Fatalf("重建后级联删除失效:通道删了还留着 %d 条名次", left)
}
}
+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') {
@@ -33,14 +33,32 @@ const BASIS_OPTIONS: Array<{ value: ChannelBasis; label: string }> = [
]
/**
* 聚合口径的两个选项:现场在「按成员平均」与「按成员总和」之间快速切。
* 聚合口径的三个选项:现场在「按成员平均」「按成员总和」「按队内最低分」之间快速切。
* 与 basis=aggregate 配套使用,点一下即选中(不做二次确认,现场要的是快)。
*
* 「队内最低分」= 队内最差的那一份成绩(不是名字里的「最低」那么简单):升序项目(时间,越小越好)
* 取的是**跑得最慢的那个人**(最后一名冲线),降序项目(次数,越大越好)取的是**做得最少的那个**,
* 方向跟着小项的「比较方向」自动走 —— 所以提示里必须把两种方向都写出来,只写「最低」会让人按字面理解。
*/
const AGGREGATE_MODE_OPTIONS: Array<{ value: AggregateMode; label: string; hint: string }> = [
{ value: 'avg', label: '平均', hint: '团体成绩 = 该单位成员成绩的平均值(缺省)' },
{ value: 'sum', label: '总和', hint: '团体成绩 = 该单位成员成绩的总和' },
{
value: 'worst',
label: '队内最低分',
hint:
'团体成绩 = 该单位成员里最差的那一份:升序项目(时间)取最后一名冲线的那个人,' +
'降序项目(次数)取做得最少的那个(方向跟着小项的「比较方向」走)',
},
]
/** 行内小结:一句话说清这一行的团体成绩是怎么来的,现场核对时照这一行读。 */
const AGGREGATE_MODE_SUMMARIES: Record<AggregateMode, string> = {
avg: '团体成绩 = 该单位成员成绩的平均值',
sum: '团体成绩 = 该单位成员成绩的总和',
worst: '团体成绩 = 该单位成员里最差的那一份(时间项目取最后一名冲线的人 / 次数项目取做得最少的那个)',
}
interface RowError {
scope?: string
basis?: string
@@ -231,11 +249,14 @@ async function save(): Promise<void> {
</div>
<div class="bd-note count-to-team-note">
<b>团体分口径</b>:名次来源选「按队内平均」的通道,可在「<b>聚合口径</b>」一列点<b>平均 / 总和</b>直接切换——
「平均」= 该单位成员成绩的平均值(缺省),「总和」= 该单位成员成绩的总和;切换后点「保存全部」生效,
该小项立即重算。两种口径的排名在<b>各队人数相同时一致</b>,人数不同时可能不同。
<b>团体分口径</b>:名次来源选「按队内平均」的通道,可在「<b>聚合口径</b>」一列点<b>平均 / 总和 / 队内最低分</b>直接切换——
「平均」= 该单位成员成绩的平均值(缺省),「总和」= 该单位成员成绩的总和,
「<b>队内最低分</b>」= 该单位成员里<b>最差的那一份</b>成绩(<b>时间项目取最后一名冲线的那个人,次数项目取做得最少的那个</b>,
方向跟着小项的「比较方向」自动走——越野 / 行军这类项目要的就是它);切换后点「保存全部」生效,
该小项立即重算。平均与总和两种口径的排名在<b>各队人数相同时一致</b>,人数不同时可能不同。
平均值口径(只算有效成绩 / 全员计入)仍按小项的「平均值口径」设置,求和时它只决定
「一个人都没成绩的单位算不算 0 分参与排名」。
「一个人都没成绩的单位算不算 0 分参与排名」,「队内最低分」下它不起作用:
一个人都没成绩的单位一律不参与排名(没人冲线就没有「最后一名」可言)。
<br />
「计入单位总分」关掉后,该通道产生的分只记在个人头上,不进单位总分(例如「个人+团体」小项的个人通道)。
新增行的默认值按小项类型来:「个人+团体」的个人通道默认「不计入」(与规则包模板一致),
@@ -271,15 +292,15 @@ async function save(): Promise<void> {
<option v-for="option in BASIS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
<span v-if="row.basis === 'aggregate'" class="profile-summary bd-note">
团体成绩 = 该单位成员成绩的{{ row.aggregateMode === 'sum' ? '总和' : '平均值' }}
{{ AGGREGATE_MODE_SUMMARIES[row.aggregateMode] }}
</span>
<span v-if="errors[row.key]?.basis" class="row-error">{{ errors[row.key]?.basis }}</span>
<span v-if="errors[row.key]?.scope" class="row-error">{{ errors[row.key]?.scope }}</span>
</td>
<td>
<!--
现场要在「按成员平均」与「按成员总和」之间临时定口径,所以不放进下拉:
两个按钮直接点,一下切换,选中态高亮;非聚合通道不显示(点了也没有意义)。
现场要在「按成员平均」「按成员总和」「按队内最低分」之间临时定口径,所以不放进下拉:
三个按钮直接点,一下切换,选中态高亮;非聚合通道不显示(点了也没有意义)。
-->
<div v-if="row.basis === 'aggregate'" class="agg-mode" role="group" aria-label="聚合口径">
<button
@@ -403,8 +424,8 @@ async function save(): Promise<void> {
margin-bottom: 10px;
}
/*
* 聚合口径的快速切换:做成按钮组而不是下拉,现场一眼看到当前是「平均」还是「总和」,
* 点目标按钮即选中(高亮),配合「保存全部」生效。
* 聚合口径的快速切换:做成按钮组而不是下拉,现场一眼看到当前是「平均」「总和」还是「队内最低分」,
* 点目标按钮即选中(高亮),配合「保存全部」生效。三个按钮横排,内边距收窄一点免得把表格撑开。
*/
.agg-mode {
display: inline-flex;
@@ -413,7 +434,7 @@ async function save(): Promise<void> {
overflow: hidden;
}
.agg-mode-btn {
padding: 4px 10px;
padding: 4px 8px;
font-size: 12px;
line-height: 1.4;
color: var(--mx-t2);
@@ -9,6 +9,7 @@ import {
normalizeCountToTeamMedals,
} from '@/shared'
import type {
AggregateMode,
AvgMode,
CompareMode,
Event,
@@ -114,6 +115,16 @@ const awardSummary = computed(() => {
return `前 ${Math.max(...ranks)} 名`
})
/**
* 小项列表里聚合通道的「名次来源」称谓:与「计分通道」页的聚合口径按钮同一套说法,
* 免得同一条通道在两张表里叫两个名字。worst 写「按队内最低分」,而不是背后的英文取值。
*/
const AGGREGATE_BASIS_LABELS: Record<AggregateMode, string> = {
avg: '按队内平均',
sum: '按队内总和',
worst: '按队内最低分',
}
const channelSummary = computed(() =>
selectedChannels.value.map((channel) => {
const profile = data.profileById(channel.profileId)
@@ -125,7 +136,7 @@ const channelSummary = computed(() =>
? '按成绩排名'
: channel.basis === 'manual'
? '手动名次'
: `按队内${normalizeAggregateMode(channel.aggregateMode) === 'sum' ? '总和' : '平均'}`,
: AGGREGATE_BASIS_LABELS[normalizeAggregateMode(channel.aggregateMode)],
profile: profile?.name ?? `模板 #${channel.profileId}`,
award: profile?.awardRanks ?? null,
countToTeam: normalizeCountToTeam(channel.countToTeam),
@@ -22,11 +22,16 @@ test('聚合口径缺字段 / 认不出的取值一律按 avg(老数据的口
assert.equal(DEFAULT_AGGREGATE_MODE, 'avg')
assert.equal(normalizeAggregateMode('sum'), 'sum')
assert.equal(normalizeAggregateMode('avg'), 'avg')
assert.equal(normalizeAggregateMode('worst'), 'worst')
// 老服务端不下发该字段(缺省值不下发),旧缓存里的脏值同样按缺省处理。
assert.equal(normalizeAggregateMode(undefined), 'avg')
assert.equal(normalizeAggregateMode(null), 'avg')
assert.equal(normalizeAggregateMode('SUM'), 'avg')
assert.equal(normalizeAggregateMode('bogus'), 'avg')
// 「取最小 / 取最大」不是取值:方向由小项的 compare 决定,写成 min / max 必须落回缺省,
// 而不是被当成某个方向(那会让一半的项目算反,且页面上看不出来)。
assert.equal(normalizeAggregateMode('min'), 'avg')
assert.equal(normalizeAggregateMode('max'), 'avg')
})
test('新增通道行默认按成员平均(avg),要用总和时现场切', () => {
@@ -42,9 +47,12 @@ test('通道编辑器读写聚合口径:存库值回填、保存时随行回
})
test('聚合口径是「一点即切」的显眼控件,且只在聚合通道上出现', () => {
// 两个口径做成按钮组(不是下拉),现场一眼看到当前口径、一下点过去。
// 三个口径做成按钮组(不是下拉),现场一眼看到当前口径、一下点过去。
assert.match(CHANNELS_SOURCE, /\{ value: 'avg', label: '平均', hint: /)
assert.match(CHANNELS_SOURCE, /\{ value: 'sum', label: '总和', hint: /)
// 第三个口径的选项写成多行(提示文案长),两段分开断言,别把格式绑死在一条正则上。
assert.match(CHANNELS_SOURCE, /value: 'worst'/)
assert.match(CHANNELS_SOURCE, /label: '队内最低分'/)
assert.match(CHANNELS_SOURCE, /class="agg-mode-btn"/)
assert.match(CHANNELS_SOURCE, /:class="\{ 'is-active': row\.aggregateMode === option\.value \}"/)
assert.match(CHANNELS_SOURCE, /@click="row\.aggregateMode = option\.value"/)
@@ -53,3 +61,18 @@ test('聚合口径是「一点即切」的显眼控件,且只在聚合通道
// 表头要让人找到它。
assert.match(CHANNELS_SOURCE, /<th class="bd-select">聚合口径<\/th>/)
})
test('新增的「队内最低分」在界面上说清方向,不把英文取值甩给操作员', () => {
// 按钮文案是中文口径名,不是 worst;提示里必须两种方向都写出来
// (升序 = 最后一名冲线、降序 = 做得最少的那个),否则操作员会按字面理解成「取最小的那个」。
assert.match(CHANNELS_SOURCE, /label: '队内最低分'/)
assert.match(CHANNELS_SOURCE, /最后一名冲线/)
assert.match(CHANNELS_SOURCE, /做得最少的那个/)
assert.doesNotMatch(CHANNELS_SOURCE, /label: 'worst'/)
// 行内小结按口径给整句话,三个口径都要有(切到 worst 时不能显示平均那句)。
assert.match(CHANNELS_SOURCE, /const AGGREGATE_MODE_SUMMARIES: Record<AggregateMode, string>/)
assert.match(CHANNELS_SOURCE, /worst: '团体成绩 = 该单位成员里最差的那一份/)
assert.match(CHANNELS_SOURCE, /\{\{ AGGREGATE_MODE_SUMMARIES\[row\.aggregateMode\] \}\}/)
// 表格上方那段说明也要提到第三个口径,否则现场只看到按钮不知道它是干什么的。
assert.match(CHANNELS_SOURCE, /平均 \/ 总和 \/ 队内最低分/)
})
@@ -0,0 +1,180 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { Event, ScoreChannel, ScoringProfile } from '@/shared'
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
// 聚合口径的第三个取值 worst(按队内最差 / 最后一名冲线)在录入页预览引擎里的口径。
//
// 预览与服务端 domain/scoring.go 的 scoreByAggregate 必须**同一口径**:下面每一组用例的
// 输入与期望值都与 server/internal/domain/aggregate_worst_test.go 里同款用例逐条对应
// (同一组成绩、同一份积分表 15/10/8),两边算出来的**名次与分**要对得上。
//
// 最容易搞反的一点:worst 取的到底是最大还是最小,**取决于小项的 compare 方向**——
// asc(时间,越小越好)取最大(最后一名冲线);desc(次数,越大越好)取最小(做得最少的那个)。
function makeEvent(overrides: Partial<Event> = {}): Event {
return {
id: 1,
sportId: 1,
code: 'TEAM_PULLUP',
name: '团体引体向上',
eventType: 'team',
resultMode: 'auto',
resultType: 'count',
compare: 'desc',
recordEligible: false,
scored: true,
awardMedals: true,
multiplier: 1,
status: 'pending',
scheduledAt: null,
venue: null,
sortOrder: 1,
remark: null,
avgMode: 'valid',
...overrides,
}
}
function makeProfile(): ScoringProfile {
return {
id: 2,
code: 'team_top6',
name: '团体项目前六名',
tieMode: 'skip',
recordBonusMode: 'max',
rules: [
{ rank: 1, points: 15 },
{ rank: 2, points: 10 },
{ rank: 3, points: 8 },
],
}
}
function makeChannel(aggregateMode: 'avg' | 'sum' | 'worst' | undefined): ScoreChannel {
return {
id: 3,
eventId: 1,
scope: 'team',
basis: 'aggregate',
profileId: 2,
sortOrder: 1,
aggregateMode,
countToTeam: true,
}
}
function makeRow(entryId: number, resultValue: number | null, teamId: number): ScorableRow {
return { entryId, teamId, athleteId: entryId, resultValue, rank: null, status: 'ok', isTie: false }
}
/** 某个单位在这次计分里拿到的团体分(单位总分只来自这条聚合通道),按单位号排序后返回。 */
function teamPoints(
rows: ScorableRow[],
aggregateMode: 'avg' | 'sum' | 'worst' | undefined,
avgMode: Event['avgMode'] = 'valid',
compare: Event['compare'] = 'desc',
): Array<[number, number]> {
const context: ScoreContext = {
event: makeEvent({ avgMode, compare }),
category: null,
channels: [makeChannel(aggregateMode)],
profiles: [makeProfile()],
weight: 1,
}
const points = computeEventScore(rows, context).teamPoints
return [...points.entries()].sort((a, b) => a[0] - b[0])
}
test('worst:asc(时间)取队内**最慢**的那份 —— 最后一名冲线的队伍排前面', () => {
// 100 队 12/15/20、101 队 13/14、102 队 30(分钟):队内最慢 20 / 14 / 30。
const rows = [
makeRow(1, 12, 100),
makeRow(2, 15, 100),
makeRow(3, 20, 100),
makeRow(4, 13, 101),
makeRow(5, 14, 101),
makeRow(6, 30, 102),
]
// 越小越好:101(14)第 1、100(20)第 2、102(30)第 3 —— 与 domain 包同名用例逐条一致。
assert.deepEqual(teamPoints(rows, 'worst', 'valid', 'asc'), [
[100, 10],
[101, 15],
[102, 8],
])
})
test('worst:asc 下取最慢 —— 名次与 avg 不同(防止把 worst 当成 avg 的别名)', () => {
// 100 队 10/30、101 队 20/21:队内最慢 30 / 21(101 更慢但更稳);平均 20 / 20.5。
const rows = [makeRow(1, 10, 100), makeRow(2, 30, 100), makeRow(3, 20, 101), makeRow(4, 21, 101)]
// 按队内最慢:101(21)第 1、100(30)第 2。
assert.deepEqual(teamPoints(rows, 'worst', 'valid', 'asc'), [
[100, 10],
[101, 15],
])
// 按平均:100(20)第 1、101(20.5)第 2 —— 正好互换。
assert.deepEqual(teamPoints(rows, 'avg', 'valid', 'asc'), [
[100, 15],
[101, 10],
])
})
test('worst:desc(次数)取队内**最少**的那份 —— 与 avg 的名次正好互换', () => {
// 100 队 20 + 1 个(平均 10.5、队内最少 1),101 队 5 + 6 个(平均 5.5、队内最少 5)。
const rows = [makeRow(1, 20, 100), makeRow(2, 1, 100), makeRow(3, 5, 101), makeRow(4, 6, 101)]
// 越大越好:按队内最少算,101(5)第 1、100(1)第 2。
assert.deepEqual(teamPoints(rows, 'worst', 'valid', 'desc'), [
[100, 10],
[101, 15],
])
// 平均口径下 100(10.5)第 1 —— 与 worst 相反,说明方向真的按 compare 取了。
assert.deepEqual(teamPoints(rows, 'avg', 'valid', 'desc'), [
[100, 15],
[101, 10],
])
})
test('worst:队内最差相同的两队并列,并列后顺延(与既有并列口径一致)', () => {
const rows = [makeRow(1, 10, 100), makeRow(2, 20, 100), makeRow(3, 20, 101), makeRow(4, 25, 102)]
assert.deepEqual(teamPoints(rows, 'worst', 'valid', 'asc'), [
[100, 15],
[101, 15],
[102, 8],
])
})
test('worst:一个有效成绩都没有的单位不参与排名(与 avg 的 valid 口径同一条规则)', () => {
const rows = [makeRow(1, 12, 100), makeRow(2, 20, 100), makeRow(3, null, 101), makeRow(4, null, 101)]
// asc 下 101 若按 0 参与会白拿第 1 名(0 是最快的),所以 valid 与 all 都不参与。
for (const avgMode of ['valid', 'all'] as const) {
assert.deepEqual(teamPoints(rows, 'worst', avgMode, 'asc'), [[100, 15]])
}
// 对照:avg 的 all 口径下零成绩单位按 0 参与(既有口径,不是 worst 的)。
assert.deepEqual(teamPoints(rows, 'avg', 'all', 'asc'), [
[100, 10],
[101, 15],
])
})
test('worst:弃权 / 失格的人的成绩不算数(队内最差只在实际出赛的人里取)', () => {
// 100 队:5、6 个,另有一人弃赛(那一格写了 0)。101 队:4、9 个。
const scratched: ScorableRow = { ...makeRow(3, 0, 100), status: 'withdrawn' }
const rows = [makeRow(1, 5, 100), makeRow(2, 6, 100), scratched, makeRow(4, 4, 101), makeRow(5, 9, 101)]
// 越大越好:100 队队内最少 5 > 101 队队内最少 4 —— 100 第 1、101 第 2。
assert.deepEqual(teamPoints(rows, 'worst', 'valid', 'desc'), [
[100, 15],
[101, 10],
])
// 两处判别力:① 把弃赛那条的 0 算进来,100 队就垫底(0 是最少的);② 按平均算,101(6.5)反而第一。
assert.deepEqual(teamPoints(rows, 'avg', 'valid', 'desc'), [
[100, 10],
[101, 15],
])
})
test('worst:缺省仍是 avg(老数据 / 老服务端不下发该字段时口径一字不变)', () => {
const rows = [makeRow(1, 20, 100), makeRow(2, 1, 100), makeRow(3, 5, 101), makeRow(4, 6, 101)]
assert.deepEqual(teamPoints(rows, undefined), teamPoints(rows, 'avg'))
// 与 worst 明显不同,证明确实落在 avg 一侧,而不是「怎么算都行」。
assert.notDeepEqual(teamPoints(rows, undefined, 'valid', 'desc'), teamPoints(rows, 'worst', 'valid', 'desc'))
})
+1 -1
View File
@@ -9,7 +9,7 @@ import type { AggregateMode, AvgMode, Event, EventInput, ScoreChannel } from './
export const AVG_MODES: readonly AvgMode[] = ['valid', 'all']
export const DEFAULT_AVG_MODE: AvgMode = 'valid'
export const AGGREGATE_MODES: readonly AggregateMode[] = ['avg', 'sum']
export const AGGREGATE_MODES: readonly AggregateMode[] = ['avg', 'sum', 'worst']
/** 聚合通道默认按成员平均汇总;缺字段的老数据 / 老服务端都按它处理。 */
export const DEFAULT_AGGREGATE_MODE: AggregateMode = 'avg'
/** 通道默认计入单位总分;关掉后该通道的分只记在个人头上。 */
+2 -2
View File
@@ -229,8 +229,8 @@ function normalizeChannels(raw: unknown): RuleChannel[] {
// 保留「未配置」与「显式开启」的区别,导出页才能如实显示规则包原文口径。
if (typeof rec.countToMedals === 'boolean') channel.countToMedals = rec.countToMedals
if (typeof rec.countToTeamMedals === 'boolean') channel.countToTeamMedals = rec.countToTeamMedals
// 汇总口径同样是三态:只在规则包显式写了 avg / sum 时落到对象上(缺省 = avg,不写)。
if (rec.aggregateMode === 'avg' || rec.aggregateMode === 'sum') {
// 汇总口径同样是三态:只在规则包显式写了 avg / sum / worst 时落到对象上(缺省 = avg,不写)。
if (rec.aggregateMode === 'avg' || rec.aggregateMode === 'sum' || rec.aggregateMode === 'worst') {
channel.aggregateMode = normalizeAggregateMode(rec.aggregateMode)
}
out.push(channel)
+27
View File
@@ -39,3 +39,30 @@ test('规则包模板仍接受原有三种类型', () => {
assert.equal(pack.channelTemplates[0]?.match.eventType, eventType)
}
})
// 规则包里显式写的汇总口径要落到通道对象上(缺省 = 不写 = avg,导出页才能如实显示原文口径);
// 认不出的取值不落到对象上,也不会把 avg / sum / worst 之外的东西带进写请求。
test('规则包通道保留 aggregateMode:avg / sum / worst 都认,缺省不写', () => {
const build = (aggregateMode: unknown) =>
normalizeRulePack({
format: 'scoreboard-rules',
formatVersion: 1,
meta: { code: 'demo', name: '示例规则' },
profiles: [{ code: 'p', name: '模板', awardRanks: 1, tieMode: 'skip', rules: [{ rank: 1, points: 1 }] }],
channelTemplates: [
{ match: { eventType: 'team' }, channels: [{ scope: 'team', basis: 'aggregate', profile: 'p', aggregateMode }] },
],
})
for (const mode of ['avg', 'sum', 'worst'] as const) {
const pack = build(mode)
assert.ok(pack !== null)
assert.equal(pack.channelTemplates[0]?.channels[0]?.aggregateMode, mode)
}
// 缺省(不写)与认不出的取值都不落到对象上:既不凭空写缺省值,也不把 min / max 当取值带过去。
for (const missing of [undefined, '', 'min', 'max', 'bogus']) {
const pack = build(missing)
assert.ok(pack !== null)
assert.equal(pack.channelTemplates[0]?.channels[0]?.aggregateMode, undefined)
}
})
+11 -4
View File
@@ -85,10 +85,17 @@ export type AvgMode = 'valid' | 'all'
/**
* 聚合通道(basis=aggregate)的汇总口径:
* `avg` 按主体成员成绩的平均值排名(缺省);`sum` 按成员成绩的总和排名。
* 与 avgMode 是两个维度:`sum` 下 avgMode 只决定「一个有效成绩都没有的单位算不算 0 分参与排名」。
* `avg` 按主体成员成绩的平均值排名(缺省);`sum` 按成员成绩的总和排名;
* `worst` 按成员成绩里**最差的一份**排名(队内最差 / 最后一名冲线)。
*
* `worst` 刻意不叫 `min` / `max`:取最小还是取最大**由小项的 compare 决定**
* (asc 越小越好 → 取最大,即最后一名冲线;desc 越大越好 → 取最小,即做得最少的那个),
* 写死成任何一个方向都会让一半的项目算反(见 admin/api/scoring.ts 的 worseThan)。
*
* 与 avgMode 是两个维度:`sum` 下 avgMode 只决定「一个有效成绩都没有的单位算不算 0 分参与排名」;
* `worst` 没有分母,一个有效成绩都没有的单位一律不参与排名(与 avg 的 valid 同规则)。
*/
export type AggregateMode = 'avg' | 'sum'
export type AggregateMode = 'avg' | 'sum' | 'worst'
export interface ScoreChannel {
id: number
@@ -98,7 +105,7 @@ export interface ScoreChannel {
profileId: number
sortOrder: number
/**
* basis=aggregate 通道的汇总口径:`avg` 按成员平均 / `sum` 按成员总和。
* basis=aggregate 通道的汇总口径:`avg` 按成员平均 / `sum` 按成员总和 / `worst` 按队内最差。
* 缺字段 = avg:老服务端不下发它(缺省值不下发),归一化见 normalizeAggregateMode。
*/
aggregateMode?: AggregateMode