feat: M3-M7 全模块落地 + 集成接线

- M3 认证/权限/审计/限流 + 全部 REST 接口
- M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制)
- M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配)
- M6 WebSocket 实时同步中心
- M7 xlsx 导出 + 赛事包 zip + 备份
- 规则包 YAML 导入导出(差异预览)
- 字体 vendor 子集化(4 套 x2 字重)
- 集成:realtime/export/rules 接入 main+httpapi;backup 子命令
- 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
This commit is contained in:
AskaEth committed 2026-09-09 19:02:24 +08:00
1 parent d0e3b93e76
commit bc8b1ee6c0
145 files changed
+25159 -321

No files matched your search

+248
View File
@@ -0,0 +1,248 @@
package rules
import (
"database/sql"
"fmt"
"sort"
"strings"
)
// ApplyResult 汇总一次导入实际落库的行数。
type ApplyResult struct {
ProfilesWritten int `json:"profilesWritten"`
ProfilesDeleted int `json:"profilesDeleted"`
ChannelsWritten int `json:"channelsWritten"`
EventsUpdated int `json:"eventsUpdated"`
SettingsWritten int `json:"settingsWritten"`
EventsSkipped []string `json:"eventsSkipped,omitempty"`
}
// Apply 把差异预览落地。规则包语义是「全量覆盖」,因此按 Next 的目标状态同步,
// 而不是逐条套用 diff 列表——这样重复导入同一份规则包是幂等的。
// 只填了 Next 的变更集(例如确认导入时重新 Parse 上传内容)同样可以调用,等价于强制全量导入。
//
// 落地顺序:模板 → 通道 → 删除无主模板 → 项目开关 → 赛事设置。
// 通道先改完再删模板,避免「模板被换掉」时误判引用。
// Apply 不负责重算积分:分值一变,调用方必须全量重算(devdocs 02 §7.3)。
func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) {
if cs == nil || cs.Next == nil {
return nil, ErrNilChangeSet
}
next := cs.Next.Canonical()
if err := next.Validate(); err != nil {
return nil, err
}
cur, err := Load(db)
if err != nil {
return nil, err
}
targets, unknown, err := resolveTargets(cur, &next)
if err != nil {
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」映射供通道引用。
profileIDs := map[string]int64{}
for _, prof := range next.Profiles {
id, err := upsertProfile(tx, prof)
if err != nil {
return nil, err
}
profileIDs[prof.Code] = id
res.ProfilesWritten++
}
// 2. 通道:只覆盖规则包明确提到的项目。
eventIDs := map[string]int64{}
for _, ev := range cur.Events {
eventIDs[ev.Code] = ev.ID
}
codes := make([]string, 0, len(targets))
for code := range targets {
codes = append(codes, code)
}
sort.Strings(codes)
for _, code := range codes {
eventID, ok := eventIDs[code]
if !ok {
continue
}
if _, err := tx.Exec(`DELETE FROM event_score_channels WHERE event_id = ?`, eventID); err != nil {
return nil, fmt.Errorf("清理项目 %s 的计分通道: %w", code, err)
}
for i, ch := range targets[code] {
profileID, ok := profileIDs[ch.Profile]
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)
VALUES (?, ?, ?, ?, ?)`, eventID, string(ch.Scope), string(ch.Basis), profileID, i); err != nil {
return nil, fmt.Errorf("写入项目 %s 的计分通道: %w", code, err)
}
}
res.ChannelsWritten++
}
// 3. 删除目标规则包里已没有的模板;仍被引用就报错,绝不静默拆掉别人的配置。
for _, prof := range cur.Pack.Profiles {
if _, keep := profileIDs[prof.Code]; keep {
continue
}
if err := deleteProfile(tx, prof.Code); err != nil {
return nil, err
}
res.ProfilesDeleted++
}
// 4. 项目开关:只改 override 里显式给出的字段。
for _, ov := range next.EventOverrides {
eventID, ok := eventIDs[ov.EventCode]
if !ok || (ov.Scored == nil && ov.Multiplier == nil) {
continue
}
scored, err := currentScored(tx, eventID)
if err != nil {
return nil, err
}
if ov.Scored != nil {
scored = *ov.Scored
}
multiplier, err := currentMultiplier(tx, eventID)
if err != nil {
return nil, err
}
if ov.Multiplier != nil {
multiplier = *ov.Multiplier
}
if _, err := tx.Exec(`UPDATE events SET scored = ?, multiplier = ? WHERE id = ?`,
boolToInt(scored), multiplier, eventID); err != nil {
return nil, fmt.Errorf("更新项目 %s: %w", ov.EventCode, err)
}
res.EventsUpdated++
}
// 5. 赛事设置。
settings := settingsFromPack(&next)
for _, key := range settingsKeys() {
if _, err := tx.Exec(`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
key, settings[key]); err != nil {
return nil, fmt.Errorf("写入赛事设置 %s: %w", key, err)
}
res.SettingsWritten++
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("提交导入事务: %w", err)
}
return res, nil
}
func upsertProfile(tx *sql.Tx, prof Profile) (int64, error) {
if _, err := tx.Exec(`INSERT INTO scoring_profiles
(code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(code) DO UPDATE SET
name = excluded.name,
tie_mode = excluded.tie_mode,
record_bonus_mode = excluded.record_bonus_mode,
award_ranks = excluded.award_ranks,
last_place_zero = excluded.last_place_zero,
updated_at = datetime('now')`,
prof.Code, prof.Name, string(prof.TieMode), string(prof.RecordBonusMode), prof.AwardRanks, boolToInt(prof.LastPlaceZero)); err != nil {
return 0, fmt.Errorf("写入积分模板 %s: %w", prof.Code, err)
}
var id int64
if err := tx.QueryRow(`SELECT id FROM scoring_profiles WHERE code = ?`, prof.Code).Scan(&id); err != nil {
return 0, fmt.Errorf("读取积分模板 %s 的 id: %w", prof.Code, err)
}
if _, err := tx.Exec(`DELETE FROM scoring_profile_rules WHERE profile_id = ?`, id); err != nil {
return 0, fmt.Errorf("清理积分模板 %s 的明细: %w", prof.Code, err)
}
for _, r := range prof.Rules {
if _, err := tx.Exec(`INSERT INTO scoring_profile_rules (profile_id, rank, points) VALUES (?, ?, ?)`,
id, r.Rank, r.Points); err != nil {
return 0, fmt.Errorf("写入积分模板 %s 的名次 %d: %w", prof.Code, r.Rank, err)
}
}
return id, nil
}
func deleteProfile(tx *sql.Tx, code string) error {
var id int64
if err := tx.QueryRow(`SELECT id FROM scoring_profiles WHERE code = ?`, code).Scan(&id); err != nil {
if err == sql.ErrNoRows {
return nil
}
return fmt.Errorf("读取积分模板 %s: %w", code, err)
}
refs, err := profileRefs(tx, id)
if err != nil {
return err
}
if len(refs) > 0 {
return fmt.Errorf("%w: 模板 %s 仍被 %s 引用", ErrProfileInUse, code, strings.Join(refs, "、"))
}
if _, err := tx.Exec(`DELETE FROM scoring_profile_rules WHERE profile_id = ?`, id); err != nil {
return fmt.Errorf("删除积分模板 %s 的明细: %w", code, err)
}
if _, err := tx.Exec(`DELETE FROM scoring_profiles WHERE id = ?`, id); err != nil {
return fmt.Errorf("删除积分模板 %s: %w", code, err)
}
return nil
}
func profileRefs(tx *sql.Tx, profileID int64) ([]string, error) {
var refs []string
var channels int
if err := tx.QueryRow(`SELECT COUNT(*) FROM event_score_channels WHERE profile_id = ?`, profileID).Scan(&channels); err != nil {
return nil, fmt.Errorf("统计模板引用: %w", err)
}
if channels > 0 {
refs = append(refs, fmt.Sprintf("%d 个计分通道", channels))
}
var sports int
if err := tx.QueryRow(`SELECT COUNT(*) FROM sports WHERE default_profile_id = ?`, profileID).Scan(&sports); err != nil {
return nil, fmt.Errorf("统计模板引用: %w", err)
}
if sports > 0 {
refs = append(refs, fmt.Sprintf("%d 个大项的默认模板", sports))
}
return refs, nil
}
func currentScored(tx *sql.Tx, eventID int64) (bool, error) {
var v int
if err := tx.QueryRow(`SELECT scored FROM events WHERE id = ?`, eventID).Scan(&v); err != nil {
return false, fmt.Errorf("读取项目计分开关: %w", err)
}
return v != 0, nil
}
func currentMultiplier(tx *sql.Tx, eventID int64) (float64, error) {
var v float64
if err := tx.QueryRow(`SELECT multiplier FROM events WHERE id = ?`, eventID).Scan(&v); err != nil {
return 0, fmt.Errorf("读取项目权重: %w", err)
}
return v, nil
}
func boolToInt(v bool) int {
if v {
return 1
}
return 0
}
+381
View File
@@ -0,0 +1,381 @@
package rules
import (
"database/sql"
"errors"
"path/filepath"
"reflect"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
const applyPackYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo-rules, name: 导入目标 }
standings:
individualToTeamWeight: 1
tieBreak: [points, gold, silver, bronze, teamCode]
medalCountsTeamEvents: true
showIndividualBoard: true
showIndividualMedals: true
profiles:
- code: individual_top6
name: 个人项目前六名
awardRanks: 6
tieMode: skip
rules:
- { rank: 1, points: 12 }
- { rank: 2, points: 9 }
- { rank: 3, points: 7 }
- { rank: 4, points: 4 }
- { rank: 5, points: 3 }
- { rank: 6, points: 2 }
- code: team_top6
name: 团体项目前六名
awardRanks: 6
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 }
- code: special_top3
name: 特别项目前三名
awardRanks: 3
tieMode: higher
rules:
- { rank: 1, points: 5 }
- { rank: 2, points: 3 }
- { rank: 3, 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 }
eventOverrides:
- eventCode: EVT_IND
name: 个人示例
multiplier: 2
channels:
- { scope: athlete, basis: result, profile: individual_top6 }
- { scope: team, basis: manual, profile: team_top6 }
- eventCode: EVT_TEAM
channels: []
`
func openEventDB(t *testing.T) *sql.DB {
t.Helper()
path := filepath.Join(t.TempDir(), "event.db")
dsn := "file:" + path +
"?_pragma=journal_mode(WAL)" +
"&_pragma=foreign_keys(1)" +
"&_pragma=busy_timeout(5000)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("打开赛事库: %v", err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("迁移赛事库: %v", err)
}
return db
}
// seedEvents 建一个示例大项与两个项目,并给团体项目挂一条通道,用于验证通道删除。
func seedEvents(t *testing.T, db *sql.DB) {
t.Helper()
res, err := db.Exec(`INSERT INTO sports (code, name) VALUES ('demo-sport', '示例大项')`)
if err != nil {
t.Fatalf("插入大项: %v", err)
}
sportID, err := res.LastInsertId()
if err != nil {
t.Fatalf("读取大项 id: %v", err)
}
for _, ev := range []struct {
code, name, eventType, resultType, compare string
}{
{"EVT_IND", "个人示例", "individual", "time", "asc"},
{"EVT_TEAM", "团体示例", "team", "none", "none"},
} {
if _, err := db.Exec(`INSERT INTO events (sport_id, code, name, event_type, result_type, compare, scored, multiplier)
VALUES (?, ?, ?, ?, ?, ?, 1, 1)`, sportID, ev.code, ev.name, ev.eventType, ev.resultType, ev.compare); err != nil {
t.Fatalf("插入项目 %s: %v", ev.code, err)
}
}
var teamProfileID int64
if err := db.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&teamProfileID); err != nil {
t.Fatalf("查询团体模板: %v", err)
}
var teamEventID int64
if err := db.QueryRow(`SELECT id FROM events WHERE code = 'EVT_TEAM'`).Scan(&teamEventID); err != nil {
t.Fatalf("查询团体项目: %v", err)
}
if _, err := db.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'team', 'manual', ?, 0)`, teamEventID, teamProfileID); err != nil {
t.Fatalf("插入团体通道: %v", err)
}
}
func TestApplyWritesChangesAndIsIdempotent(t *testing.T) {
db := openEventDB(t)
seedEvents(t, db)
next, err := Parse([]byte(applyPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
cur, err := Load(db)
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cur.Pack.Profiles) != 2 {
t.Fatalf("迁移后应有 2 个模板,实际 %d", len(cur.Pack.Profiles))
}
cs, err := Diff(cur, next)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if cs.Empty() {
t.Fatal("差异不应为空")
}
// 预览阶段不落库。
if got := countRows(t, db, "scoring_profiles"); got != 2 {
t.Fatalf("Diff 不应写库,模板数 = %d", got)
}
res, err := Apply(db, cs)
if err != nil {
t.Fatalf("Apply: %v", err)
}
if res.ProfilesWritten != 3 || res.ProfilesDeleted != 0 || res.ChannelsWritten != 2 {
t.Errorf("落库统计异常: %+v", res)
}
if got := countRows(t, db, "scoring_profiles"); got != 3 {
t.Errorf("模板数 = %d,期望 3", got)
}
if got := queryFloat(t, db, `SELECT points FROM scoring_profile_rules r
JOIN scoring_profiles p ON p.id = r.profile_id WHERE p.code = 'individual_top6' AND r.rank = 1`); got != 12 {
t.Errorf("个人模板第 1 名分值 = %v,期望 12", got)
}
if got := countRows(t, db, "event_score_channels"); got != 2 {
t.Errorf("通道数 = %d,期望 2(EVT_IND 双通道、EVT_TEAM 清空)", got)
}
if got := queryInt(t, db, `SELECT COUNT(*) FROM event_score_channels c
JOIN events e ON e.id = c.event_id WHERE e.code = 'EVT_IND'`); got != 2 {
t.Errorf("EVT_IND 通道数 = %d,期望 2", got)
}
if got := queryInt(t, db, `SELECT COUNT(*) FROM event_score_channels c
JOIN events e ON e.id = c.event_id WHERE e.code = 'EVT_TEAM'`); got != 0 {
t.Errorf("EVT_TEAM 通道数 = %d,期望 0", got)
}
if got := queryFloat(t, db, `SELECT multiplier FROM events WHERE code = 'EVT_IND'`); got != 2 {
t.Errorf("EVT_IND 权重 = %v,期望 2", got)
}
if got := queryString(t, db, `SELECT value FROM settings WHERE key = 'record_bonus.levels'`); got == "" {
t.Error("破纪录档位应写入 settings")
}
// 重复导入必须幂等:第二次差异为空,Apply 不再写任何东西。
cur2, err := Load(db)
if err != nil {
t.Fatalf("再次 Load: %v", err)
}
cs2, err := Diff(cur2, next)
if err != nil {
t.Fatalf("再次 Diff: %v", err)
}
if !cs2.Empty() {
t.Fatalf("重复导入应无差异: %+v", cs2)
}
if _, err := Apply(db, cs2); err != nil {
t.Fatalf("重复 Apply: %v", err)
}
}
func TestExportRoundTripThroughDB(t *testing.T) {
db := openEventDB(t)
seedEvents(t, db)
next, err := Parse([]byte(applyPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
cur, err := Load(db)
if err != nil {
t.Fatalf("Load: %v", err)
}
cs, err := Diff(cur, next)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if _, err := Apply(db, cs); err != nil {
t.Fatalf("Apply: %v", err)
}
meta := Meta{Code: "demo-rules", Name: "导入目标", Version: "1.0.0", UpdatedAt: "2026-01-01", Author: "tester"}
exported, err := Export(db, meta)
if err != nil {
t.Fatalf("Export: %v", err)
}
if exported.Meta != meta {
t.Errorf("导出 meta 错误: %+v", exported.Meta)
}
if len(exported.Profiles) != 3 {
t.Fatalf("导出模板数 = %d,期望 3", len(exported.Profiles))
}
if exported.Profiles[0].Code != "individual_top6" || exported.Profiles[0].Rules[0].Points != 12 {
t.Errorf("导出模板内容错误: %+v", exported.Profiles[0])
}
var indOverride *EventOverride
for i := range exported.EventOverrides {
if exported.EventOverrides[i].EventCode == "EVT_IND" {
indOverride = &exported.EventOverrides[i]
}
}
if indOverride == nil {
t.Fatal("导出的项目例外里应包含 EVT_IND")
}
if indOverride.Multiplier == nil || *indOverride.Multiplier != 2 {
t.Errorf("EVT_IND 权重导出错误: %+v", indOverride.Multiplier)
}
if indOverride.Channels == nil || len(*indOverride.Channels) != 2 {
t.Errorf("EVT_IND 通道导出错误: %+v", indOverride.Channels)
}
raw, err := Marshal(exported)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
reparsed, err := Parse(raw)
if err != nil {
t.Fatalf("重新 Parse 导出结果: %v", err)
}
if !reflect.DeepEqual(exported, reparsed) {
t.Errorf("导出结果往返不一致:\n%+v\n%+v", exported, reparsed)
}
}
func TestApplyRefusesToDeleteReferencedProfile(t *testing.T) {
db := openEventDB(t)
seedEvents(t, db)
// 这份规则包没有 team_top6,而 EVT_TEAM 的通道仍引用它,删除必须被拒绝。
const packYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 精简规则包 }
profiles:
- code: individual_top6
name: 个人项目前六名
awardRanks: 1
tieMode: skip
rules:
- { rank: 1, points: 1 }
`
next, err := Parse([]byte(packYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
cur, err := Load(db)
if err != nil {
t.Fatalf("Load: %v", err)
}
cs, err := Diff(cur, next)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if _, err := Apply(db, cs); !errors.Is(err, ErrProfileInUse) {
t.Fatalf("期望 ErrProfileInUse,实际 %v", err)
}
if got := countRows(t, db, "scoring_profiles"); got != 2 {
t.Errorf("事务应回滚,模板数 = %d,期望 2", got)
}
}
func TestApplyNilChangeSet(t *testing.T) {
db := openEventDB(t)
if _, err := Apply(db, nil); !errors.Is(err, ErrNilChangeSet) {
t.Fatalf("期望 ErrNilChangeSet,实际 %v", err)
}
}
// 确认导入时通常只剩目标规则包(预览的 ChangeSet 不跨请求),这条路要能走通。
func TestApplyAcceptsChangeSetWithOnlyNext(t *testing.T) {
db := openEventDB(t)
next, err := Parse([]byte(applyPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
res, err := Apply(db, &ChangeSet{Next: next})
if err != nil {
t.Fatalf("Apply: %v", err)
}
if res.ProfilesWritten != 3 {
t.Errorf("模板写入数 = %d,期望 3", res.ProfilesWritten)
}
if got := countRows(t, db, "scoring_profiles"); got != 3 {
t.Errorf("模板数 = %d,期望 3", got)
}
}
func countRows(t *testing.T, db *sql.DB, table string) int {
t.Helper()
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&n); err != nil {
t.Fatalf("统计 %s: %v", table, err)
}
return n
}
func queryInt(t *testing.T, db *sql.DB, query string) int {
t.Helper()
var n int
if err := db.QueryRow(query).Scan(&n); err != nil {
t.Fatalf("查询 %q: %v", query, err)
}
return n
}
func queryFloat(t *testing.T, db *sql.DB, query string) float64 {
t.Helper()
var v float64
if err := db.QueryRow(query).Scan(&v); err != nil {
t.Fatalf("查询 %q: %v", query, err)
}
return v
}
func queryString(t *testing.T, db *sql.DB, query string) string {
t.Helper()
var v string
if err := db.QueryRow(query).Scan(&v); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ""
}
t.Fatalf("查询 %q: %v", query, err)
}
return v
}
+120
View File
@@ -0,0 +1,120 @@
package rules
import "git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
// 本文件是规则包类型与 domain 类型之间的唯一转换入口;
// 转换只搬字段、不填默认值(默认值统一由 Canonical 负责),避免两处口径打架。
// ToDomain 把模板转成 domain.ScoringProfile;id 由调用方(仓储层)决定。
func (p Profile) ToDomain(id int64) domain.ScoringProfile {
tie := p.TieMode
if tie == "" {
tie = domain.TieSkip
}
bonus := p.RecordBonusMode
if bonus == "" {
bonus = domain.RecordBonusMax
}
rules := make([]domain.ScoringRule, 0, len(p.Rules))
for _, r := range p.Rules {
rules = append(rules, domain.ScoringRule{Rank: r.Rank, Points: r.Points})
}
return domain.ScoringProfile{
ID: id,
Code: p.Code,
Name: p.Name,
AwardRanks: p.AwardRanks,
LastPlaceZero: p.LastPlaceZero,
TieMode: tie,
RecordBonusMode: bonus,
Rules: rules,
}
}
// ProfileFromDomain 把 domain 模板转回规则包类型。
func ProfileFromDomain(p domain.ScoringProfile) Profile {
rules := make([]Rule, 0, len(p.Rules))
for _, r := range p.Rules {
rules = append(rules, Rule{Rank: r.Rank, Points: r.Points})
}
return Profile{
Code: p.Code,
Name: p.Name,
AwardRanks: p.AwardRanks,
LastPlaceZero: p.LastPlaceZero,
TieMode: p.TieMode,
RecordBonusMode: p.RecordBonusMode,
Rules: rules,
}
}
// ToDomain 把通道转成 domain.ScoreChannel;profileID 由调用方按模板编码查出。
func (c Channel) ToDomain(eventID, profileID int64, sortOrder int) domain.ScoreChannel {
return domain.ScoreChannel{
EventID: eventID,
Scope: c.Scope,
Basis: c.Basis,
ProfileID: profileID,
Aggregate: c.Aggregate,
SortOrder: sortOrder,
}
}
// ChannelFromDomain 把 domain 通道转回规则包类型;profileCode 由调用方按模板 ID 查出。
func ChannelFromDomain(c domain.ScoreChannel, profileCode string) Channel {
return Channel{
Scope: c.Scope,
Basis: c.Basis,
Profile: profileCode,
Aggregate: c.Aggregate,
}
}
// ProfileIndex 按模板编码建索引,便于通道引用解析。
func (p *RulePack) ProfileIndex() map[string]Profile {
out := make(map[string]Profile, len(p.Profiles))
for _, prof := range p.Profiles {
out[prof.Code] = prof
}
return out
}
// ToStandingsInput 把榜单口径映射成 domain 聚合输入。
func (s Standings) ToStandingsInput() domain.StandingsInput {
return domain.StandingsInput{
IndividualToTeamWeight: s.IndividualToTeamWeight,
ExcludeTeamEventMedals: !s.MedalCountsTeamEventsEnabled(),
}
}
// ToDomain 把破纪录配置转成 domain.RecordBonusConfig。
func (r RecordBonus) ToDomain() domain.RecordBonusConfig {
mode := r.Mode
if mode == "" {
mode = domain.RecordBonusMax
}
levels := make([]domain.RecordLevel, 0, len(r.Levels))
for _, lv := range r.Levels {
levels = append(levels, domain.RecordLevel{Code: lv.Code, Name: lv.Name, Points: lv.Points})
}
return domain.RecordBonusConfig{Enabled: r.Enabled, Mode: mode, Levels: levels}
}
// RecordBonusFromDomain 把 domain 破纪录配置转回规则包类型。
func RecordBonusFromDomain(c domain.RecordBonusConfig) RecordBonus {
levels := make([]RecordLevel, 0, len(c.Levels))
for _, lv := range c.Levels {
levels = append(levels, RecordLevel{Code: lv.Code, Name: lv.Name, Points: lv.Points})
}
return RecordBonus{Enabled: c.Enabled, Mode: c.Mode, Levels: levels}
}
// ToDomain 把人工调整开关转成 domain.ManualAdjustmentConfig。
func (m ManualAdjustment) ToDomain() domain.ManualAdjustmentConfig {
return domain.ManualAdjustmentConfig{Enabled: m.Enabled, ReasonRequired: m.ReasonRequired}
}
// ManualAdjustmentFromDomain 把 domain 人工调整开关转回规则包类型。
func ManualAdjustmentFromDomain(c domain.ManualAdjustmentConfig) ManualAdjustment {
return ManualAdjustment{Enabled: c.Enabled, ReasonRequired: c.ReasonRequired}
}
+256
View File
@@ -0,0 +1,256 @@
package rules
import (
"errors"
"fmt"
"sort"
)
// ChangeKind 是差异类型。
type ChangeKind string
const (
ChangeAdded ChangeKind = "added"
ChangeModified ChangeKind = "modified"
ChangeRemoved ChangeKind = "removed"
)
// ProfileChange 是一个积分模板的增/改/删。
type ProfileChange struct {
Code string `json:"code"`
Kind ChangeKind `json:"kind"`
Before *Profile `json:"before,omitempty"`
After *Profile `json:"after,omitempty"`
// Fields 列出被修改的字段,便于前端只高亮变化项。
Fields []string `json:"fields,omitempty"`
}
// ChannelChange 是一个项目计分通道的变化。
type ChannelChange struct {
EventCode string `json:"eventCode"`
EventName string `json:"eventName,omitempty"`
Kind ChangeKind `json:"kind"`
Before []Channel `json:"before,omitempty"`
After []Channel `json:"after,omitempty"`
}
// SettingChange 是一条赛事设置的变化。
type SettingChange struct {
Key string `json:"key"`
Before string `json:"before"`
After string `json:"after"`
}
// ChangeSet 是导入预览的结果,也是 Apply 的输入。
type ChangeSet struct {
Profiles []ProfileChange `json:"profiles,omitempty"`
Channels []ChannelChange `json:"channels,omitempty"`
Settings []SettingChange `json:"settings,omitempty"`
// UnknownEvents 是规则包里出现、但赛事库中不存在的项目编码;Apply 会跳过它们。
UnknownEvents []string `json:"unknownEvents,omitempty"`
// Next 是目标规则包;Apply 以它为完整目标状态落库。不参与 JSON 序列化。
Next *RulePack `json:"-"`
}
// Empty 判断这次导入是否真的会改动任何东西。
// 只用于预览展示;Apply 始终以 Next 为目标全量同步,不依赖它做短路。
func (cs *ChangeSet) Empty() bool {
return cs == nil || (len(cs.Profiles) == 0 && len(cs.Channels) == 0 && len(cs.Settings) == 0)
}
// Diff 计算「当前赛事库 → 目标规则包」的差异,只读不落库。
func Diff(cur *Snapshot, next *RulePack) (*ChangeSet, error) {
if cur == nil || cur.Pack == nil {
return nil, errors.New("rules: 当前快照为空,无法比对差异")
}
if next == nil {
return nil, fmt.Errorf("%w: 目标规则包为空", ErrNotRulePack)
}
target := next.Canonical()
if err := target.Validate(); err != nil {
return nil, err
}
cs := &ChangeSet{Next: &target}
cs.Profiles = diffProfiles(cur.Pack, &target)
targets, unknown, err := resolveTargets(cur, &target)
if err != nil {
return nil, err
}
cs.UnknownEvents = unknown
cs.Channels = diffChannels(cur, targets)
cs.Settings = diffSettings(cur.Pack, &target)
return cs, nil
}
func diffProfiles(cur, next *RulePack) []ProfileChange {
curIndex := cur.ProfileIndex()
nextIndex := next.ProfileIndex()
codes := make([]string, 0, len(curIndex)+len(nextIndex))
seen := map[string]bool{}
for code := range curIndex {
if !seen[code] {
seen[code] = true
codes = append(codes, code)
}
}
for code := range nextIndex {
if !seen[code] {
seen[code] = true
codes = append(codes, code)
}
}
sort.Strings(codes)
var out []ProfileChange
for _, code := range codes {
before, hasBefore := curIndex[code]
after, hasAfter := nextIndex[code]
switch {
case !hasBefore:
out = append(out, ProfileChange{Code: code, Kind: ChangeAdded, After: &after})
case !hasAfter:
out = append(out, ProfileChange{Code: code, Kind: ChangeRemoved, Before: &before})
default:
if fields := profileDiffFields(before, after); len(fields) > 0 {
out = append(out, ProfileChange{Code: code, Kind: ChangeModified, Before: &before, After: &after, Fields: fields})
}
}
}
return out
}
func profileDiffFields(a, b Profile) []string {
var fields []string
if a.Name != b.Name {
fields = append(fields, "name")
}
if a.AwardRanks != b.AwardRanks {
fields = append(fields, "awardRanks")
}
if a.LastPlaceZero != b.LastPlaceZero {
fields = append(fields, "lastPlaceZero")
}
if a.TieMode != b.TieMode {
fields = append(fields, "tieMode")
}
if a.RecordBonusMode != b.RecordBonusMode {
fields = append(fields, "recordBonusMode")
}
if !rulesEqual(a.Rules, b.Rules) {
fields = append(fields, "rules")
}
return fields
}
func rulesEqual(a, b []Rule) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// resolveTargets 把规则包展开成「项目编码 → 目标通道」。
// 优先级:eventOverrides 显式给了 channels > 第一条命中的 channelTemplates。
// 没被规则包提到的项目不出现在结果里,表示「保持原样」。
func resolveTargets(cur *Snapshot, next *RulePack) (map[string][]Channel, []string, error) {
overrideByCode := make(map[string]EventOverride, len(next.EventOverrides))
for _, o := range next.EventOverrides {
overrideByCode[o.EventCode] = o
}
targets := make(map[string][]Channel)
for _, ev := range cur.Events {
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
}
}
}
var unknown []string
for _, o := range next.EventOverrides {
if _, ok := cur.EventByCode(o.EventCode); !ok {
unknown = append(unknown, o.EventCode)
}
}
sort.Strings(unknown)
return targets, unknown, nil
}
func diffChannels(cur *Snapshot, targets map[string][]Channel) []ChannelChange {
curByCode := make(map[string]EventInfo, len(cur.Events))
for _, ev := range cur.Events {
curByCode[ev.Code] = ev
}
codes := make([]string, 0, len(targets))
for code := range targets {
codes = append(codes, code)
}
sort.Strings(codes)
var out []ChannelChange
for _, code := range codes {
ev := curByCode[code]
before := ev.Channels
after := targets[code]
if channelsEqual(before, after) {
continue
}
kind := ChangeModified
switch {
case len(before) == 0:
kind = ChangeAdded
case len(after) == 0:
kind = ChangeRemoved
}
out = append(out, ChannelChange{
EventCode: code,
EventName: ev.Name,
Kind: kind,
Before: append([]Channel(nil), before...),
After: append([]Channel(nil), after...),
})
}
return out
}
func channelsEqual(a, b []Channel) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func diffSettings(cur, next *RulePack) []SettingChange {
curSettings := settingsFromPack(cur)
nextSettings := settingsFromPack(next)
var out []SettingChange
for _, key := range settingsKeys() {
before, after := curSettings[key], nextSettings[key]
if before != after {
out = append(out, SettingChange{Key: key, Before: before, After: after})
}
}
return out
}
+203
View File
@@ -0,0 +1,203 @@
package rules
import (
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
func testSnapshot(t *testing.T, profiles []Profile, events []EventInfo) *Snapshot {
t.Helper()
pack := RulePack{
Format: FormatName,
FormatVersion: FormatVersion,
Meta: Meta{Code: "demo", Name: "当前规则"},
Standings: Standings{IndividualToTeamWeight: 1, TieBreak: DefaultTieBreak},
Profiles: profiles,
}
canonical := pack.Canonical()
return &Snapshot{Pack: &canonical, Events: events}
}
func currentSnapshot(t *testing.T) *Snapshot {
t.Helper()
return testSnapshot(t,
[]Profile{
{
Code: "individual_top6", Name: "个人项目前六名", AwardRanks: 6, TieMode: domain.TieSkip,
Rules: []Rule{{1, 10}, {2, 8}, {3, 6}, {4, 3}, {5, 2}, {6, 1}},
},
{
Code: "team_top6", Name: "团体项目前六名", AwardRanks: 2, TieMode: domain.TieSkip,
Rules: []Rule{{1, 15}, {2, 10}},
},
},
[]EventInfo{
{ID: 1, Code: "EVT_IND", Name: "个人示例", EventType: domain.EventTypeIndividual, ResultType: domain.ResultTypeTime,
Scored: true, Multiplier: 1,
Channels: []Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}}},
{ID: 2, Code: "EVT_TEAM", Name: "团体示例", EventType: domain.EventTypeTeam, ResultType: domain.ResultTypeNone,
Scored: true, Multiplier: 1,
Channels: []Channel{{Scope: domain.ScopeTeam, Basis: domain.BasisManual, Profile: "team_top6"}}},
{ID: 3, Code: "EVT_MATCH", Name: "对抗示例", EventType: domain.EventTypeMatch, ResultType: domain.ResultTypeNone,
Scored: true, Multiplier: 1,
Channels: []Channel{{Scope: domain.ScopeTeam, Basis: domain.BasisManual, Profile: "team_top6"}}},
})
}
const nextPackYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 目标规则包 }
standings:
individualToTeamWeight: 2
tieBreak: [points, gold, silver, bronze, teamCode]
medalCountsTeamEvents: true
showIndividualBoard: true
showIndividualMedals: true
profiles:
- code: individual_top6
name: 个人项目前六名
awardRanks: 6
tieMode: skip
rules:
- { rank: 1, points: 12 }
- { rank: 2, points: 9 }
- { rank: 3, points: 7 }
- { rank: 4, points: 4 }
- { rank: 5, points: 3 }
- { rank: 6, points: 2 }
- code: special_top3
name: 特别项目前三名
awardRanks: 3
tieMode: higher
rules:
- { rank: 1, points: 5 }
- { rank: 2, points: 3 }
- { rank: 3, points: 1 }
channelTemplates:
- match: { eventType: individual }
channels:
- { scope: athlete, basis: result, profile: individual_top6 }
- match: { eventType: team }
channels:
- { scope: team, basis: manual, profile: special_top3 }
eventOverrides:
- eventCode: EVT_MATCH
channels: []
- eventCode: EVT_GHOST
channels:
- { scope: team, basis: manual, profile: special_top3 }
`
func TestDiffDetectsAddModifyRemove(t *testing.T) {
cur := currentSnapshot(t)
next, err := Parse([]byte(nextPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
cs, err := Diff(cur, next)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if cs.Empty() {
t.Fatal("差异不应为空")
}
kinds := map[string]ChangeKind{}
for _, p := range cs.Profiles {
kinds[p.Code] = p.Kind
}
if kinds["individual_top6"] != ChangeModified {
t.Errorf("individual_top6 应为 modified,实际 %q", kinds["individual_top6"])
}
if kinds["team_top6"] != ChangeRemoved {
t.Errorf("team_top6 应为 removed,实际 %q", kinds["team_top6"])
}
if kinds["special_top3"] != ChangeAdded {
t.Errorf("special_top3 应为 added,实际 %q", kinds["special_top3"])
}
for _, p := range cs.Profiles {
if p.Code == "individual_top6" && len(p.Fields) == 0 {
t.Error("修改的模板应列出变化字段")
}
if p.Code == "special_top3" && (p.Before != nil || p.After == nil) {
t.Error("新增模板的 Before 应为空、After 应有值")
}
}
channels := map[string]ChannelChange{}
for _, c := range cs.Channels {
channels[c.EventCode] = c
}
if c, ok := channels["EVT_TEAM"]; !ok || c.Kind != ChangeModified {
t.Errorf("EVT_TEAM 通道应变为 special_top3: %+v", c)
}
if c, ok := channels["EVT_MATCH"]; !ok || c.Kind != ChangeRemoved || len(c.After) != 0 {
t.Errorf("EVT_MATCH 通道应被清空: %+v", c)
}
if _, ok := channels["EVT_IND"]; ok {
t.Error("EVT_IND 通道未变化,不应出现在差异里")
}
if len(cs.UnknownEvents) != 1 || cs.UnknownEvents[0] != "EVT_GHOST" {
t.Errorf("未知项目识别错误: %v", cs.UnknownEvents)
}
if len(cs.Settings) != 1 || cs.Settings[0].Key != settingIndividualToTeamWeight {
t.Errorf("设置差异错误: %+v", cs.Settings)
}
if cs.Settings[0].Before != "1" || cs.Settings[0].After != "2" {
t.Errorf("权重差异值错误: %+v", cs.Settings[0])
}
}
func TestDiffIdenticalIsEmpty(t *testing.T) {
cur := currentSnapshot(t)
same, err := Diff(cur, cur.Pack)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if !same.Empty() {
t.Errorf("同一份规则包不应产生差异: %+v", same)
}
}
func TestDiffRejectsInvalidPack(t *testing.T) {
cur := currentSnapshot(t)
bad, err := Parse([]byte(minimalPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
bad.FormatVersion = 99
if _, err := Diff(cur, bad); err == nil {
t.Fatal("非法规则包应报错")
}
}
func TestResolveTargetsPrecedence(t *testing.T) {
cur := currentSnapshot(t)
next, err := Parse([]byte(nextPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
targets, unknown, err := resolveTargets(cur, next)
if err != nil {
t.Fatalf("resolveTargets: %v", err)
}
if got := targets["EVT_IND"]; len(got) != 1 || got[0].Profile != "individual_top6" {
t.Errorf("模板展开错误: %+v", got)
}
if got := targets["EVT_TEAM"]; len(got) != 1 || got[0].Profile != "special_top3" {
t.Errorf("模板展开错误: %+v", got)
}
if got, ok := targets["EVT_MATCH"]; !ok || len(got) != 0 {
t.Errorf("override 的 channels: [] 应清空通道: %+v", got)
}
if _, ok := targets["EVT_GHOST"]; ok {
t.Error("库里不存在的项目不应出现在目标状态里")
}
if len(unknown) != 1 || unknown[0] != "EVT_GHOST" {
t.Errorf("未知项目识别错误: %v", unknown)
}
}
+18
View File
@@ -0,0 +1,18 @@
// Package rules 实现积分规则包(YAML)的解析、校验、导出与导入落地。
//
// 规则包是积分的唯一权威来源:分值、录取名次、并列口径、计分通道全部来自规则包,
// 代码里不写死任何数字(见 devdocs/02-积分与排名规则.md 第 7 节)。
//
// 典型用法:
//
// pack, err := rules.Parse(raw) // 解析 + 校验
// cur, err := rules.Load(db) // 读取赛事库当前规则
// cs, err := rules.Diff(cur, pack) // 预览差异,不落库
// if !cs.Empty() {
// res, err := rules.Apply(db, cs) // 确认后写入;全量重算由调用方触发
// }
// out, err := rules.Export(db, meta) // 导出当前规则
// yaml, err := rules.Marshal(out)
//
// 导入必须先预览再落地,且落地后必须由调用方全量重算积分(规则包一变,历史积分都得跟着变)。
package rules
+71
View File
@@ -0,0 +1,71 @@
package rules
import (
"bytes"
"errors"
"fmt"
"io"
"gopkg.in/yaml.v3"
)
// 导出文件的注释头:规则包可能含赛事敏感信息,不进版本库(rules/README.md)。
const header = `# 积分规则包 · scoreboard-rules v1
# 由系统导出,可手工编辑后再导入;导入前请先预览差异。
# 注意:本文件可能含赛事敏感信息,请勿提交到版本库。
`
// Parse 解析并校验规则包 YAML。未知字段一律报错,避免拼错的配置被静默忽略。
func Parse(data []byte) (*RulePack, error) {
if len(bytes.TrimSpace(data)) == 0 {
return nil, fmt.Errorf("%w: 内容为空", ErrNotRulePack)
}
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
var pack RulePack
if err := dec.Decode(&pack); err != nil {
if errors.Is(err, io.EOF) {
return nil, fmt.Errorf("%w: 内容为空", ErrNotRulePack)
}
return nil, fmt.Errorf("解析规则包 YAML: %w", err)
}
// 规则包只允许一个文档;多文档容易让人以为后面的也会生效。
var extra any
if err := dec.Decode(&extra); err == nil {
return nil, fmt.Errorf("%w: 只允许一个 YAML 文档", ErrNotRulePack)
} else if !errors.Is(err, io.EOF) {
return nil, fmt.Errorf("解析规则包 YAML: %w", err)
}
canonical := pack.Canonical()
if err := canonical.Validate(); err != nil {
return nil, err
}
return &canonical, nil
}
// Marshal 把规则包序列化成带注释头的 YAML;输出是规范化后的结果,可安全往返。
func Marshal(pack *RulePack) ([]byte, error) {
if pack == nil {
return nil, fmt.Errorf("%w: 规则包为空", ErrNotRulePack)
}
canonical := pack.Canonical()
if err := canonical.Validate(); err != nil {
return nil, err
}
var buf bytes.Buffer
buf.WriteString(header)
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err := enc.Encode(&canonical); err != nil {
return nil, fmt.Errorf("序列化规则包 YAML: %w", err)
}
if err := enc.Close(); err != nil {
return nil, fmt.Errorf("序列化规则包 YAML: %w", err)
}
return buf.Bytes(), nil
}
+294
View File
@@ -0,0 +1,294 @@
package rules
import (
"errors"
"reflect"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// 测试一律用内联 YAML,不读 rules/ 目录(该目录含敏感信息且不进版本库)。
const validPackYAML = `format: scoreboard-rules
formatVersion: 1
meta:
code: demo-rules
name: 示例规则包
version: 1.0.0
updatedAt: "2026-01-01"
author: tester
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: 2
tieMode: average
rules:
- { rank: 1, points: 15 }
- { rank: 2, points: 10 }
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 }
eventOverrides:
- eventCode: EVT_DEMO
name: 示例项目
channels:
- { scope: athlete, basis: result, profile: individual_top6 }
- { scope: team, basis: manual, profile: team_top6 }
- eventCode: EVT_NOSCORE
name: 只排名不计分
scored: false
`
const minimalPackYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 示例 }
profiles:
- { code: p1, name: 模板一, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 1 } ] }
`
func TestParseValidPack(t *testing.T) {
pack, err := Parse([]byte(validPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if pack.Format != FormatName || pack.FormatVersion != FormatVersion {
t.Fatalf("格式字段解析错误: %+v", pack)
}
if pack.Meta.Code != "demo-rules" || pack.Meta.Name != "示例规则包" {
t.Errorf("meta 解析错误: %+v", pack.Meta)
}
if len(pack.Profiles) != 2 {
t.Fatalf("模板数量 = %d,期望 2", len(pack.Profiles))
}
if got := pack.Profiles[0].Rules[0]; got != (Rule{Rank: 1, Points: 10}) {
t.Errorf("名次 1 分值 = %+v,期望 10", got)
}
if pack.Profiles[0].TieMode != domain.TieSkip || pack.Profiles[1].TieMode != domain.TieAverage {
t.Errorf("并列口径解析错误: %q / %q", pack.Profiles[0].TieMode, pack.Profiles[1].TieMode)
}
// 未配置 recordBonusMode 时按 max 补齐(与 domain 默认一致)。
if pack.Profiles[0].RecordBonusMode != domain.RecordBonusMax {
t.Errorf("recordBonusMode 缺省值 = %q,期望 max", pack.Profiles[0].RecordBonusMode)
}
if len(pack.ChannelTemplates) != 2 || len(pack.EventOverrides) != 2 {
t.Errorf("通道模板/项目例外数量错误: %d / %d", len(pack.ChannelTemplates), len(pack.EventOverrides))
}
if pack.EventOverrides[1].Scored == nil || *pack.EventOverrides[1].Scored {
t.Errorf("EVT_NOSCORE 的 scored 应为 false: %+v", pack.EventOverrides[1].Scored)
}
if !pack.Standings.MedalCountsTeamEventsEnabled() || !pack.Standings.ShowIndividualBoardEnabled() {
t.Error("榜单开关缺省值应为 true")
}
}
func TestParseFillsDefaults(t *testing.T) {
pack, err := Parse([]byte(minimalPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if pack.Standings.IndividualToTeamWeight != 1 {
t.Errorf("未配置权重时应补 1,实际 %v", pack.Standings.IndividualToTeamWeight)
}
if !reflect.DeepEqual(pack.Standings.TieBreak, DefaultTieBreak) {
t.Errorf("未配置 tieBreak 时应补默认值,实际 %v", pack.Standings.TieBreak)
}
if pack.RecordBonus.Mode != domain.RecordBonusMax {
t.Errorf("未配置 recordBonus.mode 时应补 max,实际 %q", pack.RecordBonus.Mode)
}
}
func TestMarshalRoundTripStable(t *testing.T) {
pack, err := Parse([]byte(validPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
first, err := Marshal(pack)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
if !strings.HasPrefix(string(first), "# 积分规则包") {
t.Errorf("导出缺少注释头:\n%s", first)
}
again, err := Parse(first)
if err != nil {
t.Fatalf("重新 Parse 导出结果: %v", err)
}
second, err := Marshal(again)
if err != nil {
t.Fatalf("再次 Marshal: %v", err)
}
if string(first) != string(second) {
t.Errorf("往返序列化不稳定:\n--- 第一次 ---\n%s\n--- 第二次 ---\n%s", first, second)
}
if !reflect.DeepEqual(pack, again) {
t.Errorf("往返后规则包内容不一致:\n%+v\n%+v", pack, again)
}
}
func TestParseRejectsBadInput(t *testing.T) {
cases := []struct {
name string
yaml string
sentinel error
}{
{"空文件", " \n", ErrNotRulePack},
{"格式标识不符", strings.Replace(validPackYAML, "format: scoreboard-rules", "format: something-else", 1), ErrNotRulePack},
{"版本不兼容", strings.Replace(validPackYAML, "formatVersion: 1", "formatVersion: 2", 1), ErrIncompatibleVersion},
{"未知字段", validPackYAML + "\nbogusField: 1\n", nil},
{"缺 meta.code", strings.Replace(validPackYAML, " code: demo-rules\n", "", 1), ErrValidation},
{"缺 meta.name", strings.Replace(validPackYAML, " name: 示例规则包\n", "", 1), ErrValidation},
{"模板编码重复", strings.Replace(validPackYAML, " - code: team_top6", " - code: individual_top6", 1), ErrValidation},
{"录取名次为负", strings.Replace(validPackYAML, "awardRanks: 6", "awardRanks: -1", 1), ErrValidation},
{"并列口径非法", strings.Replace(validPackYAML, "tieMode: skip", "tieMode: random", 1), ErrValidation},
{"分值非负", strings.Replace(validPackYAML, " - { rank: 1, points: 10 }", " - { rank: 1, points: -10 }", 1), ErrValidation},
{"名次重复", strings.Replace(validPackYAML, " - { rank: 2, points: 8 }", " - { rank: 1, points: 8 }", 1), ErrValidation},
{"通道 scope 非法", strings.Replace(validPackYAML, "{ scope: athlete, basis: result, profile: individual_top6 }", "{ scope: unit, basis: result, profile: individual_top6 }", 1), ErrValidation},
{"通道 basis 非法", strings.Replace(validPackYAML, "{ scope: team, basis: manual, profile: team_top6 }", "{ scope: team, basis: guess, profile: team_top6 }", 1), ErrValidation},
{"通道引用不存在的模板", strings.Replace(validPackYAML, "profile: individual_top6 }", "profile: ghost_top6 }", 1), ErrValidation},
{"排序键未知", strings.Replace(validPackYAML, "tieBreak: [points, gold, silver, bronze, teamCode]", "tieBreak: [points, platinum]", 1), ErrValidation},
{"启用破纪录却没有档位", strings.Replace(validPackYAML, "recordBonus:\n enabled: false\n mode: max\n levels:\n - { code: meet, name: 赛会纪录, points: 2 }", "recordBonus:\n enabled: true\n mode: max\n levels: []", 1), ErrValidation},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := Parse([]byte(tc.yaml))
if err == nil {
t.Fatal("期望报错,实际解析成功")
}
if tc.sentinel != nil && !errors.Is(err, tc.sentinel) {
t.Fatalf("期望 %v,实际 %v", tc.sentinel, err)
}
if tc.sentinel == nil && errors.Is(err, ErrIncompatibleVersion) {
t.Fatalf("未知字段不应报版本错误: %v", err)
}
})
}
}
func TestParseRejectsStructuralProblems(t *testing.T) {
cases := map[string]string{
"没有模板": `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 示例 }
profiles: []
`,
"模板没有通道": `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 示例 }
profiles:
- { code: p1, name: 模板一, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 1 } ] }
channelTemplates:
- match: { eventType: team }
channels: []
`,
"模板匹配条件为空": `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 示例 }
profiles:
- { code: p1, name: 模板一, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 1 } ] }
channelTemplates:
- match: {}
channels:
- { scope: team, basis: manual, profile: p1 }
`,
"同一项目通道重复": `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 示例 }
profiles:
- { code: p1, name: 模板一, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 1 } ] }
eventOverrides:
- eventCode: EVT_X
channels:
- { scope: team, basis: manual, profile: p1 }
- { scope: team, basis: manual, profile: p1 }
`,
}
for name, yaml := range cases {
t.Run(name, func(t *testing.T) {
if _, err := Parse([]byte(yaml)); !errors.Is(err, ErrValidation) {
t.Fatalf("期望 ErrValidation,实际 %v", err)
}
})
}
}
func TestParseRejectsMultipleDocuments(t *testing.T) {
if _, err := Parse([]byte(validPackYAML + "---\nformat: scoreboard-rules\n")); !errors.Is(err, ErrNotRulePack) {
t.Fatalf("多文档应报 ErrNotRulePack,实际 %v", err)
}
}
func TestProfileDomainConversion(t *testing.T) {
pack, err := Parse([]byte(validPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
dp := pack.Profiles[0].ToDomain(7)
if dp.ID != 7 || dp.Code != "individual_top6" || dp.TieMode != domain.TieSkip {
t.Errorf("ToDomain 结果错误: %+v", dp)
}
if len(dp.Rules) != 6 || dp.Rules[0] != (domain.ScoringRule{Rank: 1, Points: 10}) {
t.Errorf("ToDomain 明细错误: %+v", dp.Rules)
}
back := ProfileFromDomain(dp)
if !reflect.DeepEqual(back, pack.Profiles[0]) {
t.Errorf("domain 往返不一致:\n%+v\n%+v", back, pack.Profiles[0])
}
ch := pack.ChannelTemplates[0].Channels[0].ToDomain(3, 7, 0)
if ch.EventID != 3 || ch.ProfileID != 7 || ch.Scope != domain.ScopeAthlete || ch.Basis != domain.BasisResult {
t.Errorf("通道 ToDomain 结果错误: %+v", ch)
}
if got := ChannelFromDomain(ch, "individual_top6"); got != pack.ChannelTemplates[0].Channels[0] {
t.Errorf("通道往返不一致: %+v", got)
}
in := pack.Standings.ToStandingsInput()
if in.IndividualToTeamWeight != 1 || in.ExcludeTeamEventMedals {
t.Errorf("榜单口径转换错误: %+v", in)
}
rb := pack.RecordBonus.ToDomain()
if rb.Enabled || rb.Mode != domain.RecordBonusMax || len(rb.Levels) != 1 {
t.Errorf("破纪录配置转换错误: %+v", rb)
}
if got := RecordBonusFromDomain(rb); !reflect.DeepEqual(got, pack.RecordBonus) {
t.Errorf("破纪录配置往返不一致: %+v", got)
}
}
+155
View File
@@ -0,0 +1,155 @@
package rules
import (
"encoding/json"
"sort"
"strconv"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// 赛事库 settings 表里的键名;与 migrations/event/0011_seed.sql 保持一致。
const (
settingIndividualToTeamWeight = "standings.individual_to_team_weight"
settingTieBreak = "standings.tie_break"
settingMedalCountsTeamEvents = "standings.medal_counts_team_events"
settingShowIndividualBoard = "standings.show_individual_board"
settingShowIndividualMedals = "standings.show_individual_medals"
settingRecordBonusEnabled = "record_bonus.enabled"
settingRecordBonusMode = "record_bonus.mode"
settingRecordBonusLevels = "record_bonus.levels"
settingManualEnabled = "manual_adjustment.enabled"
settingManualReasonRequired = "manual_adjustment.reason_required"
)
// settingsFromPack 把规则包里能落到 settings 表的部分拍平成键值对。
// 破纪录档位库里没有表,用 JSON 存在 record_bonus.levels 里,避免导入时丢配置。
func settingsFromPack(pack *RulePack) map[string]string {
levels, err := json.Marshal(pack.RecordBonus.Levels)
if err != nil {
levels = []byte("[]")
}
return map[string]string{
settingIndividualToTeamWeight: formatFloat(pack.Standings.IndividualToTeamWeight),
settingTieBreak: strings.Join(pack.Standings.TieBreak, ","),
settingMedalCountsTeamEvents: formatBool(pack.Standings.MedalCountsTeamEventsEnabled()),
settingShowIndividualBoard: formatBool(pack.Standings.ShowIndividualBoardEnabled()),
settingShowIndividualMedals: formatBool(pack.Standings.ShowIndividualMedalsEnabled()),
settingRecordBonusEnabled: formatBool(pack.RecordBonus.Enabled),
settingRecordBonusMode: string(pack.RecordBonus.Mode),
settingRecordBonusLevels: string(levels),
settingManualEnabled: formatBool(pack.ManualAdjustment.Enabled),
settingManualReasonRequired: formatBool(pack.ManualAdjustment.ReasonRequired),
}
}
// settingsKeys 返回固定顺序的键名,保证差异预览与落库顺序稳定。
func settingsKeys() []string {
keys := []string{
settingIndividualToTeamWeight,
settingTieBreak,
settingMedalCountsTeamEvents,
settingShowIndividualBoard,
settingShowIndividualMedals,
settingRecordBonusEnabled,
settingRecordBonusMode,
settingRecordBonusLevels,
settingManualEnabled,
settingManualReasonRequired,
}
sort.Strings(keys)
return keys
}
func standingsFromSettings(m map[string]string) Standings {
s := Standings{
IndividualToTeamWeight: settingFloat(m, settingIndividualToTeamWeight, 1),
TieBreak: splitList(m[settingTieBreak]),
MedalCountsTeamEvents: BoolPtr(settingBool(m, settingMedalCountsTeamEvents, true)),
ShowIndividualBoard: BoolPtr(settingBool(m, settingShowIndividualBoard, true)),
ShowIndividualMedals: BoolPtr(settingBool(m, settingShowIndividualMedals, true)),
}
if len(s.TieBreak) == 0 {
s.TieBreak = append([]string(nil), DefaultTieBreak...)
}
return s
}
func recordBonusFromSettings(m map[string]string) RecordBonus {
rb := RecordBonus{
Enabled: settingBool(m, settingRecordBonusEnabled, false),
Mode: modeOrDefault(m[settingRecordBonusMode]),
}
if raw := strings.TrimSpace(m[settingRecordBonusLevels]); raw != "" {
var levels []RecordLevel
if err := json.Unmarshal([]byte(raw), &levels); err == nil {
rb.Levels = levels
}
}
return rb
}
func manualAdjustmentFromSettings(m map[string]string) ManualAdjustment {
return ManualAdjustment{
Enabled: settingBool(m, settingManualEnabled, false),
ReasonRequired: settingBool(m, settingManualReasonRequired, true),
}
}
func settingFloat(m map[string]string, key string, def float64) float64 {
raw, ok := m[key]
if !ok {
return def
}
v, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
if err != nil {
return def
}
return v
}
func settingBool(m map[string]string, key string, def bool) bool {
raw, ok := m[key]
if !ok {
return def
}
switch strings.ToLower(strings.TrimSpace(raw)) {
case "true", "1", "yes", "on":
return true
case "false", "0", "no", "off":
return false
default:
return def
}
}
func formatFloat(v float64) string { return strconv.FormatFloat(v, 'g', -1, 64) }
func formatBool(v bool) string {
if v {
return "true"
}
return "false"
}
func splitList(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func modeOrDefault(raw string) domain.RecordBonusMode {
if strings.TrimSpace(raw) == "" {
return domain.RecordBonusMax
}
return domain.RecordBonusMode(strings.TrimSpace(raw))
}
+279
View File
@@ -0,0 +1,279 @@
package rules
import (
"database/sql"
"fmt"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// EventInfo 是展开通道模板所需的最小项目信息。
type EventInfo struct {
ID int64
Code string
Name string
SportCode string
EventType domain.EventType
ResultType domain.ResultType
Scored bool
Multiplier float64
Channels []Channel
}
// Snapshot 是赛事库当前的规则状态:规则包 + 项目目录。
// 差异预览需要项目目录才能把 channelTemplates 展开到具体项目。
type Snapshot struct {
Pack *RulePack
Events []EventInfo
}
// EventByCode 按项目编码查找项目。
func (s *Snapshot) EventByCode(code string) (EventInfo, bool) {
for _, e := range s.Events {
if e.Code == code {
return e, true
}
}
return EventInfo{}, false
}
// Load 从赛事库读取当前规则状态。规则包元数据(meta)库里不存,保持为空。
func Load(db *sql.DB) (*Snapshot, error) {
pack := &RulePack{
Format: FormatName,
FormatVersion: FormatVersion,
}
profileByID, profiles, err := loadProfiles(db)
if err != nil {
return nil, err
}
pack.Profiles = profiles
settings, err := loadSettings(db)
if err != nil {
return nil, err
}
pack.Standings = standingsFromSettings(settings)
pack.RecordBonus = recordBonusFromSettings(settings)
pack.ManualAdjustment = manualAdjustmentFromSettings(settings)
events, err := loadEvents(db, profileByID)
if err != nil {
return nil, err
}
// 库里只有逐项目的通道、没有模板表,因此导出时把「有通道/不计分/权重非 1」的项目
// 全部写成 eventOverrides,保证「导出 → 导入」能还原当前配置。
for _, e := range events {
needOverride := len(e.Channels) > 0 || !e.Scored || e.Multiplier != 1
if !needOverride {
continue
}
ov := EventOverride{EventCode: e.Code, Name: e.Name}
if !e.Scored {
ov.Scored = BoolPtr(false)
}
if e.Multiplier != 1 {
ov.Multiplier = Float64Ptr(e.Multiplier)
}
if len(e.Channels) > 0 {
ov.Channels = ChannelListPtr(append([]Channel(nil), e.Channels...))
}
pack.EventOverrides = append(pack.EventOverrides, ov)
}
canonical := pack.Canonical()
return &Snapshot{Pack: &canonical, Events: events}, nil
}
// Export 从赛事库生成规则包;meta 由调用方提供(库里不存规则包元数据)。
func Export(db *sql.DB, meta Meta) (*RulePack, error) {
snap, err := Load(db)
if err != nil {
return nil, err
}
pack := *snap.Pack
pack.Meta = meta
if strings.TrimSpace(pack.Meta.Code) == "" {
pack.Meta.Code = "exported-rules"
}
if strings.TrimSpace(pack.Meta.Name) == "" {
if name, err := settingByKey(db, "event.name"); err == nil && strings.TrimSpace(name) != "" {
pack.Meta.Name = name
} else {
pack.Meta.Name = "未命名赛事 · 积分规则"
}
}
canonical := pack.Canonical()
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
FROM scoring_profiles ORDER BY code`)
if err != nil {
return nil, nil, fmt.Errorf("查询积分模板: %w", err)
}
defer func() { _ = rows.Close() }()
byID := map[int64]string{}
byCode := map[string]int{}
var profiles []Profile
for rows.Next() {
var (
id, awardRanks int64
code, name string
tie, bonus string
lastPlaceZeroRaw int
)
if err := rows.Scan(&id, &code, &name, &tie, &bonus, &awardRanks, &lastPlaceZeroRaw); err != nil {
return nil, nil, fmt.Errorf("读取积分模板: %w", err)
}
byID[id] = code
byCode[code] = len(profiles)
profiles = append(profiles, Profile{
Code: code,
Name: name,
AwardRanks: int(awardRanks),
LastPlaceZero: lastPlaceZeroRaw != 0,
TieMode: domain.TieMode(tie),
RecordBonusMode: domain.RecordBonusMode(bonus),
})
}
if err := rows.Err(); err != nil {
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`)
if err != nil {
return nil, nil, fmt.Errorf("查询积分模板明细: %w", err)
}
defer func() { _ = ruleRows.Close() }()
for ruleRows.Next() {
var (
profileID int64
rank int
points float64
)
if err := ruleRows.Scan(&profileID, &rank, &points); err != nil {
return nil, nil, fmt.Errorf("读取积分模板明细: %w", err)
}
code, ok := byID[profileID]
if !ok {
continue
}
idx := byCode[code]
profiles[idx].Rules = append(profiles[idx].Rules, Rule{Rank: rank, Points: points})
}
if err := ruleRows.Err(); err != nil {
return nil, nil, fmt.Errorf("读取积分模板明细: %w", err)
}
return byID, profiles, nil
}
func loadSettings(db *sql.DB) (map[string]string, error) {
rows, err := db.Query(`SELECT key, value FROM settings`)
if err != nil {
return nil, fmt.Errorf("查询赛事设置: %w", err)
}
defer func() { _ = rows.Close() }()
out := map[string]string{}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, fmt.Errorf("读取赛事设置: %w", err)
}
out[k] = v
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("读取赛事设置: %w", err)
}
return out, nil
}
func settingByKey(db *sql.DB, key string) (string, error) {
var value string
if err := db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value); err != nil {
return "", err
}
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
FROM events e JOIN sports s ON s.id = e.sport_id
ORDER BY e.sort_order, e.code`)
if err != nil {
return nil, fmt.Errorf("查询项目: %w", err)
}
defer func() { _ = rows.Close() }()
events := []EventInfo{}
index := map[int64]int{}
for rows.Next() {
var (
id int64
code, name string
sportCode string
eventType string
resultType string
scoredRaw int
multiplier float64
)
if err := rows.Scan(&id, &code, &name, &sportCode, &eventType, &resultType, &scoredRaw, &multiplier); err != nil {
return nil, fmt.Errorf("读取项目: %w", err)
}
index[id] = len(events)
events = append(events, EventInfo{
ID: id,
Code: code,
Name: name,
SportCode: sportCode,
EventType: domain.EventType(eventType),
ResultType: domain.ResultType(resultType),
Scored: scoredRaw != 0,
Multiplier: multiplier,
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("读取项目: %w", err)
}
chRows, err := db.Query(`SELECT c.event_id, c.scope, c.basis, p.code, c.sort_order
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 {
return nil, fmt.Errorf("查询计分通道: %w", err)
}
defer func() { _ = chRows.Close() }()
for chRows.Next() {
var (
eventID int64
scope string
basis string
code string
sortOrd int
)
if err := chRows.Scan(&eventID, &scope, &basis, &code, &sortOrd); err != nil {
return nil, fmt.Errorf("读取计分通道: %w", err)
}
idx, ok := index[eventID]
if !ok {
continue
}
events[idx].Channels = append(events[idx].Channels, Channel{
Scope: domain.Scope(scope),
Basis: domain.Basis(basis),
Profile: code,
})
}
if err := chRows.Err(); err != nil {
return nil, fmt.Errorf("读取计分通道: %w", err)
}
return events, nil
}
+221
View File
@@ -0,0 +1,221 @@
package rules
import "git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
// 格式标识与版本:不符即报错,不做隐式转换(devdocs 02 §7.3)。
const (
FormatName = "scoreboard-rules"
FormatVersion = 1
)
// 榜单排序键;规则包里写错键名要报错,而不是静默忽略。
const (
TieBreakPoints = "points"
TieBreakGold = "gold"
TieBreakSilver = "silver"
TieBreakBronze = "bronze"
TieBreakTeamCode = "teamCode"
TieBreakName = "name"
)
// DefaultTieBreak 是未配置 tieBreak 时的口径:总分 → 金 → 银 → 铜 → 单位编码。
var DefaultTieBreak = []string{TieBreakPoints, TieBreakGold, TieBreakSilver, TieBreakBronze, TieBreakTeamCode}
var knownTieBreakKeys = map[string]bool{
TieBreakPoints: true,
TieBreakGold: true,
TieBreakSilver: true,
TieBreakBronze: true,
TieBreakTeamCode: true,
TieBreakName: true,
}
// RulePack 是一个完整的积分规则包,对应 rules/<赛事>.yaml。
type RulePack struct {
Format string `yaml:"format" json:"format"`
FormatVersion int `yaml:"formatVersion" json:"formatVersion"`
Meta Meta `yaml:"meta" json:"meta"`
Standings Standings `yaml:"standings" json:"standings"`
Profiles []Profile `yaml:"profiles" json:"profiles"`
RecordBonus RecordBonus `yaml:"recordBonus" json:"recordBonus"`
ManualAdjustment ManualAdjustment `yaml:"manualAdjustment" json:"manualAdjustment"`
ChannelTemplates []ChannelTemplate `yaml:"channelTemplates,omitempty" json:"channelTemplates,omitempty"`
EventOverrides []EventOverride `yaml:"eventOverrides,omitempty" json:"eventOverrides,omitempty"`
}
// Meta 是规则包的标识信息;库中不存规则包元数据,导出时由调用方提供。
type Meta struct {
Code string `yaml:"code" json:"code"`
Name string `yaml:"name" json:"name"`
Version string `yaml:"version,omitempty" json:"version,omitempty"`
UpdatedAt string `yaml:"updatedAt,omitempty" json:"updatedAt,omitempty"`
Author string `yaml:"author,omitempty" json:"author,omitempty"`
Note string `yaml:"note,omitempty" json:"note,omitempty"`
}
// Standings 是榜单口径。三个开关用指针,未配置时才能区分「缺省」与「显式 false」。
type Standings struct {
IndividualToTeamWeight float64 `yaml:"individualToTeamWeight" json:"individualToTeamWeight"`
TieBreak []string `yaml:"tieBreak" json:"tieBreak"`
MedalCountsTeamEvents *bool `yaml:"medalCountsTeamEvents,omitempty" json:"medalCountsTeamEvents,omitempty"`
ShowIndividualBoard *bool `yaml:"showIndividualBoard,omitempty" json:"showIndividualBoard,omitempty"`
ShowIndividualMedals *bool `yaml:"showIndividualMedals,omitempty" json:"showIndividualMedals,omitempty"`
}
// MedalCountsTeamEventsEnabled 缺省为 true(团体奖牌榜计入团体项目奖牌)。
func (s Standings) MedalCountsTeamEventsEnabled() bool {
return boolOrDefault(s.MedalCountsTeamEvents, true)
}
// ShowIndividualBoardEnabled 缺省为 true。
func (s Standings) ShowIndividualBoardEnabled() bool {
return boolOrDefault(s.ShowIndividualBoard, true)
}
// ShowIndividualMedalsEnabled 缺省为 true。
func (s Standings) ShowIndividualMedalsEnabled() bool {
return boolOrDefault(s.ShowIndividualMedals, true)
}
// Profile 是积分模板;rules 是「名次 → 分值」明细,分值一律来自规则包。
type Profile struct {
Code string `yaml:"code" json:"code"`
Name string `yaml:"name" json:"name"`
AwardRanks int `yaml:"awardRanks" json:"awardRanks"`
LastPlaceZero bool `yaml:"lastPlaceZero,omitempty" json:"lastPlaceZero,omitempty"`
TieMode domain.TieMode `yaml:"tieMode" json:"tieMode"`
RecordBonusMode domain.RecordBonusMode `yaml:"recordBonusMode,omitempty" json:"recordBonusMode,omitempty"`
Rules []Rule `yaml:"rules" json:"rules"`
}
// Rule 是积分模板的一条明细。
type Rule struct {
Rank int `yaml:"rank" json:"rank"`
Points float64 `yaml:"points" json:"points"`
}
// Channel 是计分通道:排谁的名次 + 名次从哪来 + 用哪张积分表。
type Channel struct {
Scope domain.Scope `yaml:"scope" json:"scope"`
Basis domain.Basis `yaml:"basis" json:"basis"`
Profile string `yaml:"profile" json:"profile"`
Aggregate domain.AggregateMethod `yaml:"aggregate,omitempty" json:"aggregate,omitempty"`
}
// ChannelMatch 是模板的适用条件;三个条件都为空时匹配全部(校验层会拒绝这种模板)。
type ChannelMatch struct {
EventType domain.EventType `yaml:"eventType,omitempty" json:"eventType,omitempty"`
ResultType domain.ResultType `yaml:"resultType,omitempty" json:"resultType,omitempty"`
SportCode string `yaml:"sportCode,omitempty" json:"sportCode,omitempty"`
}
// ChannelTemplate 是按项目类型套用的计分通道。
type ChannelTemplate struct {
Match ChannelMatch `yaml:"match" json:"match"`
Channels []Channel `yaml:"channels" json:"channels"`
}
// EventOverride 是单个项目的例外配置。
//
// Scored / Multiplier 为 nil 表示「不涉及」;Channels 用指针是为了区分
// 「没写 channels」(不动该项目的通道)与「channels: []」(清空通道)。
type EventOverride struct {
EventCode string `yaml:"eventCode" json:"eventCode"`
Name string `yaml:"name,omitempty" json:"name,omitempty"`
Scored *bool `yaml:"scored,omitempty" json:"scored,omitempty"`
Multiplier *float64 `yaml:"multiplier,omitempty" json:"multiplier,omitempty"`
Channels *[]Channel `yaml:"channels,omitempty" json:"channels,omitempty"`
}
// RecordBonus 是破纪录加分配置;本期 enabled=false,结构保留。
type RecordBonus struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Mode domain.RecordBonusMode `yaml:"mode,omitempty" json:"mode,omitempty"`
Levels []RecordLevel `yaml:"levels,omitempty" json:"levels,omitempty"`
}
// RecordLevel 是破纪录的一个档位及其加分值。
type RecordLevel struct {
Code string `yaml:"code" json:"code"`
Name string `yaml:"name" json:"name"`
Points float64 `yaml:"points" json:"points"`
}
// ManualAdjustment 是人工调整开关。
type ManualAdjustment struct {
Enabled bool `yaml:"enabled" json:"enabled"`
ReasonRequired bool `yaml:"reasonRequired" json:"reasonRequired"`
}
// BoolPtr 便于构造三态布尔(导出时写显式值)。
func BoolPtr(v bool) *bool { return &v }
// Float64Ptr 便于构造三态数值。
func Float64Ptr(v float64) *float64 { return &v }
// ChannelListPtr 便于构造三态通道列表。
func ChannelListPtr(v []Channel) *[]Channel { return &v }
// Canonical 返回补齐缺省值后的副本。缺省口径集中在这里,保证 Parse / Marshal / Apply 三处一致。
func (p RulePack) Canonical() RulePack {
out := p
out.Standings.TieBreak = append([]string(nil), p.Standings.TieBreak...)
if len(out.Standings.TieBreak) == 0 {
out.Standings.TieBreak = append([]string(nil), DefaultTieBreak...)
}
// 权重 0 与未填等价:引擎对 <=0 按 1 处理,这里先对齐,避免导出与解析来回变。
if out.Standings.IndividualToTeamWeight == 0 {
out.Standings.IndividualToTeamWeight = 1
}
out.Profiles = make([]Profile, len(p.Profiles))
for i, prof := range p.Profiles {
out.Profiles[i] = prof
out.Profiles[i].Rules = append([]Rule{}, prof.Rules...)
if out.Profiles[i].TieMode == "" {
out.Profiles[i].TieMode = domain.TieSkip
}
if out.Profiles[i].RecordBonusMode == "" {
out.Profiles[i].RecordBonusMode = domain.RecordBonusMax
}
}
if out.RecordBonus.Mode == "" {
out.RecordBonus.Mode = domain.RecordBonusMax
}
out.RecordBonus.Levels = append([]RecordLevel{}, p.RecordBonus.Levels...)
out.ChannelTemplates = make([]ChannelTemplate, len(p.ChannelTemplates))
for i, t := range p.ChannelTemplates {
out.ChannelTemplates[i] = t
out.ChannelTemplates[i].Channels = canonicalChannels(t.Channels)
}
out.EventOverrides = make([]EventOverride, len(p.EventOverrides))
for i, o := range p.EventOverrides {
out.EventOverrides[i] = o
if o.Channels != nil {
cs := canonicalChannels(*o.Channels)
out.EventOverrides[i].Channels = &cs
}
}
return out
}
func canonicalChannels(in []Channel) []Channel {
out := make([]Channel, len(in))
for i, ch := range in {
out[i] = ch
if ch.Basis == domain.BasisAggregate && ch.Aggregate == "" {
out[i].Aggregate = domain.AggregateSum
}
}
return out
}
func boolOrDefault(p *bool, def bool) bool {
if p == nil {
return def
}
return *p
}
+272
View File
@@ -0,0 +1,272 @@
package rules
import (
"errors"
"fmt"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// 哨兵错误:调用方据此区分「格式不对」「版本不兼容」「内容非法」。
var (
ErrNotRulePack = errors.New("rules: 不是积分规则包")
ErrIncompatibleVersion = errors.New("rules: 规则包格式版本不兼容")
ErrValidation = errors.New("rules: 规则包校验失败")
ErrProfileInUse = errors.New("rules: 积分模板仍被引用,不能删除")
ErrNilChangeSet = errors.New("rules: 变更集或目标规则包为空,无法导入")
)
// Issue 是一条校验问题;Path 用点号路径定位到 YAML 中的位置。
type Issue struct {
Path string `json:"path"`
Msg string `json:"msg"`
}
// ValidationError 汇总一次校验中的全部问题,避免「改一个报一个」。
type ValidationError struct {
Issues []Issue `json:"issues"`
}
func (e *ValidationError) Error() string {
if len(e.Issues) == 0 {
return ErrValidation.Error()
}
parts := make([]string, 0, len(e.Issues))
for _, is := range e.Issues {
parts = append(parts, is.Path+": "+is.Msg)
}
return ErrValidation.Error() + ": " + strings.Join(parts, "; ")
}
// Unwrap 让 errors.Is(err, ErrValidation) 成立。
func (e *ValidationError) Unwrap() error { return ErrValidation }
type issueList struct {
issues []Issue
}
func (l *issueList) addf(path, format string, args ...any) {
l.issues = append(l.issues, Issue{Path: path, Msg: fmt.Sprintf(format, args...)})
}
// Validate 校验规则包。格式 / 版本不符立即返回哨兵错误,其余问题一次性汇总。
func (p *RulePack) Validate() error {
if p.Format != FormatName {
return fmt.Errorf("%w: format=%q,期望 %q", ErrNotRulePack, p.Format, FormatName)
}
if p.FormatVersion != FormatVersion {
return fmt.Errorf("%w: formatVersion=%d,本程序只支持 %d,不做隐式转换",
ErrIncompatibleVersion, p.FormatVersion, FormatVersion)
}
var l issueList
if strings.TrimSpace(p.Meta.Code) == "" {
l.addf("meta.code", "不能为空")
}
if strings.TrimSpace(p.Meta.Name) == "" {
l.addf("meta.name", "不能为空")
}
if p.Standings.IndividualToTeamWeight < 0 {
l.addf("standings.individualToTeamWeight", "不能为负,当前 %v", p.Standings.IndividualToTeamWeight)
}
validateTieBreak(&l, p.Standings.TieBreak)
profiles := make(map[string]bool, len(p.Profiles))
if len(p.Profiles) == 0 {
l.addf("profiles", "至少要有一个积分模板")
}
for i, prof := range p.Profiles {
path := fmt.Sprintf("profiles[%d]", i)
validateProfile(&l, path, prof, profiles)
}
validateRecordBonus(&l, p.RecordBonus)
validateTemplates(&l, p.ChannelTemplates, profiles)
validateOverrides(&l, p.EventOverrides, profiles)
if len(l.issues) > 0 {
return &ValidationError{Issues: l.issues}
}
return nil
}
func validateTieBreak(l *issueList, tieBreak []string) {
seen := make(map[string]bool, len(tieBreak))
for i, key := range tieBreak {
path := fmt.Sprintf("standings.tieBreak[%d]", i)
if !knownTieBreakKeys[key] {
l.addf(path, "未知的排序键 %q", key)
continue
}
if seen[key] {
l.addf(path, "排序键 %q 重复", key)
}
seen[key] = true
}
}
func validateProfile(l *issueList, path string, prof Profile, seen map[string]bool) {
if strings.TrimSpace(prof.Code) == "" {
l.addf(path+".code", "不能为空")
} else if seen[prof.Code] {
l.addf(path+".code", "模板编码 %q 重复", prof.Code)
} else {
seen[prof.Code] = true
}
if strings.TrimSpace(prof.Name) == "" {
l.addf(path+".name", "不能为空")
}
if prof.AwardRanks < 0 {
l.addf(path+".awardRanks", "不能为负,当前 %d", prof.AwardRanks)
}
if prof.TieMode != domain.TieSkip && prof.TieMode != domain.TieAverage && prof.TieMode != domain.TieHigher {
l.addf(path+".tieMode", "只能是 skip / average / higher,当前 %q", prof.TieMode)
}
if prof.RecordBonusMode != "" && prof.RecordBonusMode != domain.RecordBonusMax && prof.RecordBonusMode != domain.RecordBonusSum {
l.addf(path+".recordBonusMode", "只能是 max / sum,当前 %q", prof.RecordBonusMode)
}
if prof.AwardRanks > 0 && len(prof.Rules) == 0 {
l.addf(path+".rules", "awardRanks=%d 却没有分值明细", prof.AwardRanks)
}
ranks := make(map[int]bool, len(prof.Rules))
for j, r := range prof.Rules {
rpath := fmt.Sprintf("%s.rules[%d]", path, j)
if r.Rank < 1 {
l.addf(rpath+".rank", "名次必须 >= 1,当前 %d", r.Rank)
} else if ranks[r.Rank] {
l.addf(rpath+".rank", "名次 %d 重复", r.Rank)
}
ranks[r.Rank] = true
if r.Points < 0 {
l.addf(rpath+".points", "分值不能为负,当前 %v", r.Points)
}
}
}
func validateRecordBonus(l *issueList, rb RecordBonus) {
if rb.Mode != "" && rb.Mode != domain.RecordBonusMax && rb.Mode != domain.RecordBonusSum {
l.addf("recordBonus.mode", "只能是 max / sum,当前 %q", rb.Mode)
}
if rb.Enabled && len(rb.Levels) == 0 {
l.addf("recordBonus.levels", "启用破纪录加分却没有配置档位")
}
seen := make(map[string]bool, len(rb.Levels))
for i, lv := range rb.Levels {
path := fmt.Sprintf("recordBonus.levels[%d]", i)
if strings.TrimSpace(lv.Code) == "" {
l.addf(path+".code", "不能为空")
} else if seen[lv.Code] {
l.addf(path+".code", "档位编码 %q 重复", lv.Code)
} else {
seen[lv.Code] = true
}
if strings.TrimSpace(lv.Name) == "" {
l.addf(path+".name", "不能为空")
}
if lv.Points < 0 {
l.addf(path+".points", "分值不能为负,当前 %v", lv.Points)
}
}
}
func validateTemplates(l *issueList, templates []ChannelTemplate, profiles map[string]bool) {
seen := make(map[string]bool, len(templates))
for i, t := range templates {
path := fmt.Sprintf("channelTemplates[%d]", i)
key := matchKey(t.Match)
if key == "" {
l.addf(path+".match", "至少要指定 eventType / resultType / sportCode 之一")
} else if seen[key] {
l.addf(path+".match", "匹配条件 %s 重复", key)
}
seen[key] = true
if len(t.Channels) == 0 {
l.addf(path+".channels", "模板至少要有一个计分通道")
}
validateChannels(l, path+".channels", t.Channels, profiles)
}
}
func validateOverrides(l *issueList, overrides []EventOverride, profiles map[string]bool) {
seen := make(map[string]bool, len(overrides))
for i, o := range overrides {
path := fmt.Sprintf("eventOverrides[%d]", i)
if strings.TrimSpace(o.EventCode) == "" {
l.addf(path+".eventCode", "不能为空")
} else if seen[o.EventCode] {
l.addf(path+".eventCode", "项目编码 %q 重复", o.EventCode)
} else {
seen[o.EventCode] = true
}
if o.Multiplier != nil && *o.Multiplier < 0 {
l.addf(path+".multiplier", "不能为负,当前 %v", *o.Multiplier)
}
if o.Channels != nil {
validateChannels(l, path+".channels", *o.Channels, profiles)
}
}
}
func validateChannels(l *issueList, path string, channels []Channel, profiles map[string]bool) {
seen := make(map[string]bool, len(channels))
for i, ch := range channels {
cpath := fmt.Sprintf("%s[%d]", path, i)
if ch.Scope != domain.ScopeAthlete && ch.Scope != domain.ScopeTeam {
l.addf(cpath+".scope", "只能是 athlete / team,当前 %q", ch.Scope)
}
switch ch.Basis {
case domain.BasisResult, domain.BasisManual, domain.BasisAggregate:
default:
l.addf(cpath+".basis", "只能是 result / manual / aggregate,当前 %q", ch.Basis)
}
if strings.TrimSpace(ch.Profile) == "" {
l.addf(cpath+".profile", "不能为空")
} else if !profiles[ch.Profile] {
l.addf(cpath+".profile", "引用了不存在的积分模板 %q", ch.Profile)
}
switch ch.Aggregate {
case "":
case domain.AggregateSum, domain.AggregateAvg, domain.AggregateMax, domain.AggregateMin, domain.AggregateCount:
if ch.Basis != domain.BasisAggregate {
l.addf(cpath+".aggregate", "只有 basis=aggregate 的通道才配置 aggregate")
}
default:
l.addf(cpath+".aggregate", "未知的汇总口径 %q", ch.Aggregate)
}
// 落库有 UNIQUE(event_id, scope, basis) 约束,这里先挡住,避免导入时才炸。
key := string(ch.Scope) + "/" + string(ch.Basis)
if seen[key] {
l.addf(cpath, "同一组配置里出现重复通道 %s", key)
}
seen[key] = true
}
}
func matchKey(m ChannelMatch) string {
var parts []string
if m.EventType != "" {
parts = append(parts, "eventType="+string(m.EventType))
}
if m.ResultType != "" {
parts = append(parts, "resultType="+string(m.ResultType))
}
if m.SportCode != "" {
parts = append(parts, "sportCode="+m.SportCode)
}
return strings.Join(parts, ",")
}
func matchEvent(m ChannelMatch, e EventInfo) bool {
if m.EventType != "" && m.EventType != e.EventType {
return false
}
if m.ResultType != "" && m.ResultType != e.ResultType {
return false
}
if m.SportCode != "" && m.SportCode != e.SportCode {
return false
}
return true
}