换基线:把「底座换代」做进后台(站点数据一行不动)
导出是「底座为底 + 只覆盖托管表」,所以底座必须是手机导出的包。底座跟着数据集走, 手机重新导出后想换代,以前只能整包重新导入成新数据集(站点补录的行、主题、备注都得重收拾)。 新动作 /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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user