Files
Scoreboard/server/internal/export/backup.go
T
AskaEth 6841cf49aa fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)
## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。

## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。

主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
  「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
  上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
  「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
  CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
  反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
  过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
  单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
  基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
  报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
  20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
  导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
  基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
  访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
  会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试

## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。

## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
  watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
  管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败

## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
  与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
2026-09-11 23:46:59 +08:00

379 lines
12 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"`
// 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
}
// 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, 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
}
// 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 != ""
}