图片恢复支持对象存储(R2);专题也改成归人不归数据集
R2 上的图怎么救:库里的行没了就只剩桶里有那些键,不列桶无从得知它们叫什么。
给 internal/media 加了 ListObjects(ListObjectsV2 + SigV4 + 自动翻页 + XML 解析):
- 路径式访问、请求只到桶;查询串必须**先百分号编码再签名**(canonicalQuery 按原样取
RawQuery,两边得是同一份字节,prefix 里的 / 要写成 %2F)。
- ⚠️ R2 的 API Token 默认只给对象读写,没有 s3:ListBucket 就 403。列桶失败**不让整个
恢复失败**:本地那半照救,原因进 Warnings、页面用红色提示条显示(否则站长会以为恢复完了)。
- 候选对象 = 本地目录 ∪ 桶里 .webp(同键以本地为准);行的 storage 按「对象实际在哪」写,
bytes 用桶里报的 Size。另加一条 warning:挂回了却没配 s3_public_base ⇒ 行有了页面空白,
这比「没恢复」更难懂。
专题(用户拍板「一并做」):topics.dataset_id 同样带 CASCADE,删数据集带走专题,而专题
只存在这张表里、没有恢复来源。外键摘掉,所有查询 dataset_id → user_id(loadTopics、
topicPage、保存/删除/开关、pageBase.HasTopics);**内容仍照当前生效数据集算**
(topicContent 不动)——变的是「这份定义归谁」。Dataset 补 UserID 供前台按属主定位
用户级数据;管理专题不再要求有生效数据集。
导出链路未改动:place_images / topics / Dataset.UserID 都不参与 .rondbackup 导出,
全量验收(含导出保真、ZRAW 夹取、编号对齐那批)PASS=331 FAIL=0 SKIP=0。
This commit is contained in:
@@ -3,6 +3,8 @@ package web
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -350,3 +352,106 @@ func writeGhostBaseline(t *testing.T, path string, srcPK int, lat, lon float64)
|
||||
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, `<?xml version="1.0" encoding="UTF-8"?><ListBucketResult>
|
||||
<Contents><Key>%s</Key><Size>4242</Size></Contents>
|
||||
<Contents><Key>别的目录/notes.txt</Key><Size>9</Size></Contents>
|
||||
<IsTruncated>false</IsTruncated></ListBucketResult>`, 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, `<Error><Code>AccessDenied</Code></Error>`)
|
||||
}))
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user