图片不再随数据集删除:加「图片挂载恢复」,place_images 摘掉外键

用户报「之前上传的照片找不到了,应该是因为清了应用数据换基线」。查下来与 App 侧无关,
是站点自己两套口径打架:

- place_images.dataset_id 带 ON DELETE CASCADE,而读取按 place_key 坐标键、跨数据集。
- 于是删掉旧数据集时图片**行**被级联删了,**文件却留在 media 目录里**。
- 实测(本机):datasets 只剩 155 号、place_images 0 行、盘上 4 个 .webp 好端端躺着。
  同一批附属数据里备注(17)与结论(3+1)因为早就迁到坐标键,毫发无损——只有图片倒霉。

三件事:

1) 恢复:后台首页「图片挂载恢复 → 扫描并恢复」(POST /admin/images/recover,
   internal/web/image_recovery.go)。只插行、不删文件、不看数据集,按 object_key 判重可反复点。
   认地点两处来源:数据集还在就查它的 places;数据集已删就读它留下的 baselines/<id>.sqlite
   还原「地点号 → 坐标」。对象键两种历史布局都认(<ds>/<YYYYMM>/<srcPK>-<hash>.webp 与
   <ds>/<srcPK>/<hash>.webp);判据先看文件名有没有 `<数字>-` 前缀,再看中间目录——
   地点号也可能刚好 6 位,只看位数会认错。

2) 防复发:CREATE TABLE place_images 去掉外键 + 幂等 DROP CONSTRAINT;删图接口不再按
   dataset_id 过滤(数据集删了也得能删图);「删除数据集」的二次确认文案改成实话。

3) 报告同类陷阱(未改):topics 仍带 CASCADE,专题随数据集一起删且没有恢复来源。

测试踩到的坑:图片「按坐标键跨数据集读」+「按 object_key 判重」都不看数据集,用例之间会
互相污染两处——盘上文件(报「应当 2 张、实际 10 张」)与库里的行(Already:1 让兜底分支
根本没跑到,用例差点假绿)。加 isolateImageState(换空 DataDir + 按 place_key 清历史行),
并给夹具补收尾清理(外键没了必须自己清)。

