导出是「底座为底 + 只覆盖托管表」,所以底座必须是手机导出的包。底座跟着数据集走, 手机重新导出后想换代,以前只能整包重新导入成新数据集(站点补录的行、主题、备注都得重收拾)。 新动作 /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。
681 lines
24 KiB
Go
681 lines
24 KiB
Go
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()
|
||
}
|