Files
AskaEth 53a0cb225c feat: 备份恢复 / 图片清理 / 单位恢复 / 审计过滤 / 模板引擎 M1 + 两个导入缺陷修复
用户点名的三件事(模板功能、删死代码、剩余缺口)这一批全部落地,另修掉两个刚暴露的导入缺陷:

**新增能力**
- 备份恢复:`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。
2026-09-11 19:26:36 +08:00

323 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}