用户报「之前上传的照片找不到了,应该是因为清了应用数据换基线」。查下来与 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。
353 lines
13 KiB
Go
353 lines
13 KiB
Go
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)
|
||
}
|