用户点名的三件事(模板功能、删死代码、剩余缺口)这一批全部落地,另修掉两个刚暴露的导入缺陷:
**新增能力**
- 备份恢复:`POST /api/backup/restore`(admin)。先把校验做在拍快照之前(只读打开 + integrity_check +
关键表校验 + 异类库标志表互斥),再用 VACUUM INTO 拍「恢复前安全副本」,然后关连接、整文件替换、
触发优雅重启;`-wal` 还在时直接拒绝(宁可失败也不让旧库 WAL 套到新库上);恢复后审计直写"恢复之后
还在的那份库"(不走通用 Auditor,否则记录只会落在被替换掉的旧文件上)。顺带修掉:裸服务端连续恢复
第二次会在拍副本处崩出一句看不懂的 500,现在回 503「上次恢复后尚未重启,请先重启服务端」。
- 安全副本保留策略:每类库保留最近 5 份,独立于 .db 的 20 份滚动清理。
- 图片管理:`DELETE /api/uploads/{name}`(被引用 409 并列出引用点)、`GET /api/uploads/orphans`、
`POST /api/uploads/orphans/delete`(只删列出的孤儿,宁漏勿误),全部 admin(与字体删除同档);
`deploy/backup.bat` 补上 uploads/ 归档(logs 不归档:可再生的诊断且会无上限增长)。
- 已彻底删除单位的恢复:`POST /api/teams/{id}/restore`(与 hideTeam 同档)。幂等;恢复不会把「停用」
变成「启用」;真机确认恢复前后榜单逐字节一致、change_log 最大值不变(数据一行没动,不需要重算)。
界面加「显示已彻底删除的单位」(仅 admin)与恢复按钮;非 admin 的删除确认框里写明"本账号无法自行恢复"。
- 审计日志按 `action` 过滤(子串模糊,输入里的 %/_/\ 转义成字面量)。
- 删除死表 recent_projects(新增 system 迁移 0006,未动已应用的迁移)。
- points_override 不再"设了没效果":写入侧 400 + 引擎侧守卫,提示改用「积分调整」。
- 联队份额空壳清理(UnionAttribution/UnionSplit/Entry.TeamShares/resolvedShares 等;TeamShare 类型保留,
因为 export/realtime/奖牌流水三处在生产路径上构造它)。
- 自定义大屏模板 M1:插值引擎(parseTemplate/renderTemplate),任何情况都不抛异常、未定义→空串+警告、
警告按 (code,path) 合并计数、无 eval/innerHTML,57 个新用例。
**两个真缺陷(前两个已随 0.1.6 出包)**
- 基础数据包导入「排序」列口径不一致:小项/组别留空保持原值,单位/大项留空被写成 0(plan 没从库里
现值起手)→ 四张表统一为「留空保持原值」。
- 同类第二处:布尔列「整列被删掉」被当成"取默认值",会把库里所有停用的单位/大项静默改回启用 →
改为「缺列 = 不动」(写侧与解析侧都收口,补了"缺列保持/空格子=是/显式否=停用"三组对照用例)。
**删死代码**
- createEntry / updateEntry / deleteEntry / entryEventID / checkCategoryInEvent / getEntry /
normalizeEntryStatus / ExportBaseDataXLSX(全仓 grep 零引用)。⚠️ 随之记入文档:「组别必须属于该小项」
这道校验现在只由导入路径"按小项+组别名解析"天然保证,将来若新增从请求体接收 categoryId 的接口,
必须自己补校验(库层没有复合外键)。
**文档**
- devdocs 全面同步(01/02/03/04/05/06/07/09/11/12/13/README;含身份口径、延迟结算、大屏单栏新版式、
新增路由与设置键、R18/R19 风险);docs 复核 + 第二轮(身份口径三个易写反的分支、图片删除、备份覆盖、
审计过滤、单位恢复、去掉硬编码版本号、上线检查清单 +5 项);debug/使用说明 6 份逐份 diff 一致。
测试:服务端 13 包(连跑两遍)、前端 406 例、Console 28 例 + 18 文件语法检查 —— 全绿。
版本号 dev_0.1.6 → dev_0.1.7。
323 lines
12 KiB
Go
323 lines
12 KiB
Go
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},
|
||
// aggregate 的汇总口径(avg/max/min/count)没有落库列,已从类型里移除;写了必须报错而不是静默丢弃。
|
||
{"通道 aggregate 口径已移除", strings.Replace(validPackYAML, "{ scope: team, basis: manual, profile: team_top6 }", "{ scope: team, basis: aggregate, profile: team_top6, aggregate: avg }", 1), nil},
|
||
// standings 只认已落库的键:多写的键必须报错,不能被静默丢弃。
|
||
{"standings 未知键", strings.Replace(validPackYAML, "standings:\n", "standings:\n unionAttribution: split\n", 1), nil},
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
rb := pack.RecordBonus.ToDomain()
|
||
if rb.Enabled || rb.Mode != domain.RecordBonusMax || len(rb.Levels) != 1 {
|
||
t.Errorf("破纪录配置转换错误: %+v", rb)
|
||
}
|
||
}
|
||
|
||
// TestRecordBonusFromSettings 验证上层能从 settings 键值对读到破纪录档位:
|
||
// 只读 enabled/mode 而不读 levels,会让破纪录加分永远算 0。
|
||
func TestRecordBonusFromSettings(t *testing.T) {
|
||
settings := map[string]string{
|
||
settingRecordBonusEnabled: "true",
|
||
settingRecordBonusMode: "sum",
|
||
settingRecordBonusLevels: `[{"code":"meet","name":"赛会纪录","points":2},{"code":"city","name":"市级纪录","points":5}]`,
|
||
}
|
||
cfg := RecordBonusFromSettings(settings)
|
||
if !cfg.Enabled || cfg.Mode != domain.RecordBonusSum {
|
||
t.Fatalf("开关/口径错误: %+v", cfg)
|
||
}
|
||
if len(cfg.Levels) != 2 {
|
||
t.Fatalf("档位数 = %d,期望 2(levels 必须被解析)", len(cfg.Levels))
|
||
}
|
||
if cfg.Levels[1].Code != "city" || cfg.Levels[1].Points != 5 {
|
||
t.Fatalf("档位内容错误: %+v", cfg.Levels[1])
|
||
}
|
||
|
||
t.Run("缺省值", func(t *testing.T) {
|
||
cfg := RecordBonusFromSettings(map[string]string{})
|
||
if cfg.Enabled || cfg.Mode != domain.RecordBonusMax || len(cfg.Levels) != 0 {
|
||
t.Fatalf("缺省配置错误: %+v", cfg)
|
||
}
|
||
})
|
||
}
|
||
|
||
// TestRecordBonusSettingsRoundTrip 验证规则包写入 settings 后还能原样读回档位。
|
||
func TestRecordBonusSettingsRoundTrip(t *testing.T) {
|
||
pack, err := Parse([]byte(validPackYAML))
|
||
if err != nil {
|
||
t.Fatalf("Parse: %v", err)
|
||
}
|
||
got := RecordBonusFromSettings(settingsFromPack(pack))
|
||
want := pack.RecordBonus.ToDomain()
|
||
if !reflect.DeepEqual(got, want) {
|
||
t.Fatalf("settings 往返后破纪录配置不一致:\n%+v\n%+v", got, want)
|
||
}
|
||
}
|