feat(import): 报名模板按「小项 × 组别」分工作表;号码布判重改认证件号;备份改原子落盘

三件事合成一次提交(其中两件都落在 handlers_config.go,无法按文件拆开):

一、分工作表报名模板(新增能力)
- 新增 GET /api/entries/template.xlsx:按当前赛事的「小项 × 组别」生成模板,
  一个小项组别一张工作表,工作表名 = <小项>@<组别>(31 字符上限、重名自动去重)。
- 导入解析器认工作表名还原小项 + 组别;若与表内列冲突则报错,不静默取其一。
- 小项名与组别名禁用 @,基础数据界面 / 规则包导入 / 服务端校验三处一并拦截。
- 单表 CSV/XLSX 导入路径不回归。

二、号码布判重(修误判)
- 旧逻辑依赖证件号本身,而证件号是可选字段 → 同一个人报多个项目会被整块拦下。
- 改为:号码布相同且证件号相同 → 判定为同一人报多项,放行;
  号码布相同且证件号都能填且不同 → 仍报错(真抢号);一方证件号为空 → 放行并给提示。
- 团体行的号码布一并纳入查重。

三、自动备份的关停与落盘(修数据安全隐患)
- export.Service 增加 Wait():StartAuto 的协程进 WaitGroup,关停时 join(10 秒上限,
  超时只记 WARN 不拖死关闭),不再在备份写到一半时退出。
- vacuumInto 改为先写 <最终名>.tmp-<随机> 再 rename,失败则删临时文件;
  Backup / SafetySnapshot / ExportProjectZip 三个调用方一并受益。
- 新增 isFinalBackupName,ListBackups / safetyCopies / KindOfBackupName 统一过滤中间文件,
  避免半截 .db 被列进备份列表、进而被「一键恢复」选中。

文档:docs/ 五份(使用手册 / 报名名单导入规范 / 升级说明 / 部署指南 / 上线检查清单)同步。
This commit is contained in:
AskaEth committed 2026-09-14 01:10:16 +08:00
1 parent b74768b7d5
commit 1b8a37ff8c
32 files changed
+3302 -233

No files matched your search

