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:
1 parent
d0e3b93e76
commit
bc8b1ee6c0
145 files changed
+25159
-321
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user