换基线:把「底座换代」做进后台(站点数据一行不动)

导出是「底座为底 + 只覆盖托管表」,所以底座必须是手机导出的包。底座跟着数据集走,
手机重新导出后想换代,以前只能整包重新导入成新数据集(站点补录的行、主题、备注都得重收拾)。
新动作 /admin/baseline(后台首页加入口)就是给同一份数据的底座换代用的。

三道闸,顺序不能挪:
1. 重合判据:新包里的到访 UUID 与站点侧带 UUID 的到访比。一条都对不上直接拒
   (连 force 也不给过)——干跑只验结构,一份不相干的包照样导得出,但换下去站点
   每行都在新底座里认不出身份,全按新行插到最大编号之后,真机上就是「这些到访
   全变成新的」。重合率不到一半要显式确认;判据没过不当场报错了事:包已经收进
   uploads,页面给一个「我确认,强制替换」按钮,不必重传几十 MB。
2. 干跑导出:底座缺表/缺实体编号在这步挡住,不会出现「换完了才发现导不出、
   原来那份已经不知去哪」。
3. 旧底座留档 + 记档,换错了可一键回滚。

两个坑:
- 留档名不能只用秒级时间戳。「替换后紧接着回滚」落在同一秒时,回滚那次 rename 会把
  刚生成的留档覆盖掉,于是回滚回了个寂寞(TestRollbackBaselineRestoresPrevious 当场红)。
  改用 baselineBackupName():20060102-150405 + 冲突自增。
- ensureBaseline 必须先看记档。只认 datasets.sha256(= 最初那份包)的话,底座一丢就静默
  重建回旧包,上一次换基线白做且毫无提示。抽出 ensureBaselineTo(ctx, dsID, dst) 共用;
  「重建」入口写临时文件再 rename,不做破坏性删除。

回归用例 7 条(internal/web/baseline_swap_test.go):成功路径断言「换底座前后导出包里
ZVISIT 的 UUID 集合逐条一致」——编号会换、身份不该换,谁在底座里没对上立刻亮;
记档优先重建带反证(清掉记档后同一条路必须走不通);部分重合需确认、完全对不上 force
也不给过、非包文件第一步就挡住、回滚还原且当前那份也留档、页面渲染到底。

验收:./scripts/verify.sh 全量 PASS=318 FAIL=0 SKIP=0。
This commit is contained in:
tamakyi
2026-09-30 19:00:18 +08:00
parent 11e00a5b60
commit 6ff6f944a1
13 changed files with 1544 additions and 28 deletions
+63 -19
View File
@@ -197,22 +197,78 @@ func (s *Server) baselinePath(datasetID int64) string {
return filepath.Join(s.cfg.DataDir, "baselines", fmt.Sprintf("%d.sqlite", datasetID))
}
// ensureBaseline 确保基线库存在;缺失时从当初上传的原始备份包重建。
// ensureBaseline 确保基线库存在;缺失时按记档或当初上传的原始备份包重建。
func (s *Server) ensureBaseline(ctx context.Context, datasetID int64) (string, error) {
p := s.baselinePath(datasetID)
if _, err := os.Stat(p); err == nil {
return p, nil
}
archive, err := s.findSourceArchive(ctx, datasetID)
if err != nil {
if err := s.ensureBaselineTo(ctx, datasetID, p); err != nil {
return "", err
}
if err := rond.WriteBaseline(archive, p); err != nil {
return "", fmt.Errorf("重建基线库失败:%w", err)
}
return p, nil
}
// ensureBaselineTo 把底座库写到 dst(不论 dst 是否已存在)。调用方要先想清楚覆盖是否安全:
// 导出路径只会在底座缺失时调它,而「换基线」页要先写临时文件、成功了再改名就位。
func (s *Server) ensureBaselineTo(ctx context.Context, datasetID int64, dst string) error {
// 换过底座的数据集必须先看记档:datasets.sha256 指的是**最初**那份包,
// 拿它重建会把底座悄悄退回旧包——站长上一次换基线就白做了,而且没有任何提示。
var name, baseSHA string
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(baseline_name,''), COALESCE(baseline_sha256,'')
FROM datasets WHERE id=$1`, datasetID).Scan(&name, &baseSHA); err != nil {
return err
}
if baseSHA != "" {
archive, err := s.findUploadBySha(baseSHA, 0)
if err != nil {
return fmt.Errorf("当前底座来自 %s,但 uploads 里已找不到那份包:请到「换基线」页重新上传", name)
}
if err := rond.WriteBaseline(archive, dst); err != nil {
return fmt.Errorf("重建基线库失败:%w", err)
}
return nil
}
archive, err := s.findSourceArchive(ctx, datasetID)
if err != nil {
return err
}
if err := rond.WriteBaseline(archive, dst); err != nil {
return fmt.Errorf("重建基线库失败:%w", err)
}
return nil
}
// uploadsNewestFirst 列出 uploads 目录里的候选包,新的在前(命中即止的查找靠这个顺序)。
func (s *Server) uploadsNewestFirst() []string {
files, _ := filepath.Glob(filepath.Join(s.cfg.UploadDir, "*"))
sort.Slice(files, func(i, j int) bool {
fi, ei := os.Stat(files[i])
fj, ej := os.Stat(files[j])
if ei != nil || ej != nil {
return false
}
return fi.ModTime().After(fj.ModTime())
})
return files
}
func hashUpload(f string) (string, error) {
got, _, err := ingest.File(f)
return got, err
}
// findUploadBySha 按内容摘要找一份包(用于「换基线」记档的源包)。
// size > 0 时先按字节数筛一道再算摘要:uploads 里可能堆着几十份几十 MB 的包,
// 每份都完整读一遍只为比摘要太亏(sha 相同必然字节数相同)。
func (s *Server) findUploadBySha(sha string, size int64) (string, error) {
f := pickArchiveBySha(s.uploadsNewestFirst(), sha, size, hashUpload)
if f == "" {
return "", fmt.Errorf("找不到摘要为 %.8s… 的备份包", sha)
}
return f, nil
}
// findSourceArchive 在 uploads 目录里按 datasets.sha256 找回当初上传的备份包。
func (s *Server) findSourceArchive(ctx context.Context, datasetID int64) (string, error) {
var sha string
@@ -220,20 +276,8 @@ func (s *Server) findSourceArchive(ctx context.Context, datasetID int64) (string
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(sha256,''), COALESCE(size_bytes,0) FROM datasets WHERE id=$1`, datasetID).Scan(&sha, &size); err != nil {
return "", err
}
files, _ := filepath.Glob(filepath.Join(s.cfg.UploadDir, "*"))
// 新的在前,命中即止
sort.Slice(files, func(i, j int) bool {
fi, ei := os.Stat(files[i])
fj, ej := os.Stat(files[j])
if ei != nil || ej != nil {
return false
}
return fi.ModTime().After(fj.ModTime())
})
if f := pickArchiveBySha(files, sha, size, func(f string) (string, error) {
got, _, err := ingest.File(f)
return got, err
}); f != "" {
if f := pickArchiveBySha(s.uploadsNewestFirst(), sha, size, hashUpload); f != "" {
return f, nil
}
return "", fmt.Errorf("找不到当初上传的原始备份包(可能已清理),请到后台重新上传一次该 .rondbackup")