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 }