+45 -1
View File
@@ -147,6 +147,11 @@ type Service struct {
logger *slog.Logger
mu sync.Mutex
// wg 记着 StartAuto 起的后台协程,供 Wait 在进程退出前 join。
// 只加不减:协程退出后 Wait 照常立即返回,所以「没起过自动备份」与
// 「自动备份已经停了」两种情况下 Wait 都是零成本。
wg sync.WaitGroup
}
// NewService 构造备份服务;keep<=0 时按 DefaultBackupKeep 滚动保留。
@@ -184,7 +189,9 @@ func (s *Service) StartAuto(ctx context.Context, interval time.Duration) {
s.logger.Warn("未配置数据目录,跳过自动备份")
return
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
@@ -208,6 +215,22 @@ func (s *Service) StartAuto(ctx context.Context, interval time.Duration) {
}()
}
// Wait 等 StartAuto 起的后台协程退出,含「已经在飞、正卡在 VACUUM INTO 里」的那一轮备份。
//
// 调用点只有服务端 main:srv.Shutdown 之后、关库之前。不 join 的话,进程可能正卡在
// VACUUM INTO 中途就退出——那一轮备份会停在半路(见 vacuumInto 的原子落盘,最坏也只是
// 留下一个中间文件,但「停止写盘」这件事必须等出来才做数)。
//
// 不阻断、不改变任何既有语义:没起过自动备份(间隔 <=0、没配数据目录)时立即返回;
// 它不取消上下文、也不打断正在跑的备份,只是等它们收尾。ctx 取消(含
// POST /api/shutdown 复用的那个信号)仍然由调用方负责,本方法只解决「等没等到」。
//
// 注意:别再拿同一个 Service 在 Wait 期间调 StartAuto(sync.WaitGroup 的复用限制),
// 服务端一生只调一次。
func (s *Service) Wait() {
s.wg.Wait()
}
func (s *Service) snapshotAll(reason string, withSystem bool) ([]BackupInfo, error) {
if strings.TrimSpace(s.dir) == "" {
return nil, ErrNoDir
@@ -246,6 +269,10 @@ func (s *Service) snapshot(db *sql.DB, prefix, reason string) (BackupInfo, error
// ListBackups 按时间倒序列出备份;prefix 为空时列出全部 .db 备份。
// 不计算校验和(避免每次列表都全量读盘),需要校验时用 Backup 的返回值。
//
// 只认「已经落好的」备份文件(isFinalBackupName):中间文件一律不列。
// 备份列表是「一键恢复」选文件的唯一数据源,把一份没写完的文件列出去,
// 现场就可能拿它去覆盖好库——这比没有备份更糟。
func ListBackups(dir, prefix string) ([]BackupInfo, error) {
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
@@ -258,7 +285,7 @@ func ListBackups(dir, prefix string) ([]BackupInfo, error) {
prefix = strings.TrimSpace(prefix)
out := make([]BackupInfo, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".db") {
if e.IsDir() || !isFinalBackupName(e.Name()) {
continue
}
if prefix != "" && !strings.HasPrefix(e.Name(), prefix+"-") {
@@ -285,6 +312,23 @@ func ListBackups(dir, prefix string) ([]BackupInfo, error) {
return out, nil
}
// isFinalBackupName 判断目录项是不是一份「已经落好的」备份文件。
//
// 备份目录里会短暂(异常退出后则是长期)存在两类非最终文件,都必须挡住:
// - VACUUM INTO 的中间文件 <最终名>.tmp-<随机>(见 vacuumInto);
// - 任何别的写法留下的 .tmp 临时文件。
//
// 判据是「.db 结尾」且名字里的主干不含 tmpMark。备份名由本包生成
// (<库>-<原因>-<时间戳>[-序号].db,原因只有 manual / auto / publish / pre-restore),
// 正常备份不可能命中这个标记。
func isFinalBackupName(name string) bool {
lower := strings.ToLower(name)
if !strings.HasSuffix(lower, ".db") {
return false
}
return !strings.Contains(strings.TrimSuffix(lower, ".db"), tmpMark)
}
// LatestBackup 返回最近一份备份(ListBackups 已按时间倒序,取首个)。
//
// 注意:它**不是**健康检查的输入——05 §9 明确说 GET /api/health 不返回「最近备份时间」,
+9 -2
View File
@@ -69,9 +69,13 @@ func (k RestoreKind) safetyPrefix() string { return "pre-restore-" + string(k) }
// 认不出来的名字(例如老版本的 db- 前缀)一律不认——恢复请求的文件名只允许来自备份目录,
// 含糊的名字不进恢复流程。返回的 kind 用于显示(system / event / safety),
// dbKind 是它对应的库——恢复要覆盖的是这一份。
//
// 中间文件(VACUUM INTO 写到一半、或上一次异常退出留下的 <备份名>.tmp-<随机>)同样不认:
// 它没写完,更不该被「一键恢复」选中——这一条与 ListBackups 的过滤是同一口径,
// 挡住的是「绕过列表、直接提交文件名」的调用方(列表本身已经不列它们了)。
func KindOfBackupName(name string) (kind string, dbKind RestoreKind, ok bool) {
base := strings.ToLower(filepath.Base(strings.TrimSpace(name)))
if !strings.HasSuffix(base, ".db") {
if !isFinalBackupName(base) {
return "", "", false
}
stem := strings.TrimSuffix(base, ".db")
@@ -262,7 +266,10 @@ func safetyCopies(dir string, kind RestoreKind) ([]BackupInfo, error) {
prefix := kind.safetyPrefix() + "-"
out := make([]BackupInfo, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".db") {
// 与 ListBackups 同一口径:中间文件不是副本。
// 这一条还决定 PruneSafetyCopies 删谁——把没写完的中间文件算进保留份数,
// 会在副本数量紧张时先把正经副本删掉。
if e.IsDir() || !isFinalBackupName(e.Name()) {
continue
}
if !strings.HasPrefix(strings.ToLower(e.Name()), prefix) {
@@ -57,6 +57,12 @@ func TestKindOfBackupName(t *testing.T) {
{"pre-restore-event.db", "", "", false},
{"team-restore.db", "", "", false},
{"event-auto-20260911-120000.txt", "", "", false},
// 中间文件(写到一半 / 上一次异常退出的残留)也不是备份:
// 前缀对得上也不能进恢复流程,否则就是拿一份没写完的文件覆盖好库。
{"event-auto-20260911-120000.db.tmp-0123456789abcdef", "", "", false},
{"pre-restore-event-20260911-120000.db.tmp-0123456789abcdef", "", "", false},
{"event-auto-20260911-120000.tmp.db", "", "", false},
{"EVENT-AUTO-20260911-120000.TMP-0123456789ABCDEF", "", "", false},
}
for _, c := range cases {
kind, dbKind, ok := KindOfBackupName(c.name)
@@ -0,0 +1,340 @@
package export
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// TestWaitJoinInFlightBackupLeavesNoHalfFile 钉住「关闭时备份正在进行」这条回归。
//
// 关停(取消上下文 + Wait)之后,备份目录里不允许出现半截的最终 .db,也不允许留下中间文件:
// 在飞的那一轮要么完整写完,要么什么都没留下。
//
// 「备份正在进行」在这里是确定复现的,不靠撞时序:另一条连接先抢走库的写锁。
// VACUUM INTO 的顺序是「先按目标名建出空文件、再读源库」,源库被锁住时它会带着一个
// 0 字节的目标文件停在锁上(等到 busy_timeout)——这正是进程退出会撞上的那一刻。
func TestWaitJoinInFlightBackupLeavesNoHalfFile(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "event.db")
db := openEventDBLockable(t, dbPath, 5000)
seedEvent(t, db)
dir := t.TempDir()
release := lockEventDB(t, dbPath)
ctx, cancel := context.WithCancel(context.Background())
svc := NewService(nil, db, dir, 20, testLogger())
// 注册在最前(LIFO 最后执行的反面):先取消、再放锁、最后 join,
// 这样用例中途失败时也不会把「等锁的备份」和「删目录」搅在一起。
t.Cleanup(func() {
cancel()
release()
svc.Wait()
})
svc.StartAuto(ctx, 5*time.Millisecond)
// 备份确实开始写了:目录里已经出现它正在写的文件(中间文件或半截的最终文件)。
waitForDirEntry(t, dir, 5*time.Second)
// 要害在这里:写到一半的备份绝不能出现在备份列表里。
// GET /api/backups 与「一键恢复」选文件都走 ListBackups,列出去就等于告诉现场
// 「这份备份能用」——而它此刻还是 0 字节。
if list, err := ListBackups(dir, ""); err != nil {
t.Fatalf("列出备份: %v", err)
} else if len(list) != 0 {
t.Fatalf("写到一半的备份出现在备份列表里:%+v(正常关闭后它会变成永久残留)", list)
}
cancel()
stopped := make(chan struct{})
go func() { svc.Wait(); close(stopped) }()
// 备份还卡在 VACUUM INTO 里,Wait 不能提前返回:提前返回意味着进程会在这一刻退出。
select {
case <-stopped:
t.Fatal("备份还卡在 VACUUM INTO 里,Wait 就返回了;进程此时退出会留下半截文件")
case <-time.After(200 * time.Millisecond):
}
// 放锁:在飞的那一轮会正常写完,Wait 要等到它收尾。
release()
select {
case <-stopped:
case <-time.After(10 * time.Second):
t.Fatal("放锁后 10 秒内 Wait 都没返回")
}
assertOnlyCompleteBackups(t, dir)
}
// TestListBackupsIgnoresTempFiles 钉住列表过滤:备份目录里的中间文件
// (VACUUM INTO 写到一半、或上一次异常退出留下的)不能出现在备份列表里,
// 也不能被「一键恢复」的文件名校验认成备份——哪怕有人手工把它放进目录。
func TestListBackupsIgnoresTempFiles(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
dir := t.TempDir()
info, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Reason: "auto", Keep: 20})
if err != nil {
t.Fatalf("备份: %v", err)
}
// 三类「不是最终备份」的东西,混在真备份旁边。
strays := []string{
// 本包的中间文件名:<最终名>.tmp-<随机>。
info.Path + tmpMark + "-0123456789abcdef",
// 另一种写法的临时文件:仍然是 .db 结尾、前缀也对得上,只有名字里的标记能识破。
filepath.Join(dir, "event-auto-20260101-120000.tmp.db"),
// 安全副本那份列表(PruneSafetyCopies 用它决定删谁)也要挡住的中间文件。
filepath.Join(dir, "pre-restore-event-20260101-120000.tmp-abc.db"),
// 看着像备份、但不是库文件。
filepath.Join(dir, "event-auto-20260101-120000.txt"),
}
for _, p := range strays {
if err := os.WriteFile(p, []byte("半截内容"), 0o644); err != nil {
t.Fatalf("放置中间文件 %s: %v", p, err)
}
}
list, err := ListBackups(dir, "")
if err != nil {
t.Fatalf("列出备份: %v", err)
}
if len(list) != 1 || list[0].Name != info.Name {
t.Fatalf("备份列表 = %v,期望只有 %s(中间文件必须被过滤掉)", backupNames(list), info.Name)
}
if list, err := ListBackups(dir, "event"); err != nil {
t.Fatalf("按前缀列出备份: %v", err)
} else if len(list) != 1 {
t.Fatalf("按前缀列出 %d 份,期望 1 份", len(list))
}
latest, ok, err := LatestBackup(dir, "event")
if err != nil {
t.Fatalf("取最近备份: %v", err)
}
if !ok || latest.Name != info.Name {
t.Fatalf("最近备份 = %+v(ok=%v),期望 %s", latest, ok, info.Name)
}
if copies, err := safetyCopies(dir, RestoreKindEvent); err != nil {
t.Fatalf("列出安全副本: %v", err)
} else if len(copies) != 0 {
t.Fatalf("安全副本列表 = %v,期望为空(中间文件不是副本)", backupNames(copies))
}
// 「一键恢复」按文件名认库:中间文件必须认不出来,否则就轮不到列表那一层拦。
for _, p := range strays[:3] {
if kind, _, ok := KindOfBackupName(filepath.Base(p)); ok {
t.Errorf("KindOfBackupName(%q) 认成了 %q:中间文件不该被一键恢复选中",
filepath.Base(p), kind)
}
}
}
// TestVacuumIntoCleansTempOnFailure 钉住失败路径:VACUUM 失败之后备份目录必须干净。
//
// 源库被锁住时 VACUUM INTO 会先建出目标文件再失败,这条用例就是冲着「失败时留下的东西」
// 来的:中间文件要清掉,最终名下一个字节都不能留(复现手法与上面那条并发用例同一套)。
func TestVacuumIntoCleansTempOnFailure(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "event.db")
db := openEventDBLockable(t, dbPath, 200)
seedEvent(t, db)
dir := t.TempDir()
lockEventDB(t, dbPath)
if info, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Reason: "auto", Keep: 20}); err == nil {
t.Fatalf("源库被锁住时备份应当失败,实际成功: %+v", info)
}
if names := dirNames(t, dir); len(names) != 0 {
t.Fatalf("备份失败后目录里留下了 %v,期望空目录(半截 .db 会被当成可用备份)", names)
}
}
// TestWaitReturnsImmediatelyWhenIdle 钉住 Wait 的「不做死」:
// 没起过自动备份、自动备份被关掉(间隔 <=0 / 没配数据目录)、上下文已取消之后,
// Wait 都要立刻返回;起了协程的则必须能 join 回来。
func TestWaitReturnsImmediatelyWhenIdle(t *testing.T) {
// 一次都没 StartAuto。
svc := NewService(nil, nil, t.TempDir(), 20, testLogger())
assertWaitReturns(t, svc)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// 间隔 <=0 = 自动备份关闭,StartAuto 不起协程。
svc.StartAuto(ctx, 0)
assertWaitReturns(t, svc)
// 没配数据目录同理。
NewService(nil, nil, "", 20, testLogger()).StartAuto(ctx, time.Minute)
assertWaitReturns(t, svc)
// 起过协程:取消后必须能 join 回来(不能等出个死结)。
svc.StartAuto(ctx, time.Hour)
cancel()
assertWaitReturns(t, svc)
}
// ---- 用例用的小工具 ----
// openEventDBLockable 与 backup_restore_test.go 的 openEventDBAt 相同,区别是
// journal_mode=DELETE(rollback 模式)。用例要的是「另一条连接能真的锁住库」:
// WAL 下读不会被写锁挡住,BEGIN EXCLUSIVE 形同虚设。
// busyTimeoutMS 用来控制「卡在锁上」的时长(失败路径要快、并发路径要够久)。
func openEventDBLockable(t *testing.T, path string, busyTimeoutMS int) *sql.DB {
t.Helper()
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(DELETE)&_pragma=foreign_keys(1)&_pragma=busy_timeout(%d)",
path, busyTimeoutMS)
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
}
// lockEventDB 用另一条连接抢走库文件的写锁(BEGIN EXCLUSIVE),返回放锁函数。
// 放锁是幂等的,重复调用无副作用;用例忘了放锁也会在清理时放掉。
func lockEventDB(t *testing.T, path string) func() {
t.Helper()
ctx := context.Background()
handle, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)")
if err != nil {
t.Fatalf("打开锁连接: %v", err)
}
handle.SetMaxOpenConns(1)
conn, err := handle.Conn(ctx)
if err != nil {
_ = handle.Close()
t.Fatalf("取锁连接: %v", err)
}
if _, err := conn.ExecContext(ctx, `BEGIN EXCLUSIVE`); err != nil {
_ = conn.Close()
_ = handle.Close()
t.Fatalf("抢库写锁: %v", err)
}
// 显式读一次:锁要落地,不能只挂在事务状态上。
var n int
if err := conn.QueryRowContext(ctx, `SELECT count(*) FROM teams`).Scan(&n); err != nil {
t.Fatalf("持锁读库: %v", err)
}
var once sync.Once
release := func() {
once.Do(func() {
_, _ = conn.ExecContext(ctx, `ROLLBACK`)
_ = conn.Close()
_ = handle.Close()
})
}
t.Cleanup(release)
return release
}
// waitForDirEntry 等备份目录里出现第一个文件(备份确实开始写了),超时即失败。
func waitForDirEntry(t *testing.T, dir string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for {
if entries, err := os.ReadDir(dir); err != nil {
t.Fatalf("读取备份目录 %s: %v", dir, err)
} else if len(entries) > 0 {
return
}
if time.Now().After(deadline) {
t.Fatalf("%s 内备份目录 %s 一直没出现文件:备份没开始写", timeout, dir)
}
time.Sleep(time.Millisecond)
}
}
// assertWaitReturns 断言 Wait 在给定的等待窗口内返回(无任务时它不该阻塞)。
func assertWaitReturns(t *testing.T, svc *Service) {
t.Helper()
done := make(chan struct{})
go func() {
svc.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Wait 在无任务时没有立即返回")
}
}
// assertOnlyCompleteBackups 断言备份目录里只剩下完整可读的备份:
// 没有中间文件残留,每个列出来的 .db 都能打开、数据齐(不是半截文件)。
func assertOnlyCompleteBackups(t *testing.T, dir string) {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("读取备份目录 %s: %v", dir, err)
}
if len(entries) == 0 {
t.Fatal("等待收尾后一份备份都没有:在飞的那一轮既没写完也没留下任何文件")
}
for _, e := range entries {
if !isFinalBackupName(e.Name()) {
t.Errorf("备份目录里残留中间文件 %s:它既不是备份,也不该留着", e.Name())
}
}
list, err := ListBackups(dir, "")
if err != nil {
t.Fatalf("列出备份: %v", err)
}
if len(list) != len(entries) {
t.Fatalf("目录里 %d 个文件,备份列表只有 %d 份:列表过滤与落盘口径不一致",
len(entries), len(list))
}
for _, b := range list {
if b.Size == 0 {
t.Errorf("备份 %s 是 0 字节", b.Name)
continue
}
bdb := openSQLite(t, b.Path)
if got := countRows(t, bdb, "teams"); got != 2 {
t.Errorf("备份 %s 里 teams = %d,期望 2(文件不完整)", b.Name, got)
}
_ = bdb.Close()
}
}
// dirNames 列出目录里的文件名(排序无要求,仅供报错信息)。
func dirNames(t *testing.T, dir string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("读取备份目录 %s: %v", dir, err)
}
out := make([]string, 0, len(entries))
for _, e := range entries {
out = append(out, e.Name())
}
return out
}
func backupNames(list []BackupInfo) []string {
out := make([]string, 0, len(list))
for _, b := range list {
out = append(out, b.Name)
}
return out
}
+74 -3
View File
@@ -2,6 +2,7 @@ package export
import (
"context"
"fmt"
"io"
"log/slog"
"os"
@@ -153,9 +154,19 @@ func TestStartAutoBacksUp(t *testing.T) {
seedEvent(t, db)
dir := t.TempDir()
const interval = 10 * time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
NewService(nil, db, dir, 20, testLogger()).StartAuto(ctx, 10*time.Millisecond)
// 备份跑在后台协程里,而 t.TempDir 的清理是把目录整个删掉:测试一返回它就删,
// 协程此刻可能刚提交下一轮备份、正往目录里写新文件,RemoveAll 于是报
// 「unlinkat .../002: directory not empty」(本用例历史上就是这样偶发红的)。
// 清理按后进先出执行,这条比 t.TempDir 的清理注册得晚、所以先跑:
// 先取消 ctx 等协程真正停稳,t.TempDir 再去删就不会撞上还没落完的写。
t.Cleanup(func() {
cancel()
waitAutoBackupIdle(t, dir, interval)
})
NewService(nil, db, dir, 20, testLogger()).StartAuto(ctx, interval)
deadline := time.Now().Add(10 * time.Second)
for {
@@ -172,10 +183,70 @@ func TestStartAutoBacksUp(t *testing.T) {
if time.Now().After(deadline) {
t.Fatal("10 秒内未生成定时备份")
}
time.Sleep(10 * time.Millisecond)
time.Sleep(interval)
}
}
// waitAutoBackupIdle 等自动备份的后台协程停稳后再返回(供测试在删目录前调用)。
//
// Service.Wait 才是正式入口(StartAuto 起的协程都会被它 join,含已经在飞的那一轮备份);
// 这里仍然从目录上观察「还在不在写」,因为这一条不依赖 Service 内部实现,
// 能把「Wait 返回了但目录还在变」这种回归直接照出来。协程的退出本身是确定的——
// 每轮 select 都先看 ctx.Done,ctx 一取消就返回,最多再走完一轮已经在飞的备份——
// 所以「连续若干个 tick 目录都没有新文件落盘」就足以判定它已经停了。
func waitAutoBackupIdle(t *testing.T, dir string, interval time.Duration) {
t.Helper()
// 20 个 tick 的安静窗口:远大于单轮备份(这份小库几毫秒)与调度抖动,
// 又短到不至于让用例等出可感知的慢。
const quietTicks = 20
quiet := quietTicks * interval
const patience = 5 * time.Second
deadline := time.Now().Add(patience)
last := backupDirState(t, dir)
quietSince := time.Now()
for {
time.Sleep(interval)
if cur := backupDirState(t, dir); cur != last {
last, quietSince = cur, time.Now()
}
if time.Since(quietSince) >= quiet {
return
}
if time.Now().After(deadline) {
// 非 Fatal:这里用 t.Errorf 是为了不打断后面的清理(Fatal 会跳过 t.TempDir 的清理,
// 反而把残留目录留在 TMPDIR 里),用例仍然会红。
t.Errorf("自动备份协程在 %s 内都没停稳,目录 %s 仍在变化", patience, dir)
return
}
}
}
// backupDirState 给备份目录拍一张快照,用来判断「还有没有新备份在落盘」。
// 快照带上文件名、大小与修改时间:同一秒内的第 2、3 份备份只是文件名多了序号,
// 靠修改时间也能把它们与前一份区分开。
func backupDirState(t *testing.T, dir string) string {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return ""
}
t.Fatalf("读取备份目录 %s: %v", dir, err)
}
var b strings.Builder
for _, e := range entries {
info, err := e.Info()
if err != nil {
continue
}
fmt.Fprintf(&b, "%s|%d|%d;", e.Name(), info.Size(), info.ModTime().UnixNano())
}
return b.String()
}
// 超过保留份数时删除最旧的备份。
func TestServiceRunPrunesOldBackups(t *testing.T) {
db := openEventDB(t)
+46 -1
View File
@@ -2,6 +2,7 @@ package export
import (
"archive/zip"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
@@ -337,15 +338,59 @@ func SafeProjectName(name string) (string, error) {
return name, nil
}
// tmpMark 是「中间文件」在文件名里的标记:备份先写成 <最终名>.tmp-<随机>,成功后再改名。
// ListBackups / safetyCopies 靠它把这些文件挡在备份列表之外(见 isFinalBackupName)。
const tmpMark = ".tmp"
// vacuumInto 把 db 的快照写进 dst,并且**只以完整文件的形态出现在 dst 这个名字上**:
// 先写同目录的中间文件 <dst>.tmp-<随机>,VACUUM 成功后再改名过去。
//
// 不能直接写 dst。VACUUM INTO 是「先按最终名建出空文件,再往里灌页」:源库一旦被别的
// 进程/连接锁住(或有别的写入卡着),它会带着一个 0 字节的目标文件在那里等(实测),
// 进程此时退出(正常关闭 / 被 Console 杀掉 / 掉电)就在原地留下半截 .db。
// 备份目录里所有 .db 都会被 ListBackups 列出来、被「一键恢复」当作可恢复的文件选中,
// 而备份是最后一道防线——它自己不能是个坏文件。
//
// 中间文件与最终文件同目录,改名不跨文件系统;失败(含 VACUUM 中途失败)时中间文件会被删掉。
func vacuumInto(db *sql.DB, dst string) error {
tmp, err := vacuumTempPath(dst)
if err != nil {
return err
}
// VACUUM INTO 的目标是语句里的字面量,路径由本包自己生成,转义单引号即可。
stmt := "VACUUM INTO '" + strings.ReplaceAll(dst, "'", "''") + "'"
stmt := "VACUUM INTO '" + strings.ReplaceAll(tmp, "'", "''") + "'"
if _, err := db.Exec(stmt); err != nil {
// 半截的中间文件既不是备份,也不该在备份目录里攒着——删不掉不算新错误,
// 照原样回报 VACUUM 的失败(它才是操作员要看的原因)。
_ = os.Remove(tmp)
return fmt.Errorf("export: VACUUM INTO %s: %w", dst, err)
}
if err := os.Rename(tmp, dst); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("export: 提交备份文件 %s: %w", dst, err)
}
return nil
}
// vacuumTempPath 给一次 VACUUM INTO 挑一个中间文件名。
//
// 只挑名字、不建文件:VACUUM INTO 拒绝写已经存在的目标文件。随机串用来避开
// 「上一次异常退出留下的同名中间文件」与「并发的 CLI 备份(scoreboard backup)」。
func vacuumTempPath(dst string) (string, error) {
dir, base := filepath.Split(dst)
var buf [8]byte
if _, err := rand.Read(buf[:]); err != nil {
return "", fmt.Errorf("export: 生成备份中间文件名: %w", err)
}
p := filepath.Join(dir, base+tmpMark+"-"+hex.EncodeToString(buf[:]))
if _, err := os.Stat(p); err == nil {
return "", fmt.Errorf("export: 备份中间文件名 %s 已存在,请清理后重试", p)
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("export: 检查备份中间文件 %s: %w", p, err)
}
return p, nil
}
func buildManifest(root string) ([]ProjectFile, error) {
var out []ProjectFile
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {