换基线:把「底座换代」做进后台(站点数据一行不动)
导出是「底座为底 + 只覆盖托管表」,所以底座必须是手机导出的包。底座跟着数据集走, 手机重新导出后想换代,以前只能整包重新导入成新数据集(站点补录的行、主题、备注都得重收拾)。 新动作 /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:
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"rond-with-you/internal/ingest"
|
||||
)
|
||||
|
||||
// ---------- 换基线 ----------
|
||||
//
|
||||
// 这些用例守的是「底座换代」这件事的几条底线:
|
||||
// 站点数据一行不动、底座文件真的换了、旧底座留档可回滚、记档能撑住重建、
|
||||
// 以及**不能把一份不相干的包换进来**(那是唯一会让导出包整个错位的操作)。
|
||||
|
||||
func baselineSHA(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("读 %s 失败: %v", path, err)
|
||||
}
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// baselinePackageFile 把一份包写到临时文件,当作「新底座」用。
|
||||
// 传当前数据集的导出结果最贴近真实情形:它与原始包不是同一个文件(编号、WAL、私有列都不同),
|
||||
// 但到访 UUID 与站点完全一致。
|
||||
func baselinePackageFile(t *testing.T, name string, blob []byte) (path, sha string, size int64) {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), name)
|
||||
if err := os.WriteFile(p, blob, 0o644); err != nil {
|
||||
t.Fatalf("写 %s 失败: %v", p, err)
|
||||
}
|
||||
sha, size, err := ingest.File(p)
|
||||
if err != nil {
|
||||
t.Fatalf("算摘要失败: %v", err)
|
||||
}
|
||||
return p, sha, size
|
||||
}
|
||||
|
||||
// exportVisitUUIDs 读导出包里全部到访的身份(小写 hex),排序后返回。
|
||||
// 这是「换底座有没有把行搬到别的编号空间」最锋利的判据:编号会换、身份不该换。
|
||||
func exportVisitUUIDs(t *testing.T, edb *sql.DB) []string {
|
||||
t.Helper()
|
||||
rows, err := edb.Query(`SELECT COALESCE(hex(ZIDENTIFIER_),'') FROM ZVISIT ORDER BY 1`)
|
||||
if err != nil {
|
||||
t.Fatalf("读导出包的到访身份失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if err := rows.Scan(&s); err != nil {
|
||||
t.Fatalf("读导出包的到访身份失败: %v", err)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("读导出包的到访身份失败: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sameStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// siteCounts 数站点的到访与地点,用来断言「换底座不动数据」。
|
||||
func siteCounts(t *testing.T, f *movementFixture) (visits, places int) {
|
||||
t.Helper()
|
||||
if err := f.pg.QueryRowContext(f.ctx, `SELECT
|
||||
(SELECT count(*) FROM visits WHERE dataset_id=$1),
|
||||
(SELECT count(*) FROM places WHERE dataset_id=$1)`, f.dsID).Scan(&visits, &places); err != nil {
|
||||
t.Fatalf("数站点数据失败: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// baselineRecord 读换基线的记档。
|
||||
func baselineRecord(t *testing.T, f *movementFixture) (name, sha string, at sql.NullTime) {
|
||||
t.Helper()
|
||||
if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(baseline_name,''), COALESCE(baseline_sha256,''), baseline_at
|
||||
FROM datasets WHERE id=$1`, f.dsID).Scan(&name, &sha, &at); err != nil {
|
||||
t.Fatalf("读记档失败: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// scrambleVisitUUIDs 把站内到访留档里的 UUID 换成同一组假值:换完之后,
|
||||
// 任何真实包里都找不到这些身份。
|
||||
//
|
||||
// 只动留档(entity_raw),不动 visits 表——到访本身还在,只是「认不出身份」了。
|
||||
// 这正是「拿一份别的数据集的包来换底座」时站内看到的样子。
|
||||
func scrambleVisitUUIDs(t *testing.T, f *movementFixture, only int) {
|
||||
t.Helper()
|
||||
fake := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef"))
|
||||
q := `UPDATE entity_raw SET raw = jsonb_set(raw, '{ZIDENTIFIER_,$blob}', to_jsonb($2::text))
|
||||
WHERE dataset_id=$1 AND entity='ZVISIT' AND raw->'ZIDENTIFIER_' IS NOT NULL`
|
||||
if only > 0 {
|
||||
q += ` AND src_pk IN (SELECT src_pk FROM entity_raw WHERE dataset_id=$1 AND entity='ZVISIT'
|
||||
AND raw->'ZIDENTIFIER_' IS NOT NULL ORDER BY src_pk LIMIT $3)`
|
||||
if _, err := f.pg.ExecContext(f.ctx, q, f.dsID, fake, only); err != nil {
|
||||
t.Fatalf("改写留档 UUID 失败: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := f.pg.ExecContext(f.ctx, q, f.dsID, fake); err != nil {
|
||||
t.Fatalf("改写留档 UUID 失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwapBaselineRebasesExportAndKeepsData 换底座成功路径:
|
||||
// 底座文件真的换了、旧底座留档、记档写入、站点数据一行不动、导出内容逐条不变。
|
||||
func TestSwapBaselineRebasesExportAndKeepsData(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
ctx, dsID, srv := f.ctx, f.dsID, f.srv
|
||||
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
|
||||
t.Fatalf("准备底座失败: %v", err)
|
||||
}
|
||||
base := srv.baselinePath(dsID)
|
||||
oldSHA := baselineSHA(t, base)
|
||||
visitsBefore, placesBefore := siteCounts(t, f)
|
||||
var arrivalBefore time.Time
|
||||
if err := f.pg.QueryRowContext(ctx, `SELECT arrival FROM visits WHERE dataset_id=$1
|
||||
ORDER BY src_pk LIMIT 1`, dsID).Scan(&arrivalBefore); err != nil {
|
||||
t.Fatalf("取样本到访失败: %v", err)
|
||||
}
|
||||
|
||||
// 拿「换底座之前导出的那一份包」当新底座。先记下它的内容,换完再对一遍。
|
||||
blob := f.export(t)
|
||||
edbBefore := openExport(t, blob)
|
||||
visitsInExport := countExportAll(t, edbBefore)
|
||||
uuidsBefore := exportVisitUUIDs(t, edbBefore)
|
||||
pkg, sha, _ := baselinePackageFile(t, "new-base.rondbackup", blob)
|
||||
|
||||
rep, err := srv.swapBaseline(ctx, dsID, pkg, "new-base.rondbackup", sha, false)
|
||||
if err != nil {
|
||||
t.Fatalf("换底座失败: %v", err)
|
||||
}
|
||||
if rep.WithUUID < minOverlapVisits {
|
||||
t.Fatalf("样本量太小(%d 条带 UUID 的到访),用例前提不成立", rep.WithUUID)
|
||||
}
|
||||
// 同一批数据的包应当条条对得上:对不上就说明这条判据本身有问题
|
||||
if rep.Matched != rep.WithUUID {
|
||||
t.Errorf("同一批数据的包应当全部对上,实际 %d/%d", rep.Matched, rep.WithUUID)
|
||||
}
|
||||
if !rep.Replaced {
|
||||
t.Error("原本已有底座,应当报告为「替换」而不是「首次建立」")
|
||||
}
|
||||
|
||||
// ① 底座文件真的换了,旧的那份进了留档
|
||||
if got := baselineSHA(t, base); got == oldSHA {
|
||||
t.Error("底座文件没变,替换没生效")
|
||||
}
|
||||
if rep.Backup == "" {
|
||||
t.Fatal("没有留档旧底座")
|
||||
}
|
||||
if got := baselineSHA(t, rep.Backup); got != oldSHA {
|
||||
t.Errorf("留档的不是旧底座:%s != %s", got, oldSHA)
|
||||
}
|
||||
// ② 记档
|
||||
name, recSHA, at := baselineRecord(t, f)
|
||||
if name != "new-base.rondbackup" || recSHA != sha || !at.Valid {
|
||||
t.Errorf("记档不对:name=%q sha=%q at=%v", name, recSHA, at)
|
||||
}
|
||||
// ③ 站点数据一行没动
|
||||
visitsAfter, placesAfter := siteCounts(t, f)
|
||||
if visitsAfter != visitsBefore || placesAfter != placesBefore {
|
||||
t.Errorf("换底座动了站点数据:到访 %d→%d、地点 %d→%d",
|
||||
visitsBefore, visitsAfter, placesBefore, placesAfter)
|
||||
}
|
||||
var arrivalAfter time.Time
|
||||
if err := f.pg.QueryRowContext(ctx, `SELECT arrival FROM visits WHERE dataset_id=$1
|
||||
ORDER BY src_pk LIMIT 1`, dsID).Scan(&arrivalAfter); err != nil {
|
||||
t.Fatalf("取样本到访失败: %v", err)
|
||||
}
|
||||
if !arrivalAfter.Equal(arrivalBefore) {
|
||||
t.Errorf("样本到访的到达时间被改了:%v → %v", arrivalBefore, arrivalAfter)
|
||||
}
|
||||
// ④ 导出的内容逐条不变:换了编号空间,身份(UUID)不该动。
|
||||
// 只要有一行在新底座里认不出身份,它就会被按「新行」补一个派生 UUID 插到最大编号之后,
|
||||
// 这里立刻会亮。
|
||||
edbAfter := openExport(t, f.export(t))
|
||||
if n := countExportAll(t, edbAfter); n != visitsInExport {
|
||||
t.Errorf("换底座改变了导出包的到访数:%d → %d", visitsInExport, n)
|
||||
}
|
||||
if got := exportVisitUUIDs(t, edbAfter); !sameStrings(got, uuidsBefore) {
|
||||
t.Errorf("换底座改变了导出包里到访的身份(%d 条 → %d 条),说明有行在新底座里没对上",
|
||||
len(uuidsBefore), len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureBaselinePrefersRecordedPackage 底座文件丢了要按记档重建。
|
||||
//
|
||||
// 这是换底座必须配的一环:datasets.sha256 指的是**最初**那份包,
|
||||
// 只认它的话,底座一丢就会被悄悄重建回旧包——站长上一次换基线白做,且毫无提示。
|
||||
func TestEnsureBaselinePrefersRecordedPackage(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
ctx, dsID, srv := f.ctx, f.dsID, f.srv
|
||||
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
|
||||
t.Fatalf("准备底座失败: %v", err)
|
||||
}
|
||||
base := srv.baselinePath(dsID)
|
||||
|
||||
pkg, sha, size := baselinePackageFile(t, "new-base.rondbackup", f.export(t))
|
||||
// 换底座时上传件会收进 uploads,重建就靠它
|
||||
if _, err := srv.stashUpload(pkg, "new-base.rondbackup", sha, size); err != nil {
|
||||
t.Fatalf("收下上传件失败: %v", err)
|
||||
}
|
||||
stored, err := srv.findUploadBySha(sha, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("收下的包找不回来: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.Remove(stored) })
|
||||
if _, err := srv.swapBaseline(ctx, dsID, stored, "new-base.rondbackup", sha, false); err != nil {
|
||||
t.Fatalf("换底座失败: %v", err)
|
||||
}
|
||||
|
||||
// 把「最初那份包」的摘要指向一个不存在的值,逼出「按 datasets.sha256 找原始包」必然失败
|
||||
if _, err := f.pg.ExecContext(ctx, `UPDATE datasets SET sha256=$2 WHERE id=$1`,
|
||||
dsID, strings.Repeat("0", 64)); err != nil {
|
||||
t.Fatalf("改写数据集摘要失败: %v", err)
|
||||
}
|
||||
if err := os.Remove(base); err != nil {
|
||||
t.Fatalf("删底座失败: %v", err)
|
||||
}
|
||||
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
|
||||
t.Fatalf("应当按记档的那份包重建底座,实际报错: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(base); err != nil {
|
||||
t.Error("底座没有被重建")
|
||||
}
|
||||
|
||||
// 反证:把记档也清掉,同一条路就走不通了 —— 说明上面那次成功确实是记档起的作用,
|
||||
// 不是别的兜底把用例弄绿了。
|
||||
if _, err := f.pg.ExecContext(ctx, `UPDATE datasets SET baseline_sha256=NULL, baseline_name=NULL
|
||||
WHERE id=$1`, dsID); err != nil {
|
||||
t.Fatalf("清记档失败: %v", err)
|
||||
}
|
||||
if err := os.Remove(base); err != nil {
|
||||
t.Fatalf("删底座失败: %v", err)
|
||||
}
|
||||
if _, err := srv.ensureBaseline(ctx, dsID); err == nil {
|
||||
t.Error("记档清掉后不该还能重建(原始包已不可用)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwapBaselineAsksBeforePartialMismatch 重合率偏低时先问一句;
|
||||
// 站长点了确认(force)才放行。
|
||||
func TestSwapBaselineAsksBeforePartialMismatch(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
ctx, dsID, srv := f.ctx, f.dsID, f.srv
|
||||
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
|
||||
t.Fatalf("准备底座失败: %v", err)
|
||||
}
|
||||
base := srv.baselinePath(dsID)
|
||||
|
||||
// 先导出(此时包里的 UUID 还是真的),再把大部分站内到访的身份改掉
|
||||
pkg, sha, _ := baselinePackageFile(t, "partial.rondbackup", f.export(t))
|
||||
visitsBefore, _ := siteCounts(t, f)
|
||||
scrambleVisitUUIDs(t, f, visitsBefore*7/10)
|
||||
oldSHA := baselineSHA(t, base)
|
||||
|
||||
_, err := srv.swapBaseline(ctx, dsID, pkg, "partial.rondbackup", sha, false)
|
||||
var mism *errBaselineMismatch
|
||||
if err == nil {
|
||||
t.Fatal("重合率不到一半,不该直接放行")
|
||||
}
|
||||
if !errors.As(err, &mism) || mism.zero {
|
||||
t.Fatalf("应当是「需确认」而不该是「一条都对不上」: %v", err)
|
||||
}
|
||||
if baselineSHA(t, base) != oldSHA {
|
||||
t.Error("被拦下了却已经把底座换了")
|
||||
}
|
||||
if _, recSHA, _ := baselineRecord(t, f); recSHA != "" {
|
||||
t.Error("被拦下了却写了记档")
|
||||
}
|
||||
|
||||
// 站长确认后放行
|
||||
rep, err := srv.swapBaseline(ctx, dsID, pkg, "partial.rondbackup", sha, true)
|
||||
if err != nil {
|
||||
t.Fatalf("确认后仍无法替换: %v", err)
|
||||
}
|
||||
if rep.Replaced && baselineSHA(t, base) == oldSHA {
|
||||
t.Error("确认后底座文件仍然没变")
|
||||
}
|
||||
if _, recSHA, _ := baselineRecord(t, f); recSHA != sha {
|
||||
t.Error("确认替换后没写记档")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwapBaselineRefusesTotallyUnrelated 一条都对不上时,连「强制」都不给过。
|
||||
// 这种情况下换下去没有任何一处能对齐,等于把整个导出包搬到另一个编号空间上。
|
||||
func TestSwapBaselineRefusesTotallyUnrelated(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
ctx, dsID, srv := f.ctx, f.dsID, f.srv
|
||||
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
|
||||
t.Fatalf("准备底座失败: %v", err)
|
||||
}
|
||||
base := srv.baselinePath(dsID)
|
||||
|
||||
pkg, sha, _ := baselinePackageFile(t, "foreign.rondbackup", f.export(t))
|
||||
scrambleVisitUUIDs(t, f, 0) // 全部
|
||||
oldSHA := baselineSHA(t, base)
|
||||
|
||||
for _, force := range []bool{false, true} {
|
||||
_, err := srv.swapBaseline(ctx, dsID, pkg, "foreign.rondbackup", sha, force)
|
||||
var mism *errBaselineMismatch
|
||||
if err == nil {
|
||||
t.Fatalf("force=%v 时一份完全对不上的包也不该被接受", force)
|
||||
}
|
||||
if !errors.As(err, &mism) || !mism.zero {
|
||||
t.Fatalf("force=%v 时应当判定为「一条都对不上」: %v", force, err)
|
||||
}
|
||||
}
|
||||
if baselineSHA(t, base) != oldSHA {
|
||||
t.Error("被拒绝却已经把底座换了")
|
||||
}
|
||||
if _, recSHA, _ := baselineRecord(t, f); recSHA != "" {
|
||||
t.Error("被拒绝却写了记档")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwapBaselineRejectsNonPackage 不是备份包的文件要在第一步就被挡住:
|
||||
// 生成底座失败 ⇒ 既不换文件也不写记档,数据集保持原样可用。
|
||||
func TestSwapBaselineRejectsNonPackage(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
ctx, dsID, srv := f.ctx, f.dsID, f.srv
|
||||
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
|
||||
t.Fatalf("准备底座失败: %v", err)
|
||||
}
|
||||
base := srv.baselinePath(dsID)
|
||||
oldSHA := baselineSHA(t, base)
|
||||
|
||||
junk := filepath.Join(t.TempDir(), "junk.rondbackup")
|
||||
if err := os.WriteFile(junk, []byte("这不是备份包"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := srv.swapBaseline(ctx, dsID, junk, "junk.rondbackup", strings.Repeat("a", 64), true); err == nil {
|
||||
t.Fatal("不是备份包却换成功了")
|
||||
}
|
||||
if baselineSHA(t, base) != oldSHA {
|
||||
t.Error("被拒绝却已经把底座换了")
|
||||
}
|
||||
if _, recSHA, _ := baselineRecord(t, f); recSHA != "" {
|
||||
t.Error("被拒绝却写了记档")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRollbackBaselineRestoresPrevious 回滚要把旧底座换回来、记档清空,
|
||||
// 并且当前这份也要留档(回滚本身也得可逆)。
|
||||
func TestRollbackBaselineRestoresPrevious(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
ctx, dsID, srv := f.ctx, f.dsID, f.srv
|
||||
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
|
||||
t.Fatalf("准备底座失败: %v", err)
|
||||
}
|
||||
base := srv.baselinePath(dsID)
|
||||
oldSHA := baselineSHA(t, base)
|
||||
|
||||
pkg, sha, _ := baselinePackageFile(t, "new-base.rondbackup", f.export(t))
|
||||
if _, err := srv.swapBaseline(ctx, dsID, pkg, "new-base.rondbackup", sha, false); err != nil {
|
||||
t.Fatalf("换底座失败: %v", err)
|
||||
}
|
||||
newSHA := baselineSHA(t, base)
|
||||
if newSHA == oldSHA {
|
||||
t.Fatal("换底座没生效,后续断言没有意义")
|
||||
}
|
||||
|
||||
if _, err := srv.rollbackBaseline(ctx, dsID); err != nil {
|
||||
t.Fatalf("回滚失败: %v", err)
|
||||
}
|
||||
if got := baselineSHA(t, base); got != oldSHA {
|
||||
t.Errorf("回滚后底座不是原来那份:%s != %s", got, oldSHA)
|
||||
}
|
||||
if _, recSHA, at := baselineRecord(t, f); recSHA != "" || at.Valid {
|
||||
t.Error("回滚后应当清空记档(表示来源是数据集最初那份包)")
|
||||
}
|
||||
// 被回滚掉的那份也要留档,否则滚错了就回不去
|
||||
st, err := srv.baselineStatusOf(ctx, dsID)
|
||||
if err != nil {
|
||||
t.Fatalf("读底座状态失败: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, b := range st.Backups {
|
||||
if baselineSHA(t, filepath.Join(filepath.Dir(base), b.Name)) == newSHA {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("回滚时没有把当前这份也留档,滚错了就回不去了")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaselineUploadHandlerSwapsAndRecords 走一次真实的「上传并换底座」处理函数:
|
||||
// 303 回页面带提示、底座真的换了、上传件留在 uploads(以后底座丢了要按摘要找回它)。
|
||||
func TestBaselineUploadHandlerSwapsAndRecords(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
ctx, dsID, srv := f.ctx, f.dsID, f.srv
|
||||
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
|
||||
t.Fatalf("准备底座失败: %v", err)
|
||||
}
|
||||
base := srv.baselinePath(dsID)
|
||||
oldSHA := baselineSHA(t, base)
|
||||
|
||||
blob := f.export(t)
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
fw, err := mw.CreateFormFile("backup", "new-base.rondbackup")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fw.Write(blob); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/admin/baseline", &buf).WithContext(ctx)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
srv.adminBaselineUpload(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("应当 303 回页面,实际 %d:%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
loc := rec.Header().Get("Location")
|
||||
if !strings.HasPrefix(loc, "/admin/baseline?ok=") {
|
||||
t.Fatalf("应当带着成功提示回页面,实际 Location=%q", loc)
|
||||
}
|
||||
if baselineSHA(t, base) == oldSHA {
|
||||
t.Error("处理函数跑完了但底座没换")
|
||||
}
|
||||
var stored string
|
||||
if err := f.pg.QueryRowContext(ctx, `SELECT COALESCE(baseline_sha256,'') FROM datasets WHERE id=$1`, dsID).
|
||||
Scan(&stored); err != nil {
|
||||
t.Fatalf("读记档失败: %v", err)
|
||||
}
|
||||
if len(stored) != 64 {
|
||||
t.Fatalf("记档里的摘要不对: %q", stored)
|
||||
}
|
||||
path, err := srv.findUploadBySha(stored, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("上传的包没留在 uploads,底座丢了就重建不出来: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.Remove(path) })
|
||||
}
|
||||
|
||||
// TestBaselinePageRenders 页面能渲染到底(含尾部 </html>)。
|
||||
//
|
||||
// render 是流式直写,模板里的字段错(比如把 *time.Time 传给只认 time.Time 的函数)
|
||||
// 会在写到一半时中断:HTTP 200 + 一个「页脚之后整段不存在」的页面,服务端一声不吭。
|
||||
// 所以既要断言 200,也要断言动作之后的字和收尾标签。
|
||||
func TestBaselinePageRenders(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
ctx, srv := f.ctx, f.srv
|
||||
if _, err := srv.ensureBaseline(ctx, f.dsID); err != nil {
|
||||
t.Fatalf("准备底座失败: %v", err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/admin/baseline", nil).WithContext(ctx)
|
||||
srv.adminBaselinePage(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("页面状态码 %d:%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"当前底座", "换成另一份包当底座", "旧底座留档"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("页面里没有「%s」", want)
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(strings.TrimSpace(body), "</html>") {
|
||||
tail := body
|
||||
if len(tail) > 200 {
|
||||
tail = tail[len(tail)-200:]
|
||||
}
|
||||
t.Errorf("页面没渲染到底(尾部不是 </html>),多半是模板中途报错了:…%s", tail)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -96,17 +95,11 @@ type dupPlace struct {
|
||||
// 模式(VACUUM INTO 的产物),只做 SELECT 不会落下任何旁写文件,再加 query_only 兜住
|
||||
// 「误写基线库」这条路——基线是导出的底稿,写坏了整个数据集就导不出去了。
|
||||
func readDupBaseline(ctx context.Context, path string) (*dupBaseline, 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)")
|
||||
sdb, err := openBaselineRO(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer sdb.Close()
|
||||
sdb.SetMaxOpenConns(1)
|
||||
|
||||
places, err := readDupPlaces(ctx, sdb)
|
||||
if err != nil {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -56,6 +56,7 @@ var pageFiles = map[string]struct {
|
||||
"admin/fog-mask": {[]string{"templates/layout.html", "templates/admin/fog-mask.html"}, "layout"},
|
||||
"admin/fog-preview": {[]string{"templates/layout.html", "templates/admin/fog-preview.html"}, "layout"},
|
||||
"admin/backup-preview": {[]string{"templates/layout.html", "templates/admin/backup-preview.html"}, "layout"},
|
||||
"admin/baseline": {[]string{"templates/layout.html", "templates/admin/baseline.html"}, "layout"},
|
||||
"admin/fillup": {[]string{"templates/layout.html", "templates/admin/fillup.html"}, "layout"},
|
||||
"admin/notes": {[]string{"templates/layout.html", "templates/admin/notes.html"}, "layout"},
|
||||
"admin/edit": {[]string{"templates/layout.html", "templates/admin/edit.html"}, "layout"},
|
||||
@@ -244,6 +245,11 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("POST /admin/preview/{token}/merge", s.requireAdmin(s.adminBackupMerge))
|
||||
s.mux.HandleFunc("POST /admin/datasets/{id}/activate", s.requireAdmin(s.adminActivate))
|
||||
s.mux.HandleFunc("POST /admin/datasets/{id}/delete", s.requireAdmin(s.adminDelete))
|
||||
s.mux.HandleFunc("GET /admin/baseline", s.requireAdmin(s.adminBaselinePage))
|
||||
s.mux.HandleFunc("POST /admin/baseline", s.requireAdmin(s.adminBaselineUpload))
|
||||
s.mux.HandleFunc("POST /admin/baseline/confirm", s.requireAdmin(s.adminBaselineConfirm))
|
||||
s.mux.HandleFunc("POST /admin/baseline/rollback", s.requireAdmin(s.adminBaselineRollback))
|
||||
s.mux.HandleFunc("POST /admin/baseline/rebuild", s.requireAdmin(s.adminBaselineRebuild))
|
||||
s.mux.HandleFunc("POST /admin/place/note", s.requireAdmin(s.adminPlaceNote))
|
||||
s.mux.HandleFunc("POST /admin/place/vote", s.requireAdmin(s.adminPlaceVote))
|
||||
// 地点图片:字节由浏览器转成 WebP 后发过来,这里只负责收下、落库、取址
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
{{define "content"}}
|
||||
<div class="wrap">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>换基线</h1>
|
||||
<div class="sub">
|
||||
「底座」是导出 rondbackup 时铺数据的底稿:导出只覆盖我们托管的那几张实体表,
|
||||
<strong>其余内容(Core Data 元数据、索引、ZRAWVISIT 原始采样点…)全部从底座原样带出来</strong>。
|
||||
所以底座必须是手机导出的包;换底座<strong>不动站点里的任何数据</strong>,只换底稿。
|
||||
</div>
|
||||
</div>
|
||||
<div class="row"><a class="btn" href="/admin">← 返回后台</a></div>
|
||||
</div>
|
||||
|
||||
{{if not .Dataset}}
|
||||
<div class="card">
|
||||
<div class="empty"><div class="big">📦</div>还没有导入任何数据集,先上传一份备份再说。</div>
|
||||
</div>
|
||||
{{else}}
|
||||
|
||||
<div class="card">
|
||||
<h2>当前底座</h2>
|
||||
<div class="card-sub">数据集「{{trunc .Dataset.Name 40}}」(#{{.Dataset.ID}})的导出底稿</div>
|
||||
<div class="tw">
|
||||
<table class="tb tb-stack">
|
||||
<thead><tr><th>项目</th><th>值</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>状态</td>
|
||||
<td data-label="状态">
|
||||
{{if .Status.Exists}}
|
||||
<span class="tag">已就绪</span>
|
||||
<span class="muted">{{size .Status.Size}} · {{dtt .Status.ModTime}}</span>
|
||||
{{else}}
|
||||
<span class="tag">文件缺失</span>
|
||||
<span class="muted">导出时会按记档自动重建;也可以点下面的「重建」。</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>来源</td>
|
||||
<td data-label="来源">
|
||||
{{if .Status.SHA}}
|
||||
<span class="mono">{{trunc .Status.Name 40}}</span>
|
||||
<span class="muted">· {{dtt .Status.At}}</span>
|
||||
{{if .FromOriginal}}<span class="tag">与数据集原始包同一份</span>{{end}}
|
||||
{{else}}
|
||||
<span>数据集最初上传的那份包</span>
|
||||
<span class="mono muted">{{trunc .OrigName 40}}</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{if .Status.Exists}}
|
||||
<div class="card-sub" style="margin-top:14px">底座里有什么(换底座前后可以对着看)</div>
|
||||
{{if .CountsErr}}
|
||||
<div class="alert err">{{.CountsErr}}</div>
|
||||
{{else}}
|
||||
<div class="tw">
|
||||
<table class="tb tb-stack">
|
||||
<thead><tr><th>表</th><th>内容</th><th class="num">行数</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Counts}}
|
||||
<tr>
|
||||
<td><span class="mono">{{.Table}}</span></td>
|
||||
<td data-label="内容">{{.Label}}</td>
|
||||
<td class="num" data-label="行数">{{num .N}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="row" style="margin-top:12px">
|
||||
<form method="post" action="/admin/baseline/rebuild">
|
||||
<button class="btn" type="submit">按记档重建底座</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .PendingSHA}}
|
||||
<div class="card">
|
||||
<div class="alert err">
|
||||
刚上传的 <span class="mono">{{trunc .PendingName 40}}</span> 与当前数据集只对得上
|
||||
<strong>{{num .PendingMatched}}/{{num .PendingWith}}</strong> 条带 UUID 的到访(正常应当有九成以上)。
|
||||
它可能不是同一批数据的手机包——换下去会让导出包整个错位:站点每一行都在新底座里
|
||||
认不出身份,于是全部按「新行」插到最大编号之后,真机上就是「这些到访全变成新的、老记录还在」。
|
||||
</div>
|
||||
<div class="row">
|
||||
<form method="post" action="/admin/baseline/confirm"
|
||||
onsubmit="return confirm('确认这份包确实是同一批数据的手机包?换错会让导出的备份在真机上错位。')">
|
||||
<input type="hidden" name="sha" value="{{.PendingSHA}}">
|
||||
<input type="hidden" name="name" value="{{.PendingName}}">
|
||||
<button class="btn danger" type="submit">我确认,强制替换</button>
|
||||
</form>
|
||||
<a class="btn" href="/admin/baseline">放弃</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>换成另一份包当底座</h2>
|
||||
<div class="card-sub">
|
||||
在 rond App 里<strong>重新导出</strong>一份备份,传到这里。换完之后:
|
||||
到访数、地点、备注、主题都不变;导出 rondbackup 立刻以新底座为准。
|
||||
判据有两条——这份包得跟当前数据集是同一批数据(按到访 UUID 认),而且得真的能导出成功。
|
||||
换之前旧底座会留档,随时可以回滚。
|
||||
</div>
|
||||
<form method="post" action="/admin/baseline" enctype="multipart/form-data">
|
||||
<div class="row" style="align-items:center;flex-wrap:wrap">
|
||||
<input type="file" name="backup" accept=".rondbackup,.zip" required>
|
||||
<button class="btn primary" type="submit">上传并换底座</button>
|
||||
</div>
|
||||
<label class="muted" style="display:block;margin-top:10px;font-size:13px">
|
||||
<input type="checkbox" name="force" value="1">
|
||||
即使与当前数据集对不上也强制替换(重合率偏低时会再让你确认一次)
|
||||
</label>
|
||||
<div class="card-sub" style="margin-top:8px">单份上限 {{size .MaxSize}}。</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>旧底座留档</h2>
|
||||
<div class="card-sub">每次替换前都会把旧底座改名留一份(最近 3 份)。换错了就回滚。</div>
|
||||
{{if .Status.Backups}}
|
||||
<div class="tw">
|
||||
<table class="tb tb-stack">
|
||||
<thead><tr><th>留档</th><th>时间</th><th class="num">大小</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Status.Backups}}
|
||||
<tr>
|
||||
<td><span class="mono">{{trunc .Name 48}}</span></td>
|
||||
<td class="muted" data-label="时间">{{dt .At}}</td>
|
||||
<td class="num" data-label="大小">{{size .Size}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="row" style="margin-top:12px">
|
||||
<form method="post" action="/admin/baseline/rollback"
|
||||
onsubmit="return confirm('回滚到最近一份留档的旧底座?当前这份也会被留档,可以再滚回来。')">
|
||||
<button class="btn" type="submit">回滚到最近一份</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-sub" style="margin-top:8px">
|
||||
回滚后底座的「来源」记档会清空(表示它来自数据集最初那份包),导出照常可用。
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty"><div class="big">🗂️</div>还没有留档——这个数据集还没换过底座。</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -99,6 +99,17 @@
|
||||
<a class="btn primary" href="/admin/export/rondbackup">导出 rondbackup(可回导 rond)</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="row" style="justify-content:space-between;align-items:center">
|
||||
<h2 style="margin:0">换基线(导出底稿)</h2>
|
||||
<a class="btn" href="/admin/baseline">打开</a>
|
||||
</div>
|
||||
<div class="card-sub">
|
||||
导出 rondbackup 是以一份「底座」为底铺数据的,底座必须是手机导出的包。
|
||||
手机那边重新导出后想换代,用这里换:<b>站点数据一行不动</b>,旧底座留档、可回滚。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="row" style="justify-content:space-between;align-items:center">
|
||||
<h2 style="margin:0">已导入的数据集</h2>
|
||||
|
||||
@@ -712,6 +712,28 @@ type BackupDiffData struct {
|
||||
Identical bool
|
||||
}
|
||||
|
||||
// BaselineData 是「换基线」页的数据:当前底座是谁、里面有什么、能不能回滚。
|
||||
type BaselineData struct {
|
||||
Page
|
||||
// Dataset 为空表示还没有数据集,页面只给一句提示
|
||||
Dataset *Dataset
|
||||
Status *baselineStatus
|
||||
// Counts 是底座里各表的行数(读不出来时 CountsErr 有值)
|
||||
Counts []baselineCount
|
||||
CountsErr string
|
||||
// OrigName / OrigSHA 是数据集最初上传的那份包;FromOriginal 表示当前底座就是它,
|
||||
// 也就是说这个数据集还没换过底座。
|
||||
OrigName string
|
||||
OrigSHA string
|
||||
FromOriginal bool
|
||||
// 刚上传但判据没过、等站长确认的那份包(列表里给个「强制替换」按钮,不用重传)
|
||||
PendingSHA string
|
||||
PendingName string
|
||||
PendingMatched int
|
||||
PendingWith int
|
||||
MaxSize int64
|
||||
}
|
||||
|
||||
// FogPreviewData 是「迷雾导入预览」页的数据:先把差异摆出来,再决定叠加还是整体替换。
|
||||
type FogPreviewData struct {
|
||||
Page
|
||||
|
||||
Reference in New Issue
Block a user