用户点名的三件事(模板功能、删死代码、剩余缺口)这一批全部落地,另修掉两个刚暴露的导入缺陷:
**新增能力**
- 备份恢复:`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。
321 lines
11 KiB
Go
321 lines
11 KiB
Go
package domain
|
||
|
||
import "testing"
|
||
|
||
func bonusConfig(mode RecordBonusMode) RecordBonusConfig {
|
||
return RecordBonusConfig{
|
||
Enabled: true, Mode: mode,
|
||
Levels: []RecordLevel{
|
||
{Code: "meet", Name: "赛会纪录", Points: 2},
|
||
{Code: "city", Name: "市级纪录", Points: 5},
|
||
{Code: "province", Name: "省级纪录", Points: 8},
|
||
},
|
||
}
|
||
}
|
||
|
||
func TestRecordBonusDisabled(t *testing.T) {
|
||
cfg := bonusConfig(RecordBonusMax)
|
||
cfg.Enabled = false
|
||
got := RecordBonusPoints(cfg, []RecordBreak{{EntryID: 1, Level: "meet", State: RecordBreakApproved}})
|
||
if got != nil {
|
||
t.Fatalf("未启用时 = %+v,期望 nil", got)
|
||
}
|
||
}
|
||
|
||
func TestRecordBonusStateFilter(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
state RecordBreakState
|
||
want float64
|
||
}{
|
||
{"已通过计入", RecordBreakApproved, 2},
|
||
{"待审核不计入", RecordBreakPending, 0},
|
||
{"已驳回不计入", RecordBreakRejected, 0},
|
||
{"状态空值不计入", "", 0},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
got := RecordBonusPoints(bonusConfig(RecordBonusMax), []RecordBreak{{EntryID: 1, Level: "meet", State: tc.state}})
|
||
if tc.want == 0 {
|
||
if len(got) != 0 {
|
||
t.Fatalf("结果 = %+v,期望空", got)
|
||
}
|
||
return
|
||
}
|
||
if len(got) != 1 || !nearlyEqual(got[0].Points, tc.want) {
|
||
t.Fatalf("结果 = %+v,期望 %v 分", got, tc.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestRecordBonusUnknownLevel(t *testing.T) {
|
||
got := RecordBonusPoints(bonusConfig(RecordBonusMax), []RecordBreak{{EntryID: 1, Level: "galaxy", State: RecordBreakApproved}})
|
||
if len(got) != 0 {
|
||
t.Fatalf("未配置档位 = %+v,期望空(不能凭空加分)", got)
|
||
}
|
||
}
|
||
|
||
func TestRecordBonusModes(t *testing.T) {
|
||
breaks := []RecordBreak{
|
||
{ID: 1, EntryID: 1, Level: "meet", State: RecordBreakApproved},
|
||
{ID: 2, EntryID: 1, Level: "city", State: RecordBreakApproved},
|
||
{ID: 3, EntryID: 1, Level: "province", State: RecordBreakApproved},
|
||
}
|
||
cases := []struct {
|
||
name string
|
||
mode RecordBonusMode
|
||
want float64
|
||
}{
|
||
{"max 取最高档", RecordBonusMax, 8},
|
||
{"sum 累加", RecordBonusSum, 15},
|
||
{"模式空值按 max", "", 8},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
got := RecordBonusPoints(bonusConfig(tc.mode), breaks)
|
||
if len(got) != 1 || !nearlyEqual(got[0].Points, tc.want) {
|
||
t.Fatalf("结果 = %+v,期望 %v", got, tc.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestRecordBonusGroupsByEntry(t *testing.T) {
|
||
breaks := []RecordBreak{
|
||
{ID: 1, EntryID: 2, Level: "meet", State: RecordBreakApproved},
|
||
{ID: 2, EntryID: 1, Level: "city", State: RecordBreakApproved},
|
||
{ID: 3, EntryID: 1, Level: "meet", State: RecordBreakApproved},
|
||
}
|
||
got := RecordBonusPoints(bonusConfig(RecordBonusMax), breaks)
|
||
if len(got) != 2 {
|
||
t.Fatalf("条数 = %d,期望 2(按报名项分组)", len(got))
|
||
}
|
||
if got[0].EntryID != 1 || got[1].EntryID != 2 {
|
||
t.Fatalf("顺序 = %+v,期望按 EntryID 升序", got)
|
||
}
|
||
if !nearlyEqual(got[0].Points, 5) || !nearlyEqual(got[1].Points, 2) {
|
||
t.Fatalf("分值 = %v/%v,期望 5/2", got[0].Points, got[1].Points)
|
||
}
|
||
}
|
||
|
||
func TestRecordBonusDedupLevel(t *testing.T) {
|
||
breaks := []RecordBreak{
|
||
{ID: 1, EntryID: 1, Level: "meet", State: RecordBreakApproved},
|
||
{ID: 2, EntryID: 1, Level: "meet", State: RecordBreakApproved},
|
||
}
|
||
got := RecordBonusPoints(bonusConfig(RecordBonusSum), breaks)
|
||
if len(got) != 1 || !nearlyEqual(got[0].Points, 2) {
|
||
t.Fatalf("重复档位 = %+v,期望只计一次 2 分", got)
|
||
}
|
||
if len(got[0].Levels) != 1 {
|
||
t.Fatalf("档位列表 = %v,期望去重", got[0].Levels)
|
||
}
|
||
}
|
||
|
||
func TestRecordBonusNoBreaks(t *testing.T) {
|
||
if got := RecordBonusPoints(bonusConfig(RecordBonusMax), nil); got != nil {
|
||
t.Fatalf("无破纪录登记 = %+v,期望 nil", got)
|
||
}
|
||
}
|
||
|
||
func TestRecordBonusLedger(t *testing.T) {
|
||
entries, results := fixture(lineup(2))
|
||
base := EventScoreInput{
|
||
Event: individualEvent(1), Category: testCategory(),
|
||
Channels: []ScoreChannel{athleteResultChannel(1)},
|
||
Profiles: map[int64]ScoringProfile{1: individualTop6()},
|
||
Entries: entries, Results: results,
|
||
RecordBreaks: []RecordBreak{{
|
||
ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001,
|
||
Level: "meet", State: RecordBreakApproved,
|
||
}},
|
||
}
|
||
|
||
t.Run("未启用不产生加分流水", func(t *testing.T) {
|
||
cfg := bonusConfig(RecordBonusMax)
|
||
cfg.Enabled = false
|
||
base.RecordBonus = &cfg
|
||
es := mustScore(t, base)
|
||
for _, e := range es.Ledger {
|
||
if e.SourceType == SourceRecordBonus {
|
||
t.Fatalf("不应有加分流水: %+v", e)
|
||
}
|
||
}
|
||
})
|
||
|
||
t.Run("启用后归属到人", func(t *testing.T) {
|
||
cfg := bonusConfig(RecordBonusMax)
|
||
base.RecordBonus = &cfg
|
||
es := mustScore(t, base)
|
||
var found bool
|
||
for _, e := range es.Ledger {
|
||
if e.SourceType != SourceRecordBonus {
|
||
continue
|
||
}
|
||
found = true
|
||
if e.TeamID != 1001 || e.AthleteID != 101 || !nearlyEqual(e.Points, 2) {
|
||
t.Fatalf("加分流水 = %+v,期望 1001/101 记 2 分", e)
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatal("未产生加分流水")
|
||
}
|
||
})
|
||
}
|
||
|
||
// recordBonusPointsOf 统计一次计分产出的破纪录加分总和。
|
||
func recordBonusPointsOf(t *testing.T, in EventScoreInput) float64 {
|
||
t.Helper()
|
||
var got float64
|
||
for _, e := range mustScore(t, in).Ledger {
|
||
if e.SourceType == SourceRecordBonus {
|
||
got += e.Points
|
||
}
|
||
}
|
||
return got
|
||
}
|
||
|
||
// TestRecordBonusRequiresEligibleEvent 验证只有「可破纪录」的项目才产生加分(P1-5)。
|
||
func TestRecordBonusRequiresEligibleEvent(t *testing.T) {
|
||
entries, results := fixture(lineup(1))
|
||
cfg := bonusConfig(RecordBonusMax)
|
||
in := EventScoreInput{
|
||
Event: individualEvent(1), Category: testCategory(),
|
||
Channels: []ScoreChannel{athleteResultChannel(1)},
|
||
Profiles: map[int64]ScoringProfile{1: individualTop6()},
|
||
Entries: entries, Results: results,
|
||
RecordBonus: &cfg,
|
||
RecordBreaks: []RecordBreak{{
|
||
ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001,
|
||
Level: "meet", State: RecordBreakApproved,
|
||
}},
|
||
}
|
||
|
||
in.Event.RecordEligible = false
|
||
if got := recordBonusPointsOf(t, in); got != 0 {
|
||
t.Fatalf("不可破纪录项目的加分 = %v,期望 0", got)
|
||
}
|
||
|
||
in.Event.RecordEligible = true
|
||
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 2) {
|
||
t.Fatalf("可破纪录项目的加分 = %v,期望 2", got)
|
||
}
|
||
}
|
||
|
||
// TestRecordBonusUsesHitChannelProfileMode 验证 max/sum 口径取「命中通道」所属模板的配置,
|
||
// 而不是全局 cfg.Mode(P1-3)。
|
||
func TestRecordBonusUsesHitChannelProfileMode(t *testing.T) {
|
||
entries, results := fixture(lineup(1))
|
||
athleteProfile := individualTop6()
|
||
athleteProfile.RecordBonusMode = RecordBonusSum
|
||
teamProfile := teamTop6()
|
||
teamProfile.RecordBonusMode = RecordBonusMax
|
||
cfg := bonusConfig(RecordBonusMax)
|
||
in := EventScoreInput{
|
||
Event: individualEvent(1), Category: testCategory(),
|
||
// 团体通道排在前面、通道 id 更小,个人通道仍应优先命中。
|
||
Channels: []ScoreChannel{
|
||
{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisResult, ProfileID: 2},
|
||
{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1},
|
||
},
|
||
Profiles: map[int64]ScoringProfile{1: athleteProfile, 2: teamProfile},
|
||
Entries: entries, Results: results,
|
||
RecordBonus: &cfg,
|
||
RecordBreaks: []RecordBreak{
|
||
{ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "meet", State: RecordBreakApproved},
|
||
{ID: 2, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "city", State: RecordBreakApproved},
|
||
},
|
||
}
|
||
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 7) {
|
||
t.Fatalf("加分 = %v,期望 7(个人通道 sum:2+5)", got)
|
||
}
|
||
}
|
||
|
||
// TestRecordBonusProfileModeOverridesGlobal 验证模板口径优先于全局口径。
|
||
func TestRecordBonusProfileModeOverridesGlobal(t *testing.T) {
|
||
entries, results := fixture(lineup(1))
|
||
profile := individualTop6()
|
||
profile.RecordBonusMode = RecordBonusMax
|
||
cfg := bonusConfig(RecordBonusSum)
|
||
in := EventScoreInput{
|
||
Event: individualEvent(1), Category: testCategory(),
|
||
Channels: []ScoreChannel{athleteResultChannel(1)},
|
||
Profiles: map[int64]ScoringProfile{1: profile},
|
||
Entries: entries, Results: results,
|
||
RecordBonus: &cfg,
|
||
RecordBreaks: []RecordBreak{
|
||
{ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "meet", State: RecordBreakApproved},
|
||
{ID: 2, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "city", State: RecordBreakApproved},
|
||
},
|
||
}
|
||
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 5) {
|
||
t.Fatalf("加分 = %v,期望 5(模板 max 优先于全局 sum)", got)
|
||
}
|
||
}
|
||
|
||
// TestRecordBonusTeamEntryUsesTeamChannelMode 验证没有运动员的报名项命中团体通道的模板口径。
|
||
func TestRecordBonusTeamEntryUsesTeamChannelMode(t *testing.T) {
|
||
entries, results := fixture([]participant{{entryID: 1, teamID: 1001, value: 1, rank: 1}})
|
||
athleteProfile := individualTop6()
|
||
athleteProfile.RecordBonusMode = RecordBonusMax
|
||
teamProfile := teamTop6()
|
||
teamProfile.RecordBonusMode = RecordBonusSum
|
||
cfg := bonusConfig(RecordBonusMax)
|
||
in := EventScoreInput{
|
||
Event: teamEvent(1), Category: testCategory(),
|
||
Channels: []ScoreChannel{
|
||
{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1},
|
||
{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisResult, ProfileID: 2},
|
||
},
|
||
Profiles: map[int64]ScoringProfile{1: athleteProfile, 2: teamProfile},
|
||
Entries: entries, Results: results,
|
||
RecordBonus: &cfg,
|
||
RecordBreaks: []RecordBreak{
|
||
{ID: 1, EventID: 1, EntryID: 1, TeamID: 1001, Level: "meet", State: RecordBreakApproved},
|
||
{ID: 2, EventID: 1, EntryID: 1, TeamID: 1001, Level: "city", State: RecordBreakApproved},
|
||
},
|
||
}
|
||
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 7) {
|
||
t.Fatalf("加分 = %v,期望 7(团体通道 sum:2+5)", got)
|
||
}
|
||
}
|
||
|
||
// TestRecordBonusLedgerFiltersByCategory 验证按「小项+组别」过滤:
|
||
// 上层逐组别调用 ScoreEvent,别的组别的破纪录登记不能在本组别重复记一次加分。
|
||
func TestRecordBonusLedgerFiltersByCategory(t *testing.T) {
|
||
entries, results := fixture(lineup(2))
|
||
entries[1].CategoryID = 2 // entry2 属于另一个组别
|
||
cfg := bonusConfig(RecordBonusMax)
|
||
es := mustScore(t, EventScoreInput{
|
||
Event: individualEvent(1), Category: testCategory(),
|
||
Channels: []ScoreChannel{athleteResultChannel(1)},
|
||
Profiles: map[int64]ScoringProfile{1: individualTop6()},
|
||
Entries: entries, Results: results,
|
||
RecordBonus: &cfg,
|
||
RecordBreaks: []RecordBreak{
|
||
{ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "meet", State: RecordBreakApproved},
|
||
{ID: 2, EventID: 1, EntryID: 2, AthleteID: 102, TeamID: 1002, Level: "meet", State: RecordBreakApproved},
|
||
},
|
||
})
|
||
|
||
var thisCategory, otherCategory int
|
||
for _, e := range es.Ledger {
|
||
if e.SourceType != SourceRecordBonus {
|
||
continue
|
||
}
|
||
switch e.EntryID {
|
||
case 1:
|
||
thisCategory++
|
||
case 2:
|
||
otherCategory++
|
||
}
|
||
}
|
||
if thisCategory != 1 {
|
||
t.Fatalf("本组别加分流水 = %d 条,期望 1", thisCategory)
|
||
}
|
||
if otherCategory != 0 {
|
||
t.Fatalf("别的组别的加分不应记入本组别,实际 %d 条", otherCategory)
|
||
}
|
||
}
|