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

导出是「底座为底 + 只覆盖托管表」,所以底座必须是手机导出的包。底座跟着数据集走,
手机重新导出后想换代,以前只能整包重新导入成新数据集(站点补录的行、主题、备注都得重收拾)。
新动作 /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
+680
View File
@@ -0,0 +1,680 @@
package web
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"rond-with-you/internal/ingest"
"rond-with-you/internal/rond"
)
// 「换基线」:把当前数据集的底座库(导出时铺数据的底稿)换成另一份手机导出的包。
//
// 为什么必须能换:导出只是以底座为底、覆盖我们托管的那七张实体表,其余内容
// (Core Data 元数据、索引、持久化历史、ZRAWVISIT 采样点、ZTRIP* 等)全部从底座原样带出来。
// 所以底座**必须是手机导出的包**——拿本站导出的包当底座,编号空间与真机对不上,
// 导出会越导越失真。而底座一旦定下就跟着数据集走;手机那边再导出新包时,
// 以前唯一的办法是整包重新导入成新数据集,站点侧补录的行、主题、备注都得重新收拾。
// 这个动作就是给「同一份数据的底座换代」用的:站点数据一行不动,只换底稿。
//
// 与「上传备份」的区别:上传是重建数据(新数据集),换基线不动数据、只换底稿。
// baselineBackupKeep 是旧底座留档的保留份数(最近 N 份)。替换前一律留档,
// 换错了能一键回滚——底座是导出的命根子,写坏了整个数据集就导不出去了。
const baselineBackupKeep = 3
// minOverlapVisits 是「重合率」判据生效所需的最小样本量。站点侧带 UUID 的到访太少时
// (比如数据集本来就是站点手工建的),重合率没有统计意义,不拦。
const minOverlapVisits = 20
// baselineStatus 是底座的现状画像。
type baselineStatus struct {
Path string
Exists bool
Size int64
ModTime *time.Time
// Name / SHA / At 是「换基线」记档:这份底座是从哪份包换过来的。
// SHA 为空表示从未换过,来源就是数据集最初上传的那份原始包。
Name string
SHA string
At *time.Time
// Backups 是留档的旧底座(新的在前)。
Backups []baselineBackup
}
type baselineBackup struct {
Name string
Size int64
At time.Time
}
// baselineCount 是底座里某张表的行数(页面上的「底稿画像」)。
type baselineCount struct {
Table string
Label string
N int
}
// baselineTableLabels 是画像要列的表,顺序按对导出的重要程度。
var baselineTableLabels = []struct{ Table, Label string }{
{"ZVISIT", "到访"},
{"ZLOCATION", "地点"},
{"ZMOVEMENT", "位移"},
{"ZHOURLYWEATHER", "天气"},
{"ZRAWVISIT", "原始定位采样"},
{"ZACTIVITY", "活动类型"},
{"ZTAG", "标签"},
{"ZTRANSPORT", "交通方式"},
}
// openBaselineRO 只读打开底座库(Core Data 单文件)。
//
// 驱动只认 _pragma / _time_format 这几个 DSN 参数,没有 mode=ro;底座库是 rollback journal
// 模式(VACUUM INTO 的产物),只做 SELECT 不会落下任何旁写文件,再加 query_only 兜住
// 「误写底座库」这条路——底座是导出的底稿,写坏了整个数据集就导不出去了。
func openBaselineRO(path string) (*sql.DB, error) {
// 先确认文件在:sql.Open 默认是 READWRITE|CREATE,路径不存在时会**建一个空库**,
// 于是查表报「no such table」而不是「没有底座」——那种错误很难查。
if _, err := os.Stat(path); err != nil {
return nil, err
}
sdb, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?_pragma=query_only(1)&_pragma=busy_timeout(5000)")
if err != nil {
return nil, err
}
sdb.SetMaxOpenConns(1)
return sdb, nil
}
// baselineStatusOf 读出底座的现状:文件、记档、留档列表。
func (s *Server) baselineStatusOf(ctx context.Context, datasetID int64) (*baselineStatus, error) {
st := &baselineStatus{Path: s.baselinePath(datasetID)}
if fi, err := os.Stat(st.Path); err == nil {
st.Exists = true
st.Size = fi.Size()
t := fi.ModTime()
st.ModTime = &t
}
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(baseline_name,''), COALESCE(baseline_sha256,''),
baseline_at FROM datasets WHERE id=$1`, datasetID).Scan(&st.Name, &st.SHA, &st.At); err != nil {
return nil, err
}
baks, _ := filepath.Glob(st.Path + ".bak-*")
for _, b := range baks {
fi, err := os.Stat(b)
if err != nil {
continue
}
st.Backups = append(st.Backups, baselineBackup{Name: filepath.Base(b), Size: fi.Size(), At: fi.ModTime()})
}
// 新的在前:回滚要的是最近一份,页面也要按时间倒序列
sort.Slice(st.Backups, func(i, j int) bool { return st.Backups[i].At.After(st.Backups[j].At) })
return st, nil
}
// baselineCounts 数底座里各表的行数。表不存在(老包缺某张表)就跳过,不报错——
// 画像只是给人看的,缺一行不该拦住换底座。
func baselineCounts(ctx context.Context, path string) ([]baselineCount, error) {
sdb, err := openBaselineRO(path)
if err != nil {
return nil, err
}
defer sdb.Close()
rows, err := sdb.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type='table'`)
if err != nil {
return nil, err
}
have := map[string]bool{}
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
rows.Close()
return nil, err
}
have[strings.ToUpper(n)] = true
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
var out []baselineCount
for _, t := range baselineTableLabels {
if !have[t.Table] {
continue
}
var n int
if err := sdb.QueryRowContext(ctx, `SELECT count(*) FROM `+t.Table).Scan(&n); err != nil {
continue
}
out = append(out, baselineCount{Table: t.Table, Label: t.Label, N: n})
}
return out, nil
}
// visitUUIDOverlap 数「站点里带 UUID 的到访」有多少条能在这份底座里找到。
//
// 这是唯一能挡住「换错包」的闸。干跑导出验证的是**结构**(表齐、实体编号齐),
// 一份完全不相干的包照样能导出成功——但换下去以后,导出的包就不是这份数据了:
// 站点每一行都会在底座里找不到身份,于是全都按「新行」插到最大编号之上,
// 真机上表现为「这些到访全变成新的、老记录还在」。
//
// 站点补录/新建的行(没有 UUID)不计入分母:真机包里本来就没有它们。
func (s *Server) visitUUIDOverlap(ctx context.Context, datasetID int64, baselinePath string) (matched, withUUID int, err error) {
base, err := readDupBaseline(ctx, baselinePath)
if err != nil {
return 0, 0, err
}
site, err := s.loadStoredVisits(ctx, datasetID)
if err != nil {
return 0, 0, err
}
for _, sv := range site {
if sv.UUID == "" {
continue
}
withUUID++
if base.uuids[sv.UUID] {
matched++
}
}
return matched, withUUID, nil
}
// baselineBackupName 生成一个不与已有文件冲突的留档名。
//
// **必须防重名**:替换与回滚可能落在同一秒里,秒级时间戳直接当文件名的话,
// 后一次 os.Rename 会把前一次的留档**覆盖掉**——而被覆盖的那一份正是要恢复的旧底座。
// 实测「换底座紧接着回滚」就踩过:回滚把新底座改名成 dst.bak-<同一秒>,
// 恰好压掉刚生成的旧底座留档,于是「回滚」回了个寂寞。
func baselineBackupName(dst string, now time.Time) string {
base := fmt.Sprintf("%s.bak-%s", dst, now.Format("20060102-150405"))
p := base
for i := 1; ; i++ {
if _, err := os.Stat(p); err != nil {
return p
}
p = fmt.Sprintf("%s-%d", base, i)
}
}
// errBaselineMismatch 表示这份包与当前数据集对不上,需要站长显式确认才换。
type errBaselineMismatch struct {
matched, withUUID int
zero bool // 一条都对不上:连「强制」都不给过
}
func (e *errBaselineMismatch) Error() string {
if e.zero {
return fmt.Sprintf("这份备份跟当前数据集的 %d 条到访一条都对不上,几乎可以肯定不是同一份数据的手机包,"+
"换下去会让导出包整个错位,已放弃", e.withUUID)
}
return fmt.Sprintf("这份备份与当前数据集只对得上 %d/%d 条到访,可能不是同一批数据。确认无误请勾选「强制替换」再试",
e.matched, e.withUUID)
}
// baselineReport 是一次换底座的结果,用来给页面/提示提供具体数字。
type baselineReport struct {
Name string
SHA string
Size int64
Matched int
WithUUID int
Counts []baselineCount
Replaced bool // 是否真的换掉了(false = 原本没有底座,是首次建立)
Backup string
ElapsedMs int64
}
// swapBaseline 把当前数据集的底座换成 archive 那份包。
//
// 顺序是有讲究的,三步都不能挪:
// 1. 先在新底座上**干跑一次导出**。导出失败(缺表、缺实体编号、写入报错)在这里就挡住,
// 绝不会出现「底座已经换了、才发现导不出、而原来那份已经不知道去哪了」。
// 2. 旧底座改名留档,新的原子改名就位。中途失败要把旧的换回来,至少不能让数据集没底座。
// 3. 记档(来源文件名 + sha)。底座文件以后丢了,ensureBaseline 要按它找同一份包重建;
// 只认 datasets.sha256 的话会把底座悄悄退回最初那份旧包。
//
// 站点数据(places/visits/…)一行都不动:换的只是导出的底稿。
func (s *Server) swapBaseline(ctx context.Context, datasetID int64, archive, name, sha string, force bool) (baselineReport, error) {
start := time.Now()
rep := baselineReport{Name: name, SHA: sha}
dst := s.baselinePath(datasetID)
tmp := dst + ".new"
_ = os.Remove(tmp)
if err := rond.WriteBaseline(archive, tmp); err != nil {
return rep, fmt.Errorf("读取这份备份生成底座失败:%w", err)
}
// 成功路径会把 tmp 改名走,这里只兜住失败路径(改名后 Remove 失败无害)
defer func() { _ = os.Remove(tmp) }()
if fi, err := os.Stat(tmp); err == nil {
rep.Size = fi.Size()
}
// 判据一:这份包得跟当前数据集是同一批数据
matched, withUUID, err := s.visitUUIDOverlap(ctx, datasetID, tmp)
if err != nil {
return rep, fmt.Errorf("比对这份备份与当前数据集失败:%w", err)
}
rep.Matched, rep.WithUUID = matched, withUUID
if withUUID >= minOverlapVisits && matched == 0 {
return rep, &errBaselineMismatch{matched: matched, withUUID: withUUID, zero: true}
}
if !force && withUUID >= minOverlapVisits && matched*2 < withUUID {
return rep, &errBaselineMismatch{matched: matched, withUUID: withUUID}
}
// 判据二:这份底座得真的能导出(结构完整)
if err := rond.ExportRondbackup(ctx, s.db, datasetID, tmp, io.Discard); err != nil {
return rep, fmt.Errorf("用这份备份当底座导不出包,已放弃替换:%w", err)
}
if counts, err := baselineCounts(ctx, tmp); err == nil {
rep.Counts = counts
}
if _, err := os.Stat(dst); err == nil {
rep.Replaced = true
rep.Backup = baselineBackupName(dst, time.Now())
if err := os.Rename(dst, rep.Backup); err != nil {
return rep, fmt.Errorf("留档旧底座失败:%w", err)
}
}
if err := os.Rename(tmp, dst); err != nil {
// 尽力把旧的换回来:宁可换基线失败,也不能让数据集没有底座
if rep.Backup != "" {
if rerr := os.Rename(rep.Backup, dst); rerr != nil {
log.Printf("换基线失败且旧底座恢复不回去(%s): %v", rep.Backup, rerr)
}
}
return rep, fmt.Errorf("替换底座失败:%w", err)
}
rep.ElapsedMs = time.Since(start).Milliseconds()
if _, err := s.db.ExecContext(ctx, `UPDATE datasets SET baseline_name=$2, baseline_sha256=$3, baseline_at=now()
WHERE id=$1`, datasetID, name, sha); err != nil {
// 底座已经换好了,导出照常;只是丢了「来源是谁」的记档,不值得回滚
log.Printf("换基线记档失败(数据集 %d): %v", datasetID, err)
}
s.pruneBaselineBackups(dst)
return rep, nil
}
// pruneBaselineBackups 只留最近 N 份旧底座。底座库几十 MB,不留上限会慢慢堆满磁盘。
func (s *Server) pruneBaselineBackups(dst string) {
baks, _ := filepath.Glob(dst + ".bak-*")
if len(baks) <= baselineBackupKeep {
return
}
sort.Slice(baks, func(i, j int) bool {
fi, ei := os.Stat(baks[i])
fj, ej := os.Stat(baks[j])
if ei != nil || ej != nil {
return false
}
return fi.ModTime().After(fj.ModTime())
})
for _, b := range baks[baselineBackupKeep:] {
if err := os.Remove(b); err != nil {
log.Printf("清理旧底座留档失败(%s): %v", b, err)
}
}
}
// rollbackBaseline 把最近一份留档的旧底座换回来。
//
// 记档要一并清空:留档恰好就是「换之前的那份底座」,而换之前的那份多半是从最初上传的
// 原始包重建的。留着新那份的 sha 会让 ensureBaseline 在文件丢失时重建出错的那一份。
// 清空后退回「按 datasets.sha256 找原始包」的旧行为,与留档的实际来源一致。
func (s *Server) rollbackBaseline(ctx context.Context, datasetID int64) (string, error) {
dst := s.baselinePath(datasetID)
st, err := s.baselineStatusOf(ctx, datasetID)
if err != nil {
return "", err
}
if len(st.Backups) == 0 {
return "", errors.New("还没有可回滚的旧底座")
}
newest := st.Backups[0]
consumed := filepath.Join(filepath.Dir(dst), newest.Name)
// 先把当前这份也留档,回滚本身也要可逆(万一回滚错了还能再滚回来)
if st.Exists {
if err := os.Rename(dst, baselineBackupName(dst, time.Now())); err != nil {
return "", fmt.Errorf("留档当前底座失败:%w", err)
}
}
if err := os.Rename(consumed, dst); err != nil {
return "", fmt.Errorf("恢复旧底座失败:%w", err)
}
if _, err := s.db.ExecContext(ctx, `UPDATE datasets SET baseline_name=NULL, baseline_sha256=NULL, baseline_at=NULL
WHERE id=$1`, datasetID); err != nil {
return "", fmt.Errorf("记档清理失败:%w", err)
}
s.pruneBaselineBackups(dst)
return newest.Name, nil
}
// ---------- 页面与动作 ----------
// baselineFlash 回「换基线」页并带上提示(extra 是额外要带的查询参数,如待确认的 sha)。
func (s *Server) baselineFlash(w http.ResponseWriter, r *http.Request, kind, msg string, extra ...string) {
sep := "?"
u := "/admin/baseline"
for _, e := range append([]string{kind + "=" + urlQueryEscape(msg)}, extra...) {
if e == "" {
continue
}
u += sep + e
sep = "&"
}
http.Redirect(w, r, u, http.StatusSeeOther)
}
// baselinePendingPrefix 标记「这份包要强制替换」的查询参数。带上它时页面多渲染一个确认按钮,
// 不必让人把几十 MB 的包再传一遍。
const baselinePendingPrefix = "pending="
// adminBaselinePage 渲染「换基线」页。
func (s *Server) adminBaselinePage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
p, err := s.pageBase(r, "admin", "换基线")
if err != nil {
s.serverError(w, r, err)
return
}
p.SetFlashFromQuery(r)
d := BaselineData{Page: p, MaxSize: maxUploadBytes}
ds, err := s.datasetFor(ctx)
if err != nil {
s.serverError(w, r, err)
return
}
if ds == nil {
s.render(w, "admin/baseline", d)
return
}
d.Dataset = ds
st, err := s.baselineStatusOf(ctx, ds.ID)
if err != nil {
s.serverError(w, r, err)
return
}
d.Status = st
if st.Exists {
counts, err := baselineCounts(ctx, st.Path)
if err != nil {
d.CountsErr = "读不出底座里的行数:" + err.Error()
} else {
d.Counts = counts
}
}
// 数据集最初上传的那份包,用来判断「当前底座是否就是它」
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(original_name,''), COALESCE(sha256,'')
FROM datasets WHERE id=$1`, ds.ID).Scan(&d.OrigName, &d.OrigSHA); err != nil {
s.serverError(w, r, err)
return
}
d.FromOriginal = st.SHA == "" || st.SHA == d.OrigSHA
// 待强制确认的那份包(刚上传、判据没过,但内容已经收进 uploads 了)
if sha := r.URL.Query().Get("pending"); sha != "" {
if _, err := s.findUploadBySha(sha, 0); err == nil {
q := r.URL.Query()
d.PendingSHA = sha
d.PendingName = q.Get("pname")
if d.PendingName == "" {
d.PendingName = "刚上传的那份备份"
}
d.PendingMatched, _ = strconv.Atoi(q.Get("pm"))
d.PendingWith, _ = strconv.Atoi(q.Get("pw"))
}
}
s.render(w, "admin/baseline", d)
}
// adminBaselineUpload 收下作为新底座的备份并立即尝试替换。
//
// 判据没过时**不报错就算了**:包已经收进 uploads,页面给一个「强制替换」按钮,
// 让站长确认后直接用它,不必为了勾一下复选框把几十 MB 再传一遍。
func (s *Server) adminBaselineUpload(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil {
s.serverError(w, r, err)
return
}
if ds == nil {
s.baselineFlash(w, r, "err", "还没有可换底座的数据集,请先导入一份备份")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+1<<20)
if err := r.ParseMultipartForm(32 << 20); err != nil {
s.baselineFlash(w, r, "err", "读取上传内容失败:"+err.Error())
return
}
file, hdr, err := r.FormFile("backup")
if err != nil {
s.baselineFlash(w, r, "err", "请选择要作为底座的 .rondbackup 文件")
return
}
defer file.Close()
name := filepath.Base(hdr.Filename)
lower := strings.ToLower(name)
if !strings.HasSuffix(lower, ".rondbackup") && !strings.HasSuffix(lower, ".zip") {
s.baselineFlash(w, r, "err", "文件类型不正确,请上传 rond 导出的 .rondbackup 备份")
return
}
// 先落到临时目录算摘要(不能在 uploads 里算:那一份会把自己认成「已存在」)
tmpDir, err := ingest.TempDir(s.cfg.DataDir, "baseline")
if err != nil {
s.serverError(w, r, err)
return
}
defer os.RemoveAll(tmpDir)
incoming := filepath.Join(tmpDir, sanitize(name))
out, err := os.Create(incoming)
if err != nil {
s.serverError(w, r, err)
return
}
n, cerr := io.Copy(out, io.LimitReader(file, maxUploadBytes+1))
if err := out.Close(); err != nil && cerr == nil {
cerr = err
}
if cerr != nil {
s.baselineFlash(w, r, "err", "读取文件失败:"+cerr.Error())
return
}
if n > maxUploadBytes {
s.baselineFlash(w, r, "err", "备份文件过大,疑似不是 rond 备份")
return
}
sha, size, err := ingest.File(incoming)
if err != nil {
s.baselineFlash(w, r, "err", "读取备份失败:"+err.Error())
return
}
stored, err := s.stashUpload(incoming, name, sha, size)
if err != nil {
s.serverError(w, r, err)
return
}
rep, err := s.swapBaseline(ctx, ds.ID, stored, name, sha, r.FormValue("force") != "")
if err != nil {
var mism *errBaselineMismatch
if errors.As(err, &mism) && !mism.zero {
// 包已经收好了,让站长在页面上点一下确认即可,不必重传
s.baselineFlash(w, r, "err", err.Error(),
baselinePendingPrefix+sha, "pname="+urlQueryEscape(name),
fmt.Sprintf("pm=%d", mism.matched), fmt.Sprintf("pw=%d", mism.withUUID))
return
}
s.baselineFlash(w, r, "err", err.Error())
return
}
s.baselineFlash(w, r, "ok", baselineSwapMessage(rep, ds.Name))
}
// adminBaselineConfirm 用页面上的「强制替换」按钮落地一份已经收好的包(不重传)。
func (s *Server) adminBaselineConfirm(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil {
s.serverError(w, r, err)
return
}
if ds == nil {
s.baselineFlash(w, r, "err", "还没有可换底座的数据集")
return
}
if err := r.ParseForm(); err != nil {
s.baselineFlash(w, r, "err", "读取表单失败:"+err.Error())
return
}
sha := strings.TrimSpace(r.FormValue("sha"))
if len(sha) != 64 {
s.baselineFlash(w, r, "err", "要替换的备份已失效,请重新上传")
return
}
stored, err := s.findUploadBySha(sha, 0)
if err != nil {
s.baselineFlash(w, r, "err", "找不到那份备份了,请重新上传")
return
}
name := filepath.Base(stored)
if n := strings.TrimSpace(r.FormValue("name")); n != "" {
name = filepath.Base(n)
}
rep, err := s.swapBaseline(ctx, ds.ID, stored, name, sha, true)
if err != nil {
s.baselineFlash(w, r, "err", err.Error())
return
}
s.baselineFlash(w, r, "ok", "已按确认强制替换。"+baselineSwapMessage(rep, ds.Name))
}
// adminBaselineRollback 回滚到上一份底座。
func (s *Server) adminBaselineRollback(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil {
s.serverError(w, r, err)
return
}
if ds == nil {
s.baselineFlash(w, r, "err", "还没有可换底座的数据集")
return
}
name, err := s.rollbackBaseline(ctx, ds.ID)
if err != nil {
s.baselineFlash(w, r, "err", "回滚失败:"+err.Error())
return
}
s.baselineFlash(w, r, "ok", "已回滚到旧底座("+name+")。当前底座记录的来源已清空,导出照常可用。")
}
// adminBaselineRebuild 按记档(或最初上传的包)重建底座文件。
// 底座库被误删、或换了机器没带 baselines 目录时用得上——导出时其实也会自动重建,
// 这里给一个能立刻看到结果、并把「重建不出来」说清楚的入口。
func (s *Server) adminBaselineRebuild(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil {
s.serverError(w, r, err)
return
}
if ds == nil {
s.baselineFlash(w, r, "err", "还没有可换底座的数据集")
return
}
path := s.baselinePath(ds.ID)
tmp := path + ".new"
_ = os.Remove(tmp)
// 先写临时文件、成功了再改名就位:重建失败时不能让数据集连底座都没有
if err := s.ensureBaselineTo(ctx, ds.ID, tmp); err != nil {
_ = os.Remove(tmp)
s.baselineFlash(w, r, "err", "重建底座失败:"+err.Error())
return
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
s.baselineFlash(w, r, "err", "就位底座失败:"+err.Error())
return
}
fi, _ := os.Stat(path)
size := int64(0)
if fi != nil {
size = fi.Size()
}
s.baselineFlash(w, r, "ok", fmt.Sprintf("已重建底座(%s)。", fmtSize(size)))
}
// stashUpload 把一份上传件收进 uploads,供以后按摘要找回(换基线记档要用)。
// 内容已经在 uploads 里就不再存第二份:底座库几十 MB,重复堆几份纯属浪费。
func (s *Server) stashUpload(incoming, name, sha string, size int64) (string, error) {
if exist, err := s.findUploadBySha(sha, size); err == nil && exist != "" {
return exist, nil
}
if err := os.MkdirAll(s.cfg.UploadDir, 0o755); err != nil {
return "", err
}
dst := filepath.Join(s.cfg.UploadDir, fmt.Sprintf("%d-%s", time.Now().Unix(), sanitize(name)))
if err := os.Rename(incoming, dst); err != nil {
// 跨卷(临时目录与 uploads 不在同一个卷)时退回复制
if cerr := copyFileShallow(incoming, dst); cerr != nil {
return "", fmt.Errorf("保存备份失败:%w", cerr)
}
}
return dst, nil
}
// copyFileShallow 是给 stashUpload 用的兜底拷贝(rename 跨卷失败时)。
func copyFileShallow(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}
// baselineSwapMessage 拼一条说清楚「换了什么」的提示:替换前后底座的行数对比。
func baselineSwapMessage(rep baselineReport, datasetName string) string {
var b strings.Builder
if rep.Replaced {
fmt.Fprintf(&b, "已换底座:%s(%s)。旧底座已留档,可回滚。", rep.Name, fmtSize(rep.Size))
} else {
fmt.Fprintf(&b, "已为数据集 #%s 建立底座:%s(%s)。", datasetName, rep.Name, fmtSize(rep.Size))
}
if rep.WithUUID > 0 {
fmt.Fprintf(&b, " 这份包与站内 %d/%d 条带 UUID 的到访对得上。", rep.Matched, rep.WithUUID)
}
for _, c := range rep.Counts {
if c.Table == "ZVISIT" || c.Table == "ZLOCATION" {
fmt.Fprintf(&b, " %s %d;", c.Label, c.N)
}
}
b.WriteString(" 站点数据一行没动,导出即刻以新底座为准。")
return b.String()
}