Files
rond-with-you/internal/web/baseline.go
T
tamakyi 6ff6f944a1 换基线:把「底座换代」做进后台(站点数据一行不动)
导出是「底座为底 + 只覆盖托管表」,所以底座必须是手机导出的包。底座跟着数据集走,
手机重新导出后想换代,以前只能整包重新导入成新数据集(站点补录的行、主题、备注都得重收拾)。
新动作 /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。
2026-09-30 19:00:18 +08:00

681 lines
24 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 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()
}