package web import ( "database/sql" "fmt" "net/http" "net/http/httptest" "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, " "), fmt.Sprintf("%d.sqlite", ghost)) { t.Errorf("应当说明这份坐标是从底座库 %d.sqlite 认出来的,实际 %+v", ghost, 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) } // TestRecoverPlaceImagesFromObjectStorage 图片存在 R2/S3 上时同样救得回来。 // // 库里的行没了就只剩桶里有那些键,不列桶无从得知它们叫什么——所以恢复必须同时扫两端。 // 这里用假的对象存储(只实现「列桶」)验证:只收 .webp、storage 记成 s3、 // 取址走 s3_public_base,页面口径能看到这张图。 func TestRecoverPlaceImagesFromObjectStorage(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) key := fmt.Sprintf("%d/202609/%d-r2deadbeef.webp", dsID, srcPK) var listed int fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/rond" { http.NotFound(w, r) return } listed++ w.Header().Set("Content-Type", "application/xml") fmt.Fprintf(w, ` %s4242 别的目录/notes.txt9 false`, key) })) defer fake.Close() f.cfg.S3Endpoint, f.cfg.S3Region, f.cfg.S3Bucket = fake.URL, "auto", "rond" f.cfg.S3AccessKey, f.cfg.S3SecretKey, f.cfg.S3PublicBase = "ak", "sk", fake.URL+"/pub" rep, err := srv.recoverPlaceImages(ctx, true) if err != nil { t.Fatalf("恢复失败: %v", err) } if listed == 0 { t.Fatal("没有去列桶,对象存储上的图永远找不回来") } // 桶里那个 .txt 不该算进来 if rep.Scanned != 1 || rep.Remote != 1 { t.Errorf("应当只扫到 1 个图片对象(其中对象存储 1 个),实际 共 %d / 远端 %d", rep.Scanned, rep.Remote) } if rep.Attached != 1 { t.Fatalf("应当挂回 1 张,实际 %d(认不出 %d)", rep.Attached, len(rep.Unresolved)) } var storage, gotKey string var size int64 if err := f.pg.QueryRowContext(ctx, `SELECT storage, place_key, bytes FROM place_images WHERE object_key=$1`, key).Scan(&storage, &gotKey, &size); err != nil { t.Fatalf("没挂回(或挂错): %v", err) } if storage != "s3" { t.Errorf("storage 应当记 s3(对象只剩桶里有),实际 %q", storage) } if gotKey != rond.PlaceCoordKey(lat, lon) { t.Errorf("认出的地点不对: %q", gotKey) } if size != 4242 { t.Errorf("bytes 应当取桶里报的大小 4242,实际 %d", size) } // 页面口径:取址要能指向对象存储的公开地址,否则又是破图 if !containsStr(f.imageURLs(t, srcPK), fake.URL+"/pub/"+key) { t.Errorf("地点页看不到这张图(实际 %v)", f.imageURLs(t, srcPK)) } } // TestRecoverPlaceImagesReportsObjectStorageFailure 列桶失败不能把本地那半也拖黄: // 本地照救,原因作为 warning 带出去(多半是 R2 的 Token 没给 s3:ListBucket 权限)。 func TestRecoverPlaceImagesReportsObjectStorageFailure(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) } fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) fmt.Fprint(w, `AccessDenied`) })) defer fake.Close() f.cfg.S3Endpoint, f.cfg.S3Region, f.cfg.S3Bucket = fake.URL, "auto", "rond" f.cfg.S3AccessKey, f.cfg.S3SecretKey = "ak", "sk" rep, err := srv.recoverPlaceImages(ctx, true) if err != nil { t.Fatalf("列桶失败不该让整个恢复失败: %v", err) } if rep.Attached != 2 { t.Errorf("本地那两张照样要挂回,实际 %d", rep.Attached) } if len(rep.Warnings) == 0 { t.Fatal("没把「对象存储没扫成」说出来,站长会以为恢复完了") } if !strings.Contains(strings.Join(rep.Warnings, " "), "s3:ListBucket") { t.Errorf("warning 里没点出该开哪个权限: %v", rep.Warnings) } for _, k := range keys { if imageRows(t, f, k) != 1 { t.Errorf("%s 没挂回", k) } } } // TestRecoverPlaceImagesFallsBackToSwappedBaselineBackup 钉住「换了底座就认不出编号」这个真实场景。 // // 换基线会**覆盖** baselines/.sqlite,当前那份未必是上传那会儿的编号空间。 // 用户的实测反馈就是这样:能读出 ZLOCATION,却报「数据集 #1 里没有编号 389 的地点」, // 而旧编号很可能还在换基线留下的 `.bak` 留档里。所以坐标来源必须**全部读进来合并**, // 不能「第一个能读的就算了」。 func TestRecoverPlaceImagesFallsBackToSwappedBaselineBackup(t *testing.T) { f := newMovementFixture(t) ctx, srv := f.ctx, f.srv srcPK, _, lat, lon := pickPlaceWithCoords(t, f) isolateImageState(t, f, lat, lon) const ghost = 987655 base := srv.baselinePath(ghost) if err := os.MkdirAll(filepath.Dir(base), 0o755); err != nil { t.Fatal(err) } // 当前底座:换了别的一套编号,里面**没有**我们这个编号 writeGhostBaseline(t, base, 1001, 0.1, 0.1) // 换基线留下的留档:才是上传那会儿的编号空间 bak := base + ".bak-20260101-000000" writeGhostBaseline(t, bak, srcPK, lat, lon) t.Cleanup(func() { os.Remove(base); os.Remove(bak) }) put := func(name string, ds int, pk int) string { key := filepath.ToSlash(filepath.Join(strconv.Itoa(ds), strconv.Itoa(pk), fmt.Sprintf("%s-%d.webp", name, 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.WriteFile(dst, fakeWebP(0x33, 64), 0o644); err != nil { t.Fatal(err) } return key } want := put("swapped", ghost, srcPK) // 另一个数据集连留档都没有:应当认不出,但要把「已查过哪些来源」说出来 const none = 987656 nobase := srv.baselinePath(none) writeGhostBaseline(t, nobase, 2002, 0.2, 0.2) t.Cleanup(func() { os.Remove(nobase) }) nope := put("unknown", none, srcPK) rep, err := srv.recoverPlaceImages(ctx, true) if err != nil { t.Fatalf("恢复失败: %v", err) } if imageRows(t, f, want) != 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`, want). Scan(&gotKey); err != nil { t.Fatal(err) } if gotKey != rond.PlaceCoordKey(lat, lon) { t.Errorf("认出的地点不对:%q", gotKey) } if !strings.Contains(strings.Join(rep.Notes, " "), filepath.Base(bak)) { t.Errorf("报告里应当写明用到了留档 %s,实际 %v", filepath.Base(bak), rep.Notes) } // 认不出的那张:理由要带上「已查来源」,否则站长判断不出该去找哪份包 var reason string for _, u := range rep.Unresolved { if u.ObjectKey == nope { reason = u.Reason } } if reason == "" { t.Fatalf("这张本该认不出,实际没有出现在清单里:%+v", rep.Unresolved) } if !strings.Contains(reason, "已查来源") { t.Errorf("认不出的理由里没写已查过哪些来源(没法排查):%q", reason) } if !containsStr(f.imageURLs(t, srcPK), "/media/"+want) { t.Errorf("页面口径看不到这张图:%v", f.imageURLs(t, srcPK)) } }