From 496c992e5e73e671260150b761c66ea064a4a40a Mon Sep 17 00:00:00 2001 From: tamakyi Date: Wed, 30 Sep 2026 19:45:51 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BE=E7=89=87=E4=B8=8D=E5=86=8D=E9=9A=8F?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E9=9B=86=E5=88=A0=E9=99=A4=EF=BC=9A=E5=8A=A0?= =?UTF-8?q?=E3=80=8C=E5=9B=BE=E7=89=87=E6=8C=82=E8=BD=BD=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E3=80=8D=EF=BC=8Cplace=5Fimages=20=E6=91=98=E6=8E=89=E5=A4=96?= =?UTF-8?q?=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户报「之前上传的照片找不到了,应该是因为清了应用数据换基线」。查下来与 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/.sqlite 还原「地点号 → 坐标」。对象键两种历史布局都认(//-.webp 与 //.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。 --- .gitignore | 2 + .workbuddy/memory/2026-09-30.md | 30 ++ .workbuddy/memory/MEMORY-details.md | 49 +++ .workbuddy/memory/MEMORY.md | 6 +- README.md | 15 + internal/db/schema.sql | 11 +- internal/web/handlers_images.go | 8 +- internal/web/image_recovery.go | 407 ++++++++++++++++++++++++ internal/web/image_recovery_test.go | 352 ++++++++++++++++++++ internal/web/movement_e2e_test.go | 9 + internal/web/server.go | 1 + internal/web/templates/admin/index.html | 17 +- 12 files changed, 900 insertions(+), 7 deletions(-) create mode 100644 internal/web/image_recovery.go create mode 100644 internal/web/image_recovery_test.go diff --git a/.gitignore b/.gitignore index 033144e..1f22bc8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # 构建产物 /rond /rond.exe +# go build ./... 会按模块名输出到仓库根:漏了这个就会一直当未跟踪文件冒出来 +/rond-with-you.exe *.exe~ /bin diff --git a/.workbuddy/memory/2026-09-30.md b/.workbuddy/memory/2026-09-30.md index f32548e..e295dd0 100644 --- a/.workbuddy/memory/2026-09-30.md +++ b/.workbuddy/memory/2026-09-30.md @@ -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` 坐标键、跨数据集**。 +- 于是「删掉旧数据集」把图片**行**级联删了,**文件却留在 `/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/.sqlite` 还原「地点号 → 坐标」。 + 对象键两种历史布局都认(`//-.webp` 与 `//.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`)。 diff --git a/.workbuddy/memory/MEMORY-details.md b/.workbuddy/memory/MEMORY-details.md index 89dde34..c06ab09 100644 --- a/.workbuddy/memory/MEMORY-details.md +++ b/.workbuddy/memory/MEMORY-details.md @@ -583,3 +583,52 @@ Core Data 元数据、索引、持久化历史、`ZRAWVISIT` 采样点、`ZTRIP* 记档优先重建(**带反证**:清掉记档后同一条路必须走不通,防止用例被别的兜底弄绿)、 部分重合需确认、一条都对不上 force 也不给过、非包文件第一步就挡住、回滚还原且当前那份也留档、 页面渲染到底(尾部 ``)。 + + +--- + +## 三十四、图片挂载恢复 + `place_images` 摘掉外键(2026-09-30) + +### 症状与根因(用户报「之前上传的照片找不到了」) +`place_images.dataset_id` 原本带 `ON DELETE CASCADE`,而**读取按 `place_key` 坐标键、跨数据集**。 +两套口径打架:删掉旧数据集时图片**行**被级联删掉,**文件却留在 `/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/.sqlite`(换基线留下的 `.bak-*` 也算,新的优先)→ 还原「地点号 → 坐标」。 + ⚠️ **底座文件是救图的唯一依据**:清掉 baselines 就永远认不出这些图属于哪个地点(只报「认不出」)。 +- 对象键**两种布局都要认**(历史原因): + `//-<内容哈希>.webp`(现在)与 `//<内容哈希>.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` 的表驱动、**数据集已删 ⇒ 靠底座库认回**(并在报告里说明来源)、 +删数据集不再带走图片行与文件。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index 15bef69..6d29eda 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -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/.sqlite`(**别清 baselines 目录**)。`image_recovery_test.go`(details 卅四) +- ⚠️ `topics` **仍**带 CASCADE(专题随数据集删掉、无恢复来源)——同类陷阱,待拍板。 - ⚠️ `schema.sql` 迁移块必须收敛且排在依赖表之后(插在 `place_images` 前:开发库无症、**新库起不来**)。 - 合并 `backup_merge.go`:整个 `withTx`、不删除;新行 `*_visit_src` 写**库里的 src_pk**;必须 `writeRawRow`;删地点前先查有无到访(CASCADE)。 diff --git a/README.md b/README.md index 138eeb1..448bf04 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,21 @@ go build -o rond . - 换过的底座记档在 `datasets.baseline_name/baseline_sha256`:底座文件丢了会**按记档重建**, 而不是退回最初那份旧包(否则上一次换基线会静默失效)。 +## 地点图片 + +- **字节在浏览器里转成 WebP** 再上传,服务端只做形状校验、不重新编码(1 核机器扛不住解码大图)。 +- **存哪边**由后台设置决定:服务器本地磁盘(`/media/`)或对象存储(S3 / R2)。 + 对象键是 `<数据集>/<上传月份>/<地点号>-<内容哈希>.webp`,按月份分目录、带内容哈希天然去重。 +- **图片认地点靠坐标键**,跟备注、结论同一套:换数据集之后同一个地点照样看到同一张图。 +- ⚠️ **早期版本图片记录挂在数据集上**(`place_images.dataset_id` 带 `ON DELETE CASCADE`), + 于是「删掉旧数据集」会把图片记录一起删掉,**而文件还留在磁盘上**——读取按坐标键、行却按数据集级联, + 两套口径打架,实测丢过一批图。现在外键已摘掉,删数据集不再带走图片;已经丢掉的那批可以恢复: + 后台首页「图片挂载恢复 → 扫描并恢复」,它按坐标把盘上还在的文件挂回当前数据集的地点上 + (只插记录、不动文件、可反复执行)。认地点要用旧数据集留下的底座库(`/baselines/.sqlite`) + 来还原「地点号 → 坐标」,**所以别删那个文件**;底座也没了就认不出来了,报告里会列出来。 +- **删除数据集**只清掉从备份导来的记录与原始行留档;备注、结论、图片按坐标键保留。 + 专题(`topics`)目前仍旧随数据集删除——同一个数据集删掉后要重建。 + ## 照片导入(补录没有轨迹的时间段) 手机里的照片带着拍摄时间和 GPS,可以批量补成「地点 + 到访」。 diff --git a/internal/db/schema.sql b/internal/db/schema.sql index 63d603c..2f2e5ce 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -437,9 +437,15 @@ ALTER TABLE places ADD COLUMN IF NOT EXISTS src_lon DOUBLE PRECISION; -- 地点图片:站长给地点配的图。字节由**浏览器**转成 WebP 后再上传(服务端只负责收下与转存), -- 1 核机器不用扛解码。storage 记这一张实际放在哪、object_key 是本地相对路径或对象存储 key: -- 换了存储后端之后旧图仍按各自那一列去取,不会因为改了设置就全变破图。 +-- +-- ⚠️ dataset_id **故意不带外键**(早期版本带 ON DELETE CASCADE,后果见下)。 +-- 图片的「认地点」靠坐标键 place_key,与数据集无关;数据集只是当初上传时的落点。 +-- 带 CASCADE 时删除旧数据集会把图片行一并删掉,而**文件还留在磁盘上**—— +-- 读取按坐标键、行却按数据集级联,两套口径打架,实测就丢过一批图(2026-09-30)。 +-- 备注/结论早就改成坐标键了,图片这里跟上,dataset_id 退化成「出处」这一列。 CREATE TABLE IF NOT EXISTS place_images ( id BIGSERIAL PRIMARY KEY, - dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE, + dataset_id BIGINT NOT NULL, src_pk INT NOT NULL, -- place_key 与备注、结论共用同一套坐标键:换数据集后图片还挂得回原地点。 -- 对象键里仍然带 dataset_id(那只是存储路径,与「认不认得出是同一个地点」无关)。 @@ -455,6 +461,9 @@ CREATE TABLE IF NOT EXISTS place_images ( ); CREATE INDEX IF NOT EXISTS idx_place_images_place ON place_images(dataset_id, src_pk, position); +-- 老库摘掉那根外键(新库上面的 CREATE 里就没有)。幂等:约束不在时什么都不做。 +ALTER TABLE place_images DROP CONSTRAINT IF EXISTS place_images_dataset_id_fkey; + -- ---------- 附属数据改挂坐标键(一次性迁移)---------- -- 备注 / 结论 / 图片原本挂 (dataset_id, src_pk),换个数据集就全看不见了。 -- 这里把它们搬到坐标键上;只在「还没有 place_key 列」时执行,所以新库直接走上面的 diff --git a/internal/web/handlers_images.go b/internal/web/handlers_images.go index cbaba97..81ca125 100644 --- a/internal/web/handlers_images.go +++ b/internal/web/handlers_images.go @@ -164,6 +164,9 @@ func (s *Server) adminPlaceImageUpload(w http.ResponseWriter, r *http.Request) { } // adminPlaceImageDelete 删掉一张图:先删对象再删行(对象删失败也不留孤儿行)。 +// +// 按 id 就行,**不按数据集过滤**:图片认地点靠坐标键、与数据集无关,而且数据集可能已经被删 +// (那时行还在、dataset_id 指向一个不存在的编号),带 dataset_id 过滤就永远删不掉了。 func (s *Server) adminPlaceImageDelete(w http.ResponseWriter, r *http.Request) { ctx := r.Context() ds, err := s.datasetFor(ctx) @@ -181,7 +184,7 @@ func (s *Server) adminPlaceImageDelete(w http.ResponseWriter, r *http.Request) { } var backend, key string if err := s.db.QueryRowContext(ctx, `SELECT storage, object_key FROM place_images - WHERE id=$1 AND dataset_id=$2`, id, ds.ID).Scan(&backend, &key); err != nil { + WHERE id=$1`, id).Scan(&backend, &key); err != nil { s.json(w, map[string]any{"error": "图片不存在"}) return } @@ -191,8 +194,7 @@ func (s *Server) adminPlaceImageDelete(w http.ResponseWriter, r *http.Request) { s.json(w, map[string]any{"error": "删除文件失败:" + err.Error()}) return } - if _, err := s.db.ExecContext(ctx, `DELETE FROM place_images WHERE id=$1 AND dataset_id=$2`, - id, ds.ID); err != nil { + if _, err := s.db.ExecContext(ctx, `DELETE FROM place_images WHERE id=$1`, id); err != nil { s.serverError(w, r, err) return } diff --git a/internal/web/image_recovery.go b/internal/web/image_recovery.go new file mode 100644 index 0000000..5656013 --- /dev/null +++ b/internal/web/image_recovery.go @@ -0,0 +1,407 @@ +package web + +import ( + "context" + "database/sql" + "errors" + "fmt" + "io/fs" + "log" + "net/http" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "rond-with-you/internal/rond" +) + +// 图片挂载恢复:把**盘上还在、但库里已经没有行**的地点图片按坐标重新挂回地点。 +// +// 为什么会有这种孤儿:`place_images` 原本在 `dataset_id` 上带 ON DELETE CASCADE, +// 而读取用的是坐标键(换数据集之后同一个地点本就该看到同一张图)。 +// 于是「删掉旧数据集」这件事会把图片行一起带走,**文件却留在 media 目录里**—— +// 备注、结论早就改成坐标键了,只有图片还挂在数据集上,删数据集就它倒霉。 +// 2026-09-30 实测:数据集 9 被删后 place_images 从 N 行变成 0 行,4 个 .webp 文件好端端躺在盘上。 +// +// 这个动作只**插入**行:不删文件、不改文件、不碰已有行,可重复执行(幂等)。 +// 真正的防复发在 schema 里:外键已去掉,删数据集不再带走图片行。 + +// coordRef 是一个地点在**某个数据集**里的坐标与名字。 +type coordRef struct { + Lat, Lon float64 + Name string +} + +// coordSource 是「旧数据集编号 → 坐标表」的一次解析结果,顺带说明这份数据是从哪来的。 +type coordSource struct { + refs map[int]coordRef + note string +} + +type recoveredPlace struct { + Key string + Place string + Count int +} + +type unresolvedImage struct { + ObjectKey string + Reason string +} + +type imageRecoverReport struct { + Scanned int // 盘上扫到的图片文件数 + Attached int // 本次新挂回的行数(apply=false 时是「可挂回」) + Already int // 库里已经有行、跳过的 + Recovered []recoveredPlace // 按地点汇总(新挂回/可挂回的) + Unresolved []unresolvedImage + Notes []string // 坐标来源说明,便于核对「为什么是这么认的」 + Applied bool +} + +// parseImageKey 从对象键里取出 (数据集编号, 地点编号)。 +// +// 键有两种形状,历史原因并存,都要认: +// +// //-<内容哈希>.webp ← 现在的写法(见 savePlaceImage) +// //<内容哈希>.webp ← 早期写法:中间那层目录名就是地点编号 +// +// 判据不能只看「中间那层是不是 6 位数字」:地点编号也可能刚好是 6 位。 +// 先看文件名里有没有 `<数字>-` 前缀(现在的写法),没有再把中间那层当地点编号。 +func parseImageKey(key string) (datasetID, srcPK int, ok bool) { + parts := strings.Split(key, "/") + if len(parts) != 3 { + return 0, 0, false + } + dsID, err := strconv.Atoi(parts[0]) + if err != nil || dsID <= 0 { + return 0, 0, false + } + base := strings.TrimSuffix(parts[2], filepath.Ext(parts[2])) + if i := strings.Index(base, "-"); i > 0 { + if pk, err := strconv.Atoi(base[:i]); err == nil && pk > 0 { + return dsID, pk, true + } + } + if pk, err := strconv.Atoi(parts[1]); err == nil && pk > 0 { + return dsID, pk, true + } + return 0, 0, false +} + +// mediaKeys 列出本地媒体目录里的全部 .webp(正斜杠相对路径 = 对象键)。 +func (s *Server) mediaKeys() ([]string, error) { + root := s.cfg.MediaDir() + var out []string + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err // 目录读不动就报出来,别静默漏文件 + } + if d.IsDir() || !strings.EqualFold(filepath.Ext(p), ".webp") { + return nil + } + rel, err := filepath.Rel(root, p) + if err != nil { + return nil + } + out = append(out, filepath.ToSlash(rel)) + return nil + }) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil // 还没用过本地图片 + } + sort.Strings(out) + return out, err +} + +// attachedImageKeys 读出库里已经挂着的对象键,用来跳过(幂等)。 +func (s *Server) attachedImageKeys(ctx context.Context) (map[string]bool, error) { + rows, err := s.db.QueryContext(ctx, `SELECT object_key FROM place_images`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]bool{} + for rows.Next() { + var k string + if err := rows.Scan(&k); err != nil { + return nil, err + } + out[k] = true + } + return out, rows.Err() +} + +// currentPlaceIndex 把当前数据集的地点按坐标键索引起来:图片就是靠它认回地点的。 +func (s *Server) currentPlaceIndex(ctx context.Context, datasetID int64) (map[string]PlaceRef, error) { + rows, err := s.db.QueryContext(ctx, `SELECT src_pk, COALESCE(name,''), COALESCE(lat,0), COALESCE(lon,0) + FROM places WHERE dataset_id=$1`, datasetID) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]PlaceRef{} + for rows.Next() { + var r PlaceRef + if err := rows.Scan(&r.SrcPK, &r.Name, &r.Lat, &r.Lon); err != nil { + return nil, err + } + if r.Lat == 0 && r.Lon == 0 { + continue + } + out[rond.PlaceCoordKey(r.Lat, r.Lon)] = r + } + return out, rows.Err() +} + +// PlaceRef 是恢复时用到的最小地点信息。 +type PlaceRef struct { + SrcPK int + Name string + Lat, Lon float64 +} + +// srcPKCoords 取某个数据集里「地点编号 → 坐标 + 名字」。 +// +// 两条来源,按可靠性排: +// 1. 该数据集还在库里 ⇒ 直接查它的 places 表(最准:站点改过坐标也认)。 +// 2. 数据集已经删了 ⇒ 退到它留下的**底座库**(`baselines/.sqlite`)。 +// 删数据集不会删底座文件,这正是这些图片还能救回来的原因;底座也被清掉就只能报「认不出」。 +func (s *Server) srcPKCoords(ctx context.Context, datasetID int64) (coordSource, error) { + var exists bool + if err := s.db.QueryRowContext(ctx, `SELECT true FROM datasets WHERE id=$1`, datasetID).Scan(&exists); err == nil { + rows, err := s.db.QueryContext(ctx, `SELECT src_pk, COALESCE(lat,0), COALESCE(lon,0), COALESCE(name,'') + FROM places WHERE dataset_id=$1`, datasetID) + if err != nil { + return coordSource{}, err + } + defer rows.Close() + refs := map[int]coordRef{} + for rows.Next() { + var pk int + var r coordRef + if err := rows.Scan(&pk, &r.Lat, &r.Lon, &r.Name); err != nil { + return coordSource{}, err + } + refs[pk] = r + } + if err := rows.Err(); err != nil { + return coordSource{}, err + } + return coordSource{refs: refs, note: fmt.Sprintf("#%d:库里的 places 表", datasetID)}, nil + } + + // 数据集没了:找它留下的底座库(换基线留下的 .bak 也算,按时间新的优先) + base := s.baselinePath(datasetID) + cands := []string{base} + if baks, _ := filepath.Glob(base + ".bak-*"); len(baks) > 0 { + sort.Sort(sort.Reverse(sort.StringSlice(baks))) + cands = append(cands, baks...) + } + for _, p := range cands { + refs, err := readLocationCoords(ctx, p) + if err != nil { + continue + } + return coordSource{refs: refs, note: fmt.Sprintf("#%d:底座库 %s", datasetID, filepath.Base(p))}, nil + } + return coordSource{}, fmt.Errorf("数据集 #%d 已删除,也没找到它留下的底座库(%s)", datasetID, base) +} + +// readLocationCoords 从底座库里读「地点编号 → 坐标 + 名字」(Core Data 的 ZLOCATION)。 +func readLocationCoords(ctx context.Context, path string) (map[int]coordRef, error) { + sdb, err := openBaselineRO(path) + if err != nil { + return nil, err + } + defer sdb.Close() + rows, err := sdb.QueryContext(ctx, `SELECT Z_PK, ZLATITUDE, ZLONGITUDE, COALESCE(ZNAME_,'') + FROM ZLOCATION`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int]coordRef{} + for rows.Next() { + var pk int + var r coordRef + var lat, lon sql.NullFloat64 + if err := rows.Scan(&pk, &lat, &lon, &r.Name); err != nil { + return nil, err + } + if !lat.Valid || !lon.Valid { + continue + } + r.Lat, r.Lon = lat.Float64, lon.Float64 + out[pk] = r + } + return out, rows.Err() +} + +// recoverPlaceImages 扫描媒体目录,把库里没有行的图片挂回当前数据集的地点上。 +// apply=false 只统计不写库。 +func (s *Server) recoverPlaceImages(ctx context.Context, apply bool) (imageRecoverReport, error) { + var rep imageRecoverReport + ds, err := s.datasetFor(ctx) + if err != nil { + return rep, err + } + if ds == nil { + return rep, errors.New("还没有生效的数据集,先导入一份备份") + } + keys, err := s.mediaKeys() + if err != nil { + return rep, fmt.Errorf("扫描图片目录失败:%w", err) + } + attached, err := s.attachedImageKeys(ctx) + if err != nil { + return rep, err + } + cur, err := s.currentPlaceIndex(ctx, ds.ID) + if err != nil { + return rep, err + } + byPlace := map[string]*recoveredPlace{} + cache := map[int]coordSource{} // 每个旧数据集只解析一次坐标表 + failed := map[int]string{} // 解析不过的记下原因,避免每个文件各报一遍 + + for _, key := range keys { + rep.Scanned++ + if attached[key] { + rep.Already++ + continue + } + dsID, srcPK, ok := parseImageKey(key) + if !ok { + rep.Unresolved = append(rep.Unresolved, unresolvedImage{key, "对象键里看不出数据集/地点编号"}) + continue + } + src, ok := cache[dsID] + if !ok { + if msg, bad := failed[dsID]; bad { + rep.Unresolved = append(rep.Unresolved, unresolvedImage{key, msg}) + continue + } + src, err = s.srcPKCoords(ctx, int64(dsID)) + if err != nil { + msg := fmt.Sprintf("认不出这张图属于哪个地点:%v", err) + failed[dsID] = msg + rep.Unresolved = append(rep.Unresolved, unresolvedImage{key, msg}) + continue + } + cache[dsID] = src + rep.Notes = append(rep.Notes, src.note) + } + ref, ok := src.refs[srcPK] + if !ok { + rep.Unresolved = append(rep.Unresolved, unresolvedImage{key, + fmt.Sprintf("数据集 #%d 里没有编号 %d 的地点", dsID, srcPK)}) + continue + } + pkey := rond.PlaceCoordKey(ref.Lat, ref.Lon) + place, ok := cur[pkey] + if !ok { + rep.Unresolved = append(rep.Unresolved, unresolvedImage{key, + fmt.Sprintf("当前数据集里没有坐标 %s 的地点(旧地点「%s」)", pkey, ref.Name)}) + continue + } + entry := byPlace[pkey] + if entry == nil { + entry = &recoveredPlace{Key: pkey, Place: place.Name} + byPlace[pkey] = entry + } + entry.Count++ + rep.Attached++ + if !apply { + continue + } + fi, err := os.Stat(filepath.Join(s.cfg.MediaDir(), filepath.FromSlash(key))) + var size int64 + if err == nil { + size = fi.Size() + } + // storage 固定 local:能在这个目录里扫到,就说明它是本地后端的对象。 + // width/height 无从得知(页面只用到 URL,留 0 不影响渲染),bytes 用文件大小。 + if _, err := s.db.ExecContext(ctx, `INSERT INTO place_images + (dataset_id, src_pk, place_key, storage, object_key, width, height, bytes, position) + VALUES ($1,$2,$3,'local',$4,0,0,$5, + COALESCE((SELECT max(position)+1 FROM place_images WHERE place_key=$3), 0)) + ON CONFLICT (dataset_id, src_pk, object_key) DO NOTHING`, + ds.ID, place.SrcPK, pkey, key, size); err != nil { + return rep, fmt.Errorf("挂回 %s 失败:%w", key, err) + } + attached[key] = true + } + for _, e := range byPlace { + rep.Recovered = append(rep.Recovered, *e) + } + sort.Slice(rep.Recovered, func(i, j int) bool { + if rep.Recovered[i].Count != rep.Recovered[j].Count { + return rep.Recovered[i].Count > rep.Recovered[j].Count + } + return rep.Recovered[i].Key < rep.Recovered[j].Key + }) + rep.Notes = dedupStrings(rep.Notes) + rep.Applied = apply + return rep, nil +} + +func dedupStrings(in []string) []string { + seen := map[string]bool{} + var out []string + for _, s := range in { + if seen[s] { + continue + } + seen[s] = true + out = append(out, s) + } + return out +} + +// adminImagesRecover 是「扫描并恢复」按钮的处理函数。只插行、不删文件,可反复点。 +func (s *Server) adminImagesRecover(w http.ResponseWriter, r *http.Request) { + rep, err := s.recoverPlaceImages(r.Context(), true) + if err != nil { + s.adminFlash(w, r, "err", "图片恢复失败:"+err.Error()) + return + } + if rep.Attached == 0 && len(rep.Unresolved) == 0 { + s.adminFlash(w, r, "ok", fmt.Sprintf("盘上 %d 个图片文件都已在库里,无需恢复。", rep.Scanned)) + return + } + msg := fmt.Sprintf("扫描 %d 个文件:新挂回 %d 张", rep.Scanned, rep.Attached) + if len(rep.Recovered) > 0 { + var parts []string + for i, e := range rep.Recovered { + if i == 3 { + parts = append(parts, fmt.Sprintf("等 %d 个地点", len(rep.Recovered))) + break + } + name := e.Place + if name == "" { + name = e.Key + } + parts = append(parts, fmt.Sprintf("%s ×%d", name, e.Count)) + } + msg += "(" + strings.Join(parts, "、") + ")" + } + if rep.Already > 0 { + msg += fmt.Sprintf(";已在库 %d 张", rep.Already) + } + if len(rep.Unresolved) > 0 { + msg += fmt.Sprintf(";认不出 %d 张", len(rep.Unresolved)) + for i, u := range rep.Unresolved { + if i == 2 { + msg += fmt.Sprintf(",另有 %d 张", len(rep.Unresolved)-i) + break + } + msg += fmt.Sprintf("。%s:%s", u.ObjectKey, u.Reason) + } + log.Printf("图片恢复:%d 张认不出,例:%s(%s)", len(rep.Unresolved), + rep.Unresolved[0].ObjectKey, rep.Unresolved[0].Reason) + } + s.adminFlash(w, r, "ok", msg) +} diff --git a/internal/web/image_recovery_test.go b/internal/web/image_recovery_test.go new file mode 100644 index 0000000..8ffa26e --- /dev/null +++ b/internal/web/image_recovery_test.go @@ -0,0 +1,352 @@ +package web + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "rond-with-you/internal/rond" +) + +// ---------- 图片挂载恢复 ---------- +// +// 守的是这么一件事:图片的「认地点」靠坐标键、与数据集无关,但早期 `place_images.dataset_id` +// 上带 ON DELETE CASCADE ⇒ 删掉旧数据集时行被级联删掉,**文件却留在磁盘上**。 +// 2026-09-30 实测:数据集 9 被删后 place_images 从 N 行变 0 行,4 个 .webp 还在盘上。 +// 这三条用例分别守:文件能挂回来、历史对象键布局也认得出、以及「以后不会再丢」。 + +// imageURLs 读某个地点在页面上会看到哪些图片(与 placeImagesOf 同一口径)。 +func (f *movementFixture) imageURLs(t *testing.T, srcPK int) []string { + t.Helper() + imgs, err := f.srv.placeImagesOf(f.ctx, f.dsID, srcPK) + if err != nil { + t.Fatalf("读地点图片失败: %v", err) + } + var out []string + for _, im := range imgs { + out = append(out, im.URL) + } + return out +} + +// isolateImageState 让这一轮用例独占「这个地点的图片」这份状态。 +// +// 必须隔离,两处都要: +// 1. **盘上的文件**:media 目录是共用的,而图片按坐标键跨数据集读,目录里只要有一张 +// 「坐标相同」的图(上一轮跑剩的、别的用例留下的)就会被算成这个地点的图—— +// 没隔离时它报「应当 2 张、实际 10 张」。 +// 2. **库里的行**:`recoverPlaceImages` 按 object_key 判重、`placeImagesOf` 按 place_key 读, +// 都不看数据集;库里留着上一轮的行时,新文件会被当成「已经在库里」直接跳过—— +// 实测过 `Already:1` 让兜底分支根本没被执行到,用例却只看「挂回 1 张」就差点放过。 +// +// 隔离不了的部分(别的坐标的残留)不影响这些断言;本轮插的行归本数据集,夹具收尾会清掉。 +func isolateImageState(t *testing.T, f *movementFixture, lat, lon float64) string { + t.Helper() + f.cfg.DataDir = t.TempDir() + key := rond.PlaceCoordKey(lat, lon) + if _, err := f.pg.ExecContext(f.ctx, `DELETE FROM place_images WHERE place_key=$1`, key); err != nil { + t.Fatalf("清理历史图片行失败: %v", err) + } + t.Cleanup(func() { + f.pg.ExecContext(f.ctx, `DELETE FROM place_images WHERE place_key=$1`, key) + }) + return key +} + +// fakeWebP 造一段最小的 WebP 字节:只需要 RIFF....WEBP 头骗过 isWebP(不真的解码)。 +func fakeWebP(seed byte, n int) []byte { + if n < 32 { + n = 32 + } + b := make([]byte, n) + copy(b, "RIFF") + copy(b[8:], "WEBP") + for i := 12; i < n; i++ { + b[i] = seed + } + return b +} + +// pickPlaceWithCoords 挑一个有坐标的地点(图片只能挂到有坐标的地点上)。 +func pickPlaceWithCoords(t *testing.T, f *movementFixture) (srcPK int, name string, lat, lon float64) { + t.Helper() + if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk, COALESCE(name,''), lat, lon FROM places + WHERE dataset_id=$1 AND lat IS NOT NULL AND lon IS NOT NULL AND (lat<>0 OR lon<>0) + ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&srcPK, &name, &lat, &lon); err != nil { + t.Fatalf("取地点失败: %v", err) + } + return +} + +// imageRowCount 数某个 key 在库里的行数。 +func imageRows(t *testing.T, f *movementFixture, objectKey string) int { + t.Helper() + var n int + if err := f.pg.QueryRowContext(f.ctx, `SELECT count(*) FROM place_images WHERE object_key=$1`, + objectKey).Scan(&n); err != nil { + t.Fatalf("查图片行失败: %v", err) + } + return n +} + +// saveTwoImages 按正常路径给一个地点存两张图,返回它们的对象键。 +func saveTwoImages(t *testing.T, f *movementFixture, srcPK int) []string { + t.Helper() + var keys []string + for i, seed := range []byte{0x11, 0x22} { + _, url, err := f.srv.savePlaceImage(f.ctx, f.dsID, srcPK, fakeWebP(seed, 64+i), 4, 4) + if err != nil { + t.Fatalf("存第 %d 张图失败: %v", i+1, err) + } + key := strings.TrimPrefix(url, "/media/") + if key == "" || key == url { + t.Fatalf("本地存储应当返回 /media/,实际 %q", url) + } + if _, err := os.Stat(filepath.Join(f.cfg.MediaDir(), filepath.FromSlash(key))); err != nil { + t.Fatalf("图片文件没落盘(%s): %v", key, err) + } + keys = append(keys, key) + } + return keys +} + +// TestRecoverPlaceImagesReattachesFilesOnDisk 行没了、文件还在 ⇒ 按坐标挂回来。 +func TestRecoverPlaceImagesReattachesFilesOnDisk(t *testing.T) { + f := newMovementFixture(t) + ctx, dsID, srv := f.ctx, f.dsID, f.srv + srcPK, name, lat, lon := pickPlaceWithCoords(t, f) + wantKey := isolateImageState(t, f, lat, lon) + keys := saveTwoImages(t, f, srcPK) + + // 模拟早期行为:删掉数据集时行被级联带走(文件留在盘上) + if _, err := f.pg.ExecContext(ctx, `DELETE FROM place_images WHERE dataset_id=$1`, dsID); err != nil { + t.Fatalf("清理图片行失败: %v", err) + } + for _, k := range keys { + if _, err := os.Stat(filepath.Join(f.cfg.MediaDir(), filepath.FromSlash(k))); err != nil { + t.Fatalf("用例前提不成立:文件应当还留在盘上(%s): %v", k, err) + } + } + + // 预演不许写库 + rep, err := srv.recoverPlaceImages(ctx, false) + if err != nil { + t.Fatalf("预演失败: %v", err) + } + if rep.Attached != 2 { + t.Errorf("预演应当报 2 张可挂回,实际 %d(认不出 %d)", rep.Attached, len(rep.Unresolved)) + } + if imageRows(t, f, keys[0]) != 0 { + t.Error("预演就把行写进去了") + } + + rep, err = srv.recoverPlaceImages(ctx, true) + if err != nil { + t.Fatalf("恢复失败: %v", err) + } + if rep.Attached != 2 { + t.Errorf("应当挂回 2 张,实际 %d", rep.Attached) + } + for _, k := range keys { + if imageRows(t, f, k) != 1 { + t.Errorf("%s 没有挂回(或挂重了)", k) + } + } + // 挂在当前数据集上、地点对得上、坐标键正确:这三条决定它能不能在页面上显示 + var gotPK int + var gotKey, gotStorage string + if err := f.pg.QueryRowContext(ctx, `SELECT src_pk, place_key, storage FROM place_images + WHERE object_key=$1`, keys[0]).Scan(&gotPK, &gotKey, &gotStorage); err != nil { + t.Fatal(err) + } + if gotPK != srcPK || gotKey != wantKey || gotStorage != "local" { + t.Errorf("挂回的行不对:src_pk=%d(想 %d)place_key=%q(想 %q)storage=%q", + gotPK, srcPK, gotKey, wantKey, gotStorage) + } + // 页面口径(按坐标键跨数据集读)必须能看到这两张图 + urls := f.imageURLs(t, srcPK) + for _, k := range keys { + if !containsStr(urls, "/media/"+k) { + t.Errorf("地点「%s」的图片里没有挂回的 %s(实际 %v)", name, k, urls) + } + } + if len(urls) != 2 { + t.Errorf("地点「%s」应当正好 2 张图,实际 %d:%v", name, len(urls), urls) + } + + // 幂等:再跑一次不该重复插 + rep, err = srv.recoverPlaceImages(ctx, true) + if err != nil { + t.Fatalf("重复恢复失败: %v", err) + } + if rep.Attached != 0 || rep.Already < 2 { + t.Errorf("重复执行应当全部跳过,实际新挂 %d、已在库 %d", rep.Attached, rep.Already) + } + for _, k := range keys { + if imageRows(t, f, k) != 1 { + t.Errorf("重复执行把 %s 插重了", k) + } + } +} + +// TestRecoverPlaceImagesUnderstandsLegacyKeyLayout 早期对象键是 +// `<数据集>/<地点号>/<哈希>.webp`(中间那层目录就是地点号),也要认得出。 +func TestRecoverPlaceImagesUnderstandsLegacyKeyLayout(t *testing.T) { + for _, tc := range []struct { + name string + key string + ds int + pk int + ok bool + }{ + {"现在的写法", "9/202609/309-042beb5e786707bb.webp", 9, 309, true}, + {"早期写法", "9/286/ebe4914ed6d18f6b626d.webp", 9, 286, true}, + {"地点号刚好 6 位(早期写法)", "9/100001/beef.webp", 9, 100001, true}, + {"看不出数据集", "x/202609/1-aa.webp", 0, 0, false}, + {"层数不对", "9/309.webp", 0, 0, false}, + {"文件名没有地点号前缀、中间也不是数字", "9/xx/aa.webp", 0, 0, false}, + } { + ds, pk, ok := parseImageKey(tc.key) + if ok != tc.ok || (ok && (ds != tc.ds || pk != tc.pk)) { + t.Errorf("parseImageKey(%q) = (%d,%d,%v),想 (%d,%d,%v)", tc.key, ds, pk, ok, tc.ds, tc.pk, tc.ok) + } + } + + // 真数据:把文件改名成早期布局,仍然挂得回去 + f := newMovementFixture(t) + ctx, dsID, srv := f.ctx, f.dsID, f.srv + srcPK, _, lat, lon := pickPlaceWithCoords(t, f) + isolateImageState(t, f, lat, lon) + keys := saveTwoImages(t, f, srcPK) + if _, err := f.pg.ExecContext(ctx, `DELETE FROM place_images WHERE dataset_id=$1`, dsID); err != nil { + t.Fatal(err) + } + legacy := filepath.ToSlash(filepath.Join(strconv.Itoa(int(dsID)), strconv.Itoa(srcPK), fmt.Sprintf("abc123-%d.webp", time.Now().UnixNano()))) + if err := os.MkdirAll(filepath.Dir(filepath.Join(f.cfg.MediaDir(), filepath.FromSlash(legacy))), 0o755); err != nil { + t.Fatal(err) + } + old := filepath.Join(f.cfg.MediaDir(), filepath.FromSlash(keys[0])) + if err := os.Rename(old, filepath.Join(f.cfg.MediaDir(), filepath.FromSlash(legacy))); err != nil { + t.Fatal(err) + } + + rep, err := srv.recoverPlaceImages(ctx, true) + if err != nil { + t.Fatalf("恢复失败: %v", err) + } + if rep.Attached != 2 { + t.Errorf("两个文件(含早期布局那个)都该挂回,实际 %d(认不出 %d)", rep.Attached, len(rep.Unresolved)) + } + if imageRows(t, f, legacy) != 1 { + t.Error("早期布局那个文件没挂回") + } + urls := f.imageURLs(t, srcPK) + if !containsStr(urls, "/media/"+legacy) || !containsStr(urls, "/media/"+keys[1]) { + t.Errorf("早期布局与现在布局的文件都该看得见,实际 %v", urls) + } + if len(urls) != 2 { + t.Errorf("应当正好 2 张图,实际 %d:%v", len(urls), urls) + } +} + +// TestRecoverPlaceImagesFallsBackToBaseline 数据集已经被删(places 行没了)时, +// 靠它留下的底座库认回地点——这正是「换基线后图片看不见」的那个场景。 +func TestRecoverPlaceImagesFallsBackToBaseline(t *testing.T) { + f := newMovementFixture(t) + ctx, dsID, srv := f.ctx, f.dsID, f.srv + srcPK, _, lat, lon := pickPlaceWithCoords(t, f) + isolateImageState(t, f, lat, lon) + keys := saveTwoImages(t, f, srcPK) + if _, err := f.pg.ExecContext(ctx, `DELETE FROM place_images WHERE dataset_id=$1`, dsID); err != nil { + t.Fatal(err) + } + + // 造一个「已经被删掉的数据集」的对象键:编号库里没有,只有一份底座库认得它 + const ghost = 987654 + ghostBase := srv.baselinePath(ghost) + if err := os.MkdirAll(filepath.Dir(ghostBase), 0o755); err != nil { + t.Fatal(err) + } + writeGhostBaseline(t, ghostBase, srcPK, lat, lon) + t.Cleanup(func() { os.Remove(ghostBase) }) + + key := filepath.ToSlash(filepath.Join(strconv.Itoa(ghost), strconv.Itoa(srcPK), fmt.Sprintf("deadbeef-%d.webp", time.Now().UnixNano()))) + dst := filepath.Join(f.cfg.MediaDir(), filepath.FromSlash(key)) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Rename(filepath.Join(f.cfg.MediaDir(), filepath.FromSlash(keys[0])), dst); err != nil { + t.Fatal(err) + } + + rep, err := srv.recoverPlaceImages(ctx, true) + if err != nil { + t.Fatalf("恢复失败: %v", err) + } + if imageRows(t, f, key) != 1 { + t.Fatalf("没靠底座库认回地点(认不出 %d 张,理由:%v)", len(rep.Unresolved), rep.Unresolved) + } + var gotKey string + if err := f.pg.QueryRowContext(ctx, `SELECT place_key FROM place_images WHERE object_key=$1`, key). + Scan(&gotKey); err != nil { + t.Fatal(err) + } + if gotKey != placeKeyOfCoords(lat, lon) { + t.Errorf("认出的地点不对:%q", gotKey) + } + if !strings.Contains(strings.Join(rep.Notes, " "), "底座库") { + t.Errorf("应当说明这份坐标是从底座库来的,实际 %+v", rep) + } +} + +// TestDeleteDatasetKeepsImageRows 防复发:删数据集不再带走图片行。 +// +// 外键去掉之前,删数据集会把 place_images 连带删掉、文件却留在盘上——两套口径打架。 +// 现在图片只认坐标键,dataset_id 退化成「出处」这一列。 +func TestDeleteDatasetKeepsImageRows(t *testing.T) { + f := newMovementFixture(t) + ctx, dsID := f.ctx, f.dsID + srcPK, _, lat, lon := pickPlaceWithCoords(t, f) + isolateImageState(t, f, lat, lon) + keys := saveTwoImages(t, f, srcPK) + + if _, err := f.pg.ExecContext(ctx, `DELETE FROM datasets WHERE id=$1`, dsID); err != nil { + t.Fatalf("删数据集失败: %v", err) + } + for _, k := range keys { + if imageRows(t, f, k) != 1 { + t.Errorf("删数据集把图片行 %s 带走了(外键没摘干净?)", k) + } + if _, err := os.Stat(filepath.Join(f.cfg.MediaDir(), filepath.FromSlash(k))); err != nil { + t.Errorf("文件也不该被删: %v", err) + } + } +} + +// writeGhostBaseline 造一份只有 ZLOCATION 的最小底座库(模拟「数据集已删、底座还在」)。 +func writeGhostBaseline(t *testing.T, path string, srcPK int, lat, lon float64) { + t.Helper() + db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(`CREATE TABLE ZLOCATION ( + Z_PK INTEGER PRIMARY KEY, ZLATITUDE REAL, ZLONGITUDE REAL, ZNAME_ TEXT)`); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO ZLOCATION (Z_PK, ZLATITUDE, ZLONGITUDE, ZNAME_) + VALUES (?,?,?,'旧地点')`, srcPK, lat, lon); err != nil { + t.Fatal(err) + } +} + +// placeKeyOfCoords 与生产同口径的坐标键(图片「认地点」靠的就是它)。 +func placeKeyOfCoords(lat, lon float64) string { + return rond.PlaceCoordKey(lat, lon) +} diff --git a/internal/web/movement_e2e_test.go b/internal/web/movement_e2e_test.go index e3e2fb7..a4d467c 100644 --- a/internal/web/movement_e2e_test.go +++ b/internal/web/movement_e2e_test.go @@ -11,6 +11,7 @@ import ( "net/url" "os" "path/filepath" + "strconv" "strings" "testing" "time" @@ -119,6 +120,14 @@ func newMovementFixture(t *testing.T) *movementFixture { if err != nil { t.Fatalf("初始化服务失败: %v", err) } + // 图片的清理要自己来:place_images 只认坐标键、**不再随数据集级联**(见 schema 里的说明), + // 所以删用户/删数据集都不会带走它。不显式清的话,上一个用例留下的行会在按坐标键读图时 + // 被算进下一个用例(实测「应当 2 张、实际 4 张」就是这么来的)。 + // 注册得最晚 ⇒ t.Cleanup 后进先出,它会在删用户之前跑。 + t.Cleanup(func() { + pg.ExecContext(ctx, `DELETE FROM place_images WHERE dataset_id=$1`, dsID) + os.RemoveAll(filepath.Join(cfg.MediaDir(), strconv.FormatInt(dsID, 10))) + }) return &movementFixture{cfg: cfg, pg: pg, srv: srv, dsID: dsID, src: src, ctx: ctxWithUser(&auth.User{ID: uid, Username: user})} } diff --git a/internal/web/server.go b/internal/web/server.go index 9054e00..443d51b 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -255,6 +255,7 @@ func (s *Server) routes() { // 地点图片:字节由浏览器转成 WebP 后发过来,这里只负责收下、落库、取址 s.mux.HandleFunc("POST /admin/place/images", s.requireAdmin(s.adminPlaceImageUpload)) s.mux.HandleFunc("POST /admin/place/images/delete", s.requireAdmin(s.adminPlaceImageDelete)) + s.mux.HandleFunc("POST /admin/images/recover", s.requireAdmin(s.adminImagesRecover)) // 本地存储的图片直出;对象存储的图走公开域名,不经过这里 s.mux.HandleFunc("GET /media/", s.serveMedia) s.mux.HandleFunc("POST /admin/notes/bulk", s.requireAdmin(s.adminNotesBulk)) diff --git a/internal/web/templates/admin/index.html b/internal/web/templates/admin/index.html index 073df13..f109b80 100644 --- a/internal/web/templates/admin/index.html +++ b/internal/web/templates/admin/index.html @@ -99,6 +99,21 @@ 导出 rondbackup(可回导 rond) +
+
+

图片挂载恢复

+
+ +
+
+
+ 换了数据集之后地点图片看不见了?早期版本的图片记录挂在数据集上,删掉旧数据集时会连记录一起删, + 而图片文件还留在磁盘里。点这里按坐标把它们挂回去(假如图片属于旧数据集,先别删它的底座文件—— + 认地点要用它)。现在这版已经不会再有这个问题了。 +
+
+

换基线(导出底稿)

@@ -147,7 +162,7 @@ {{end}}
+ onsubmit="return confirm('删除后该数据集的到访、地点、位移、原始行留档都会从数据库移除,且不可恢复;挂在它下面的专题也会一起删掉(备注、结论、图片按坐标键保留,不受影响)。确认删除?')">