Files
rond-with-you/internal/web/image_recovery_test.go
T
tamakyi 496c992e5e 图片不再随数据集删除:加「图片挂载恢复」,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。
2026-09-30 19:45:51 +08:00

353 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/<key>,实际 %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)
}