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:
AskaEth committed 2026-09-11 19:26:36 +08:00
1 parent 631c8dd8e1
commit 53a0cb225c
61 files changed
+6386 -699

No files matched your search

+36 -9
View File
@@ -35,14 +35,41 @@ type BackupOptions struct {
// BackupInfo 是一份备份的描述。
type BackupInfo struct {
Path string `json:"path"`
Name string `json:"name"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
Reason string `json:"reason"`
Path string `json:"path"`
Name string `json:"name"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
Reason string `json:"reason"`
// Kind 是这份备份的性质:系统库备份 / 赛事库备份 / 恢复前安全副本(见 KindSystem 等)。
// 名字认不出来(老版本的 db- 前缀)时为空串,前端据此不给恢复入口。
Kind string `json:"kind"`
// DBKind 是这份备份对应的库(system / event);安全副本也一定属于某一份库。
// 恢复请求要的就是它:性质是 safety,但覆盖的仍是它对应的那份库。
DBKind string `json:"dbKind"`
CreatedAt time.Time `json:"createdAt"`
}
// 备份文件的性质,同时用在 BackupInfo.Kind 与列表接口上。
const (
// KindSystem 是系统库(账号 / 会话)的普通备份。
KindSystem = "system"
// KindEvent 是赛事库(赛程 / 成绩)的普通备份。
KindEvent = "event"
// KindSafety 是恢复前自动留存的安全副本。
KindSafety = "safety"
)
// backupInfoFor 用文件名补全一份备份的性质字段;名字认不出来时两个字段都留空。
func backupInfoFor(info BackupInfo) BackupInfo {
kind, dbKind, ok := KindOfBackupName(info.Name)
if !ok {
return info
}
info.Kind = kind
info.DBKind = string(dbKind)
return info
}
// Backup 用 VACUUM INTO 生成一致性快照,并按 Keep 滚动清理旧备份。
// VACUUM INTO 由 SQLite 自己保证快照一致,服务运行中也能安全备份。
func Backup(db *sql.DB, opts BackupOptions) (BackupInfo, error) {
@@ -81,14 +108,14 @@ func Backup(db *sql.DB, opts BackupOptions) (BackupInfo, error) {
// 文件名里的时间戳就是备份时间,文件时间与之一致,排序不会错乱。
_ = os.Chtimes(path, now, now)
info := BackupInfo{
info := backupInfoFor(BackupInfo{
Path: path,
Name: filepath.Base(path),
Size: size,
SHA256: sum,
Reason: reason,
CreatedAt: now,
}
})
if !opts.SkipPrune {
if _, err := PruneBackups(dir, prefix, opts.Keep); err != nil {
return info, err
@@ -237,13 +264,13 @@ func ListBackups(dir, prefix string) ([]BackupInfo, error) {
if err != nil {
return nil, fmt.Errorf("export: 读取备份信息 %s: %w", e.Name(), err)
}
out = append(out, BackupInfo{
out = append(out, backupInfoFor(BackupInfo{
Path: filepath.Join(dir, e.Name()),
Name: e.Name(),
Size: info.Size(),
Reason: reasonFromName(e.Name(), prefix),
CreatedAt: info.ModTime(),
})
}))
}
sort.Slice(out, func(i, j int) bool {
if !out[i].CreatedAt.Equal(out[j].CreatedAt) {
+480
View File
@@ -0,0 +1,480 @@
package export
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// 本文件是备份的「反向操作」:把某一份备份恢复成当前库。
//
// 与 Backup 一样按库分家:系统库(账号 / 会话)与赛事库(赛程 / 成绩)是两份文件、
// 两套备份,恢复必须指名道姓,绝不接受「看着像」的猜测——把一份库盖到另一份上,
// 现场只会表现为「账号全没了」或「名单空了」,很难倒查。
// RestoreKind 区分要恢复的是哪一个库。
type RestoreKind string
const (
// RestoreKindSystem 是系统库(<数据目录>/system.db,账号与会话)。
RestoreKindSystem RestoreKind = "system"
// RestoreKindEvent 是赛事库(<数据目录>/event_projs/<项目>/event.db,赛程与成绩)。
RestoreKindEvent RestoreKind = "event"
)
// ErrInvalidBackup 表示这份文件不能拿来恢复:读不到、不是 SQLite 库、完整性检查不过、
// 缺关键表,或者它根本是另一个库的备份。恢复前必须先把它挡在门外——
// 盖上去等于用好库换坏库,而坏的那份还在备份目录里,现场会以为数据没救。
var ErrInvalidBackup = errors.New("export: 备份文件不可用")
// ParseRestoreKind 解析请求里的 kind;只认 system / event。
func ParseRestoreKind(raw string) (RestoreKind, bool) {
switch RestoreKind(strings.TrimSpace(raw)) {
case RestoreKindSystem:
return RestoreKindSystem, true
case RestoreKindEvent:
return RestoreKindEvent, true
default:
return "", false
}
}
// Label 是给人看的中文库名,用于提示与审计。
func (k RestoreKind) Label() string {
switch k {
case RestoreKindSystem:
return "系统库"
case RestoreKindEvent:
return "赛事库"
default:
return string(k)
}
}
// safetyPrefix 是「恢复前安全副本」的文件名前缀。
// 它故意不叫 system- / event-,因为那种名字会被 PruneBackups 当成普通备份,
// 在下一轮滚动清理里删掉——安全副本是恢复失败时的唯一退路,不能被自动清理。
func (k RestoreKind) safetyPrefix() string { return "pre-restore-" + string(k) }
// KindOfBackupName 从备份文件名反推它的性质与对应的库。
//
// 认两种形态:普通备份 <库>-<原因>-<时间戳>.db,安全副本 pre-restore-<库>-<时间戳>.db;
// 认不出来的名字(例如老版本的 db- 前缀)一律不认——恢复请求的文件名只允许来自备份目录,
// 含糊的名字不进恢复流程。返回的 kind 用于显示(system / event / safety),
// dbKind 是它对应的库——恢复要覆盖的是这一份。
func KindOfBackupName(name string) (kind string, dbKind RestoreKind, ok bool) {
base := strings.ToLower(filepath.Base(strings.TrimSpace(name)))
if !strings.HasSuffix(base, ".db") {
return "", "", false
}
stem := strings.TrimSuffix(base, ".db")
// 先判安全副本(更长、更具体的前缀),否则 pre-restore-event-… 会被误判成普通备份。
for _, k := range []RestoreKind{RestoreKindSystem, RestoreKindEvent} {
if strings.HasPrefix(stem, k.safetyPrefix()+"-") {
return KindSafety, k, true
}
}
for _, k := range []RestoreKind{RestoreKindSystem, RestoreKindEvent} {
if strings.HasPrefix(stem, string(k)+"-") {
return string(k), k, true
}
}
return "", "", false
}
// restoreKeyTables 是恢复后必须存在的关键表:备份里缺了它们,就算文件能打开也不能恢复。
var restoreKeyTables = map[RestoreKind][]string{
RestoreKindSystem: {"users", "sessions", "settings"},
RestoreKindEvent: {"settings", "teams", "athletes", "events", "results", "audit_logs"},
}
// restoreForeignTables 是「另一个库」的标志表。
// settings 两边都有、不能当标志;命中这里的表说明文件根本不是这个库的备份,必须拒绝。
var restoreForeignTables = map[RestoreKind][]string{
RestoreKindSystem: {"teams", "athletes"},
RestoreKindEvent: {"users", "sessions"},
}
// ValidateBackup 校验一份备份文件能不能拿来恢复:只读打开 + integrity_check + 关键表检查。
// 任何一步不过都返回 ErrInvalidBackup,调用方据此在动当前库之前就把请求拒掉。
func ValidateBackup(path string, kind RestoreKind) error {
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return fmt.Errorf("%w:读不到文件 %s", ErrInvalidBackup, filepath.Base(path))
}
if info.Size() == 0 {
return fmt.Errorf("%w:文件为空", ErrInvalidBackup)
}
db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
if err != nil {
return fmt.Errorf("%w:%v", ErrInvalidBackup, err)
}
defer func() { _ = db.Close() }()
db.SetMaxOpenConns(1)
var check string
if err := db.QueryRow(`PRAGMA integrity_check`).Scan(&check); err != nil {
// 截断 / 写垃圾的文件多半在这里现形("file is not a database")。
return fmt.Errorf("%w:完整性检查无法执行(%v),文件可能已损坏", ErrInvalidBackup, err)
}
if !strings.EqualFold(strings.TrimSpace(check), "ok") {
return fmt.Errorf("%w:完整性检查未通过(%s)", ErrInvalidBackup, strings.TrimSpace(check))
}
tables, err := tableNames(db)
if err != nil {
return fmt.Errorf("%w:%v", ErrInvalidBackup, err)
}
for _, table := range restoreKeyTables[kind] {
if !tables[table] {
return fmt.Errorf("%w:缺少关键表 %s,不是%s的完整备份", ErrInvalidBackup, table, kind.Label())
}
}
for _, table := range restoreForeignTables[kind] {
if tables[table] {
return fmt.Errorf("%w:它含有%s的表 %s,可能选错了库",
ErrInvalidBackup, foreignLabel(kind), table)
}
}
return nil
}
func foreignLabel(kind RestoreKind) string {
if kind == RestoreKindSystem {
return "赛事库"
}
return "系统库"
}
func tableNames(db *sql.DB) (map[string]bool, error) {
rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type = 'table'`)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
out := make(map[string]bool)
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
out[name] = true
}
return out, rows.Err()
}
// SafetySnapshot 给「还活着的」当前库拍一份恢复前安全副本,返回副本信息。
//
// 用 VACUUM INTO:请求进来时服务端还在跑、可能还有别的连接在写,
// 只有 SQLite 自己的一致性快照靠得住(和普通备份同一套机制)。
// 副本用 pre-restore- 前缀,因此不参与 20 份滚动清理(见 safetyPrefix);
// 它自己那份保留策略由 PruneSafetyCopies 管(每类库最近 DefaultSafetyCopyKeep 份)。
func SafetySnapshot(db *sql.DB, dir string, kind RestoreKind, now time.Time) (BackupInfo, error) {
if db == nil {
return BackupInfo{}, ErrNoDatabase
}
if strings.TrimSpace(dir) == "" {
return BackupInfo{}, ErrNoDir
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return BackupInfo{}, fmt.Errorf("export: 创建备份目录 %s: %w", dir, err)
}
now = nowOr(now)
path, err := safetyCopyPath(dir, kind, now)
if err != nil {
return BackupInfo{}, err
}
if err := vacuumInto(db, path); err != nil {
return BackupInfo{}, err
}
sum, size, err := hashFile(path)
if err != nil {
return BackupInfo{}, err
}
_ = os.Chtimes(path, now, now)
return BackupInfo{
Path: path,
Name: filepath.Base(path),
Size: size,
SHA256: sum,
Reason: "pre-restore",
Kind: KindSafety,
DBKind: string(kind),
CreatedAt: now,
}, nil
}
// DefaultSafetyCopyKeep 是每类库保留的「恢复前安全副本」份数。
// 它和普通备份的 DefaultBackupKeep 是两条独立的策略:安全副本不参与 20 份滚动清理
// (清理按 ListBackups 的口径认 <库>- 前缀,混进来会把普通备份挤掉),
// 但也必须有上限,否则每次恢复都多留一份整库文件,迟早把磁盘占满。
const DefaultSafetyCopyKeep = 5
// PruneSafetyCopies 把某类库的安全副本裁到最近 keep 份(<=0 取 DefaultSafetyCopyKeep),
// 按修改时间从旧到新删,最新的那一份永远不删;返回被删掉的文件路径。
//
// 只看 pre-restore-<库>-*.db:普通备份与其它文件一概不碰。
func PruneSafetyCopies(dir string, kind RestoreKind, keep int) ([]string, error) {
if keep <= 0 {
keep = DefaultSafetyCopyKeep
}
copies, err := safetyCopies(dir, kind)
if err != nil {
return nil, err
}
if len(copies) <= keep {
return nil, nil
}
removed := make([]string, 0, len(copies)-keep)
var errs []error
// copies 已按「旧 → 新」排好,从头删到只剩 keep 份,因此最新的那份一定留下。
for _, c := range copies[:len(copies)-keep] {
if err := os.Remove(c.Path); err != nil && !os.IsNotExist(err) {
errs = append(errs, fmt.Errorf("export: 删除旧安全副本 %s: %w", c.Path, err))
continue
}
removed = append(removed, c.Path)
}
return removed, errors.Join(errs...)
}
// safetyCopies 列出某类库的安全副本,按修改时间从旧到新;时间相同时按文件名排,保证顺序稳定。
func safetyCopies(dir string, kind RestoreKind) ([]BackupInfo, error) {
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("export: 读取备份目录 %s: %w", dir, err)
}
prefix := kind.safetyPrefix() + "-"
out := make([]BackupInfo, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".db") {
continue
}
if !strings.HasPrefix(strings.ToLower(e.Name()), prefix) {
continue
}
info, err := e.Info()
if err != nil {
return nil, fmt.Errorf("export: 读取安全副本信息 %s: %w", e.Name(), err)
}
out = append(out, backupInfoFor(BackupInfo{
Path: filepath.Join(dir, e.Name()),
Name: e.Name(),
Size: info.Size(),
Reason: "pre-restore",
CreatedAt: info.ModTime(),
}))
}
sort.Slice(out, func(i, j int) bool {
if !out[i].CreatedAt.Equal(out[j].CreatedAt) {
return out[i].CreatedAt.Before(out[j].CreatedAt)
}
return out[i].Name < out[j].Name
})
return out, nil
}
// safetyCopyPath 为安全副本选一个不重名的路径(同一秒连续恢复也要各留一份)。
func safetyCopyPath(dir string, kind RestoreKind, now time.Time) (string, error) {
base := fmt.Sprintf("%s-%s", kind.safetyPrefix(), now.Format("20060102-150405"))
p := filepath.Join(dir, base+".db")
for i := 2; i <= 100; i++ {
_, err := os.Stat(p)
if os.IsNotExist(err) {
return p, nil
}
if err != nil {
return "", fmt.Errorf("export: 检查恢复前副本路径 %s: %w", p, err)
}
p = filepath.Join(dir, fmt.Sprintf("%s-%d.db", base, i))
}
return "", fmt.Errorf("export: 恢复前副本文件名 %s 冲突过多,放弃重试", base)
}
// ApplyRestore 用备份文件的内容整体替换目标库文件。
//
// 调用方必须先关掉目标库的全部连接:Windows 上没法替换一个正被打开的文件,
// 而「就地改写」在用中的库文件会把库写坏(SQLite 的页缓存与 WAL 都对不上)。
// 备份文件本身不动(先复制到目标目录再改名),恢复失败时它还得是完整的;
// 临时文件建在目标目录里,保证改名不跨文件系统。
//
// 关掉连接后仍残留 -wal 说明还有别的进程握着这个库:旧库的日志套到新库上会直接写坏数据,
// 这种情况一律拒绝,宁可让操作员先把服务端停干净。
func ApplyRestore(backupPath, targetPath string) error {
if _, err := os.Stat(targetPath + "-wal"); err == nil {
return fmt.Errorf("export: %s 仍被占用(-wal 还在),请先停掉服务端再恢复", targetPath)
}
src, err := os.Open(backupPath)
if err != nil {
return fmt.Errorf("export: 打开备份 %s: %w", filepath.Base(backupPath), err)
}
defer func() { _ = src.Close() }()
dir := filepath.Dir(targetPath)
tmp, err := os.CreateTemp(dir, filepath.Base(targetPath)+".restore-*")
if err != nil {
return fmt.Errorf("export: 在 %s 创建临时文件: %w", dir, err)
}
tmpPath := tmp.Name()
discard := func() { _ = os.Remove(tmpPath) }
if _, err := io.Copy(tmp, src); err != nil {
_ = tmp.Close()
discard()
return fmt.Errorf("export: 复制备份到 %s: %w", tmpPath, err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
discard()
return fmt.Errorf("export: 落盘 %s: %w", tmpPath, err)
}
if err := tmp.Close(); err != nil {
discard()
return fmt.Errorf("export: 关闭 %s: %w", tmpPath, err)
}
// 沿用原库的权限位,免得恢复后的库文件突然变成 0600 / 0644 里的另一个。
if info, err := os.Stat(targetPath); err == nil {
_ = os.Chmod(tmpPath, info.Mode().Perm())
} else {
_ = os.Chmod(tmpPath, 0o644)
}
if err := os.Rename(tmpPath, targetPath); err != nil {
discard()
return fmt.Errorf("export: 替换 %s: %w", targetPath, err)
}
// -wal 已在上面确认不存在;-shm 只是共享内存索引,留着没有意义,顺手清掉。
_ = os.Remove(targetPath + "-shm")
return nil
}
// RestoreAuditRow 是要写进被恢复库的审计行;字段与 audit_logs 一一对应。
type RestoreAuditRow struct {
Action string
EntityType string
ActorID int64
IP string
RequestID string
// Detail 会被序列化成 after_json,记下这次恢复的关键事实。
Detail any
}
// RecordRestoreAudit 直接向 path 指向的库追加一条恢复审计。
//
// 不走 middleware.Auditor:审计是中间件在 handler 返回之后落库的,而恢复赛事库时
// 旧的赛事库连接已经关掉、文件也已经换掉,那一笔只会写进被替换掉的旧文件里。
// 恢复系统的动作要能留在「恢复之后还在的那份库」上,才查得到。
func RecordRestoreAudit(path string, row RestoreAuditRow) error {
if strings.TrimSpace(path) == "" {
return errors.New("export: 审计库路径为空")
}
db, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)")
if err != nil {
return fmt.Errorf("export: 打开审计库 %s: %w", path, err)
}
defer func() { _ = db.Close() }()
db.SetMaxOpenConns(1)
var actor any
if row.ActorID != 0 {
actor = row.ActorID
}
var after any
if row.Detail != nil {
raw, err := json.Marshal(row.Detail)
if err != nil {
return fmt.Errorf("export: 序列化审计详情: %w", err)
}
after = string(raw)
}
_, err = db.Exec(
`INSERT INTO audit_logs (user_id, action, entity_type, entity_id, before_json, after_json, ip, request_id)
VALUES (?, ?, ?, NULL, NULL, ?, ?, ?)`,
actor, row.Action, row.EntityType, after, row.IP, row.RequestID)
if err != nil {
return fmt.Errorf("export: 写入审计: %w", err)
}
return nil
}
// PendingRestoreFile 是「一次恢复正在进行」的落盘标记文件名。
const PendingRestoreFile = "pending_restore.json"
// PendingRestore 记录一次恢复请求的关键事实,在替换库文件之前先落盘。
//
// 正常路径上它在替换成功后立刻被删掉;只有进程恰好在替换前后意外死掉时才会留在数据目录里,
// 此时它是现场唯一的线索——「刚才要恢复哪份备份、覆盖谁、退回用的安全副本是哪一份」。
type PendingRestore struct {
Kind RestoreKind `json:"kind"`
Backup string `json:"backup"`
BackupName string `json:"backupName"`
Target string `json:"target"`
SafetyCopy string `json:"safetyCopy"`
Operator string `json:"operator"`
RequestedAt time.Time `json:"requestedAt"`
}
// WritePendingRestore 原子写入恢复标记(先写临时文件再改名),返回标记文件路径。
func WritePendingRestore(dataDir string, p PendingRestore) (string, error) {
if strings.TrimSpace(dataDir) == "" {
return "", errors.New("export: 数据目录为空,无法写入恢复标记")
}
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return "", fmt.Errorf("export: 创建数据目录 %s: %w", dataDir, err)
}
raw, err := json.MarshalIndent(p, "", " ")
if err != nil {
return "", fmt.Errorf("export: 序列化恢复标记: %w", err)
}
raw = append(raw, '\n')
tmp, err := os.CreateTemp(dataDir, PendingRestoreFile+".tmp-*")
if err != nil {
return "", fmt.Errorf("export: 创建恢复标记临时文件: %w", err)
}
tmpPath := tmp.Name()
if _, err := tmp.Write(raw); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpPath)
return "", fmt.Errorf("export: 写入恢复标记: %w", err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpPath)
return "", fmt.Errorf("export: 关闭恢复标记: %w", err)
}
path := filepath.Join(dataDir, PendingRestoreFile)
if err := os.Rename(tmpPath, path); err != nil {
_ = os.Remove(tmpPath)
return "", fmt.Errorf("export: 落盘恢复标记: %w", err)
}
return path, nil
}
// ClearPendingRestore 删除恢复标记;标记本来就可能不存在(上一次恢复成功时已删),
// 删不到不算错误,只有删不掉(权限等)才回报。
func ClearPendingRestore(dataDir string) error {
if strings.TrimSpace(dataDir) == "" {
return nil
}
err := os.Remove(filepath.Join(dataDir, PendingRestoreFile))
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("export: 删除恢复标记: %w", err)
}
return nil
}
@@ -0,0 +1,447 @@
package export
import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// openEventDBAt 与 helpers_test.go 的 openEventDB 相同,区别是路径由调用方给定:
// 恢复测试必须知道库文件在哪(要关连接、要替换文件、要读文件系统)。
func openEventDBAt(t *testing.T, path string) *sql.DB {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("创建赛事目录: %v", err)
}
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
}
func TestKindOfBackupName(t *testing.T) {
cases := []struct {
name string
kind string
dbKind RestoreKind
ok bool
}{
{"event-auto-20260911-120000.db", KindEvent, RestoreKindEvent, true},
{"event-publish-20260911-120000-2.db", KindEvent, RestoreKindEvent, true},
{"system-manual-20260911-120000.db", KindSystem, RestoreKindSystem, true},
// 安全副本也是合法备份:恢复选错时间点时,还能拿副本再恢复一次。
// 它的「性质」是 safety,但对应的库仍是 event / system。
{"pre-restore-event-20260911-120000.db", KindSafety, RestoreKindEvent, true},
{"pre-restore-system-20260911-120000.db", KindSafety, RestoreKindSystem, true},
{"EVENT-AUTO-20260911-120000.DB", KindEvent, RestoreKindEvent, true},
// 认不出来的一律不进恢复流程:含糊的名字没有理由被当成某一类库。
{"db-manual-20260911-120000.db", "", "", false},
{"event.db", "", "", false},
{"pre-restore-event.db", "", "", false},
{"team-restore.db", "", "", false},
{"event-auto-20260911-120000.txt", "", "", false},
}
for _, c := range cases {
kind, dbKind, ok := KindOfBackupName(c.name)
if ok != c.ok || kind != c.kind || dbKind != c.dbKind {
t.Errorf("KindOfBackupName(%q) = (%q, %q, %v),期望 (%q, %q, %v)",
c.name, kind, dbKind, ok, c.kind, c.dbKind, c.ok)
}
}
}
func TestParseRestoreKind(t *testing.T) {
if kind, ok := ParseRestoreKind(" event "); !ok || kind != RestoreKindEvent {
t.Errorf("ParseRestoreKind(\" event \") = (%q, %v)", kind, ok)
}
if kind, ok := ParseRestoreKind("system"); !ok || kind != RestoreKindSystem {
t.Errorf("ParseRestoreKind(system) = (%q, %v)", kind, ok)
}
for _, raw := range []string{"", "event.db", "System", "both"} {
if kind, ok := ParseRestoreKind(raw); ok {
t.Errorf("ParseRestoreKind(%q) 应拒绝,实际 = %q", raw, kind)
}
}
}
// 好备份必须放行;坏文件必须被挡在门外(恢复的全部安全前提都压在这一步上)。
func TestValidateBackup(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
dir := t.TempDir()
eventBackup, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Reason: "manual", SkipPrune: true})
if err != nil {
t.Fatalf("备份赛事库: %v", err)
}
if err := ValidateBackup(eventBackup.Path, RestoreKindEvent); err != nil {
t.Fatalf("合法赛事库备份被拒: %v", err)
}
// 拿赛事库备份去恢复系统库:两份库的表完全不同,必须拒绝。
if err := ValidateBackup(eventBackup.Path, RestoreKindSystem); !errors.Is(err, ErrInvalidBackup) {
t.Errorf("赛事库备份当成系统库备份 = %v,期望 ErrInvalidBackup", err)
}
st, err := store.Open(filepath.Join(t.TempDir(), "sysdata"))
if err != nil {
t.Fatalf("打开系统库: %v", err)
}
defer func() { _ = st.Close() }()
if err := st.Migrate(); err != nil {
t.Fatalf("迁移系统库: %v", err)
}
sysBackup, err := Backup(st.DB(), BackupOptions{Dir: dir, Prefix: "system", Reason: "manual", SkipPrune: true})
if err != nil {
t.Fatalf("备份系统库: %v", err)
}
if err := ValidateBackup(sysBackup.Path, RestoreKindSystem); err != nil {
t.Fatalf("合法系统库备份被拒: %v", err)
}
if err := ValidateBackup(sysBackup.Path, RestoreKindEvent); !errors.Is(err, ErrInvalidBackup) {
t.Errorf("系统库备份当成赛事库备份 = %v,期望 ErrInvalidBackup", err)
}
raw, err := os.ReadFile(eventBackup.Path)
if err != nil {
t.Fatalf("读取备份: %v", err)
}
cases := []struct {
name string
path string
body []byte
}{
{"截断到三分之一", filepath.Join(dir, "event-manual-cut.db"), raw[:len(raw)/3]},
{"全是垃圾字节", filepath.Join(dir, "event-manual-junk.db"), []byte(strings.Repeat("这不是数据库,只是 200 字节垃圾。", 20))},
{"空文件", filepath.Join(dir, "event-manual-empty.db"), nil},
}
for _, c := range cases {
if err := os.WriteFile(c.path, c.body, 0o644); err != nil {
t.Fatalf("写坏文件 %s: %v", c.name, err)
}
if err := ValidateBackup(c.path, RestoreKindEvent); err == nil {
t.Errorf("%s:坏备份必须被拒绝", c.name)
} else if !errors.Is(err, ErrInvalidBackup) {
t.Errorf("%s:错误类型 = %v,期望 ErrInvalidBackup", c.name, err)
}
}
if err := ValidateBackup(filepath.Join(dir, "event-manual-missing.db"), RestoreKindEvent); !errors.Is(err, ErrInvalidBackup) {
t.Errorf("不存在的备份 = %v,期望 ErrInvalidBackup", err)
}
if err := ValidateBackup(dir, RestoreKindEvent); !errors.Is(err, ErrInvalidBackup) {
t.Errorf("目录当成备份 = %v,期望 ErrInvalidBackup", err)
}
}
// 安全副本必须是「改动之后」的现场,恢复则必须把库退回备份时刻,且备份文件本身要留着。
func TestSafetySnapshotAndApplyRestore(t *testing.T) {
target := filepath.Join(t.TempDir(), "event_projs", "default", "event.db")
db := openEventDBAt(t, target)
seedEvent(t, db) // 2 个单位
dir := t.TempDir()
backup, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Reason: "manual", SkipPrune: true})
if err != nil {
t.Fatalf("备份: %v", err)
}
if _, err := db.Exec(`INSERT INTO teams (code, name, short_name, sort_order) VALUES ('T3','第三分公司','三公司',3)`); err != nil {
t.Fatalf("备份后改数据: %v", err)
}
safety, err := SafetySnapshot(db, dir, RestoreKindEvent, time.Date(2026, 9, 11, 10, 0, 0, 0, time.Local))
if err != nil {
t.Fatalf("拍安全副本: %v", err)
}
if !strings.HasPrefix(safety.Name, "pre-restore-event-20260911-100000") {
t.Errorf("安全副本命名 = %q,期望 pre-restore-event-<时间戳>.db", safety.Name)
}
// 列表与回执要靠这两个字段说清「这是什么、它对应哪份库」,前端不再猜文件名。
if safety.Kind != KindSafety || safety.DBKind != string(RestoreKindEvent) {
t.Errorf("安全副本 kind/dbKind = (%q, %q),期望 (%q, event)",
safety.Kind, safety.DBKind, KindSafety)
}
// 真实链路里由 handler 关连接;这里照做,才能验证「关机后替换」这套动作本身。
if err := db.Close(); err != nil {
t.Fatalf("关闭赛事库: %v", err)
}
if _, err := os.Stat(target + "-wal"); err == nil {
t.Fatalf("关闭后仍残留 -wal,恢复会被自己的占用检查挡住")
}
sdb := openSQLite(t, safety.Path)
if got := countRows(t, sdb, "teams"); got != 3 {
t.Errorf("安全副本 teams = %d,期望 3(备份之后的现场)", got)
}
_ = sdb.Close()
if err := ApplyRestore(backup.Path, target); err != nil {
t.Fatalf("替换数据库: %v", err)
}
// 恢复用的是「复制」而不是「搬走」:备份文件还得在,才能反复恢复。
if _, err := os.Stat(backup.Path); err != nil {
t.Errorf("恢复后备份文件应当保留: %v", err)
}
if _, err := os.Stat(target + "-shm"); !os.IsNotExist(err) {
t.Errorf("恢复后不应残留 -shm,err=%v", err)
}
rdb := openSQLite(t, target)
if got := countRows(t, rdb, "teams"); got != 2 {
t.Errorf("恢复后 teams = %d,期望 2(备份时刻)", got)
}
_ = rdb.Close()
if err := ValidateBackup(target, RestoreKindEvent); err != nil {
t.Errorf("恢复后的库必须自身也能通过校验: %v", err)
}
}
// 安全副本自己的保留策略:每类库只留最近 N 份,按 mtime 从旧到新删,最新的永不删;
// 普通备份与另一类库的副本一份都不许碰。
func TestPruneSafetyCopies(t *testing.T) {
dir := t.TempDir()
base := time.Date(2026, 9, 11, 10, 0, 0, 0, time.Local)
// 7 份赛事库安全副本(mtime 依次递增),外加两份「不该被删」的干扰文件。
for i := 0; i < 7; i++ {
name := filepath.Join(dir, fmt.Sprintf("pre-restore-event-20260911-1000%02d.db", i))
if err := os.WriteFile(name, []byte("x"), 0o644); err != nil {
t.Fatalf("造安全副本: %v", err)
}
at := base.Add(time.Duration(i) * time.Minute)
if err := os.Chtimes(name, at, at); err != nil {
t.Fatalf("改时间: %v", err)
}
}
kept := filepath.Join(dir, "event-auto-20260911-090000.db")
if err := os.WriteFile(kept, []byte("x"), 0o644); err != nil {
t.Fatalf("造普通备份: %v", err)
}
other := filepath.Join(dir, "pre-restore-system-20260911-100000.db")
if err := os.WriteFile(other, []byte("x"), 0o644); err != nil {
t.Fatalf("造系统库安全副本: %v", err)
}
removed, err := PruneSafetyCopies(dir, RestoreKindEvent, DefaultSafetyCopyKeep)
if err != nil {
t.Fatalf("清理安全副本: %v", err)
}
if len(removed) != 2 {
t.Fatalf("清理份数 = %d,期望 2(7-5)", len(removed))
}
// 删掉的是最旧的两份。
for _, i := range []int{0, 1} {
gone := filepath.Join(dir, fmt.Sprintf("pre-restore-event-20260911-1000%02d.db", i))
if _, err := os.Stat(gone); !os.IsNotExist(err) {
t.Errorf("最旧的 %s 应被删除,err=%v", gone, err)
}
}
// 最新的一份必须还在(无论 keep 传多少,它都不该被删)。
newest := filepath.Join(dir, "pre-restore-event-20260911-100006.db")
if _, err := os.Stat(newest); err != nil {
t.Errorf("最新的安全副本必须保留: %v", err)
}
if _, err := os.Stat(kept); err != nil {
t.Errorf("普通备份不属于安全副本,不该被动: %v", err)
}
if _, err := os.Stat(other); err != nil {
t.Errorf("另一类库的安全副本不该被动: %v", err)
}
// 份数已经等于保留数:再清一次什么都不动。
again, err := PruneSafetyCopies(dir, RestoreKindEvent, DefaultSafetyCopyKeep)
if err != nil {
t.Fatalf("再清理: %v", err)
}
if len(again) != 0 {
t.Errorf("已裁到上限时不应再删,实际删了 %v", again)
}
// keep<=0 取默认份数,而不是「一份不留」。
if removed, err := PruneSafetyCopies(dir, RestoreKindEvent, 0); err != nil || len(removed) != 0 {
t.Errorf("keep<=0 应按默认份数处理,removed=%v err=%v", removed, err)
}
// 目录不存在不是错误。
if removed, err := PruneSafetyCopies(filepath.Join(dir, "没有这个目录"), RestoreKindEvent, 5); err != nil || len(removed) != 0 {
t.Errorf("目录不存在应返回空,removed=%v err=%v", removed, err)
}
}
// 列表接口要能自描述:普通备份带 kind/dbKind,安全副本 kind=safety 但 dbKind 仍是它那份库。
func TestListBackupsReportsKind(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
dir := t.TempDir()
base := time.Date(2026, 9, 11, 10, 0, 0, 0, time.Local)
if _, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Reason: "manual", SkipPrune: true, Now: base}); err != nil {
t.Fatalf("赛事库备份: %v", err)
}
if _, err := Backup(db, BackupOptions{Dir: dir, Prefix: "system", Reason: "auto", SkipPrune: true, Now: base}); err != nil {
t.Fatalf("系统库备份: %v", err)
}
if _, err := SafetySnapshot(db, dir, RestoreKindEvent, base.Add(time.Minute)); err != nil {
t.Fatalf("安全副本: %v", err)
}
list, err := ListBackups(dir, "")
if err != nil {
t.Fatalf("列出备份: %v", err)
}
want := map[string][2]string{
"event-manual-20260911-100000.db": {KindEvent, "event"},
"system-auto-20260911-100000.db": {KindSystem, "system"},
"pre-restore-event-20260911-100100.db": {KindSafety, "event"},
}
for _, item := range list {
expect, ok := want[item.Name]
if !ok {
t.Errorf("多出一份未预期的备份 %s", item.Name)
continue
}
if item.Kind != expect[0] || item.DBKind != expect[1] {
t.Errorf("%s 的 kind/dbKind = (%q, %q),期望 (%q, %q)",
item.Name, item.Kind, item.DBKind, expect[0], expect[1])
}
}
if len(list) != len(want) {
t.Errorf("备份份数 = %d,期望 %d", len(list), len(want))
}
}
// Backup 的返回值也要带性质:调用方(如 CLI / 面板)不必再从文件名推。
func TestBackupReportsKind(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
dir := t.TempDir()
info, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Reason: "manual", SkipPrune: true})
if err != nil {
t.Fatalf("备份: %v", err)
}
if info.Kind != KindEvent || info.DBKind != "event" {
t.Errorf("Backup 的 kind/dbKind = (%q, %q),期望 (event, event)", info.Kind, info.DBKind)
}
// 认不出前缀的(老版本 db- 前缀)留空,前端据此不给恢复入口,而不是猜成某一类。
legacy, err := Backup(db, BackupOptions{Dir: dir, Prefix: "db", Reason: "manual", SkipPrune: true})
if err != nil {
t.Fatalf("备份: %v", err)
}
if legacy.Kind != "" || legacy.DBKind != "" {
t.Errorf("无法识别前缀时 kind/dbKind 应为空,实际 (%q, %q)", legacy.Kind, legacy.DBKind)
}
}
// 目标库旁边还留着 -wal,说明有连接没关干净(或别的进程握着它):
// 旧库的日志套到新库上会直接写坏数据,必须拒绝而不是硬上。
func TestApplyRestoreRefusesWhileWalPresent(t *testing.T) {
target := filepath.Join(t.TempDir(), "event.db")
db := openEventDBAt(t, target)
seedEvent(t, db)
dir := t.TempDir()
backup, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Reason: "manual", SkipPrune: true})
if err != nil {
t.Fatalf("备份: %v", err)
}
if err := db.Close(); err != nil {
t.Fatalf("关闭赛事库: %v", err)
}
if err := os.WriteFile(target+"-wal", []byte("stale"), 0o644); err != nil {
t.Fatalf("伪造 -wal: %v", err)
}
if err := ApplyRestore(backup.Path, target); err == nil {
t.Fatal("-wal 残留时替换必须被拒绝")
}
}
// 审计直接写进赛事库(middleware.Auditor 的落库时机在换库之后,指望不上)。
func TestRecordRestoreAudit(t *testing.T) {
target := filepath.Join(t.TempDir(), "event.db")
db := openEventDBAt(t, target)
if err := RecordRestoreAudit(target, RestoreAuditRow{
Action: "system.restore",
EntityType: "backup",
ActorID: 7,
IP: "10.0.0.9",
RequestID: "req-1",
Detail: map[string]any{"kind": "event", "file": "event-manual-x.db"},
}); err != nil {
t.Fatalf("写审计: %v", err)
}
_ = db.Close()
rdb := openSQLite(t, target)
defer func() { _ = rdb.Close() }()
var action, after, ip string
var actorID int64
if err := rdb.QueryRow(`SELECT action, after_json, ip, user_id FROM audit_logs ORDER BY id DESC LIMIT 1`).
Scan(&action, &after, &ip, &actorID); err != nil {
t.Fatalf("读审计: %v", err)
}
if action != "system.restore" || actorID != 7 || ip != "10.0.0.9" {
t.Errorf("审计行 = (%s, %d, %s),期望 (system.restore, 7, 10.0.0.9)", action, actorID, ip)
}
if !strings.Contains(after, "event-manual-x.db") {
t.Errorf("审计 after_json = %q,应含备份文件名", after)
}
}
// 恢复标记:写盘后能删掉;重复删不报错(安装目录里可能根本没有这个文件)。
func TestPendingRestoreMarker(t *testing.T) {
dataDir := t.TempDir()
path, err := WritePendingRestore(dataDir, PendingRestore{
Kind: RestoreKindEvent,
Backup: "/data/backups/event-manual-20260911-100000.db",
BackupName: "event-manual-20260911-100000.db",
Target: "/data/event_projs/default/event.db",
SafetyCopy: "/data/backups/pre-restore-event-20260911-100001.db",
Operator: "admin#1",
RequestedAt: time.Date(2026, 9, 11, 10, 0, 1, 0, time.Local),
})
if err != nil {
t.Fatalf("写恢复标记: %v", err)
}
if filepath.Base(path) != PendingRestoreFile {
t.Errorf("标记路径 = %q,期望 %q", path, PendingRestoreFile)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读恢复标记: %v", err)
}
for _, want := range []string{"pre-restore-event-20260911-100001.db", "admin#1", `"kind": "event"`} {
if !strings.Contains(string(raw), want) {
t.Errorf("标记内容缺少 %q:%s", want, raw)
}
}
if err := ClearPendingRestore(dataDir); err != nil {
t.Fatalf("删恢复标记: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("标记应被删除,err=%v", err)
}
if err := ClearPendingRestore(dataDir); err != nil {
t.Errorf("重复删除不应报错: %v", err)
}
}
-13
View File
@@ -147,19 +147,6 @@ func (wb BaseDataWorkbook) records(sheet string) []BaseDataRecord {
}
}
// ExportBaseDataXLSX 导出基础数据包到 dst。
func ExportBaseDataXLSX(db *sql.DB, dst string, opts Options) error {
wb, err := LoadBaseDataWorkbook(db, opts)
if err != nil {
return err
}
f, err := baseDataFile(wb)
if err != nil {
return err
}
return saveWorkbook(f, dst)
}
// WriteBaseDataXLSX 把基础数据包写成 xlsx 流,供 HTTP 直接响应下载。
func WriteBaseDataXLSX(w io.Writer, wb BaseDataWorkbook) error {
f, err := baseDataFile(wb)