feat(0.5.1): 业务逻辑审查 9 条 P0 全修 + 自绘安装器 + 现场反馈四项

## 算分正确性(6 份只读审查 → 收敛成 5 个病根,均先复现失败再修)
- 「人工名次」与「自动名次回写」互斥:原来同一份成绩**发布两次分数不同**
  (团体报名项 dual 通道 10/8/6 → 25/18/14),现在连发多次逐字节一致
- 删小项时在同一事务作废旧流水并推榜:原来分数**永久残留**在总榜、任何重算都打不到
- 规则包导入整体事务化:原来中途失败会清空名次表且补不回(奖牌全丢);
  另加守卫「compare=none 的小项不得配 basis=result/aggregate」
- 末位归零只抹真正落在末位的那一档:原来跨末位的并列组会被整组抹 0
  (7 人三人并列第 5 时第 5、6 名的分也一起没了)
- 模板「分值表档位超出录取名次」现在拒绝保存:原来静默截断成 0 分

## 现场反馈(主人实测报的)
- 已发布成绩可改:行内「修改本条」+ 成绩格/状态格可点(退回草稿→改→重新发布本组),
  与「作废」语义分开;取消路径不产生任何副作用
- 单位积分明细补「运动员 / 来源」列,并显示「已修订 ×N(时间·原因)」:
  管理端、大屏覆盖层、xlsx 备注列三处同源
- 单位奖牌榜改为按奖牌独立排名(金→银→铜→单位编码),与积分榜解耦
- 历史孤儿积分流水清理(迁移 0022,只动 result/record_bonus,人工调整分不受影响)

## 新功能
- 团体项目聚合口径二选一:平均(默认)/ 总和(迁移 0023,通道编辑器可切)

## 安装器
- 自绘五页安装界面:无边框、自绘标题栏、DPI 自适应(4K@200% 实测)、
  安装/卸载逻辑复用 electron-builder 宏不变;默认「为所有用户安装」并自动放行防火墙 19540
- 构建期校验:安装器素材里的版本号必须与 VERSION 一致,否则构建失败

自检:服务端 14 包 ×2 / 前端 vue-tsc + build + 742 例 ×2 / Console 20 文件 + 68 例 ×2,全绿。
端到端对拍:修前 vs 修后,纯团体 / 双计 / 普通个人三形态**逐字节一致**,仅出 bug 的双通道形态按要求改变。
This commit is contained in:
AskaEth committed 2026-09-13 15:20:18 +08:00
1 parent 78b14efaa9
commit 877ff15823
181 files changed
+8747 -371

No files matched your search

@@ -0,0 +1,220 @@
package rules
import (
"database/sql"
"errors"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// 聚合口径(basis=aggregate 的 avg / sum)在规则包这条路上的口径:
// 解析认 avg/sum、缺省即 avg;导入落库补成具体值;导出只写非缺省值(sum),
// 规则包里显式写 avg 与不写等价,不该被当成一次「通道改动」。
//
// 现场背景:团体引体向上这类双计项目的团体通道,领导还没定按平均还是按总和,
// 两种都要能在规则包里表达,且默认仍是升级前的平均值口径。
const aggregateModePackYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo-rules, name: 示例规则包 }
standings: { individualToTeamWeight: 1, tieBreak: [points, gold, silver, bronze, teamCode] }
profiles:
- code: team_top6
name: 团体项目前六名
awardRanks: 2
tieMode: skip
rules:
- { rank: 1, points: 15 }
- { rank: 2, points: 10 }
recordBonus: { enabled: false, mode: max }
manualAdjustment: { enabled: false, reasonRequired: true }
channelTemplates:
- match: { eventType: individual }
channels:
- { scope: athlete, basis: result, profile: team_top6 }
eventOverrides:
- eventCode: EVT_SUM
name: 团体引体向上(总和)
channels:
- { scope: team, basis: aggregate, profile: team_top6, aggregateMode: sum }
- eventCode: EVT_AVG
name: 团体引体向上(平均,显式写缺省值)
channels:
- { scope: team, basis: aggregate, profile: team_top6, aggregateMode: avg }
`
// seedAggregateEvents 建两个可排名的团体小项(compare 必须有方向,否则 aggregate 通道用不了)。
func seedAggregateEvents(t *testing.T, db *sql.DB) {
t.Helper()
sportID := insertID(t, db, `INSERT INTO sports (code, name) VALUES ('demo-sport', '示例大项')`)
for _, ev := range []struct{ code, name string }{
{"EVT_SUM", "团体引体向上(总和)"},
{"EVT_AVG", "团体引体向上(平均,显式写缺省值)"},
} {
if _, err := db.Exec(`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier)
VALUES (?, ?, ?, 'individual_team', 'auto', 'count', 'desc', 1, 1)`, sportID, ev.code, ev.name); err != nil {
t.Fatalf("插入项目 %s: %v", ev.code, err)
}
}
}
// TestParseAggregateMode 守解析:avg / sum 都认,缺省即 avg,拼错的取值必须报错。
func TestParseAggregateMode(t *testing.T) {
pack, err := Parse([]byte(aggregateModePackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
overrides := map[string]Channel{}
for _, ov := range pack.EventOverrides {
overrides[ov.EventCode] = (*ov.Channels)[0]
}
if got := overrides["EVT_SUM"].AggregatesBy(); got != domain.AggregateSum {
t.Errorf("aggregateMode: sum 应解析成 sum,实际 %q", got)
}
// 显式写 avg 与不写等价(缺省值),解析结果都要是 avg。
if got := overrides["EVT_AVG"].AggregatesBy(); got != domain.AggregateAvg {
t.Errorf("aggregateMode: avg 应解析成 avg,实际 %q", got)
}
// 普通通道(basis=result)不写该字段时同样是缺省 avg,不影响任何行为。
tmpl := pack.ChannelTemplates[0].Channels[0]
if tmpl.AggregateMode != "" || tmpl.AggregatesBy() != domain.AggregateAvg {
t.Errorf("缺省通道的汇总口径应为空(等价 avg),实际 %q", tmpl.AggregateMode)
}
// 拼错的取值必须报错:静默按 avg 处理会让现场以为切到了总和。
bad := strings.Replace(aggregateModePackYAML, "aggregateMode: sum", "aggregateMode: biggest", 1)
if _, err := Parse([]byte(bad)); !errors.Is(err, ErrValidation) {
t.Fatalf("非法聚合口径应报校验错误,实际 %v", err)
} else if !strings.Contains(err.Error(), "aggregateMode") {
t.Errorf("报错应点明 aggregateMode 字段,实际 %v", err)
}
}
// TestApplyAndExportAggregateMode 守落库与导出:
// sum 落库并导出回来;显式 avg 落库成 avg、导出时按缺省省略(导出 → 导入不产生多余差异)。
func TestApplyAndExportAggregateMode(t *testing.T) {
db := openEventDB(t)
seedAggregateEvents(t, db)
next, err := Parse([]byte(aggregateModePackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if _, err := Apply(db, &ChangeSet{Next: next}); err != nil {
t.Fatalf("Apply: %v", err)
}
modeOf := func(code string) string {
t.Helper()
var mode string
if err := db.QueryRow(`SELECT c.aggregate_mode FROM event_score_channels c
JOIN events e ON e.id = c.event_id WHERE e.code = ? AND c.basis = 'aggregate'`, code).Scan(&mode); err != nil {
t.Fatalf("读取 %s 的聚合口径: %v", code, err)
}
return mode
}
if got := modeOf("EVT_SUM"); got != string(domain.AggregateSum) {
t.Errorf("EVT_SUM 落库 = %q,期望 sum", got)
}
// 缺省口径也落成具体值 avg,引擎侧不必再判空。
if got := modeOf("EVT_AVG"); got != string(domain.AggregateAvg) {
t.Errorf("EVT_AVG 落库 = %q,期望 avg", got)
}
exported, err := Export(db, Meta{Code: "demo", Name: "导出"})
if err != nil {
t.Fatalf("Export: %v", err)
}
exportedMode := map[string]string{}
for _, ov := range exported.EventOverrides {
exportedMode[ov.EventCode] = (*ov.Channels)[0].AggregateMode
}
// sum 是非缺省口径,必须写进导出文件,否则「导出 → 导入」会把总和口径丢成平均。
if exportedMode["EVT_SUM"] != string(domain.AggregateSum) {
t.Errorf("EVT_SUM 导出 = %q,期望 sum", exportedMode["EVT_SUM"])
}
// avg 是缺省值,导出文件里不出现,往返比对不会凭空多出差异。
if exportedMode["EVT_AVG"] != "" {
t.Errorf("EVT_AVG 导出 = %q,期望省略(avg 是缺省值)", exportedMode["EVT_AVG"])
}
// 导出的包重新导入:库里已有 sum / avg,差异必须为空。
cur, err := Load(db)
if err != nil {
t.Fatalf("Load: %v", err)
}
reparsed, err := Parse(mustMarshal(t, exported))
if err != nil {
t.Fatalf("重新 Parse 导出结果: %v", err)
}
cs, err := Diff(cur, reparsed)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if !cs.Empty() {
t.Fatalf("导出再导入不应有差异: %+v", cs.Channels)
}
}
// TestDiffIgnoresExplicitDefaultAggregateMode 守差异比对:
// 规则包显式写 avg 与库里缺省值等价,不能被当成「通道改动」而触发一次无意义的重算。
func TestDiffIgnoresExplicitDefaultAggregateMode(t *testing.T) {
db := openEventDB(t)
seedAggregateEvents(t, db)
next, err := Parse([]byte(aggregateModePackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if _, err := Apply(db, &ChangeSet{Next: next}); err != nil {
t.Fatalf("Apply: %v", err)
}
cur, err := Load(db)
if err != nil {
t.Fatalf("Load: %v", err)
}
// 库里 EVT_AVG 是 avg(导出时按缺省省略),规则包再显式写一遍 avg:语义相同,不该有差异。
if cs, err := Diff(cur, next); err != nil {
t.Fatalf("Diff: %v", err)
} else if !cs.Empty() {
t.Fatalf("显式 avg 与缺省 avg 等价,不应有差异: %+v", cs.Channels)
}
// 对照:把 EVT_AVG 换成 sum 就必须报出一次通道改动(否则现场改了口径却不重算)。
target := strings.Replace(aggregateModePackYAML,
"{ scope: team, basis: aggregate, profile: team_top6, aggregateMode: avg }",
"{ scope: team, basis: aggregate, profile: team_top6, aggregateMode: sum }", 1)
changed, err := Parse([]byte(target))
if err != nil {
t.Fatalf("Parse 改口径后的规则包: %v", err)
}
cs, err := Diff(cur, changed)
if err != nil {
t.Fatalf("Diff: %v", err)
}
hit := false
for _, ch := range cs.Channels {
if ch.EventCode == "EVT_AVG" {
hit = true
}
}
if !hit {
t.Fatalf("把口径从 avg 改成 sum 必须报出通道改动,实际 %+v", cs.Channels)
}
}
// insertID 执行一条 INSERT 并返回自增 id。
func insertID(t *testing.T, db *sql.DB, query string, args ...any) int64 {
t.Helper()
res, err := db.Exec(query, args...)
if err != nil {
t.Fatalf("执行 SQL 失败: %v\n%s", err, query)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("获取 id: %v", err)
}
return id
}
+38 -17
View File
@@ -17,14 +17,44 @@ type ApplyResult struct {
EventsSkipped []string `json:"eventsSkipped,omitempty"`
}
// Apply 把差异预览落地。规则包语义是「全量覆盖」,因此按 Next 的目标状态同步,
// 而不是逐条套用 diff 列表——这样重复导入同一份规则包是幂等的。
// Apply 把差异预览落地(自带事务)。
//
// 规则包语义是「全量覆盖」,因此按 Next 的目标状态同步,而不是逐条套用 diff 列表——
// 这样重复导入同一份规则包是幂等的。
// 只填了 Next 的变更集(例如确认导入时重新 Parse 上传内容)同样可以调用,等价于强制全量导入。
//
// Apply 不负责重算积分:分值一变,调用方必须全量重算(devdocs 02 §7.3)。
// 需要「换通道」与「重算」同事务时用 ApplyTx(规则包导入走的是那条路,见其注释)。
func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) {
tx, err := db.Begin()
if err != nil {
return nil, fmt.Errorf("开启事务: %w", err)
}
defer func() { _ = tx.Rollback() }()
res, err := ApplyTx(tx, cs)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("提交导入事务: %w", err)
}
return res, nil
}
// ApplyTx 在调用方给定的**未提交**事务里落地规则包变更,自己不提交。
//
// 为什么要有这个入口:换通道会先 `DELETE FROM event_score_channels` 再插新行,而 channel_ranks
// 的 channel_id 挂的是 `ON DELETE CASCADE`——通道一删,该通道的名次就跟着没了。名次由紧随其后的
// 全量重算重建,两者分属不同事务时,只要重算失败(例如某个小项配不出名次),名次表就停在
// 「已经被清空、又补不回来」的中间态:榜单分还在、奖牌全 0,而且自愈与重试导入都会因同一个小项
// 再次失败(审查 04 P0-1,实测 2 行 → 0 行、金牌 1 → 0)。
// 共用一个事务后,重算失败即整包回滚,名次表回到导入前的状态。
//
// 落地顺序:模板 → 通道 → 删除无主模板 → 项目开关 → 赛事设置。
// 通道先改完再删模板,避免「模板被换掉」时误判引用。
// Apply 不负责重算积分:分值一变,调用方必须全量重算(devdocs 02 §7.3)。
func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) {
// 现状快照在**本事务内**读取(loadSnapshot(tx)),保证看到的就是即将写入的这份数据。
func ApplyTx(tx *sql.Tx, cs *ChangeSet) (*ApplyResult, error) {
if cs == nil || cs.Next == nil {
return nil, ErrNilChangeSet
}
@@ -33,7 +63,7 @@ func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) {
return nil, err
}
cur, err := Load(db)
cur, err := loadSnapshot(tx)
if err != nil {
return nil, err
}
@@ -42,12 +72,6 @@ func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) {
return nil, err
}
tx, err := db.Begin()
if err != nil {
return nil, fmt.Errorf("开启事务: %w", err)
}
defer func() { _ = tx.Rollback() }()
res := &ApplyResult{EventsSkipped: unknown}
// 1. 模板全量 upsert,并建立「编码 → id」映射供通道引用。
@@ -85,8 +109,9 @@ func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) {
if !ok {
return nil, fmt.Errorf("项目 %s 的通道引用了不存在的模板 %q", code, ch.Profile)
}
if _, err := tx.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team, count_to_medals, count_to_team_medals)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, eventID, string(ch.Scope), string(ch.Basis), profileID, i,
if _, err := tx.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, aggregate_mode, count_to_team, count_to_medals, count_to_team_medals)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, eventID, string(ch.Scope), string(ch.Basis), profileID, i,
string(ch.AggregatesBy()),
boolToInt(ch.CountsToTeam()), boolToInt(ch.CountsToMedals()), boolToInt(ch.CountsToTeamMedals())); err != nil {
return nil, fmt.Errorf("写入项目 %s 的计分通道: %w", code, err)
}
@@ -149,10 +174,6 @@ func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) {
}
res.SettingsWritten++
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("提交导入事务: %w", err)
}
return res, nil
}
+139
View File
@@ -0,0 +1,139 @@
package rules
import (
"errors"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// 本文件钉住工单 E:分值表的档位超出「录取档位数(awardRanks)」时必须拒收。
//
// 算分引擎只在 award_ranks > 0 时按名次截断(domain/scoring.go 的 basePoints),
// 而本包原先只拒 awardRanks < 0:一份「配到第 7 档、录取名次还留着 6」的规则包能过校验、
// 安静导入——第 7 档在模板页上写着分,榜上恒为 0,界面上看不出任何异常(审查 01 P1)。
// awardRanks: 0 表示不按名次封顶(既有语义),这种包必须继续放行。
// sevenRankPackYAML 是「配到第 7 档」的规则包;录取名次由用例替换成 6 / 0。
const sevenRankPackYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 第七档 }
profiles:
- code: p7
name: 前七名
awardRanks: 6
tieMode: skip
rules:
- { rank: 1, points: 10 }
- { rank: 2, points: 8 }
- { rank: 3, points: 6 }
- { rank: 4, points: 5 }
- { rank: 5, points: 4 }
- { rank: 6, points: 3 }
- { rank: 7, points: 2 }
channelTemplates:
- match: { eventType: individual }
channels:
- { scope: athlete, basis: result, profile: p7 }
`
// 录取名次 6、分值表到第 7 档:导入必须整体拒收,且说明要说清「哪一档超了」「怎么改」。
func TestParseRejectsAwardRanksOverflow(t *testing.T) {
_, err := Parse([]byte(sevenRankPackYAML))
if !errors.Is(err, ErrValidation) {
t.Fatalf("第 7 档 + awardRanks=6 应报 ErrValidation,实际 %v", err)
}
var ve *ValidationError
if !errors.As(err, &ve) {
t.Fatalf("应返回 *ValidationError,实际 %T", err)
}
var found bool
for _, is := range ve.Issues {
if is.Path != "profiles[0].awardRanks" {
continue
}
found = true
// 现场要能照着改:说清超出的档位、当前录取名次,以及「0 表示不封顶」这个出口。
for _, want := range []string{"第 7 档", "第 6 档", "0 表示不按名次封顶"} {
if !strings.Contains(is.Msg, want) {
t.Fatalf("说明里缺少 %q:%s", want, is.Msg)
}
}
}
if !found {
t.Fatalf("问题应挂在 profiles[0].awardRanks 上,实际 %+v", ve.Issues)
}
}
// 这条检查只在**写入**侧生效:导出(Marshal)走的是 Validate,必须继续容忍越档模板。
// 库里修好之前存下的历史配置如果让导不出来,GET /api/rules/export 与赛事项目包(备份)会一起 500,
// 而它们正是现场排障与备份的入口——所以导出侧宁可原样导出,由用户在模板页上改。
func TestMarshalStillAllowsLegacyAwardRanksOverflow(t *testing.T) {
pack := &RulePack{
Format: FormatName,
FormatVersion: FormatVersion,
Meta: Meta{Code: "legacy", Name: "历史越档模板"},
Profiles: []Profile{{
Code: "p7", Name: "前七名", AwardRanks: 6, TieMode: domain.TieSkip,
Rules: []Rule{{1, 10}, {2, 8}, {3, 6}, {4, 5}, {5, 4}, {6, 3}, {7, 2}},
}},
}
if err := pack.Validate(); err != nil {
t.Fatalf("导出侧校验不该拦越档模板,实际 %v", err)
}
if _, err := Marshal(pack); err != nil {
t.Fatalf("越档模板必须还能导出,实际 %v", err)
}
// 但同一份配置从导入进来时就要被拒(Parse → ValidateForImport)。
if _, err := Parse([]byte(sevenRankPackYAML)); !errors.Is(err, ErrValidation) {
t.Fatalf("导入侧应拒收越档模板,实际 %v", err)
}
}
// 两种合法形态不得被误杀:正好相等(第 6 档 + awardRanks 6)、不封顶(第 7 档 + awardRanks 0)。
func TestParseAcceptsAwardRanksCoveringRules(t *testing.T) {
cases := map[string]string{
"正好相等": strings.Replace(sevenRankPackYAML, "awardRanks: 6", "awardRanks: 7", 1),
"不封顶": strings.Replace(sevenRankPackYAML, "awardRanks: 6", "awardRanks: 0", 1),
}
for name, yaml := range cases {
t.Run(name, func(t *testing.T) {
pack, err := Parse([]byte(yaml))
if err != nil {
t.Fatalf("应解析成功,实际 %v", err)
}
if got := len(pack.Profiles[0].Rules); got != 7 {
t.Fatalf("分值表应保留 7 档,实际 %d", got)
}
})
}
}
// AwardRanksOverflow 是保存模板与导入规则包共用的判定,边界直接钉在函数上。
func TestAwardRanksOverflowBoundaries(t *testing.T) {
cases := []struct {
awardRanks int
maxRuleRank int
overflow bool
}{
{0, 10, false}, // 0 = 不封顶
{-1, 10, false}, // 负数由别处拦,这里不重复报
{6, 0, false}, // 没有分值明细
{6, 6, false}, // 正好覆盖
{7, 6, false}, // 录取名次多于档位:合法(后面几档没人拿得到)
{6, 7, true},
{6, 10, true},
}
for _, tc := range cases {
msg := AwardRanksOverflow(tc.awardRanks, tc.maxRuleRank)
if tc.overflow != (msg != "") {
t.Fatalf("awardRanks=%d maxRuleRank=%d 应 overflow=%v,实际 %q",
tc.awardRanks, tc.maxRuleRank, tc.overflow, msg)
}
}
// 超出的档位取的是「最大的那一档」,不是第一条超出的档位。
if msg := AwardRanksOverflow(6, 10); !strings.Contains(msg, "第 10 档") {
t.Fatalf("应点名最大的超档档位,实际 %q", msg)
}
}
+8 -6
View File
@@ -32,14 +32,16 @@ func (p Profile) ToDomain(id int64) domain.ScoringProfile {
}
// ToDomain 把通道转成 domain.ScoreChannel;profileID 由调用方按模板编码查出。
// 三个开关缺省(true)时不落指针,保持与「库里只有显式关闭的通道才写 0」一致。
// 三个开关缺省(true)时不落指针,保持与「库里只有显式关闭的通道才写 0」一致;
// 汇总口径落库时补成具体值(avg / sum),缺省即 avg。
func (c Channel) ToDomain(eventID, profileID int64, sortOrder int) domain.ScoreChannel {
out := domain.ScoreChannel{
EventID: eventID,
Scope: c.Scope,
Basis: c.Basis,
ProfileID: profileID,
SortOrder: sortOrder,
EventID: eventID,
Scope: c.Scope,
Basis: c.Basis,
ProfileID: profileID,
SortOrder: sortOrder,
AggregateMode: c.AggregatesBy(),
}
if !c.CountsToTeam() {
out.CountToTeam = BoolPtr(false)
+69 -7
View File
@@ -4,6 +4,9 @@ import (
"errors"
"fmt"
"sort"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// ChangeKind 是差异类型。
@@ -181,14 +184,18 @@ func resolveTargets(cur *Snapshot, next *RulePack) (map[string][]Channel, []stri
}
if ov, ok := overrideByCode[ev.Code]; ok && ov.Channels != nil {
targets[ev.Code] = append([]Channel(nil), (*ov.Channels)...)
continue
}
for _, t := range next.ChannelTemplates {
if matchEvent(t.Match, ev) {
targets[ev.Code] = append([]Channel(nil), t.Channels...)
break
} else {
for _, t := range next.ChannelTemplates {
if matchEvent(t.Match, ev) {
targets[ev.Code] = append([]Channel(nil), t.Channels...)
break
}
}
}
// 目标通道算出来了,落地前先确认它在这个小项上跑得起来(见 checkCompareCompatible)。
if err := checkCompareCompatible(ev, targets[ev.Code]); err != nil {
return nil, nil, err
}
}
var unknown []string
@@ -201,6 +208,57 @@ func resolveTargets(cur *Snapshot, next *RulePack) (map[string][]Channel, []stri
return targets, unknown, nil
}
// CompareConflictError 是「规则包给 compare=none 的小项配了需要比较方向的通道」这一配置错误。
//
// 带小项编码与名称是必须的:导入失败的提示如果只说「basis=result 的通道要求小项配置
// compare=asc/desc」,现场在一场几十上百个小项的赛事里根本找不到是哪一个。
type CompareConflictError struct {
EventCode string
EventName string
// Bases 是冲突的通道口径(result / aggregate),按通道出现顺序、已去重。
Bases []string
}
func (e *CompareConflictError) Error() string {
return fmt.Sprintf("小项 %s(%s)配置的是 compare=none(名次靠人工录入),规则包却给它配了 basis=%s 的通道:"+
"这类通道要先按成绩排出名次,compare=none 时排不出来,导入后这个小项算不了分。"+
"请在规则包里为它写一段 eventOverrides,把 channels 改成 basis=manual"+
"(个人名次用 scope: athlete,团体名次用 scope: team),或调整通道模板的 match 条件别匹配到它。",
e.EventCode, e.EventName, strings.Join(e.Bases, "、"))
}
// checkCompareCompatible 拦下「compare=none 的小项被配上 basis=result / basis=aggregate 通道」。
//
// 为什么要在这里拦:这两种口径都要先按成绩排名,而 compare=none(名次靠人工录入)没有名次可算,
// domain.ScoreEvent 会直接返回 ErrCompareRequired。以前这条配置能过,代价是导入「先换通道」已经提交
// (换通道时 channel_ranks 被外键级联清空)、紧接着的全量重算才报错,名次表就停在空表状态,
// 而且自愈与重试导入都会因同一个小项再次失败(审查 04 P0-1)。
// 现在改成在**落地之前**整包拒收:一个字节都不写,失败提示直接指出哪个小项、为什么。
//
// 只校验本次要写入的目标通道(模板套用与显式 override 一视同仁):库里既有的、规则包没覆盖到的
// 通道不动它——那属于「改配置」而不是「导入规则包」的事,越权清理会误伤别人手工配好的设置。
func checkCompareCompatible(ev EventInfo, channels []Channel) error {
if len(channels) == 0 || ev.Compare != domain.CompareNone {
return nil
}
var bases []string
seen := map[string]bool{}
for _, ch := range channels {
if ch.Basis != domain.BasisResult && ch.Basis != domain.BasisAggregate {
continue
}
if seen[string(ch.Basis)] {
continue
}
seen[string(ch.Basis)] = true
bases = append(bases, string(ch.Basis))
}
if len(bases) == 0 {
return nil
}
return &CompareConflictError{EventCode: ev.Code, EventName: ev.Name, Bases: bases}
}
func diffChannels(cur *Snapshot, targets map[string][]Channel) []ChannelChange {
curByCode := make(map[string]EventInfo, len(cur.Events))
for _, ev := range cur.Events {
@@ -240,7 +298,8 @@ func diffChannels(cur *Snapshot, targets map[string][]Channel) []ChannelChange {
}
// channelsEqual 逐字段比较目标通道与库中通道。
// Channel 含 *bool 字段,整体比较会退化成比较指针地址,这里按补齐缺省后的值比。
// Channel 含 *bool 字段,整体比较会退化成比较指针地址,这里按补齐缺省后的值比;
// 汇总口径同理按「缺省即 avg」比,避免规则包显式写 avg 与库里缺省值被当成一次改动。
func channelsEqual(a, b []Channel) bool {
if len(a) != len(b) {
return false
@@ -249,6 +308,9 @@ func channelsEqual(a, b []Channel) bool {
if a[i].Scope != b[i].Scope || a[i].Basis != b[i].Basis || a[i].Profile != b[i].Profile {
return false
}
if a[i].AggregatesBy() != b[i].AggregatesBy() {
return false
}
if a[i].CountsToTeam() != b[i].CountsToTeam() {
return false
}
+91
View File
@@ -1,6 +1,8 @@
package rules
import (
"errors"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
@@ -258,6 +260,95 @@ func TestResolveTargetsClearsChannelsForUnscored(t *testing.T) {
})
}
// compare=none 的个人小项(名次靠人工录入)不能被配上 basis=result / basis=aggregate 的通道:
// 这两种口径都要先按成绩排名,而 compare=none 没有名次可算,算分时必然返回 ErrCompareRequired。
// 以前这条配置能过,代价是「先换通道」(channel_ranks 被外键级联清空)已经提交、紧接着的重算才失败,
// 名次表停在空表状态且补不回来(审查 04 P0-1)。现在必须在落地之前整包拒收。
func TestResolveTargetsRejectsCompareRequiredChannelOnCompareNoneEvent(t *testing.T) {
cur := currentSnapshot(t)
cur.Events = append(cur.Events, EventInfo{
ID: 9, Code: "EVT_MANUAL", Name: "手榴弹投准",
EventType: domain.EventTypeIndividual, ResultType: domain.ResultTypeNone,
Compare: domain.CompareNone, Scored: true, Multiplier: 1,
Channels: []Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisManual, Profile: "individual_top6"}},
})
next, err := Parse([]byte(nextPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
t.Run("模板套出来的 basis=result 被拦下", func(t *testing.T) {
_, _, err := resolveTargets(cur, next)
var conflict *CompareConflictError
if !errors.As(err, &conflict) {
t.Fatalf("compare=none 的小项被配上 basis=result 应报 CompareConflictError,得到 %v", err)
}
if conflict.EventCode != "EVT_MANUAL" || conflict.EventName != "手榴弹投准" {
t.Errorf("错误里必须指名是哪个小项,得到 %+v", conflict)
}
// 人话:说清哪个小项、为什么、怎么改。
msg := conflict.Error()
for _, want := range []string{"EVT_MANUAL", "手榴弹投准", "compare=none", "basis=result", "eventOverrides", "basis=manual"} {
if !strings.Contains(msg, want) {
t.Errorf("错误信息缺少 %q:%s", want, msg)
}
}
// 预览阶段就必须失败:不能预览说「就改这些」,落地时才炸。
if _, err := Diff(cur, next); !errors.As(err, &conflict) {
t.Errorf("Diff 也应报 CompareConflictError,得到 %v", err)
}
})
t.Run("显式 override 写 basis=result 一样被拦下", func(t *testing.T) {
patched := *next
patched.EventOverrides = append(append([]EventOverride(nil), next.EventOverrides...), EventOverride{
EventCode: "EVT_MANUAL",
Channels: ChannelListPtr([]Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}}),
})
var conflict *CompareConflictError
if _, _, err := resolveTargets(cur, &patched); !errors.As(err, &conflict) {
t.Errorf("override 显式写了 basis=result 也应拦下,得到 %v", err)
}
})
t.Run("override 改成 basis=manual 即放行", func(t *testing.T) {
patched := *next
patched.EventOverrides = append(append([]EventOverride(nil), next.EventOverrides...), EventOverride{
EventCode: "EVT_MANUAL",
Channels: ChannelListPtr([]Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisManual, Profile: "individual_top6"}}),
})
targets, _, err := resolveTargets(cur, &patched)
if err != nil {
t.Fatalf("basis=manual 不该被拦:%v", err)
}
if got := targets["EVT_MANUAL"]; len(got) != 1 || got[0].Basis != domain.BasisManual {
t.Errorf("EVT_MANUAL 的目标通道 = %+v,期望一条 basis=manual", got)
}
// 规则包没覆盖到的其它小项不受影响。
if got := targets["EVT_IND"]; len(got) != 1 || got[0].Basis != domain.BasisResult {
t.Errorf("EVT_IND 的目标通道 = %+v,期望模板给的 basis=result", got)
}
})
t.Run("不计分的小项不参与校验", func(t *testing.T) {
unscored := currentSnapshot(t)
unscored.Events = append(unscored.Events, EventInfo{
ID: 10, Code: "EVT_OFF", Name: "不计分手工名次",
EventType: domain.EventTypeIndividual, ResultType: domain.ResultTypeNone,
Compare: domain.CompareNone, Scored: false, Multiplier: 1,
})
targets, _, err := resolveTargets(unscored, next)
if err != nil {
t.Fatalf("只排名不计分的小项不该被拦:%v", err)
}
if got, ok := targets["EVT_OFF"]; !ok || len(got) != 0 {
t.Errorf("不计分小项的目标通道 = %+v,期望空", got)
}
})
}
// 「个人+团体」模板:match.eventType 支持 individual_team,并按 athlete + team 两条通道展开。
// 校验层不认识这个取值时会直接报错,因此 Parse 成功本身就是「不报未知类型」的断言。
// 这也是默认双计模板:个人通道按个人成绩排名且不计入单位总分,团体通道按平均成绩计分。
+2
View File
@@ -15,4 +15,6 @@
// yaml, err := rules.Marshal(out)
//
// 导入必须先预览再落地,且落地后必须由调用方全量重算积分(规则包一变,历史积分都得跟着变)。
// 调用方若能把「落地」与「重算」放进同一个事务(规则包导入就是这么做的),用 ApplyTx 代替 Apply:
// 换通道会按外键级联清空 channel_ranks,两步分开提交时重算一失败名次表就补不回来了。
package rules
@@ -0,0 +1,153 @@
package rules
import (
"strings"
"testing"
)
// 随包规则包(rules/2026-驭电利刃杯军事运动会.yaml)里两个模板的「末位归零」都是关的:
// 主人 2026-09-13 的最终口径是「规则里面没配置的名次分数就是 0 分」——分只按表发,
// 系统不该替他多发或扣分。这个开关做的是另一件事:参赛人数不超过表里档位数时,
// 把名次最末的那一位也抹成 0(3 人参赛 + 6 档模板 → 第 3 名本该按表拿 6 分,却被归零),
// 那件事主人没要。一旦这里变成 true,重新导入规则包就会把这个开关静默打开,末位又丢分。
//
// 为什么不直接读 rules/ 目录那个文件:规则包不进版本库(.gitignore 的 rules/*),
// 换台机器跑测试就读不到。所以这里放一份与随包文件同构的节选样本(只有本用例用得上的字段),
// 测的是「解析链路不会丢掉这个开关」——Parse → Canonical → ToDomain 任一环丢字段都会被抓到。
const militaryGamesPackYAML = `format: scoreboard-rules
formatVersion: 1
meta:
code: 2026-yudianliren-military-games
name: 2026 驭电利刃杯军事运动会 · 积分规则
version: 1.1.0
updatedAt: "2026-09-13"
author: AskaEth
note: 个人/团体项目均录取前六名
standings:
individualToTeamWeight: 1
tieBreak: [points, gold, silver, bronze, teamCode]
medalCountsTeamEvents: true
showIndividualBoard: true
showIndividualMedals: true
profiles:
- code: individual_top6
name: 个人项目前六名
awardRanks: 6
lastPlaceZero: false
tieMode: skip
rules:
- { rank: 1, points: 10 }
- { rank: 2, points: 8 }
- { rank: 3, points: 6 }
- { rank: 4, points: 3 }
- { rank: 5, points: 2 }
- { rank: 6, points: 1 }
- code: team_top6
name: 团体项目前六名
awardRanks: 6
lastPlaceZero: false
tieMode: skip
rules:
- { rank: 1, points: 15 }
- { rank: 2, points: 10 }
- { rank: 3, points: 8 }
- { rank: 4, points: 4 }
- { rank: 5, points: 2 }
- { rank: 6, points: 1 }
recordBonus:
enabled: false
mode: max
levels:
- { code: meet, name: 赛会纪录, points: 2 }
manualAdjustment:
enabled: false
reasonRequired: true
channelTemplates:
- match: { eventType: individual }
channels:
- { scope: athlete, basis: result, profile: individual_top6 }
- match: { eventType: team }
channels:
- { scope: team, basis: manual, profile: team_top6 }
- match: { eventType: match }
channels:
- { scope: team, basis: manual, profile: team_top6 }
- match: { eventType: individual_team }
channels:
- { scope: athlete, basis: result, profile: individual_top6, countToTeam: false }
- { scope: team, basis: aggregate, profile: team_top6 }
eventOverrides:
- eventCode: TEAM_PULLUP
name: 团体引体向上
channels:
- { scope: athlete, basis: result, profile: individual_top6, countToTeam: false }
- { scope: team, basis: aggregate, profile: team_top6 }
- eventCode: FLIGHT_FITNESS
name: 飞行人员战斗体能
scored: false
`
func TestParseLastPlaceZeroDisabledForBothProfiles(t *testing.T) {
if n := strings.Count(militaryGamesPackYAML, "lastPlaceZero: false"); n != 2 {
t.Fatalf("内嵌样本应给两个模板各写一处 lastPlaceZero: false,实际 %d 处(样本与随包文件不同构了)", n)
}
pack, err := Parse([]byte(militaryGamesPackYAML))
if err != nil {
t.Fatalf("解析随包规则包样本失败: %v", err)
}
index := pack.ProfileIndex()
for _, code := range []string{"individual_top6", "team_top6"} {
prof, ok := index[code]
if !ok {
t.Fatalf("随包规则包里缺少模板 %s", code)
}
if prof.LastPlaceZero {
t.Errorf("模板 %s 解析后 lastPlaceZero=true,应随主人的最终口径为 false(只按表发分)", code)
}
// 导入会把模板转成 domain.ScoringProfile 落库,转换丢字段同样等于「静默打开」。
if dom := prof.ToDomain(0); dom.LastPlaceZero {
t.Errorf("模板 %s 转成 domain.ScoringProfile 后 lastPlaceZero=true,落库时会被打开", code)
}
}
}
// 上面那条只有在「开关真会被读出来」时才有意义,所以补反向对照:
// 同一份样本把值改成 true / 整行删掉,解析结果必须跟着变——
// 证明拿到 false 是因为 YAML 里写着 false(或没写),而不是解析器恒给 false。
func TestParseLastPlaceZeroFollowsYAML(t *testing.T) {
cases := []struct {
name string
yaml string
want bool
}{
{
name: "显式 true",
yaml: strings.ReplaceAll(militaryGamesPackYAML, "lastPlaceZero: false", "lastPlaceZero: true"),
want: true,
},
{
name: "整行删掉(未配置,与显式 false 等价)",
yaml: strings.ReplaceAll(militaryGamesPackYAML, " lastPlaceZero: false\n", ""),
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.yaml == militaryGamesPackYAML {
t.Fatal("样本没有被改动,用例跑不出结论")
}
pack, err := Parse([]byte(tc.yaml))
if err != nil {
t.Fatalf("解析样本失败: %v", err)
}
for _, prof := range pack.Profiles {
if prof.LastPlaceZero != tc.want {
t.Errorf("模板 %s 的 lastPlaceZero=%v,期望 %v", prof.Code, prof.LastPlaceZero, tc.want)
}
}
})
}
}
+6
View File
@@ -44,6 +44,12 @@ func Parse(data []byte) (*RulePack, error) {
if err := canonical.Validate(); err != nil {
return nil, err
}
// 导入是写入路径:在解析阶段就拦下「这份配置算不出分」的情况(例如分值表配到第 7 档、
// awardRanks 还是 6,第 7 档进来就会静默变 0 分)。导出侧不走这里——历史库里已经存在的
// 越档模板照样要能导出来(见 Validate 与 ValidateForImport 的注释)。
if err := canonical.ValidateForImport(); err != nil {
return nil, err
}
return &canonical, nil
}
+36 -15
View File
@@ -19,7 +19,18 @@ type EventInfo struct {
Scored bool
Multiplier float64
AwardMedals bool
Channels []Channel
// Compare 是排序方向。必须读出来:basis=result / basis=aggregate 的通道要先按成绩排名,
// compare=none(名次靠人工录入)的小项配不了它们(见 resolveTargets 的守卫)。
Compare domain.CompareMode
Channels []Channel
}
// queryer 是 *sql.DB 与 *sql.Tx 共有的读接口。
// Load 既要在库上读,也要在导入事务**内部**读:ApplyTx 必须看到与自己写入同一个事务的一致视图,
// 否则「读到的现状」可能已被别处的改动覆盖,按过期现状算出的目标通道会写错。
type queryer interface {
Query(query string, args ...any) (*sql.Rows, error)
QueryRow(query string, args ...any) *sql.Row
}
// Snapshot 是赛事库当前的规则状态:规则包 + 项目目录。
@@ -40,19 +51,22 @@ func (s *Snapshot) EventByCode(code string) (EventInfo, bool) {
}
// Load 从赛事库读取当前规则状态。规则包元数据(meta)库里不存,保持为空。
func Load(db *sql.DB) (*Snapshot, error) {
func Load(db *sql.DB) (*Snapshot, error) { return loadSnapshot(db) }
// loadSnapshot 是 Load 的实现体,接受 *sql.DB 或 *sql.Tx。
func loadSnapshot(q queryer) (*Snapshot, error) {
pack := &RulePack{
Format: FormatName,
FormatVersion: FormatVersion,
}
profileByID, profiles, err := loadProfiles(db)
profileByID, profiles, err := loadProfiles(q)
if err != nil {
return nil, err
}
pack.Profiles = profiles
settings, err := loadSettings(db)
settings, err := loadSettings(q)
if err != nil {
return nil, err
}
@@ -60,7 +74,7 @@ func Load(db *sql.DB) (*Snapshot, error) {
pack.RecordBonus = recordBonusFromSettings(settings)
pack.ManualAdjustment = manualAdjustmentFromSettings(settings)
events, err := loadEvents(db, profileByID)
events, err := loadEvents(q, profileByID)
if err != nil {
return nil, err
}
@@ -114,8 +128,8 @@ func Export(db *sql.DB, meta Meta) (*RulePack, error) {
return &canonical, nil
}
func loadProfiles(db *sql.DB) (map[int64]string, []Profile, error) {
rows, err := db.Query(`SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero
func loadProfiles(q queryer) (map[int64]string, []Profile, error) {
rows, err := q.Query(`SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero
FROM scoring_profiles ORDER BY code`)
if err != nil {
return nil, nil, fmt.Errorf("查询积分模板: %w", err)
@@ -150,7 +164,7 @@ func loadProfiles(db *sql.DB) (map[int64]string, []Profile, error) {
return nil, nil, fmt.Errorf("读取积分模板: %w", err)
}
ruleRows, err := db.Query(`SELECT profile_id, rank, points FROM scoring_profile_rules ORDER BY profile_id, rank`)
ruleRows, err := q.Query(`SELECT profile_id, rank, points FROM scoring_profile_rules ORDER BY profile_id, rank`)
if err != nil {
return nil, nil, fmt.Errorf("查询积分模板明细: %w", err)
}
@@ -178,8 +192,8 @@ func loadProfiles(db *sql.DB) (map[int64]string, []Profile, error) {
return byID, profiles, nil
}
func loadSettings(db *sql.DB) (map[string]string, error) {
rows, err := db.Query(`SELECT key, value FROM settings`)
func loadSettings(q queryer) (map[string]string, error) {
rows, err := q.Query(`SELECT key, value FROM settings`)
if err != nil {
return nil, fmt.Errorf("查询赛事设置: %w", err)
}
@@ -207,8 +221,8 @@ func settingByKey(db *sql.DB, key string) (string, error) {
return value, nil
}
func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
rows, err := db.Query(`SELECT e.id, e.code, e.name, s.code, e.event_type, e.result_type, e.scored, e.multiplier, e.award_medals
func loadEvents(q queryer, profileByID map[int64]string) ([]EventInfo, error) {
rows, err := q.Query(`SELECT e.id, e.code, e.name, s.code, e.event_type, e.result_type, e.compare, e.scored, e.multiplier, e.award_medals
FROM events e JOIN sports s ON s.id = e.sport_id
ORDER BY e.sort_order, e.code`)
if err != nil {
@@ -225,11 +239,12 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
sportCode string
eventType string
resultType string
compare string
scoredRaw int
multiplier float64
awardMedalsRaw int
)
if err := rows.Scan(&id, &code, &name, &sportCode, &eventType, &resultType, &scoredRaw, &multiplier, &awardMedalsRaw); err != nil {
if err := rows.Scan(&id, &code, &name, &sportCode, &eventType, &resultType, &compare, &scoredRaw, &multiplier, &awardMedalsRaw); err != nil {
return nil, fmt.Errorf("读取项目: %w", err)
}
index[id] = len(events)
@@ -240,6 +255,7 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
SportCode: sportCode,
EventType: domain.EventType(eventType),
ResultType: domain.ResultType(resultType),
Compare: domain.CompareMode(compare),
Scored: scoredRaw != 0,
Multiplier: multiplier,
AwardMedals: awardMedalsRaw != 0,
@@ -249,7 +265,7 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
return nil, fmt.Errorf("读取项目: %w", err)
}
chRows, err := db.Query(`SELECT c.event_id, c.scope, c.basis, p.code, c.sort_order, c.count_to_team, c.count_to_medals, c.count_to_team_medals
chRows, err := q.Query(`SELECT c.event_id, c.scope, c.basis, p.code, c.sort_order, c.aggregate_mode, c.count_to_team, c.count_to_medals, c.count_to_team_medals
FROM event_score_channels c JOIN scoring_profiles p ON p.id = c.profile_id
ORDER BY c.event_id, c.sort_order, c.id`)
if err != nil {
@@ -264,11 +280,12 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
basis string
code string
sortOrd int
aggregateMode string
countToTeam int
countToMedals int
countToTeamMedals int
)
if err := chRows.Scan(&eventID, &scope, &basis, &code, &sortOrd, &countToTeam, &countToMedals, &countToTeamMedals); err != nil {
if err := chRows.Scan(&eventID, &scope, &basis, &code, &sortOrd, &aggregateMode, &countToTeam, &countToMedals, &countToTeamMedals); err != nil {
return nil, fmt.Errorf("读取计分通道: %w", err)
}
idx, ok := index[eventID]
@@ -280,6 +297,10 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
Basis: domain.Basis(basis),
Profile: code,
}
// 只把非缺省的口径写进导出文件:avg 是缺省值,写了会让「导出 → 导入」凭空多出差异。
if aggregateMode == string(domain.AggregateSum) {
ch.AggregateMode = string(domain.AggregateSum)
}
// 只把显式关闭的通道写成 false:导出文件里不出现缺省值,往返比对才不会凭空多出差异。
if countToTeam == 0 {
ch.CountToTeam = BoolPtr(false)
+13 -1
View File
@@ -96,11 +96,15 @@ type Rule struct {
}
// Channel 是计分通道:排谁的名次 + 名次从哪来 + 用哪张积分表。
// basis=aggregate 按同一主体成绩的平均值汇总,分母由项目的 avg_mode 决定。
// basis=aggregate 把同一主体的成绩汇总成一个值再排名:汇总口径由 AggregateMode 决定
// (avg 平均 / sum 求和),平均值内部的分母口径由项目的 avg_mode 决定。
type Channel struct {
Scope domain.Scope `yaml:"scope" json:"scope"`
Basis domain.Basis `yaml:"basis" json:"basis"`
Profile string `yaml:"profile" json:"profile"`
// AggregateMode 是 basis=aggregate 通道的汇总口径:avg 按成员平均(缺省)/ sum 按成员总和。
// 只对 aggregate 通道有意义;缺省(不写)等价于 avg,与库里的默认值一致。
AggregateMode string `yaml:"aggregateMode,omitempty" json:"aggregateMode,omitempty"`
// CountToTeam 决定该通道产出的流水是否计入单位总分;缺省 true。
// 「个人+团体」项目的个人通道写 false:分只记在个人头上,不进单位总分。
CountToTeam *bool `yaml:"countToTeam,omitempty" json:"countToTeam,omitempty"`
@@ -112,6 +116,14 @@ type Channel struct {
CountToTeamMedals *bool `yaml:"countToTeamMedals,omitempty" json:"countToTeamMedals,omitempty"`
}
// AggregatesBy 返回补齐缺省值后的汇总口径:未配置或写 avg 时按 avg(与库里的默认值一致)。
func (c Channel) AggregatesBy() domain.AggregateMode {
if c.AggregateMode == string(domain.AggregateSum) {
return domain.AggregateSum
}
return domain.AggregateAvg
}
// CountsToTeam 返回补齐缺省值后的开关(缺省 true)。
func (c Channel) CountsToTeam() bool { return boolOrDefault(c.CountToTeam, true) }
+55
View File
@@ -51,6 +51,13 @@ func (l *issueList) addf(path, format string, args ...any) {
}
// 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)
@@ -92,6 +99,31 @@ func (p *RulePack) Validate() error {
return nil
}
// ValidateForImport 是导入侧在 Validate 之外额外拦下的「这份配置根本算不出分」检查。
//
// 目前只有一条:分值表的档位超出录取档位数(awardRanks)。见 AwardRanksOverflow。
// 单独成一个方法而不是塞进 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)
}
}
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 {
@@ -145,6 +177,24 @@ func validateProfile(l *issueList, path string, prof Profile, seen map[string]bo
}
}
// 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)
@@ -226,6 +276,11 @@ func validateChannels(l *issueList, path string, channels []Channel, profiles ma
} 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] {