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:
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 成功本身就是「不报未知类型」的断言。
|
||||
// 这也是默认双计模板:个人通道按个人成绩排名且不计入单位总分,团体通道按平均成绩计分。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
|
||||
|
||||
@@ -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] {
|
||||
|
||||
Reference in new issue
Block a user