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

+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
}