Files
AskaEth 1b8a37ff8c 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/ 五份(使用手册 / 报名名单导入规范 / 升级说明 / 部署指南 / 上线检查清单)同步。
2026-09-14 01:10:16 +08:00

423 lines
14 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 export
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// DefaultBackupKeep 是滚动保留的备份份数(见 08-安全与可靠性 第 6 节)。
const DefaultBackupKeep = 20
// BackupOptions 描述一次备份。
type BackupOptions struct {
// Dir 是备份目录,通常为 <dataDir>/backups。
Dir string
// Prefix 是文件名前缀,用于区分不同库(如 event / system)。
Prefix string
// Reason 记录触发原因:manual / publish / auto。
Reason string
// Keep 是滚动保留份数,<=0 时用 DefaultBackupKeep。
Keep int
// SkipPrune 为 true 时只备份不清理,供「手动留档」场景使用。
SkipPrune bool
// Now 便于测试注入固定时间;零值取当前时间。
Now time.Time
}
// BackupInfo 是一份备份的描述。
type BackupInfo struct {
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) {
if db == nil {
return BackupInfo{}, ErrNoDatabase
}
dir := strings.TrimSpace(opts.Dir)
if dir == "" {
return BackupInfo{}, ErrNoDir
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return BackupInfo{}, fmt.Errorf("export: 创建备份目录 %s: %w", dir, err)
}
prefix := strings.TrimSpace(opts.Prefix)
if prefix == "" {
prefix = "db"
}
reason := strings.TrimSpace(opts.Reason)
if reason == "" {
reason = "manual"
}
now := nowOr(opts.Now)
path, err := uniqueBackupPath(dir, prefix, reason, 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)
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
}
}
return info, nil
}
// BackupBeforePublish 是「发布前备份」入口:同一套逻辑,只是把原因记为 publish,
// 便于在备份列表里一眼看出哪些是发布节点留的。
//
// 当前没有生产调用方(代码卫生项 C29):生产走 Service.RunEvent("publish")(同一把 Service.mu
// 串行化,见 backup.go 的 Service),只有 backup_test.go 在用。留着是因为「reason=publish 的
// 备份名能被 ListBackups/reasonFromName 认回来」这条口径需要它来钉。
func BackupBeforePublish(db *sql.DB, opts BackupOptions) (BackupInfo, error) {
if strings.TrimSpace(opts.Reason) == "" {
opts.Reason = "publish"
}
return Backup(db, opts)
}
// Service 收拢「系统库 + 赛事库」的备份,并串行化手动、发布前、定时三类触发:
// 同一时刻只允许一个 VACUUM INTO 在跑,避免并发快照互相拖慢或写入同一文件。
type Service struct {
system *sql.DB
event *sql.DB
dir string
keep int
logger *slog.Logger
mu sync.Mutex
// wg 记着 StartAuto 起的后台协程,供 Wait 在进程退出前 join。
// 只加不减:协程退出后 Wait 照常立即返回,所以「没起过自动备份」与
// 「自动备份已经停了」两种情况下 Wait 都是零成本。
wg sync.WaitGroup
}
// NewService 构造备份服务;keep<=0 时按 DefaultBackupKeep 滚动保留。
func NewService(system, event *sql.DB, dir string, keep int, logger *slog.Logger) *Service {
if logger == nil {
logger = slog.Default()
}
return &Service{system: system, event: event, dir: dir, keep: keep, logger: logger}
}
// Run 备份系统库与赛事库各一份(手动备份、定时备份用)。
func (s *Service) Run(reason string) ([]BackupInfo, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.snapshotAll(reason, true)
}
// RunEvent 只备份赛事库(发布前备份用);与 Run 共用同一把锁。
func (s *Service) RunEvent(reason string) ([]BackupInfo, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.snapshotAll(reason, false)
}
// StartAuto 跟随 ctx 定时备份:ctx 取消即停止,间隔 <=0 表示关闭。
//
// 定时备份不写审计:一天下来会产生几百条 system.backup,把审计表刷满;
// 手动备份与发布前备份照旧写审计。
func (s *Service) StartAuto(ctx context.Context, interval time.Duration) {
if interval <= 0 {
s.logger.Info("自动备份已关闭", "interval", interval.String())
return
}
if strings.TrimSpace(s.dir) == "" {
s.logger.Warn("未配置数据目录,跳过自动备份")
return
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if ctx.Err() != nil {
return
}
infos, err := s.Run("auto")
if err != nil {
s.logger.Error("自动备份失败", "err", err)
continue
}
for _, info := range infos {
s.logger.Info("自动备份完成", "path", info.Path, "size", info.Size)
}
}
}
}()
}
// 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
}
out := make([]BackupInfo, 0, 2)
if withSystem && s.system != nil {
info, err := s.snapshot(s.system, "system", reason)
if err != nil {
return out, err
}
out = append(out, info)
}
if s.event != nil {
info, err := s.snapshot(s.event, "event", reason)
if err != nil {
return out, err
}
out = append(out, info)
}
return out, nil
}
func (s *Service) snapshot(db *sql.DB, prefix, reason string) (BackupInfo, error) {
info, err := Backup(db, BackupOptions{Dir: s.dir, Prefix: prefix, Reason: reason, Keep: s.keep})
if err == nil {
return info, nil
}
// 快照已生成、只是滚动清理旧备份失败时 Backup 仍返回有效 info:
// 删不掉旧文件不算本次备份失败,记日志即可(下次备份会再试)。
if info.Path != "" {
s.logger.Warn("清理旧备份失败", "prefix", prefix, "err", err)
return info, nil
}
return BackupInfo{}, err
}
// ListBackups 按时间倒序列出备份;prefix 为空时列出全部 .db 备份。
// 不计算校验和(避免每次列表都全量读盘),需要校验时用 Backup 的返回值。
//
// 只认「已经落好的」备份文件(isFinalBackupName):中间文件一律不列。
// 备份列表是「一键恢复」选文件的唯一数据源,把一份没写完的文件列出去,
// 现场就可能拿它去覆盖好库——这比没有备份更糟。
func ListBackups(dir, prefix string) ([]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 = strings.TrimSpace(prefix)
out := make([]BackupInfo, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !isFinalBackupName(e.Name()) {
continue
}
if prefix != "" && !strings.HasPrefix(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: reasonFromName(e.Name(), prefix),
CreatedAt: info.ModTime(),
}))
}
sort.Slice(out, func(i, j int) bool {
if !out[i].CreatedAt.Equal(out[j].CreatedAt) {
return out[i].CreatedAt.After(out[j].CreatedAt)
}
return out[i].Name > out[j].Name
})
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 不返回「最近备份时间」,
// 备份列表接口走的是 ListBackups。当前只有 backup_test.go 在用它(代码卫生项 C29),
// 加调用方前先想清楚要不要真的把备份时间暴露到健康检查里。
func LatestBackup(dir, prefix string) (BackupInfo, bool, error) {
list, err := ListBackups(dir, prefix)
if err != nil || len(list) == 0 {
return BackupInfo{}, false, err
}
return list[0], true, nil
}
// PruneBackups 只保留最近 keep 份,返回被删除的文件路径;
// 个别文件删不掉只汇总到返回的错误里,不影响继续清理其余旧备份。
func PruneBackups(dir, prefix string, keep int) ([]string, error) {
if keep <= 0 {
keep = DefaultBackupKeep
}
list, err := ListBackups(dir, prefix)
if err != nil {
return nil, err
}
if len(list) <= keep {
return nil, nil
}
removed := make([]string, 0, len(list)-keep)
var errs []error
for _, b := range list[keep:] {
if err := os.Remove(b.Path); err != nil && !os.IsNotExist(err) {
errs = append(errs, fmt.Errorf("export: 删除旧备份 %s: %w", b.Path, err))
continue
}
removed = append(removed, b.Path)
}
return removed, errors.Join(errs...)
}
// uniqueBackupPath 为备份选一个不重名的路径。
// 重试次数有上限:目录不可读等非 NotExist 错误不能让备份线程死循环(它持锁会阻塞全部备份)。
func uniqueBackupPath(dir, prefix, reason string, now time.Time) (string, error) {
base := fmt.Sprintf("%s-%s-%s", prefix, reason, 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)
}
// knownBackupPrefixes 是系统内置的备份前缀;prefix 为空(列出全部备份)时用它识别并剥掉前缀。
var knownBackupPrefixes = []string{"event", "system", "db"}
// reasonFromName 从 <prefix>-<reason>-<yyyyMMdd>-<HHmmss>[-<序号>].db 里取回原因;
// 同一秒重复备份会多一段序号,所以从右侧找「8 位日期 + 6 位时间」再截断。
func reasonFromName(name, prefix string) string {
base := strings.TrimSuffix(name, filepath.Ext(name))
if prefix != "" {
base = strings.TrimPrefix(base, prefix+"-")
} else {
for _, p := range knownBackupPrefixes {
if strings.HasPrefix(base, p+"-") {
base = strings.TrimPrefix(base, p+"-")
break
}
}
}
parts := strings.Split(base, "-")
for i := len(parts) - 2; i >= 0; i-- {
if len(parts[i]) == 8 && len(parts[i+1]) == 6 && allDigits(parts[i]) && allDigits(parts[i+1]) {
return strings.Join(parts[:i], "-")
}
}
return ""
}
func allDigits(s string) bool {
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return s != ""
}