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

+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)