Files
Scoreboard/server/internal/rules/validate.go
T
AskaEth 7f9701c465 fix: 审查修复波次(P0x4 + P1x30+)
- domain/rules: 计分与排名解耦、破纪录按组别、tieBreak 生效、调整分不丢、移除 aggregate 假能力
- realtime: 新建 internal/dto,快照三项契约对齐,/ws 只认会话身份
- httpapi: 流水幂等、standings 整表载荷、广播接线、强制改密、XFF 防伪造、records 写入、审计、赛事包内容、发布前备份、result.points、实例列表
- web: FormData 上传、YAML 解析、快照归一化、大屏解析层、bootstrap 降级、访客判定、主题切换
- console: 优雅关闭握手、中文化、免验证改管理员账号、选屏与日志轮转修复
- docs: 用户文档三份 + README
- 主代理: 破纪录加分接线(levels 未加载导致恒 0)
2026-09-09 21:06:40 +08:00

264 lines
8.3 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)
}
// 落库有 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
}