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。
This commit is contained in:
1 parent
631c8dd8e1
commit
53a0cb225c
61 files changed
+6386
-699
No files matched your search
@@ -605,7 +605,14 @@ func (p *baseDataPlanner) planTeams(rows []export.BaseDataRow) {
|
||||
p.rowError(sheet, line, "单位名称必填")
|
||||
continue
|
||||
}
|
||||
plan := baseDataTeamPlan{line: line, code: code, name: name, active: true}
|
||||
// 起手值一律取库里现值:**列缺席 = 不动该字段**(布尔列也一样,见下面的解析块),
|
||||
// 列在但格子空才落到「取默认值」:排序空值不改起手值、启用空值由解析取「是」。
|
||||
// 新建时 p.teams 取不到,给与建表默认一致的起手值:启用、排序 0。
|
||||
existing, ok := p.teams[code]
|
||||
if !ok {
|
||||
existing = baseDataExistingTeam{active: true}
|
||||
}
|
||||
plan := baseDataTeamPlan{line: line, code: code, name: name, active: existing.active, sortOrder: existing.sortOrder}
|
||||
if row.Has["active"] {
|
||||
active, ok := export.ParseBaseDataBool(row.Cells["active"], true)
|
||||
if !ok {
|
||||
@@ -624,7 +631,6 @@ func (p *baseDataPlanner) planTeams(rows []export.BaseDataRow) {
|
||||
plan.sortOrder = *n
|
||||
}
|
||||
}
|
||||
existing, ok := p.teams[code]
|
||||
if !ok {
|
||||
plan.action = "added"
|
||||
if row.Has["shortName"] {
|
||||
@@ -685,7 +691,13 @@ func (p *baseDataPlanner) planSports(rows []export.BaseDataRow) {
|
||||
p.rowError(sheet, line, "大项名称必填")
|
||||
continue
|
||||
}
|
||||
plan := baseDataSportPlan{line: line, code: code, name: name, active: true}
|
||||
// 与单位同口径:起手取库里现值,列缺席 = 不动该字段(启用也一样);
|
||||
// 列在(row.Has 为真)才走下面的解析块,格子空由它取默认「是」。新建才落默认:启用、排序 0。
|
||||
existing, ok := p.sports[code]
|
||||
if !ok {
|
||||
existing = baseDataExistingSport{active: true}
|
||||
}
|
||||
plan := baseDataSportPlan{line: line, code: code, name: name, active: existing.active, sortOrder: existing.sortOrder}
|
||||
if row.Has["active"] {
|
||||
active, ok := export.ParseBaseDataBool(row.Cells["active"], true)
|
||||
if !ok {
|
||||
@@ -704,7 +716,6 @@ func (p *baseDataPlanner) planSports(rows []export.BaseDataRow) {
|
||||
plan.sortOrder = *n
|
||||
}
|
||||
}
|
||||
existing, ok := p.sports[code]
|
||||
if !ok {
|
||||
plan.action = "added"
|
||||
if row.Has["shortName"] {
|
||||
@@ -1447,3 +1458,249 @@ func (s *server) listBackups(w http.ResponseWriter, r *http.Request) (any, error
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// backupRestoreResult 是恢复请求被接受后的回执。
|
||||
//
|
||||
// SafetyCopy 是「恢复前安全副本」的文件名,必须回报给操作员:
|
||||
// 应用这次恢复的理由是数据被改乱了,那恢复本身错了(选错时间点 / 选错库)的概率就不低,
|
||||
// 现场要能立刻拿副本退回。
|
||||
type backupRestoreResult struct {
|
||||
// Kind 是这次用的文件的性质:system / event / safety(安全副本也能拿来退回)。
|
||||
Kind string `json:"kind"`
|
||||
// DBKind 是被恢复的库:system / event。
|
||||
DBKind string `json:"dbKind"`
|
||||
KindLabel string `json:"kindLabel"`
|
||||
File string `json:"file"`
|
||||
Target string `json:"target"`
|
||||
// SafetyCopy 与本策略同名文件在备份目录里的完整路径;PrunedSafetyCopies 是这次顺带清掉的旧副本份数。
|
||||
SafetyCopy string `json:"safetyCopy"`
|
||||
SafetyCopyPath string `json:"safetyCopyPath"`
|
||||
PrunedSafetyCopies int `json:"prunedSafetyCopies"`
|
||||
Restarting bool `json:"restarting"`
|
||||
Message string `json:"message"`
|
||||
Warning string `json:"warning,omitempty"`
|
||||
}
|
||||
|
||||
// restoreBackupHandler 用备份文件恢复当前库(仅 admin)。
|
||||
//
|
||||
// 形态与「切换赛事」同路:先做完全部校验与留底,再动库文件,最后优雅退出让 Console 拉起。
|
||||
// 不在运行中热替换——Windows 上替换不了正被打开的文件,硬写会把库写坏;
|
||||
// 所以这里先把目标库的连接关掉(服务端从此不再持有它),整文件替换,再触发重启。
|
||||
//
|
||||
// 顺序是有讲究的,任何一步失败都不能留下「半截」状态:
|
||||
//
|
||||
// 校验备份(坏文件直接 400,当前库毫发无伤)
|
||||
// → 给当前库拍安全副本(失败就放弃,什么都没动)
|
||||
// → 落恢复标记(死了也有线索)
|
||||
// → 关连接、替换文件
|
||||
// → 复核新库 + 写审计 + 回执(复核不过自动退回安全副本)
|
||||
func (s *server) restoreBackupHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in restoreBackupPayload
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kind, ok := export.ParseRestoreKind(in.Kind)
|
||||
if !ok {
|
||||
return nil, badRequest("请指定要恢复的库:kind 只能是 system(系统库)或 event(赛事库)", nil)
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(in.File)
|
||||
if name == "" {
|
||||
return nil, badRequest("请选择要恢复的备份文件", nil)
|
||||
}
|
||||
// 只收裸文件名:带分隔符(或 ..)的输入会把恢复变成「任意文件覆盖任意路径」。
|
||||
if name != filepath.Base(name) || strings.ContainsAny(name, `/\`) {
|
||||
return nil, badRequest("备份文件名非法,只能填备份目录里的文件名", nil)
|
||||
}
|
||||
fileKind, fileDBKind, ok := export.KindOfBackupName(name)
|
||||
if !ok {
|
||||
return nil, badRequest("无法从这个文件名判断它属于哪个库,请从备份列表里选择", nil)
|
||||
}
|
||||
// 比的是「它对应的库」:安全副本的性质是 safety,但覆盖的仍是它那一份库,
|
||||
// 拿安全副本退回上一个时间点是正当操作(列表接口会给出 dbKind)。
|
||||
if fileDBKind != kind {
|
||||
return nil, badRequest(fmt.Sprintf("%s 是%s的备份,不能恢复成%s;请确认要恢复的库",
|
||||
name, fileDBKind.Label(), kind.Label()), nil)
|
||||
}
|
||||
|
||||
backupsDir := filepath.Join(s.dataDir, "backups")
|
||||
backupPath := filepath.Join(backupsDir, name)
|
||||
if err := export.ValidateBackup(backupPath, kind); err != nil {
|
||||
return nil, badRequest(trimExportPrefix(err.Error()), nil)
|
||||
}
|
||||
|
||||
target, db, apiErr := s.restoreTarget(kind)
|
||||
if apiErr != nil {
|
||||
return nil, apiErr
|
||||
}
|
||||
// 上一次恢复(或任何原因)已经把连接关掉了:这时再往下走,拍安全副本只会撞上
|
||||
// 「sql: database is closed」,报出来是一句看不懂的 500。直接说清「先重启服务端」。
|
||||
if err := db.PingContext(r.Context()); err != nil && dbClosed(err) {
|
||||
return nil, serviceUnavailable("INTERNAL", "数据库连接已关闭(上一次恢复后尚未重启),请先重启服务端再恢复")
|
||||
}
|
||||
|
||||
safety, err := export.SafetySnapshot(db, backupsDir, kind, time.Now())
|
||||
if err != nil {
|
||||
return nil, internalMsg("生成恢复前安全副本失败,当前库未做任何改动", err)
|
||||
}
|
||||
// 安全副本自己那份保留策略(每类库最近 5 份,最新的永不删),与普通备份的 20 份滚动互不干扰。
|
||||
// 清不掉旧副本不算恢复失败:磁盘上多留一份整库文件,比因此拒绝一次现场恢复划算得多。
|
||||
pruned := 0
|
||||
if removed, pruneErr := export.PruneSafetyCopies(backupsDir, kind, export.DefaultSafetyCopyKeep); pruneErr != nil {
|
||||
s.logger.Warn("清理旧安全副本失败", "kind", string(kind), "err", pruneErr)
|
||||
} else {
|
||||
pruned = len(removed)
|
||||
if pruned > 0 {
|
||||
s.logger.Info("已清理旧安全副本", "kind", string(kind),
|
||||
"removed", pruned, "keep", export.DefaultSafetyCopyKeep)
|
||||
}
|
||||
}
|
||||
|
||||
operator := "guest"
|
||||
if u := middleware.User(r.Context()); u != nil && u.ID != 0 {
|
||||
operator = fmt.Sprintf("%s#%d", u.Username, u.ID)
|
||||
}
|
||||
markerPath, err := export.WritePendingRestore(s.dataDir, export.PendingRestore{
|
||||
Kind: kind,
|
||||
Backup: backupPath,
|
||||
BackupName: name,
|
||||
Target: target,
|
||||
SafetyCopy: safety.Path,
|
||||
Operator: operator,
|
||||
RequestedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, internalMsg("写入恢复标记失败,当前库未做任何改动", err)
|
||||
}
|
||||
|
||||
// 停机第一步:断开目标库的全部连接。此后服务端不再持有这个库,
|
||||
// 直到本次请求触发优雅退出、Console 把服务端重新拉起来。
|
||||
if err := db.Close(); err != nil {
|
||||
s.logger.Warn("关闭目标库连接失败,继续恢复", "kind", string(kind), "err", err)
|
||||
}
|
||||
if err := export.ApplyRestore(backupPath, target); err != nil {
|
||||
// 连接已经断开,即使文件没换成,也得让服务端重启一次把库重新打开。
|
||||
s.triggerRestart(kind)
|
||||
return nil, internalMsg("替换数据库失败,当前库文件未被改动;恢复标记留在 "+markerPath+
|
||||
";请用恢复前安全副本 "+safety.Path+" 退回", err)
|
||||
}
|
||||
// 备份在替换前校验过一次,这里是兜底复核:万一新库打不开,立刻退回安全副本,
|
||||
// 绝不能让一次恢复留下一个打不开的库。
|
||||
if err := export.ValidateBackup(target, kind); err != nil {
|
||||
if rbErr := export.ApplyRestore(safety.Path, target); rbErr != nil {
|
||||
s.triggerRestart(kind)
|
||||
return nil, internalMsg("恢复后校验失败,且自动回滚也失败;请立即用安全副本 "+
|
||||
safety.Path+" 手工恢复", err)
|
||||
}
|
||||
_ = export.ClearPendingRestore(s.dataDir)
|
||||
s.triggerRestart(kind)
|
||||
return nil, internalMsg("恢复后校验失败,已自动回滚到恢复前状态(安全副本 "+safety.Name+")", err)
|
||||
}
|
||||
_ = export.ClearPendingRestore(s.dataDir)
|
||||
|
||||
// 审计直接写给「恢复之后还在的那份库」(审计表在赛事库);
|
||||
// 恢复赛事库时旧连接已关、旧文件已换,走中间件的审计只会留在被换掉的文件上。
|
||||
warning := ""
|
||||
if err := s.recordRestoreAudit(r, kind, name, target, safety.Name); err != nil {
|
||||
s.logger.Error("恢复审计写入失败", "kind", string(kind), "err", err)
|
||||
warning = "恢复已完成,但审计写入失败:" + err.Error()
|
||||
}
|
||||
|
||||
base := fmt.Sprintf("已用 %s 恢复%s", name, kind.Label())
|
||||
if s.shutdown == nil {
|
||||
// 裸服务端(没接 Console)没有可触发的优雅重启,库文件已经换好但进程还抱着旧连接,
|
||||
// 只能由操作员手工重启——回执必须说实话,不能让人以为它会自己回来。
|
||||
base += "。服务端未接入自动重启,请手工重启服务后刷新页面"
|
||||
} else {
|
||||
base += ";服务端正在重启,请稍候刷新页面"
|
||||
}
|
||||
notes := []string{"恢复前安全副本:" + safety.Name}
|
||||
if pruned > 0 {
|
||||
notes = append(notes, fmt.Sprintf("已清理 %d 份更旧的安全副本(每类库保留最近 %d 份)",
|
||||
pruned, export.DefaultSafetyCopyKeep))
|
||||
}
|
||||
if kind == export.RestoreKindEvent {
|
||||
// 备份文件名里没有项目名,从别的项目备下来的 event-…db 长得一模一样。
|
||||
notes = append(notes, "赛事库备份的文件名里不含来源项目,恢复前请确认来源")
|
||||
}
|
||||
|
||||
result := backupRestoreResult{
|
||||
Kind: fileKind,
|
||||
DBKind: string(kind),
|
||||
KindLabel: kind.Label(),
|
||||
File: name,
|
||||
Target: target,
|
||||
SafetyCopy: safety.Name,
|
||||
SafetyCopyPath: safety.Path,
|
||||
PrunedSafetyCopies: pruned,
|
||||
Restarting: s.shutdown != nil,
|
||||
Message: base + "。" + strings.Join(notes, ";"),
|
||||
Warning: warning,
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": result})
|
||||
s.triggerRestart(kind)
|
||||
return nil, errHandled
|
||||
}
|
||||
|
||||
// triggerRestart 走「切换赛事」同一条收尾:优雅退出,由 Console 拉起服务端。
|
||||
//
|
||||
// 恢复一旦执行到「关掉目标库连接」这一步,无论后面成没成,都必须重启一次——
|
||||
// 连接已经断开,只有新进程会把库重新打开。调用点在响应写完之后:
|
||||
// srv.Shutdown 会等本次请求(含响应与审计落库)结束,不会截断回执。
|
||||
func (s *server) triggerRestart(kind export.RestoreKind) {
|
||||
if s.shutdown == nil {
|
||||
// 与切换赛事一致的降级:没接入优雅关闭时文件已经换好,下次重启即生效。
|
||||
s.logger.Warn("服务端未接入优雅关闭,恢复将在下次重启后生效", "kind", string(kind))
|
||||
return
|
||||
}
|
||||
go s.shutdown()
|
||||
}
|
||||
|
||||
// restoreTarget 返回要恢复的库文件路径与当前连接。
|
||||
// 赛事库的路径取自服务端实际打开的赛事目录,而不是从记忆文件重新推一遍:
|
||||
// 恢复必须覆盖「当前正在用的那份」,退役项目里的同名文件不是目标。
|
||||
func (s *server) restoreTarget(kind export.RestoreKind) (string, *sql.DB, error) {
|
||||
switch kind {
|
||||
case export.RestoreKindSystem:
|
||||
if strings.TrimSpace(s.dataDir) == "" || s.sys == nil {
|
||||
return "", nil, serviceUnavailable("INTERNAL", "服务端未配置数据目录,无法恢复系统库")
|
||||
}
|
||||
return filepath.Join(s.dataDir, "system.db"), s.sys, nil
|
||||
case export.RestoreKindEvent:
|
||||
if strings.TrimSpace(s.projectDir) == "" || s.ev == nil {
|
||||
return "", nil, serviceUnavailable("INTERNAL", "服务端未配置赛事项目目录,无法恢复赛事库")
|
||||
}
|
||||
return filepath.Join(s.projectDir, "event.db"), s.ev, nil
|
||||
default:
|
||||
return "", nil, badRequest("要恢复的库只能是 system 或 event", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// recordRestoreAudit 把这次恢复写进赛事库的 audit_logs(audit 表在赛事库,系统库没有)。
|
||||
func (s *server) recordRestoreAudit(r *http.Request, kind export.RestoreKind, file, target, safetyName string) error {
|
||||
if strings.TrimSpace(s.projectDir) == "" {
|
||||
return errors.New("未配置赛事项目目录,没有可写审计的库")
|
||||
}
|
||||
detail := map[string]any{
|
||||
"kind": string(kind),
|
||||
"file": file,
|
||||
"target": target,
|
||||
"safetyCopy": safetyName,
|
||||
}
|
||||
if project := currentProjectName(s.projectDir); project != "" {
|
||||
detail["project"] = project
|
||||
}
|
||||
return export.RecordRestoreAudit(filepath.Join(s.projectDir, "event.db"), export.RestoreAuditRow{
|
||||
Action: "system.restore",
|
||||
EntityType: "backup",
|
||||
ActorID: middlewareUserID(r),
|
||||
IP: middleware.ClientIP(r),
|
||||
RequestID: middleware.GetRequestID(r.Context()),
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
|
||||
// trimExportPrefix 去掉 export 包错误里的包名前缀,让提示读起来像人话。
|
||||
func trimExportPrefix(message string) string {
|
||||
return strings.TrimPrefix(message, "export: ")
|
||||
}
|
||||
Reference in new issue
Block a user