diff --git a/.workbuddy/memory/2026-10-01.md b/.workbuddy/memory/2026-10-01.md new file mode 100644 index 0000000..d65f350 --- /dev/null +++ b/.workbuddy/memory/2026-10-01.md @@ -0,0 +1,36 @@ +# 2026-10-01 + +## 防复发:把「删数据集带走自建数据」做成结构性门禁(00:30) + +用户放弃继续救那批旧图:「我只想避免类似的事件再次发生」。于是把这次的教训固化成三件东西 +(`internal/web/dataset_ownership_test.go` + `schema.sql` 抬头 + README 新章节): + +1. **归属铁律**:判断标准只有一句——**这份记录能不能从备份重新导入?** + 能(places/visits/movements/weather/activities/tags/entity_raw)⇒ 可挂 CASCADE; + 不能(place_images/place_notes/place_votes/topics)⇒ 只留 dataset_id 当出处、不许挂外键。 +2. **两道门禁**:运行时查 `pg_constraint`(表必须在白名单里,反向也查)+ 静态扫 `schema.sql` + (新建的库走 CREATE TABLE,只看运行时有盲点)。做变异验证时确认了静态那条**真的会响**。 +3. **体检常驻后台首页**:库里 N 张 / 本地盘上 M 个 / 落单 K 个(`imageHealth`),落单 >0 直接报警。 + 事故的本质不是「删数据集」本身,而是**行与对象悄悄对不上、没人发现**——让它当场可见。 + +变异验证的收获(比测试本身更有价值): +- 运行时门禁单独看**不够**:夹具每次跑迁移,迁移自己就把坏外键摘了 ⇒ 它永远不会在不健康的库上报警。 + 补了 `TestMigrationDropsStaleDatasetForeignKeys`:注入事故原样的外键再跑迁移,断言被摘掉。 +- 这条自愈用例又抓出缺口:`place_notes`/`place_votes` 上同类外键**迁移并不会摘** + (原先只写了 place_images 与 topics 两句 DROP)⇒ 补成四张自建数据表全覆盖。 + **部署出去的库要能自愈,DROP 必须覆盖全部同类表。** +- 静态扫描必须先跳过 `--` 注释行:规矩的说明文字里就写着 `REFERENCES datasets(id)`, + 不跳过会把自己写的文档当成违规。 + +顺带订正:后台「删除数据集」的二次确认文案在专题改完后已说过时话(说专题会一起删),改成实话。 + +### 补记:迁移块排序的老规矩我又踩了一次(00:55) + +把「摘外键」那五句 ALTER 收成一段放在 `topics` 之后,而 `place_images` 的 CREATE 在这段**之后** +⇒ 开发库(表已存在)毫无症状,**全新库起不来**:`ALTER TABLE place_images …` 报「关系不存在」。 +正是记忆里那条老规矩:「迁移块必须收敛且排在依赖表之后(插在 place_images 前:开发库无症、新库起不来)」。 +被 `TestFreshDatabaseServesAppQueries` 当场抓住(这条门存在的意义)。 + +改成整段排在 `place_images` 建表之后(它是五张里最后一张),并把这个坑写进 schema.sql 的注释里。 +教训:**凡是 ALTER 某个表的迁移块,先确认那张表在文件里已经建过了**—— +只在本机库上跑一遍是测不出来的,必须跑 fresh-database 用例。 diff --git a/.workbuddy/memory/MEMORY-details.md b/.workbuddy/memory/MEMORY-details.md index 8320a41..e2c8ae4 100644 --- a/.workbuddy/memory/MEMORY-details.md +++ b/.workbuddy/memory/MEMORY-details.md @@ -681,3 +681,47 @@ Core Data 元数据、索引、持久化历史、`ZRAWVISIT` 采样点、`ZTRIP* 教训(可复用):**「能读出数据」不等于「读对了那一版」**。凡是从「可能被覆盖过的留档」里还原编号/坐标, 都要把历史版本一起纳入,并且在失败信息里列出已查过的来源 —— 否则用户只能看到「认不出」,无从下手。 + +### 三十五、防复发:归属口径做成门禁(2026-10-01,用户「我只想避免类似的事件再次发生」) + +用户放弃继续救那批旧图,要求的是**结构性防复发**。所以把这次的教训固化成三件东西: + +1. **归属铁律(写进 schema.sql 抬头 + README)** + 判断标准只有一句:**这份记录能不能从备份重新导入?** + - 能(places/visits/movements/weather/activities/tags/entity_raw)⇒ 可以挂 + `REFERENCES datasets(id) ON DELETE CASCADE`,删数据集时跟着走是对的。 + - 不能(place_images/place_notes/place_votes/topics 等站点自建数据)⇒ + **只留 dataset_id 当出处、不许挂外键**。挂在备份里没有的记录上,删数据集=永久丢失。 + +2. **两道门禁(`internal/web/dataset_ownership_test.go`)** + - 运行时 `TestOnlyBackupDerivedTablesCascadeFromDatasets`:查 `pg_constraint`, + 凡挂 `datasets` 的 FK 其表名必须在白名单里;**反向也查**(白名单里的表不许被顺手摘掉, + 否则删数据集会留成片孤儿行、或外键变 RESTRICT 之后干脆删不动)。 + - 静态 `TestSchemaDeclaresNoDatasetFKForUserData`:直接扫 `schema.sql` 的 `CREATE TABLE`。 + 只看运行时有盲点——**新建的库走 CREATE TABLE**,开发库可能压根没跑过那次迁移。 + - 新加一张带 dataset_id 的表时,写 CREATE TABLE 的那一刻就会被拦住。 + +3. **体检常驻(真正的预防)**:`imageHealth` 每次进后台都算 + 「库里 N 张 / 本地盘上 M 个 / 落单 K 个」,落单 > 0 直接在首页报警。 + 那次事故的本质不是「删数据集」本身,而是**行与对象悄悄对不上、没人发现**, + 等照片看不见了才追——让它当场可见,同样的漂移下次立刻亮 + (`TestAdminHomeReportsOrphanImages` 守着这个提示)。 + 只扫本地盘(毫秒级);对象存储要列一次桶,放在「扫描并恢复」动作里,不在每次进后台的开销里。 + +顺带订正:后台「删除数据集」的二次确认文案在专题改完之后已经说过时话(说专题会一起删),改成实话。 + +### 三十五补记:变异验证又抓出一个真缺口(2026-10-01) + +给两道门禁做**变异验证**时(按项目纪律,新回归测试必须证明它会响): +- 静态门禁:把 `topics` 的 CASCADE 外键补回去 ⇒ 立刻红,还原后绿 ✓ + (顺带发现扫描必须先跳过 `--` 注释行:规矩的说明文字里就写着 `REFERENCES datasets(id)`, + 不跳过会把自己写的文档当成违规误报。) +- 运行时门禁:手动给 `topics` 加上外键后**没响**——因为夹具每次都跑 `db.Migrate`, + 迁移自己就把它摘了。⇒ 运行时判据单独看不够:它永远不会在不健康的库上报警。 + 真正的自愈靠迁移,于是补了 `TestMigrationDropsStaleDatasetForeignKeys`: + 把事故原样的外键**注入回去**再跑一次迁移,断言它们没了(并先确认变异真的生效,防假绿)。 + +而这条自愈用例又抓出一个**此前漏掉的缺口**:`place_notes` / `place_votes` 上的同类外键 +(更早那次改坐标键时摘的,老库/生产库上未必摘干净)**迁移并不会摘**—— +原来只写了 `place_images` 与 `topics` 两句 DROP。已补成四张自建数据表全覆盖 +(含 `place_visitor_votes` 兜底)。**部署出去的库要能自愈,DROP 必须覆盖全部同类表,不是只覆盖这次动过的那两张。** diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index 24115e0..4779e1b 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -52,6 +52,15 @@ - 站内新建行 `entity_raw.raw IS NULL`;`ZCATEGORY_` 真机只有 `MKPOICategoryXxx` 枚举。 - 类别候选:编辑/照片页=库里在用 `GROUP BY`;补全页=在用 ∪ 静态表;下拉留「其它(自己写)」,默认「不动」。 +## 归属口径 / 防复发(details 卅五) +- ⚠️ **铁律:这份记录能不能从备份重新导入?** 能 ⇒ 可挂 `dataset_id … ON DELETE CASCADE` + (places/visits/movements/weather/activities/tags/entity_raw);**不能 ⇒ 只留 dataset_id 当出处、不许挂外键** + (place_images/place_notes/place_votes/topics)。挂在备份里没有的表上 = 删数据集就永久丢失。 +- 两道门禁都钉着它:运行时查 `pg_constraint`(表必须在白名单里,反向也查)+ **静态扫 `schema.sql`** + (新建的库走 CREATE TABLE,只看运行时会有盲点)。`dataset_ownership_test.go` +- **体检常驻后台首页**:库里 N 张 / 本地盘上 M 个 / 落单 K 个(`imageHealth`)—— + 事故的本质是「行与对象悄悄对不上、没人发现」,让它当场可见而不是等照片消失才追。 + ## 跨备份 / 附属数据(details 十九~廿一、卅四) - 预演按 `VisitUUID`/`ActivityUUID` 对齐、只读;`raw #>> '{ZIDENTIFIER_,$blob}'` 是 base64 须转 hex 比;地点只认坐标(`PlaceCoordKey` 6 位)。 - 附属数据(备注/投票/图片)主键=`place_key` 坐标键(非 dataset+src_pk);Go 与 SQL 两套须逐位一致。 diff --git a/README.md b/README.md index 41af7d3..a573530 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,26 @@ go build -o rond . - 换过的底座记档在 `datasets.baseline_name/baseline_sha256`:底座文件丢了会**按记档重建**, 而不是退回最初那份旧包(否则上一次换基线会静默失效)。 +## 数据归属(删掉一份数据集会带走什么) + +一条铁律:**从备份导入的数据随数据集删除;站点自建的数据不随数据集删除。** + +| 会随数据集删掉 | 不随数据集删掉 | +|---|---| +| 到访、地点、位移、天气、活动、标签、原始行留档
(重新上传同一份备份就能再导回来) | 备注、结论、地点图片、专题 | + +判断标准很简单:**这份记录能不能从备份重新导入?** 能 ⇒ 挂 `dataset_id … ON DELETE CASCADE`; +不能 ⇒ 只留 `dataset_id` 当出处、**不许挂外键**。图片那次事故就是反过来的:行被级联删了、 +文件却留在盘上,而读取是按坐标键跨数据集的——两套口径打架,删数据集就等于永久丢失。 + +这条规矩有两道门禁守着,改坏当场红(不用靠记性): +- 运行时:查 `pg_constraint`,凡是挂 `datasets` 的表必须在白名单里(`TestOnlyBackupDerivedTablesCascadeFromDatasets`) +- 静态:直接扫 `schema.sql` 的 `CREATE TABLE`(`TestSchemaDeclaresNoDatasetFKForUserData`)—— + 只看运行时有个盲点:新建的库走的是 CREATE TABLE,开发库可能压根没跑过那次迁移。 + +另外**图片挂载体检**常驻后台首页(库里 N 张 / 本地盘上 M 个 / 落单 K 个): +行与对象对不上时会直接报警,不必等到照片看不见了才追。 + ## 地点图片 - **字节在浏览器里转成 WebP** 再上传,服务端只做形状校验、不重新编码(1 核机器扛不住解码大图)。 diff --git a/internal/db/schema.sql b/internal/db/schema.sql index 0c47bdd..fc9656c 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -1,5 +1,16 @@ -- rond-with-you 数据结构 -- 一次上传 = 一个 dataset,所有事实表挂在其下,支持多份备份共存与切换 +-- +-- ⚠️️ 新增一张带 dataset_id 的表时,先答一个问题: +-- **它的记录能不能从备份重新导入?** +-- 能(到访/地点/位移/天气/活动/标签/原始行留档)⇒ 可以挂 +-- `REFERENCES datasets(id) ON DELETE CASCADE`:删数据集时它们跟着走是对的。 +-- 不能(图片、备注、结论、专题等**站点自建**的数据)⇒ **只留 dataset_id 当出处,不许挂外键**: +-- 删数据集会把它们一起删掉,而这些记录不在任何备份里,删了就是永久丢失 +-- (2026-09-30 图片事故:行被 CASCADE、文件却留在盘上,读取按坐标键、行按数据集级联, +-- 两套口径打架)。参考 place_images / place_notes / place_votes / topics 现在的写法。 +-- 这条规矩由 `TestOnlyBackupDerivedTablesCascadeFromDatasets` 与 +-- `TestSchemaDeclaresNoDatasetFKForUserData`(运行时 + 静态两道)守住,改这里会当场红。 CREATE TABLE IF NOT EXISTS users ( id BIGSERIAL PRIMARY KEY, @@ -398,9 +409,6 @@ CREATE TABLE IF NOT EXISTS topics ( ); CREATE INDEX IF NOT EXISTS idx_topics_user ON topics(user_id, sort_order, id); --- 老库摘掉那根外键(新库上面的 CREATE 里就没有)。幂等:约束不在时什么都不做。 --- 摘掉之后删数据集不再带走专题——它们只存在这张表里,删了就没有别的恢复来源了。 -ALTER TABLE topics DROP CONSTRAINT IF EXISTS topics_dataset_id_fkey; -- fog of world 快照里「非已探索层」的原始瓦片文件(Model/~ 与 Model/#)。 -- 站点只用已探索层渲染,但导出必须原样写回这两层,否则重导出的快照会丢掉约 55% 的内容。 @@ -470,8 +478,20 @@ 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; +-- ---------- 站点自建数据:一律摘掉 datasets 的外键 ---------- +-- ⚠️ 这一段必须排在**所有相关 CREATE TABLE 之后**(place_images 是最后一张): +-- 迁移块插在建表之前时,开发库(表已存在)毫无症状,**全新库却起不来** +-- (`ALTER TABLE place_images …` 报「关系不存在」)——`TestFreshDatabaseServesAppQueries` +-- 就是守这个的,本项目踩过。 +-- 这一段是给**已经部署出去的库**自愈用的,幂等:约束不在时什么都不做。 +-- 五张表全都兜住:只摘 place_images / topics 不够——place_notes / place_votes 是更早那次 +-- 改坐标键时摘的,老库上未必摘干净(变异验证就是这么抓出来的:把外键补回去后发现迁移不管它们)。 +-- 摘掉之后「删数据集」不再带走任何站点自建的数据。 +ALTER TABLE place_images DROP CONSTRAINT IF EXISTS place_images_dataset_id_fkey; +ALTER TABLE place_notes DROP CONSTRAINT IF EXISTS place_notes_dataset_id_fkey; +ALTER TABLE place_votes DROP CONSTRAINT IF EXISTS place_votes_dataset_id_fkey; +ALTER TABLE place_visitor_votes DROP CONSTRAINT IF EXISTS place_visitor_votes_dataset_id_fkey; +ALTER TABLE topics DROP CONSTRAINT IF EXISTS topics_dataset_id_fkey; -- ---------- 附属数据改挂坐标键(一次性迁移)---------- -- 备注 / 结论 / 图片原本挂 (dataset_id, src_pk),换个数据集就全看不见了。 diff --git a/internal/web/dataset_ownership_test.go b/internal/web/dataset_ownership_test.go new file mode 100644 index 0000000..15e7870 --- /dev/null +++ b/internal/web/dataset_ownership_test.go @@ -0,0 +1,234 @@ +package web + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "rond-with-you/internal/db" +) + +// ---------- 归属口径:删数据集只许带走「从备份导入」的数据 ---------- +// +// 事故(2026-09-30):place_images 与 topics 带 `dataset_id … ON DELETE CASCADE`, +// 删掉旧数据集时把**站点自建**的记录一起删了——图片行没了、文件却还留在盘上(读取按坐标键、 +// 行按数据集级联,两套口径打架)。这类记录**不在备份里**,删掉就是永久丢失。 +// +// 这不是靠记性能守住的事,所以做成结构性门禁: +// 凡是要挂在 datasets 上的外键,表名必须在这份白名单里。新增一张带 dataset_id 的表时, +// 先问一句「它的记录能不能从备份重新导入」——能,才允许随数据集删除;不能,就只留 +// dataset_id 当出处、不许挂外键(参考 place_images / topics 现在的写法)。 + +// backupDerivedTables 是从备份导入、删了还能再导回来的那些表。删数据集时它们跟着走是对的。 +var backupDerivedTables = []string{ + "places", "visits", "movements", "weather", "activities", "tags", "entity_raw", +} + +// userOwnedTables 是站点自建、删数据集**必须留下**的表(只有列、没有外键)。 +var userOwnedTables = []string{"place_images", "place_notes", "place_votes", "topics"} + +// TestOnlyBackupDerivedTablesCascadeFromDatasets 运行时判据:查 pg_constraint。 +func TestOnlyBackupDerivedTablesCascadeFromDatasets(t *testing.T) { + f := newMovementFixture(t) + rows, err := f.pg.QueryContext(f.ctx, `SELECT conrelid::regclass::text, confdeltype + FROM pg_constraint WHERE confrelid='datasets'::regclass`) + if err != nil { + t.Fatalf("查外键失败: %v", err) + } + defer rows.Close() + allowed := map[string]bool{} + for _, n := range backupDerivedTables { + allowed[n] = true + } + got := map[string]string{} // 表 -> 删除行为 + for rows.Next() { + var tbl, del string + if err := rows.Scan(&tbl, &del); err != nil { + t.Fatal(err) + } + got[tbl] = del + if !allowed[tbl] { + t.Errorf("%s 挂了 datasets 的外键(删除行为 %s):它的记录不是从备份导入的,"+ + "删数据集会连它一起删掉、且无法恢复。只留 dataset_id 当出处,不要挂外键。", tbl, del) + } + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + // 反向:白名单里的表必须还在。顺手去掉 places/visits 的级联会留下成片孤儿行, + // 而删数据集的提示文案是按「这些都随数据集删除」写的。 + for _, n := range backupDerivedTables { + if _, ok := got[n]; !ok { + t.Errorf("%s 应当仍随数据集删除(现在没有 datasets 外键了):删数据集会留下一堆孤儿行,"+ + "或者外键变成 RESTRICT 之后干脆删不动数据集。", n) + } + } +} + +// TestMigrationDropsStaleDatasetForeignKeys 「库里残留着旧外键」⇒ 跑一次迁移必须把它摘掉。 +// +// 这条最要紧:**已经部署出去的库**上就带着那根 CASCADE 外键(本机库现在还是), +// 光改 CREATE TABLE 救不了它——必须靠 schema.sql 里那句 ALTER TABLE … DROP CONSTRAINT。 +// 这里故意把事故原样的外键加回去,跑一次迁移,断言它们没了:迁移能自愈这件事被钉住。 +// +// 顺带解释了为什么「运行时判据」单独看不够:测试夹具每次都会跑迁移, +// 于是运行时那条永远不会在不健康的库上报警——真正的自愈靠这条。 +func TestMigrationDropsStaleDatasetForeignKeys(t *testing.T) { + f := newMovementFixture(t) + ctx := f.ctx + gone := func(table string) bool { + var n int + if err := f.pg.QueryRowContext(ctx, `SELECT count(*) FROM pg_constraint + WHERE conrelid=$1::regclass AND confrelid='datasets'::regclass`, table).Scan(&n); err != nil { + t.Fatalf("查 %s 的约束失败: %v", table, err) + } + return n == 0 + } + // 先把事故原样的外键加回去(NOT VALID 跳过既有行的校验,只关心约束在不在) + for _, tbl := range userOwnedTables { + if !hasDatasetColumn(t, f, tbl) { + continue + } + if _, err := f.pg.ExecContext(ctx, fmt.Sprintf( + `ALTER TABLE %s DROP CONSTRAINT IF EXISTS %s_dataset_id_fkey`, tbl, tbl)); err != nil { + t.Fatalf("清理 %s 的旧约束失败: %v", tbl, err) + } + if _, err := f.pg.ExecContext(ctx, fmt.Sprintf( + `ALTER TABLE %s ADD CONSTRAINT %s_dataset_id_fkey + FOREIGN KEY (dataset_id) REFERENCES datasets(id) ON DELETE CASCADE NOT VALID`, tbl, tbl)); err != nil { + t.Fatalf("给 %s 补回外键失败: %v", tbl, err) + } + } + // 确认变异生效(否则这条用例本身是假绿的) + injected := false + for _, tbl := range userOwnedTables { + if hasDatasetColumn(t, f, tbl) && !gone(tbl) { + injected = true + } + } + if !injected { + t.Fatal("变异没生效:没有任何一张自建数据表被挂上外键,这条用例等于没测") + } + + if err := db.Migrate(ctx, f.pg); err != nil { + t.Fatalf("迁移失败: %v", err) + } + for _, tbl := range userOwnedTables { + if hasDatasetColumn(t, f, tbl) && !gone(tbl) { + t.Errorf("跑完迁移后 %s 上还挂着 datasets 的外键:那句 DROP CONSTRAINT 没生效,"+ + "已经部署出去的库就不会自愈。", tbl) + } + } +} + +// hasDatasetColumn 判断某表有没有 dataset_id 列(不是所有自建数据表都有)。 +func hasDatasetColumn(t *testing.T, f *movementFixture, table string) bool { + t.Helper() + var n int + if err := f.pg.QueryRowContext(f.ctx, `SELECT count(*) FROM pg_attribute + WHERE attrelid=$1::regclass AND attname='dataset_id' AND NOT attisdropped`, + table).Scan(&n); err != nil { + t.Fatalf("查 %s 的列失败: %v", table, err) + } + return n > 0 +} + +// TestSchemaDeclaresNoDatasetFKForUserData 静态判据:直接扫 schema.sql。 +// +// 只看运行时约束有个盲点:**新建的库走的是 CREATE TABLE**,而开发库可能压根没跑过那次迁移。 +// 两边都钉住才不会漏——新加一张表时,写 CREATE TABLE 的那一刻就会被这条拦住。 +func TestSchemaDeclaresNoDatasetFKForUserData(t *testing.T) { + b, err := os.ReadFile(filepath.Join(moduleRoot(t), "internal", "db", "schema.sql")) + if err != nil { + t.Fatalf("读 schema.sql 失败: %v", err) + } + src := string(b) + allowed := map[string]bool{} + for _, n := range backupDerivedTables { + allowed[n] = true + } + userOwned := map[string]bool{} + for _, n := range userOwnedTables { + userOwned[n] = true + } + + createRe := regexp.MustCompile(`(?i)CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([a-z_]+)`) + refRe := regexp.MustCompile(`(?i)REFERENCES\s+datasets\s*\(`) + cur := "" + for _, line := range strings.Split(src, "\n") { + // 必须先跳过注释:这份规矩的说明文字里**就写着** REFERENCES datasets(id) 这几个字, + // 不跳过会把自己的文档当成违规(实测就这么误报过一次)。 + if strings.HasPrefix(strings.TrimSpace(line), "--") { + continue + } + if m := createRe.FindStringSubmatch(line); m != nil { + cur = m[1] + } + if !refRe.MatchString(line) { + continue + } + if cur == "" { + t.Errorf("有一条 datasets 的外键没归属到任何 CREATE TABLE:%s", strings.TrimSpace(line)) + continue + } + if !allowed[cur] { + t.Errorf("schema.sql 里 %s 声明了 datasets 的外键:它的记录不是从备份导入的,"+ + "删数据集会连它一起删掉(%s)。", cur, strings.TrimSpace(line)) + } + } + // 反向:自建数据表必须**真的存在**且带 dataset_id 列(防止有人顺手删表、或漏建列, + // 让这条门禁失去意义) + for _, n := range userOwnedTables { + if !regexp.MustCompile(`(?i)CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?` + n + `\b`).MatchString(src) { + t.Errorf("schema.sql 里找不到 %s 的建表语句", n) + } + } +} + +// TestAdminHomeReportsOrphanImages 后台首页要当场报出「有图片文件在库里没有记录」。 +// +// 这才是真正的预防:那次事故不是「删数据集」本身,而是**行与对象悄悄对不上、没人发现**, +// 等到照片看不见了才追。让它每次进后台都能看到,同样的漂移下次当场就亮。 +func TestAdminHomeReportsOrphanImages(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) + saveTwoImages(t, f, srcPK) + + // 没漂移时:不该报警 + rows, objects, orphans := srv.imageHealth(ctx) + if rows != 2 || objects != 2 || orphans != 0 { + t.Fatalf("刚存完两张应当完全对得上,实际 行%d 对象%d 落单%d", rows, objects, orphans) + } + // 制造事故形状:行没了、文件还在 + if _, err := f.pg.ExecContext(ctx, `DELETE FROM place_images WHERE dataset_id=$1`, dsID); err != nil { + t.Fatal(err) + } + rows, objects, orphans = srv.imageHealth(ctx) + if rows != 0 || objects != 2 || orphans != 2 { + t.Fatalf("行删掉之后应当报 落单 2,实际 行%d 对象%d 落单%d", rows, objects, orphans) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/admin", nil).WithContext(ctx) + srv.adminHome(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("后台首页状态码 %d:%s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + if !strings.Contains(body, "落单 2 个") { + t.Error("后台首页没有报出落单数,漂移就还是静默的") + } + if !strings.Contains(body, "在库里没有记录") { + t.Error("后台首页没解释「落单」是什么意思") + } + if !strings.HasSuffix(strings.TrimSpace(body), "") { + t.Error("后台首页没渲染到底") + } +} diff --git a/internal/web/handlers_admin.go b/internal/web/handlers_admin.go index 1b8a766..d2f7a49 100644 --- a/internal/web/handlers_admin.go +++ b/internal/web/handlers_admin.go @@ -173,6 +173,8 @@ func (s *Server) adminHome(w http.ResponseWriter, r *http.Request) { } } d.FogMasks = len(s.masksFor()) + // 图片挂载体检:行与对象对不上就当场可见(见 imageHealth 的注释) + d.ImgRows, d.ImgObjects, d.ImgOrphans = s.imageHealth(r.Context()) var fogUpdated sql.NullTime if err := s.db.QueryRowContext(r.Context(), `SELECT max(updated_at) FROM fog_blocks`).Scan(&fogUpdated); err != nil { diff --git a/internal/web/image_recovery.go b/internal/web/image_recovery.go index 510b07b..99eb21f 100644 --- a/internal/web/image_recovery.go +++ b/internal/web/image_recovery.go @@ -562,6 +562,37 @@ func dedupStrings(in []string) []string { return out } +// imageHealth 报告「库里的图片行」与「盘上的图片对象」对不对得上。 +// +// 对不上(对象比行多)就说明有一批图其实还在、却看不见——正是那次事故的形状: +// 行被级联删了、文件留在盘上。**让它随时可见**,下次再漂移当场就能发现,不必等照片消失。 +// +// 只扫本地磁盘(走一次目录,毫秒级,适合每次进后台都算);对象存储要真正列一次桶才知道, +// 那是一次 API 调用,放在「扫描并恢复」动作里,不放到每次进后台的开销里。 +func (s *Server) imageHealth(ctx context.Context) (rows, objects, orphans int) { + if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM place_images`).Scan(&rows); err != nil { + return 0, 0, 0 + } + keys, err := s.mediaKeys() + if err != nil { + return rows, 0, 0 + } + objects = len(keys) + if objects == 0 { + return rows, 0, 0 + } + attached, err := s.attachedImageKeys(ctx) + if err != nil { + return rows, objects, 0 + } + for _, k := range keys { + if !attached[k] { + orphans++ + } + } + return rows, objects, orphans +} + // adminImagesRecover 是「扫描并恢复」按钮的处理函数。只插行、不删对象,可反复点。 func (s *Server) adminImagesRecover(w http.ResponseWriter, r *http.Request) { rep, err := s.recoverPlaceImages(r.Context(), true) diff --git a/internal/web/templates/admin/index.html b/internal/web/templates/admin/index.html index f109b80..6df15ec 100644 --- a/internal/web/templates/admin/index.html +++ b/internal/web/templates/admin/index.html @@ -112,6 +112,16 @@ 而图片文件还留在磁盘里。点这里按坐标把它们挂回去(假如图片属于旧数据集,先别删它的底座文件—— 认地点要用它)。现在这版已经不会再有这个问题了。 +
+ 体检:库里 {{num .ImgRows}} 张 · 本地盘上 {{num .ImgObjects}} 个{{if gt .ImgOrphans 0}} + · 落单 {{num .ImgOrphans}} 个{{end}} +
+ {{if gt .ImgOrphans 0}} +
+ 有 {{num .ImgOrphans}} 个图片文件在库里没有记录——它们现在谁也看不到。 + 点「扫描并恢复」按坐标挂回去(对象存储上的也会一并扫,需要 s3:ListBucket 权限)。 +
+ {{end}}
@@ -162,7 +172,7 @@ {{end}}
+ onsubmit="return confirm('删除后这份数据集的到访、地点、位移、天气、活动、标签、原始行留档都会从数据库移除,且不可恢复(重新上传同一份备份可以再导回来)。你在站点上自建的备注、结论、地点图片、专题都不受影响,会按坐标键 / 按人保留。确认删除?')">
diff --git a/internal/web/view.go b/internal/web/view.go index e942177..50d5ffe 100644 --- a/internal/web/view.go +++ b/internal/web/view.go @@ -659,6 +659,9 @@ type AdminData struct { FogMasks int // DefaultPW 表示管理员仍在使用初始口令,模板顶部给出警示条 DefaultPW bool + // 图片挂载体检:库里的行数、盘上的对象数、以及「对象有而库里没行」的落单数。 + // 落单 > 0 就是「有一批图其实还在、却看不见」,模板据此给警示条。 + ImgRows, ImgObjects, ImgOrphans int } // VisitRow 是一条到访摊平后的展示行。地点与活动写名称而不是编号——