Files
Scoreboard/server/internal/rules/validate.go
T
AskaEth 1b8a37ff8c feat(import): 报名模板按「小项 × 组别」分工作表;号码布判重改认证件号;备份改原子落盘
三件事合成一次提交(其中两件都落在 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/ 五份(使用手册 / 报名名单导入规范 / 升级说明 / 部署指南 / 上线检查清单)同步。
2026-09-14 01:10:16 +08:00

338 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}