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
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user