## 需求(主人原话)
> 记分通道团体名次的名次来源缺一个模式,添加一个「按队内最低分」。
> 有一个特殊项目是取团队的最后一人的成绩(最后一名冲线)来排名的。
## 口径(这里最容易写反,所以定死)
新增 `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` 两张)。
338 lines
13 KiB
Go
338 lines
13 KiB
Go
package rules
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||
)
|
||
|
||
// 哨兵错误:调用方据此区分「格式不对」「版本不兼容」「内容非法」。
|
||
var (
|
||
ErrNotRulePack = errors.New("rules: 不是积分规则包")
|
||
ErrIncompatibleVersion = errors.New("rules: 规则包格式版本不兼容")
|
||
ErrValidation = errors.New("rules: 规则包校验失败")
|
||
ErrProfileInUse = errors.New("rules: 积分模板仍被引用,不能删除")
|
||
ErrNilChangeSet = errors.New("rules: 变更集或目标规则包为空,无法导入")
|
||
)
|
||
|
||
// Issue 是一条校验问题;Path 用点号路径定位到 YAML 中的位置。
|
||
type Issue struct {
|
||
Path string `json:"path"`
|
||
Msg string `json:"msg"`
|
||
}
|
||
|
||
// ValidationError 汇总一次校验中的全部问题,避免「改一个报一个」。
|
||
type ValidationError struct {
|
||
Issues []Issue `json:"issues"`
|
||
}
|
||
|
||
func (e *ValidationError) Error() string {
|
||
if len(e.Issues) == 0 {
|
||
return ErrValidation.Error()
|
||
}
|
||
parts := make([]string, 0, len(e.Issues))
|
||
for _, is := range e.Issues {
|
||
parts = append(parts, is.Path+": "+is.Msg)
|
||
}
|
||
return ErrValidation.Error() + ": " + strings.Join(parts, "; ")
|
||
}
|
||
|
||
// Unwrap 让 errors.Is(err, ErrValidation) 成立。
|
||
func (e *ValidationError) Unwrap() error { return ErrValidation }
|
||
|
||
type issueList struct {
|
||
issues []Issue
|
||
}
|
||
|
||
func (l *issueList) addf(path, format string, args ...any) {
|
||
l.issues = append(l.issues, Issue{Path: path, Msg: fmt.Sprintf(format, args...)})
|
||
}
|
||
|
||
// Validate 校验规则包。格式 / 版本不符立即返回哨兵错误,其余问题一次性汇总。
|
||
//
|
||
// 它服务的**不只是**导入:导出(Marshal → Validate)走的是同一条路,而导出正是现场备份与排障的
|
||
// 入口(GET /api/rules/export、赛事项目包里的 rules.yaml)。因此这里只放「这份文件是不是一份
|
||
// 能读懂的规则包」,不放「这份配置能不能算分」——库里修好之前存下的历史配置(awardRanks 与
|
||
// 分值表对不上的模板)如果让导不出来,等于把备份入口一并打掉(同类伤疤见
|
||
// httpapi 的 TestUpdateProfileCodeRequiredKeepsRulesExportUsable)。
|
||
// 写入前才该拦的检查放在 ValidateForImport。
|
||
func (p *RulePack) Validate() error {
|
||
if p.Format != FormatName {
|
||
return fmt.Errorf("%w: format=%q,期望 %q", ErrNotRulePack, p.Format, FormatName)
|
||
}
|
||
if p.FormatVersion != FormatVersion {
|
||
return fmt.Errorf("%w: formatVersion=%d,本程序只支持 %d,不做隐式转换",
|
||
ErrIncompatibleVersion, p.FormatVersion, FormatVersion)
|
||
}
|
||
|
||
var l issueList
|
||
if strings.TrimSpace(p.Meta.Code) == "" {
|
||
l.addf("meta.code", "不能为空")
|
||
}
|
||
if strings.TrimSpace(p.Meta.Name) == "" {
|
||
l.addf("meta.name", "不能为空")
|
||
}
|
||
|
||
if p.Standings.IndividualToTeamWeight < 0 {
|
||
l.addf("standings.individualToTeamWeight", "不能为负,当前 %v", p.Standings.IndividualToTeamWeight)
|
||
}
|
||
validateTieBreak(&l, p.Standings.TieBreak)
|
||
|
||
profiles := make(map[string]bool, len(p.Profiles))
|
||
if len(p.Profiles) == 0 {
|
||
l.addf("profiles", "至少要有一个积分模板")
|
||
}
|
||
for i, prof := range p.Profiles {
|
||
path := fmt.Sprintf("profiles[%d]", i)
|
||
validateProfile(&l, path, prof, profiles)
|
||
}
|
||
|
||
validateRecordBonus(&l, p.RecordBonus)
|
||
validateTemplates(&l, p.ChannelTemplates, profiles)
|
||
validateOverrides(&l, p.EventOverrides, profiles)
|
||
|
||
if len(l.issues) > 0 {
|
||
return &ValidationError{Issues: l.issues}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ValidateForImport 是导入侧在 Validate 之外额外拦下的「这份配置根本算不出分」检查。
|
||
//
|
||
// 两条:分值表的档位超出录取档位数(awardRanks,见 AwardRanksOverflow),
|
||
// 以及小项名里出现分工作表模板的分隔符 @(见 eventOverrides[].name)。
|
||
// 单独成一个方法而不是塞进 Validate,是因为 Validate 同时被导出路径用着——历史库里已经存在的
|
||
// 越档模板必须照样能导出(否则备份与规则导出会 500),但绝不能再从导入这条路进来;
|
||
// 名字里带 @ 的历史规则包同理:还能导出、还能留在库里,但不许再导进来。
|
||
func (p *RulePack) ValidateForImport() error {
|
||
var l issueList
|
||
for i, prof := range p.Profiles {
|
||
maxRank := 0
|
||
for _, r := range prof.Rules {
|
||
if r.Rank > maxRank {
|
||
maxRank = r.Rank
|
||
}
|
||
}
|
||
if msg := AwardRanksOverflow(prof.AwardRanks, maxRank); msg != "" {
|
||
// 走 %s 而不是把 msg 当格式串:文案以后加个百分号就会把消息打坏。
|
||
l.addf(fmt.Sprintf("profiles[%d].awardRanks", i), "%s", msg)
|
||
}
|
||
}
|
||
// 规则包里的小项名(eventOverrides[].name)会随规则包一起被现场复用到别的赛事,
|
||
// 而它正是「小项名称@组别名称」的一半:名字里带 @,那个赛事的分工作表模板就生成不出来。
|
||
for i, ov := range p.EventOverrides {
|
||
if strings.IndexAny(ov.Name, "@@") < 0 {
|
||
continue
|
||
}
|
||
l.addf(fmt.Sprintf("eventOverrides[%d].name", i), "%s", ruleNameSeparatorMessage(ov.Name))
|
||
}
|
||
if len(l.issues) > 0 {
|
||
return &ValidationError{Issues: l.issues}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ruleNameSeparatorMessage 与 httpapi 的 atSignNameMessage 是同一件事的同一句话
|
||
// (规则包属于 rules 包、接口层属于 httpapi 包,两边不互相 import,只能各写一份)。
|
||
// 改这里必须同步改 server/internal/httpapi/entries_sheet_template.go 的那份。
|
||
func ruleNameSeparatorMessage(name string) string {
|
||
return fmt.Sprintf("小项名称「%s」里不能有 @(也不能有全角 @):@ 是分工作表报名模板里"+
|
||
"「小项名称@组别名称」的分隔符,写进名字会让工作表名拆不开、模板生成不出来。"+
|
||
"请把规则包里这个名字改成与现场一致、且不含 @ 的写法", name)
|
||
}
|
||
|
||
func validateTieBreak(l *issueList, tieBreak []string) {
|
||
seen := make(map[string]bool, len(tieBreak))
|
||
for i, key := range tieBreak {
|
||
path := fmt.Sprintf("standings.tieBreak[%d]", i)
|
||
if !knownTieBreakKeys[key] {
|
||
l.addf(path, "未知的排序键 %q", key)
|
||
continue
|
||
}
|
||
if seen[key] {
|
||
l.addf(path, "排序键 %q 重复", key)
|
||
}
|
||
seen[key] = true
|
||
}
|
||
}
|
||
|
||
func validateProfile(l *issueList, path string, prof Profile, seen map[string]bool) {
|
||
if strings.TrimSpace(prof.Code) == "" {
|
||
l.addf(path+".code", "不能为空")
|
||
} else if seen[prof.Code] {
|
||
l.addf(path+".code", "模板编码 %q 重复", prof.Code)
|
||
} else {
|
||
seen[prof.Code] = true
|
||
}
|
||
if strings.TrimSpace(prof.Name) == "" {
|
||
l.addf(path+".name", "不能为空")
|
||
}
|
||
if prof.AwardRanks < 0 {
|
||
l.addf(path+".awardRanks", "不能为负,当前 %d", prof.AwardRanks)
|
||
}
|
||
if prof.TieMode != domain.TieSkip && prof.TieMode != domain.TieAverage && prof.TieMode != domain.TieHigher {
|
||
l.addf(path+".tieMode", "只能是 skip / average / higher,当前 %q", prof.TieMode)
|
||
}
|
||
if prof.RecordBonusMode != "" && prof.RecordBonusMode != domain.RecordBonusMax && prof.RecordBonusMode != domain.RecordBonusSum {
|
||
l.addf(path+".recordBonusMode", "只能是 max / sum,当前 %q", prof.RecordBonusMode)
|
||
}
|
||
if prof.AwardRanks > 0 && len(prof.Rules) == 0 {
|
||
l.addf(path+".rules", "awardRanks=%d 却没有分值明细", prof.AwardRanks)
|
||
}
|
||
ranks := make(map[int]bool, len(prof.Rules))
|
||
for j, r := range prof.Rules {
|
||
rpath := fmt.Sprintf("%s.rules[%d]", path, j)
|
||
if r.Rank < 1 {
|
||
l.addf(rpath+".rank", "名次必须 >= 1,当前 %d", r.Rank)
|
||
} else if ranks[r.Rank] {
|
||
l.addf(rpath+".rank", "名次 %d 重复", r.Rank)
|
||
}
|
||
ranks[r.Rank] = true
|
||
if r.Points < 0 {
|
||
l.addf(rpath+".points", "分值不能为负,当前 %v", r.Points)
|
||
}
|
||
}
|
||
}
|
||
|
||
// AwardRanksOverflow 在「分值表的档位超出录取档位数」时返回一句人话说明,没问题时返回空串。
|
||
//
|
||
// 为什么要拦:算分引擎只在 award_ranks > 0 时按名次截断(domain/scoring.go 的 basePoints),
|
||
// 于是「分值表配到第 10 档、录取名次还留着 6」这种配置能安静落库——第 7~10 档在模板页上看着有分,
|
||
// 算出来恒为 0,榜上没有任何异常提示(审查 01 P1)。
|
||
// 模板保存(httpapi/repo.go 的 validateProfileAwardCoverage)与规则包导入(ValidateForImport)
|
||
// 共用这一句说明:同一份配置在两条路上必须得到同样的说法,现场才能照着改。
|
||
//
|
||
// awardRanks <= 0(0 表示不按名次封顶)是既有语义,一律放行,并在文案里把这个出口告诉用户。
|
||
func AwardRanksOverflow(awardRanks, maxRuleRank int) string {
|
||
if awardRanks <= 0 || maxRuleRank <= awardRanks {
|
||
return ""
|
||
}
|
||
return fmt.Sprintf("你配了第 %d 档,但录取名次(awardRanks)只到第 %d 档,第 %d 档及以后不会计分:"+
|
||
"要么把录取名次改成 %d 或 0(0 表示不按名次封顶),要么删掉第 %d 档之后的档位",
|
||
maxRuleRank, awardRanks, awardRanks+1, maxRuleRank, awardRanks)
|
||
}
|
||
|
||
func validateRecordBonus(l *issueList, rb RecordBonus) {
|
||
if rb.Mode != "" && rb.Mode != domain.RecordBonusMax && rb.Mode != domain.RecordBonusSum {
|
||
l.addf("recordBonus.mode", "只能是 max / sum,当前 %q", rb.Mode)
|
||
}
|
||
if rb.Enabled && len(rb.Levels) == 0 {
|
||
l.addf("recordBonus.levels", "启用破纪录加分却没有配置档位")
|
||
}
|
||
seen := make(map[string]bool, len(rb.Levels))
|
||
for i, lv := range rb.Levels {
|
||
path := fmt.Sprintf("recordBonus.levels[%d]", i)
|
||
if strings.TrimSpace(lv.Code) == "" {
|
||
l.addf(path+".code", "不能为空")
|
||
} else if seen[lv.Code] {
|
||
l.addf(path+".code", "档位编码 %q 重复", lv.Code)
|
||
} else {
|
||
seen[lv.Code] = true
|
||
}
|
||
if strings.TrimSpace(lv.Name) == "" {
|
||
l.addf(path+".name", "不能为空")
|
||
}
|
||
if lv.Points < 0 {
|
||
l.addf(path+".points", "分值不能为负,当前 %v", lv.Points)
|
||
}
|
||
}
|
||
}
|
||
|
||
func validateTemplates(l *issueList, templates []ChannelTemplate, profiles map[string]bool) {
|
||
seen := make(map[string]bool, len(templates))
|
||
for i, t := range templates {
|
||
path := fmt.Sprintf("channelTemplates[%d]", i)
|
||
key := matchKey(t.Match)
|
||
if key == "" {
|
||
l.addf(path+".match", "至少要指定 eventType / resultType / sportCode 之一")
|
||
} else if seen[key] {
|
||
l.addf(path+".match", "匹配条件 %s 重复", key)
|
||
}
|
||
seen[key] = true
|
||
if len(t.Channels) == 0 {
|
||
l.addf(path+".channels", "模板至少要有一个计分通道")
|
||
}
|
||
validateChannels(l, path+".channels", t.Channels, profiles)
|
||
}
|
||
}
|
||
|
||
func validateOverrides(l *issueList, overrides []EventOverride, profiles map[string]bool) {
|
||
seen := make(map[string]bool, len(overrides))
|
||
for i, o := range overrides {
|
||
path := fmt.Sprintf("eventOverrides[%d]", i)
|
||
if strings.TrimSpace(o.EventCode) == "" {
|
||
l.addf(path+".eventCode", "不能为空")
|
||
} else if seen[o.EventCode] {
|
||
l.addf(path+".eventCode", "项目编码 %q 重复", o.EventCode)
|
||
} else {
|
||
seen[o.EventCode] = true
|
||
}
|
||
if o.Multiplier != nil && *o.Multiplier < 0 {
|
||
l.addf(path+".multiplier", "不能为负,当前 %v", *o.Multiplier)
|
||
}
|
||
if o.Channels != nil {
|
||
validateChannels(l, path+".channels", *o.Channels, profiles)
|
||
}
|
||
}
|
||
}
|
||
|
||
func validateChannels(l *issueList, path string, channels []Channel, profiles map[string]bool) {
|
||
seen := make(map[string]bool, len(channels))
|
||
for i, ch := range channels {
|
||
cpath := fmt.Sprintf("%s[%d]", path, i)
|
||
if ch.Scope != domain.ScopeAthlete && ch.Scope != domain.ScopeTeam {
|
||
l.addf(cpath+".scope", "只能是 athlete / team,当前 %q", ch.Scope)
|
||
}
|
||
switch ch.Basis {
|
||
case domain.BasisResult, domain.BasisManual, domain.BasisAggregate:
|
||
default:
|
||
l.addf(cpath+".basis", "只能是 result / manual / aggregate,当前 %q", ch.Basis)
|
||
}
|
||
if strings.TrimSpace(ch.Profile) == "" {
|
||
l.addf(cpath+".profile", "不能为空")
|
||
} else if !profiles[ch.Profile] {
|
||
l.addf(cpath+".profile", "引用了不存在的积分模板 %q", ch.Profile)
|
||
}
|
||
// 汇总口径只有 avg / sum / worst 三个取值;不写 = avg(缺省),拼错的取值必须报错而不是被当成 avg。
|
||
if 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)
|
||
if seen[key] {
|
||
l.addf(cpath, "同一组配置里出现重复通道 %s", key)
|
||
}
|
||
seen[key] = true
|
||
}
|
||
}
|
||
|
||
func matchKey(m ChannelMatch) string {
|
||
var parts []string
|
||
if m.EventType != "" {
|
||
parts = append(parts, "eventType="+string(m.EventType))
|
||
}
|
||
if m.ResultType != "" {
|
||
parts = append(parts, "resultType="+string(m.ResultType))
|
||
}
|
||
if m.SportCode != "" {
|
||
parts = append(parts, "sportCode="+m.SportCode)
|
||
}
|
||
return strings.Join(parts, ",")
|
||
}
|
||
|
||
func matchEvent(m ChannelMatch, e EventInfo) bool {
|
||
if m.EventType != "" && m.EventType != e.EventType {
|
||
return false
|
||
}
|
||
if m.ResultType != "" && m.ResultType != e.ResultType {
|
||
return false
|
||
}
|
||
if m.SportCode != "" && m.SportCode != e.SportCode {
|
||
return false
|
||
}
|
||
return true
|
||
}
|