Files
Scoreboard/server/internal/export/backup.go
T
AskaEth 13f1f03cd0 fix: 修掉十路审查发现的 9 个 P0 与一批 P1(数据正确性 / 越权 / 现场可用性)
P0(9 条全修):
- 多通道项目奖牌重复计数:domain 层按 (EventID,CategoryID,EntryID/Subject) 去重,
  通道优先级 athlete→team→id
- 「只排名不计分」项目(scored:false)不再进奖牌榜,REST/WS/导出口径统一
- aggregate 流水 entry_id 恒 NULL 导致重算后分数翻倍:作废范围改按 event+category 定位
- 已发布成绩仍可被「存草稿」覆盖并清空 rank:服务端 409 STATE_INVALID + 前端禁编辑
- voidResult / 规则导入重算后名次不回写:接住 Ranked 调 applyComputedRanks
- 赛事包覆盖导入可能「旧项目已删、新项目没成」:先改名成功再清理,失败回滚
- 导入赛事包可覆盖正在运行的赛事目录:name==当前项目直接 400
- Console spawn 失败后守护器永久卡死:清 child/pid + 调度重启 + stop 超时兜底
- /ws 快照向非 admin 下发 admin-only 配置:按角色裁剪 settings

P1(计分口径 / 契约 / 现场):
- standings.tieBreak 三处接线;profiles[].recordBonusMode 生效;破纪录校验
  RecordEligible 与 entry 归属;破纪录登记校验已发布成绩
- export.loadRanked 与主路径对齐;ListBackups reason 解析;CLI backup 前缀修正
  (原来只增不减会撑爆磁盘);单位积分明细/导出统一权重折算;停用单位展示
- 规则导入原子化(重算并入同一事务)+ 返回 compatible/affectedEventCount/
  recalculatedLedgerRows;登录审计 user_id 修正
- WS 策略性断开用 Close(code,reason);HelloMessage 补 page/instanceId;
  实例注册表清理;settings/单位/项目写操作补 change_log 触发大屏刷新
- 大屏:全量快照不再被版本守卫丢弃;个人积分榜刷新;公告返回页/轮播;行数截断;
  REST 竞态;1008 停止重连;握手超时;时区补 Z;401 仅 AUTH_REQUIRED 才登出
- 管理端:published 行禁编辑;saveDraft 补 unit(1:06.33 显示);前端预览改用服务端
  points;空成绩行不再恒「已修改」;修订详情按 id 拉取;切项目请求序号与 dirty 保护;
  发布后刷新项目状态;NAV/路由按角色过滤;recorder 不再打 admin-only 接口
- Console:大屏窗口销毁后重建;大屏 URL 复用 host 归一化;backup.bat 改纯 ASCII
  (已在 Meowhome 实跑验证)

自检:服务端 gofmt/build/vet 通过、12 包测试全绿;前端 vue-tsc + build 通过、
node --test 24/24;console check-syntax 12 文件通过。
2026-09-09 22:16:58 +08:00

344 lines
9.9 KiB
Go
Raw 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"`
CreatedAt time.Time `json:"createdAt"`
}
// 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 := 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,
// 便于在备份列表里一眼看出哪些是发布节点留的。
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
}
// 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
}
go func() {
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)
}
}
}
}()
}
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 的返回值。
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() || !strings.HasSuffix(strings.ToLower(e.Name()), ".db") {
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, 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
}
// LatestBackup 返回最近一份备份,用于健康检查展示「最近备份时间」。
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 != ""
}