Files
Scoreboard/server/internal/rules/validate.go
T
AskaEth bc8b1ee6c0 feat: M3-M7 全模块落地 + 集成接线
- M3 认证/权限/审计/限流 + 全部 REST 接口
- M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制)
- M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配)
- M6 WebSocket 实时同步中心
- M7 xlsx 导出 + 赛事包 zip + 备份
- 规则包 YAML 导入导出(差异预览)
- 字体 vendor 子集化(4 套 x2 字重)
- 集成:realtime/export/rules 接入 main+httpapi;backup 子命令
- 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
2026-09-09 19:02:24 +08:00

273 lines
8.7 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 校验规则包。格式 / 版本不符立即返回哨兵错误,其余问题一次性汇总。
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)
}
switch ch.Aggregate {
case "":
case domain.AggregateSum, domain.AggregateAvg, domain.AggregateMax, domain.AggregateMin, domain.AggregateCount:
if ch.Basis != domain.BasisAggregate {
l.addf(cpath+".aggregate", "只有 basis=aggregate 的通道才配置 aggregate")
}
default:
l.addf(cpath+".aggregate", "未知的汇总口径 %q", ch.Aggregate)
}
// 落库有 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
}