From 6ff6f944a198e60154457b8cdb6124e12ff03d1e Mon Sep 17 00:00:00 2001 From: tamakyi Date: Wed, 30 Sep 2026 19:00:18 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8D=A2=E5=9F=BA=E7=BA=BF=EF=BC=9A=E6=8A=8A?= =?UTF-8?q?=E3=80=8C=E5=BA=95=E5=BA=A7=E6=8D=A2=E4=BB=A3=E3=80=8D=E5=81=9A?= =?UTF-8?q?=E8=BF=9B=E5=90=8E=E5=8F=B0=EF=BC=88=E7=AB=99=E7=82=B9=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E4=B8=80=E8=A1=8C=E4=B8=8D=E5=8A=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 导出是「底座为底 + 只覆盖托管表」,所以底座必须是手机导出的包。底座跟着数据集走, 手机重新导出后想换代,以前只能整包重新导入成新数据集(站点补录的行、主题、备注都得重收拾)。 新动作 /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。 --- .workbuddy/memory/2026-09-30.md | 30 + .workbuddy/memory/MEMORY-details.md | 43 ++ .workbuddy/memory/MEMORY.md | 3 +- README.md | 16 + internal/db/schema.sql | 9 + internal/web/baseline.go | 680 +++++++++++++++++++++ internal/web/baseline_swap_test.go | 500 +++++++++++++++ internal/web/edit_duplicates.go | 9 +- internal/web/handlers_export.go | 82 ++- internal/web/server.go | 6 + internal/web/templates/admin/baseline.html | 161 +++++ internal/web/templates/admin/index.html | 11 + internal/web/view.go | 22 + 13 files changed, 1544 insertions(+), 28 deletions(-) create mode 100644 internal/web/baseline.go create mode 100644 internal/web/baseline_swap_test.go create mode 100644 internal/web/templates/admin/baseline.html diff --git a/.workbuddy/memory/2026-09-30.md b/.workbuddy/memory/2026-09-30.md index 9a45aa4..c5961b1 100644 --- a/.workbuddy/memory/2026-09-30.md +++ b/.workbuddy/memory/2026-09-30.md @@ -162,3 +162,33 @@ V2(+3 个站内新建地点)、V3(手机里也有的行按手机原值重 (反忽略六行写法 + 只放行 .md 的理由 + `git check-ignore --no-index` 打靶复核 + `MAX_MEMORY_CHARS=1e4` 按字符判定 + 只有 MEMORY.md 参与注入 + CRLF 归一与 「`grep -c $'\r'` 在 Git Bash 里会全命中」这个假信号)。 + +## 「换基线」做进后台(2026-09-30 18:55) + +上一轮用户手工走的换基线闭环,这轮做成了后台动作:`/admin/baseline`(后台首页加了入口)。 +**站点数据一行不动,只换导出底稿**——这正是「底座必须是手机包」这条铁律的可操作形态。 + +三道闸,顺序不可挪(写在 `internal/web/baseline.go` 的文件头注释里): +1. **重合判据**:拿新包里的到访 UUID 与站点侧带 UUID 的到访比(站点补录行本来就没 UUID,不计入分母)。 + `matched==0` ⇒ 硬拒,**force 也不给过**(干跑只验结构,一份不相干的包照样导得出,但换下去站点每行都 + 认不出身份 ⇒ 全按新行插到最大编号之后 ⇒ 真机上「这些到访全变成新的」)。`matched*2 < withUUID` ⇒ + 要显式确认;判据没过不当场报错了事——包已收好,页面给一个 `?pending=` 的「强制替换」按钮,不必重传 20MB。 +2. **干跑导出**(`ExportRondbackup(..., tmp, io.Discard)`):底座缺表/缺实体编号在这步就挡住, + 不会出现「换完了才发现导不出、原来那份已经不知去哪」。 +3. **旧底座留档 + 记档**:`datasets.baseline_name/baseline_sha256/baseline_at`。 + +踩到两个坑,都写进了 details 卅三: +- ⚠️ **留档名不能只用秒级时间戳**:「替换后紧接着回滚」落在同一秒时,回滚那次 rename 会把刚生成的 + 留档覆盖掉,于是回滚「回了个寂寞」——`TestRollbackBaselineRestoresPrevious` 当场红给我看。改用 + `baselineBackupName()`(`20060102-150405` + 冲突自增)。 +- ⚠️ **`ensureBaseline` 必须先看记档**:只认 `datasets.sha256`(= 最初那份包)的话,底座一丢就静默 + 重建回旧包,换基线白做且毫无提示。共用实现 `ensureBaselineTo(ctx, dsID, dst)`;「重建」入口写临时 + 文件再 rename,不做破坏性删除。用例里带了**反证**(清掉记档后同一条路必须走不通),免得被别的兜底弄绿。 +- 另一个小坑:算摘要的落盘要在 `uploads` **之外**,否则 `findUploadBySha` 会把自己认成「已存在」。 + +第一次跑定点用例时还红了一条 `换底座后导出包里的到访数 824 != 站点 823`——那是**我的断言写错了**: +导出条数与站点条数本来就不严格相等(导入会跳过内容重复的行),既有用例都只做「导出前后相对比较」。 +改成更锋利的判据:换底座前后导出包里 `ZVISIT` 的 **UUID 集合逐条一致**——编号会换、身份不该换。 + +验收:`./scripts/verify.sh` 全量 **PASS=318 FAIL=0 SKIP=0**(10m15s)。 +新增用例 7 条(`internal/web/baseline_swap_test.go`)。 diff --git a/.workbuddy/memory/MEMORY-details.md b/.workbuddy/memory/MEMORY-details.md index e2ef3f9..5e38c00 100644 --- a/.workbuddy/memory/MEMORY-details.md +++ b/.workbuddy/memory/MEMORY-details.md @@ -534,3 +534,46 @@ zip 包,**条目内容本身是 zlib 流**;`Model/*/` = 头 32768B + 每块 - 验收四件套 `ROND_TEST_CONF`+`ROND_SRC`+`FOG_SRC`+`ROND_DSN`(须指 `rond_fog_test`);`-count=1 -v ./...` 要 **0 skip**。两个假绿坑:缺 `ROND_SRC` 静默 Skip;共用库 `Import` 让 `internal/stats` 假 skip ⇒ 用专用库,`t.Cleanup` 在 `defer` **之后**才跑。 - 静态守卫走 `moduleRoot(t)`/`pkgFile(t,name)`;`-race` 需 mingw 在 PATH。纪律:只走 IPv4、303 不跟随;**同一文件绝不并行发多条 Edit**;新回归测试做**变异验证**。 - 📐 覆盖率**必须 `-coverpkg=./internal/...`**(`-cover` 只算本包 ⇒ rond/geo 假报 0%,真值 82.2%/92.9%);解析 profile 按 `(文件,行段)` 取 **max 别求和**;**死代码删掉、别给它补测试**。关键页冒烟 `scripts/smoke.py`(`verify.sh --smoke`/`--all`):关键区块不能用 `nav.nav`、用 `.card` 不用 `div.card`。`details 廿九` + + +--- + +## 三十三、「换基线」(底座换代)做进后台(2026-09-30) + +### 是什么 / 为什么要 +导出的实现是「**底座为底 + 只覆盖托管表**」(见 `internal/rond/export.go` 头部注释): +Core Data 元数据、索引、持久化历史、`ZRAWVISIT` 采样点、`ZTRIP*` 全从底座原样带出来。 +所以**底座必须是手机导出的包**。底座跟着数据集走,手机重新导出后想换代, +以前唯一办法是整包重新导入成新数据集(站点补录的行、主题、备注全要重收拾)。 +新动作:**同一份数据的底座换代,站点数据一行不动**。 + +### 实现要点(`internal/web/baseline.go` + `handlers_export.go` + `templates/admin/baseline.html`) +- 顺序不可挪:写临时底座 → ①重合判据 → ②干跑导出 → 旧底座改名留档 → `rename` 就位 → 记档 → prune。 + 干跑(`rond.ExportRondbackup(..., tmp, io.Discard)`)把「底座缺表/缺实体编号」这类失败挡在替换之前, + 否则会出现「底座换了、才发现导不出、原来那份已经不知去哪」。 +- ①重合判据(唯一能挡住「换错包」的闸):分母是**站点侧带 UUID 的到访**(站点补录行本来就没有 UUID, + 不计入);`minOverlapVisits=20` 以下不判;`matched==0` ⇒ 硬拒(**force 也不给过**); + `matched*2 < withUUID` ⇒ 要显式确认。理由:干跑只验结构,一份不相干的包照样导得出, + 但换下去站点每行都在新底座里认不出身份 ⇒ 全按新行插到最大编号之后 ⇒ 真机上「这些到访全变成新的」。 +- ⚠️ **留档名必须防重名**:`fmt.Sprintf("%s.bak-%d", dst, time.Now().Unix())` 是秒级; + 「替换后紧接着回滚」两个操作落在同一秒时,回滚那次 `rename` 会把刚生成的留档**覆盖掉**, + 于是回滚拿到的还是新底座——实测 `TestRollbackBaselineRestoresPrevious` 就是这么红的。 + 改用 `baselineBackupName()`(`20060102-150405` + 冲突自增)。 +- 记档三列 `datasets.baseline_name/baseline_sha256/baseline_at`。 + **`ensureBaseline` 必须先看记档**:只认 `datasets.sha256`(= 最初那份包)的话, + 底座一丢就静默重建回旧包,上一次换基线白做。共用实现 `ensureBaselineTo(ctx, dsID, dst)`; + 「重建」入口写临时文件再 `rename`,不做破坏性 `os.Remove`(重建失败不能让数据集没底座)。 +- 上传件要落 `uploads`(`stashUpload`,按 sha 去重后复用已有那份),否则底座丢了按记档找不回包。 + ⚠️ 算摘要的落盘必须在 `uploads` **之外**(`ingest.TempDir` 下),否则 `findUploadBySha` + 会把自己认成「已存在」。 +- 判据没过不当场报错了事:包已经收好了,页面用 `?pending=&pname=&pm=&pw=` 渲染一个 + 「我确认,强制替换」按钮(不必重传几十 MB);`POST /admin/baseline/confirm` 走同一条路。 +- 导出内容在换底座前后**逐条不变**(`ZVISIT` 的 UUID 集合):编号会换、身份不该换, + 这条是回归用例最锋利的断言(谁在底座里没对上,就会多出一条派生 UUID 的行)。 +- 路由:`GET /admin/baseline`、`POST /admin/baseline`(上传并换)、`/confirm`、`/rollback`、`/rebuild`。 + +### 用例 `internal/web/baseline_swap_test.go`(7 条) +成功路径(文件真换/旧底座留档 sha 一致/记档写入/站点计数与样本时间不变/导出 UUID 集合一致)、 +记档优先重建(**带反证**:清掉记档后同一条路必须走不通,防止用例被别的兜底弄绿)、 +部分重合需确认、一条都对不上 force 也不给过、非包文件第一步就挡住、回滚还原且当前那份也留档、 +页面渲染到底(尾部 ``)。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index f7c9731..15bef69 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -1,6 +1,6 @@ # rond-with-you 硬规矩(索引) -> 全文:`.workbuddy/memory/MEMORY-details.md`(34 节);验证机制:技能 `rond-fix-verify`;备份包解析/导出:技能 `rond-backup-parse`。本文件只做关键词索引——**新规矩写 details,别堆这里**。 +> 全文:`.workbuddy/memory/MEMORY-details.md`(35 节);验证机制:技能 `rond-fix-verify`;备份包解析/导出:技能 `rond-backup-parse`。本文件只做关键词索引——**新规矩写 details,别堆这里**。 > > ⚠️ 本文件**按字符数**注入,硬上限 `MAX_MEMORY_CHARS = 1e4`(WorkBuddy `app.asar` 里 `getWorkingMemoryContent`;中文 1 字算 1,不是按字节)。超了会被 `substring(0,1e4)` 截断并写明「先整理再干活」——即整段尾部静默丢失。日常日志与本 details **不参与注入**,可以随便写。现值见 `wc -m`,留 20% 余量。 @@ -21,6 +21,7 @@ - rows 收尾必须 `drainRows`(先 `Err()` 再 `Close()`)。`parse_rows_test.go` - **扫进裸 `bool` 的列必须先 `COALESCE`**:`weather.is_daylight` 漏写 ⇒ NULL ⇒ 整包拒收(details 卅C)。 - 核对导出包**别按留档 UUID 找行**:站点新建行会被补派生 UUID(`coreDataUUID`)。 +- **换基线**(底座换代,`/admin/baseline`):站点数据一行不动,只换底稿。判据=到访 UUID 重合率(`matched==0` 硬拒)+ 干跑导出;记档 `datasets.baseline_*`,`ensureBaseline` **必须先看记档**;留档名**必须防重名**(秒级时间戳会让回滚覆盖掉刚生成的留档)。`baseline_swap_test.go`(details 卅三) ## 迷雾 / 遮罩(details 三、九、十、廿二) - `.fwss`=zip、条目 zlib;`Model/*/`=头 32768B+每块 515B;`Model/~/`、`Model/#/` 原样写回;清空/替换都要连 `fog_raw_files`。 diff --git a/README.md b/README.md index 7f9bb8d..138eeb1 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,22 @@ go build -o rond . - **回导**:后台可导出修改过的 `.rondbackup`(含补录的记录,坐标为 GCJ-02 原样写回), 可直接导回 rond App。导出走「基线库 + 覆盖」,不重建库 +## 换基线(让导出底稿跟着手机换代) + +导出**不是**从零重建数据库,而是以一份「底座」(`storage/baselines/<数据集>.sqlite`)为底, +只覆盖站点托管的那几张实体表。Core Data 元数据、索引、持久化历史、`ZRAWVISIT` 原始采样点、 +`ZTRIP*` 等一律原样带出来——**所以底座必须是手机导出的包**,拿本站导出的包当底座会越导越失真。 + +底座一旦定下就跟着数据集走。手机那边重新导出后想换代,以前只能整包重新导入成新数据集 +(站点侧补录的行、主题、备注都得重新收拾)。后台「换基线」就是给同一份数据的底座换代用的: + +- **站点数据一行不动**,只换底稿;换完导出立刻以新底座为准。 +- 两道判据:① 这份包与当前数据集得是同一批数据(按到访 UUID 认,低于一半要显式确认, + 一条都对不上直接拒绝);② 这份底座得真的能导出(先用它干跑一次导出,失败就放弃替换)。 +- 替换前旧底座自动留档(最近 3 份),换错了在页面上一键回滚;回滚本身也留档,可再滚回来。 +- 换过的底座记档在 `datasets.baseline_name/baseline_sha256`:底座文件丢了会**按记档重建**, + 而不是退回最初那份旧包(否则上一次换基线会静默失效)。 + ## 照片导入(补录没有轨迹的时间段) 手机里的照片带着拍摄时间和 GPS,可以批量补成「地点 + 到访」。 diff --git a/internal/db/schema.sql b/internal/db/schema.sql index 2d1e75d..63d603c 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -302,6 +302,15 @@ ALTER TABLE place_notes ADD COLUMN IF NOT EXISTS rond_note TEXT NOT NULL DEFAULT -- 需要把原始 .rondbackup 重新上传一次(即便内容相同也会补抓)。 ALTER TABLE datasets ADD COLUMN IF NOT EXISTS coredata_meta JSONB; +-- 「换基线」的记档:底座库(baselines/.sqlite)是哪一份包换出来的。 +-- 记着它有两个用处:① 底座文件丢了可以按 baseline_sha256 去 uploads 里找回同一份包重建 +-- (否则 ensureBaseline 只会退回 datasets.sha256,也就是最初那份旧包——底座会被悄悄换回去); +-- ② 页面上能说清「现在这个底座来自哪个文件」。 +-- baseline_sha256 IS NULL = 从未换过底座,来源就是最初上传的原始包。 +ALTER TABLE datasets ADD COLUMN IF NOT EXISTS baseline_name TEXT; +ALTER TABLE datasets ADD COLUMN IF NOT EXISTS baseline_sha256 CHAR(64); +ALTER TABLE datasets ADD COLUMN IF NOT EXISTS baseline_at TIMESTAMPTZ; + -- rond 原始行留档:每条实体在 Core Data 里的**完整一行**(含站点未映射的字段) -- 按 JSONB 存下来。有了它,导出不再依赖基线库去补那些字段—— -- 基线即便丢了也能精确还原,站点侧也具备完整数据,不会因为映射范围窄而漏字段。 diff --git a/internal/web/baseline.go b/internal/web/baseline.go new file mode 100644 index 0000000..eed839d --- /dev/null +++ b/internal/web/baseline.go @@ -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() +} diff --git a/internal/web/baseline_swap_test.go b/internal/web/baseline_swap_test.go new file mode 100644 index 0000000..821d3e2 --- /dev/null +++ b/internal/web/baseline_swap_test.go @@ -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 页面能渲染到底(含尾部 )。 +// +// 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), "") { + tail := body + if len(tail) > 200 { + tail = tail[len(tail)-200:] + } + t.Errorf("页面没渲染到底(尾部不是 ),多半是模板中途报错了:…%s", tail) + } +} diff --git a/internal/web/edit_duplicates.go b/internal/web/edit_duplicates.go index 49eb582..bd48e08 100644 --- a/internal/web/edit_duplicates.go +++ b/internal/web/edit_duplicates.go @@ -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 { diff --git a/internal/web/handlers_export.go b/internal/web/handlers_export.go index 8377957..4f4b5db 100644 --- a/internal/web/handlers_export.go +++ b/internal/web/handlers_export.go @@ -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") diff --git a/internal/web/server.go b/internal/web/server.go index ad945e1..9054e00 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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 后发过来,这里只负责收下、落库、取址 diff --git a/internal/web/templates/admin/baseline.html b/internal/web/templates/admin/baseline.html new file mode 100644 index 0000000..83f900e --- /dev/null +++ b/internal/web/templates/admin/baseline.html @@ -0,0 +1,161 @@ +{{define "content"}} +
+
+
+

换基线

+
+ 「底座」是导出 rondbackup 时铺数据的底稿:导出只覆盖我们托管的那几张实体表, + 其余内容(Core Data 元数据、索引、ZRAWVISIT 原始采样点…)全部从底座原样带出来。 + 所以底座必须是手机导出的包;换底座不动站点里的任何数据,只换底稿。 +
+
+ +
+ + {{if not .Dataset}} +
+
📦
还没有导入任何数据集,先上传一份备份再说。
+
+ {{else}} + +
+

当前底座

+
数据集「{{trunc .Dataset.Name 40}}」(#{{.Dataset.ID}})的导出底稿
+
+ + + + + + + + + + + + +
项目值
状态 + {{if .Status.Exists}} + 已就绪 + {{size .Status.Size}} · {{dtt .Status.ModTime}} + {{else}} + 文件缺失 + 导出时会按记档自动重建;也可以点下面的「重建」。 + {{end}} +
来源 + {{if .Status.SHA}} + {{trunc .Status.Name 40}} + · {{dtt .Status.At}} + {{if .FromOriginal}}与数据集原始包同一份{{end}} + {{else}} + 数据集最初上传的那份包 + {{trunc .OrigName 40}} + {{end}} +
+
+ + {{if .Status.Exists}} +
底座里有什么(换底座前后可以对着看)
+ {{if .CountsErr}} +
{{.CountsErr}}
+ {{else}} +
+ + + + {{range .Counts}} + + + + + + {{end}} + +
表内容行数
{{.Table}}{{.Label}}{{num .N}}
+
+ {{end}} + {{else}} +
+
+ +
+
+ {{end}} +
+ + {{if .PendingSHA}} +
+
+ 刚上传的 {{trunc .PendingName 40}} 与当前数据集只对得上 + {{num .PendingMatched}}/{{num .PendingWith}} 条带 UUID 的到访(正常应当有九成以上)。 + 它可能不是同一批数据的手机包——换下去会让导出包整个错位:站点每一行都在新底座里 + 认不出身份,于是全部按「新行」插到最大编号之后,真机上就是「这些到访全变成新的、老记录还在」。 +
+
+
+ + + +
+ 放弃 +
+
+ {{end}} + +
+

换成另一份包当底座

+
+ 在 rond App 里重新导出一份备份,传到这里。换完之后: + 到访数、地点、备注、主题都不变;导出 rondbackup 立刻以新底座为准。 + 判据有两条——这份包得跟当前数据集是同一批数据(按到访 UUID 认),而且得真的能导出成功。 + 换之前旧底座会留档,随时可以回滚。 +
+
+
+ + +
+ +
单份上限 {{size .MaxSize}}。
+
+
+ +
+

旧底座留档

+
每次替换前都会把旧底座改名留一份(最近 3 份)。换错了就回滚。
+ {{if .Status.Backups}} +
+ + + + {{range .Status.Backups}} + + + + + + {{end}} + +
留档时间大小
{{trunc .Name 48}}{{dt .At}}{{size .Size}}
+
+
+
+ +
+
+
+ 回滚后底座的「来源」记档会清空(表示它来自数据集最初那份包),导出照常可用。 +
+ {{else}} +
🗂️
还没有留档——这个数据集还没换过底座。
+ {{end}} +
+ + {{end}} +
+{{end}} diff --git a/internal/web/templates/admin/index.html b/internal/web/templates/admin/index.html index e25c1b0..073df13 100644 --- a/internal/web/templates/admin/index.html +++ b/internal/web/templates/admin/index.html @@ -99,6 +99,17 @@ 导出 rondbackup(可回导 rond) +
+
+

换基线(导出底稿)

+ 打开 +
+
+ 导出 rondbackup 是以一份「底座」为底铺数据的,底座必须是手机导出的包。 + 手机那边重新导出后想换代,用这里换:站点数据一行不动,旧底座留档、可回滚。 +
+
+

已导入的数据集

diff --git a/internal/web/view.go b/internal/web/view.go index 40f6318..e942177 100644 --- a/internal/web/view.go +++ b/internal/web/view.go @@ -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