新增用例 4 条;验收:./scripts/verify.sh 全量 PASS=323 FAIL=0 SKIP=0。
This commit is contained in:
tamakyi
2026-09-30 19:45:51 +08:00
parent 52a82631da
commit 496c992e5e
12 changed files with 900 additions and 7 deletions
+30
View File
@@ -211,3 +211,33 @@ UUID 集合也与换之前逐条一致)。
第二轮全量验收:**PASS=319 FAIL=0 SKIP=0**。技能 `rond-backup-parse` 补了「导出条数 ≠ 站点条数」
与「换基线已做成后台动作」两处。
## 图片丢失的根因与恢复(2026-09-30 19:40)
用户报「之前上传的照片现在找不到了,应该是因为清了应用数据换基线」。查下来**跟 App 侧无关**,
是站点自己的外键口径不一致:
- `place_images.dataset_id` 带 `ON DELETE CASCADE`,而**读取按 `place_key` 坐标键、跨数据集**。
- 于是「删掉旧数据集」把图片**行**级联删了,**文件却留在 `<data_dir>/media/<数据集>/…`**。
- 本机实测:`datasets` 只剩 155 号,`place_images` **0 行**,盘上 4 个 `.webp` 好端端躺着。
同一批附属数据里备注(17)与结论(3+1)因为早就迁到坐标键,**毫发无损**——只有图片倒霉。
做的三件事:
1. **恢复**:`internal/web/image_recovery.go` + 后台首页「图片挂载恢复 → 扫描并恢复」(`POST /admin/images/recover`)。
只插行、不动文件、可反复点(按 `object_key` 判重,`apply=false` 是预演)。认地点两处来源:
① 数据集还在就查它的 `places`;② 数据集已删就读它留下的 `baselines/<id>.sqlite` 还原「地点号 → 坐标」。
对象键两种历史布局都认(`<ds>/<YYYYMM>/<srcPK>-<hash>.webp` 与 `<ds>/<srcPK>/<hash>.webp`)。
2. **防复发**:schema 里 `CREATE TABLE place_images` 去掉外键 + 幂等 `DROP CONSTRAINT`;
删图的接口不再按 `dataset_id` 过滤;后台「删除数据集」的确认文案改成实话(专题也会删、附属数据不受影响)。
3. **同类陷阱报告**:`topics` 仍带 CASCADE —— 专题随数据集一起删且**没有恢复来源**(本机 0 行,
之前那次换数据集就没了)。专题内容本来是「时间+区域」派生的,可以做成跨数据集的,但列表/详情都按
`dataset_id` 过滤,要改就一起改 —— **等用户拍板**,没擅自动。
本机预期可恢复 4 张:`上班` ×1(旧地点号 286)、`海之尚酒店` ×3(旧地点号 309),靠 `baselines/9.sqlite` 认。
踩到的测试坑(都写进 details 卅四):图片「按坐标键跨数据集读」+「按 object_key 判重」都**不看数据集**,
用例之间会互相污染两处——盘上文件(「应当 2 张、实际 10 张」)与库里的行
(`Already:1` 让兜底分支根本没跑到,用例差点假绿)。加了 `isolateImageState`(换空 DataDir + 按 place_key 清行),
并给夹具补了收尾清理(外键没了,必须自己清)。
新增用例 4 条(`image_recovery_test.go`)。
+49
View File
@@ -583,3 +583,52 @@ Core Data 元数据、索引、持久化历史、`ZRAWVISIT` 采样点、`ZTRIP*
记档优先重建(**带反证**:清掉记档后同一条路必须走不通,防止用例被别的兜底弄绿)、
部分重合需确认、一条都对不上 force 也不给过、非包文件第一步就挡住、回滚还原且当前那份也留档、
页面渲染到底(尾部 `</html>`)。
---
## 三十四、图片挂载恢复 + `place_images` 摘掉外键(2026-09-30)
### 症状与根因(用户报「之前上传的照片找不到了」)
`place_images.dataset_id` 原本带 `ON DELETE CASCADE`,而**读取按 `place_key` 坐标键、跨数据集**。
两套口径打架:删掉旧数据集时图片**行**被级联删掉,**文件却留在 `<data_dir>/media/<数据集>/…`**。
实测(本机 `rond_with_you`):`datasets` 只剩 155 号,`place_images` 0 行,盘上 4 个 `.webp` 好端端躺着。
同一批附属数据里,备注(17 行)、结论(3+1 行)因为早就迁到坐标键,**毫发无损**——只有图片倒霉。
### 恢复动作
- 入口:后台首页「图片挂载恢复 → 扫描并恢复」⇒ `POST /admin/images/recover`(`internal/web/image_recovery.go`)。
只**插入**行:不删文件、不动已有行、可反复点(按 `object_key` 判重)。`apply=false` 是预演。
- 认地点的两处来源:① 数据集还在库里 ⇒ 查它的 `places`(最准);② 数据集已删 ⇒ 读它留下的
**底座库** `baselines/<id>.sqlite`(换基线留下的 `.bak-*` 也算,新的优先)→ 还原「地点号 → 坐标」。
⚠️ **底座文件是救图的唯一依据**:清掉 baselines 就永远认不出这些图属于哪个地点(只报「认不出」)。
- 对象键**两种布局都要认**(历史原因):
`<dataset>/<YYYYMM>/<srcPK>-<内容哈希>.webp`(现在)与 `<dataset>/<srcPK>/<内容哈希>.webp`(早期,目录名就是地点号)。
判据不能只看「中间那层是不是 6 位数字」——地点号也可能刚好 6 位:先看文件名有没有 `<数字>-` 前缀,再退到目录名。
- `width/height` 无从得知、留 0:页面只用 `URL`(模板里没有宽高),`bytes` 用文件大小。
### 防复发
- `schema.sql`:`CREATE TABLE place_images` **去掉外键**,另加幂等迁移
`ALTER TABLE place_images DROP CONSTRAINT IF EXISTS place_images_dataset_id_fkey;`。
`dataset_id` 退化成「出处」这一列——图片认地点本来就不看它。
- `adminPlaceImageDelete` 不再按 `dataset_id` 过滤(数据集删了也得能删图),SELECT/DELETE 都只按 `id`。
- 后台「删除数据集」的二次确认文案改成实话:删的是到访/地点/位移/原始行留档 + **专题**,
备注/结论/图片按坐标键保留。
- ⚠️ **同类陷阱还剩一个**:`topics` 仍带 CASCADE。专题内容是「时间 + 区域」派生的,本可以跨数据集活,
但列表/详情都按 `dataset_id` 过滤,去掉外键就得同时改成用户级查询——**未做,待用户拍板**。
已丢的那批专题(本机 0 行)没有本地恢复来源。
### ⚠️ 测试隔离(这批用例踩出来的)
图片「按坐标键跨数据集读」+「按 object_key 判重」,都**不看数据集** ⇒ 用例之间会互相污染,两处都要隔离:
1. **盘上文件**:共用 media 目录里任何一张「坐标相同」的图都会被算成该地点的图
⇒ 实测「应当 2 张、实际 10 张」(上一轮跑剩的文件全在那张坐标上)。
2. **库里的行**:上一轮留下的 `object_key` 会让新文件被当成「已经在库里」直接跳过
⇒ 实测 `Already:1`,**兜底分支根本没被执行到**,而用例只看「挂回 1 张」差点就放过(假绿)。
做法:`isolateImageState(t, f, lat, lon)` —— 换空 `DataDir`(`t.TempDir()`)+ 按 `place_key`
清历史行(前置清一次、`t.Cleanup` 再清一次)。改名用的文件名再带 `UnixNano` 后缀避免撞键。
夹具 `newMovementFixture` 也补了收尾:显式 `DELETE FROM place_images WHERE dataset_id=$1` + 删 media 子目录
(**不再级联,必须自己清**),注册得最晚 ⇒ 在删用户之前跑。
### 用例 `internal/web/image_recovery_test.go`(4 条)
行没了文件还在 ⇒ 按坐标挂回(含预演不写库、幂等、挂回后**页面口径** `placeImagesOf` 看得见)、
早期键布局与 `parseImageKey` 的表驱动、**数据集已删 ⇒ 靠底座库认回**(并在报告里说明来源)、
删数据集不再带走图片行与文件。
+4 -2
View File
@@ -1,6 +1,6 @@
# rond-with-you 硬规矩(索引)
> 全文:`.workbuddy/memory/MEMORY-details.md`(35 节);验证机制:技能 `rond-fix-verify`;备份包解析/导出:技能 `rond-backup-parse`。本文件只做关键词索引——**新规矩写 details,别堆这里**。
> 全文:`.workbuddy/memory/MEMORY-details.md`(36 节);验证机制:技能 `rond-fix-verify`;备份包解析/导出:技能 `rond-backup-parse`。本文件只做关键词索引——**新规矩写 details,别堆这里**。
>
> ⚠️ 本文件**按字符数**注入,硬上限 `MAX_MEMORY_CHARS = 1e4`(WorkBuddy `app.asar` 里 `getWorkingMemoryContent`;中文 1 字算 1,不是按字节)。超了会被 `substring(0,1e4)` 截断并写明「先整理再干活」——即整段尾部静默丢失。日常日志与本 details **不参与注入**,可以随便写。现值见 `wc -m`,留 20% 余量。
@@ -52,9 +52,11 @@
- 站内新建行 `entity_raw.raw IS NULL`;`ZCATEGORY_` 真机只有 `MKPOICategoryXxx` 枚举。
- 类别候选:编辑/照片页=库里在用 `GROUP BY`;补全页=在用 ∪ 静态表;下拉留「其它(自己写)」,默认「不动」。
## 跨备份 / 附属数据(details 十九~廿一)
## 跨备份 / 附属数据(details 十九~廿一、卅四)
- 预演按 `VisitUUID`/`ActivityUUID` 对齐、只读;`raw #>> '{ZIDENTIFIER_,$blob}'` 是 base64 须转 hex 比;地点只认坐标(`PlaceCoordKey` 6 位)。
- 附属数据(备注/投票/图片)主键=`place_key` 坐标键(非 dataset+src_pk);Go 与 SQL 两套须逐位一致。
- ⚠️ **附属数据不能挂数据集外键**:`place_images` 曾按 `dataset_id` CASCADE ⇒ 删旧数据集=删图片行、**文件却留在盘上**(读取按坐标键、行按数据集级联,两套口径打架)。已摘掉外键;恢复走后台「图片挂载恢复」,认地点靠旧数据集留下的 `baselines/<id>.sqlite`(**别清 baselines 目录**)。`image_recovery_test.go`(details 卅四)
- ⚠️ `topics` **仍**带 CASCADE(专题随数据集删掉、无恢复来源)——同类陷阱,待拍板。
- ⚠️ `schema.sql` 迁移块必须收敛且排在依赖表之后(插在 `place_images` 前:开发库无症、**新库起不来**)。
- 合并 `backup_merge.go`:整个 `withTx`、不删除;新行 `*_visit_src` 写**库里的 src_pk**;必须 `writeRawRow`;删地点前先查有无到访(CASCADE)。