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 校验规则包。格式 / 版本不符立即返回哨兵错误,其余问题一次性汇总。 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 } 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) } } } 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) } // 落库有 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 }