- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
273 lines
8.7 KiB
Go
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
|
|
}
|