三件事合成一次提交(其中两件都落在 handlers_config.go,无法按文件拆开): 一、分工作表报名模板(新增能力) - 新增 GET /api/entries/template.xlsx:按当前赛事的「小项 × 组别」生成模板, 一个小项组别一张工作表,工作表名 = <小项>@<组别>(31 字符上限、重名自动去重)。 - 导入解析器认工作表名还原小项 + 组别;若与表内列冲突则报错,不静默取其一。 - 小项名与组别名禁用 @,基础数据界面 / 规则包导入 / 服务端校验三处一并拦截。 - 单表 CSV/XLSX 导入路径不回归。 二、号码布判重(修误判) - 旧逻辑依赖证件号本身,而证件号是可选字段 → 同一个人报多个项目会被整块拦下。 - 改为:号码布相同且证件号相同 → 判定为同一人报多项,放行; 号码布相同且证件号都能填且不同 → 仍报错(真抢号);一方证件号为空 → 放行并给提示。 - 团体行的号码布一并纳入查重。 三、自动备份的关停与落盘(修数据安全隐患) - export.Service 增加 Wait():StartAuto 的协程进 WaitGroup,关停时 join(10 秒上限, 超时只记 WARN 不拖死关闭),不再在备份写到一半时退出。 - vacuumInto 改为先写 <最终名>.tmp-<随机> 再 rename,失败则删临时文件; Backup / SafetySnapshot / ExportProjectZip 三个调用方一并受益。 - 新增 isFinalBackupName,ListBackups / safetyCopies / KindOfBackupName 统一过滤中间文件, 避免半截 .db 被列进备份列表、进而被「一键恢复」选中。 文档:docs/ 五份(使用手册 / 报名名单导入规范 / 升级说明 / 部署指南 / 上线检查清单)同步。
338 lines
13 KiB
Go
338 lines
13 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 校验规则包。格式 / 版本不符立即返回哨兵错误,其余问题一次性汇总。
|
||
//
|
||
// 它服务的**不只是**导入:导出(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 两个取值;不写 = avg(缺省),拼错的取值必须报错而不是被当成 avg。
|
||
if ch.AggregateMode != "" &&
|
||
ch.AggregateMode != string(domain.AggregateAvg) && ch.AggregateMode != string(domain.AggregateSum) {
|
||
l.addf(cpath+".aggregateMode", "只能是 avg / sum,当前 %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
|
||
}
|