图片恢复支持对象存储(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:
tamakyi
2026-09-30 23:31:17 +08:00
parent 496c992e5e
commit 770e752a7a
13 changed files with 622 additions and 60 deletions
+134
View File
@@ -0,0 +1,134 @@
package media
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"rond-with-you/internal/config"
)
// ---------- 列桶(ListObjectsV2)----------
//
// 为什么要它:「图片挂载恢复」要找回**库里已经没有记录**的图片,而那些对象的键只剩下
// 桶里有。列不出来就只救得回本地磁盘那一半。
// 这几条用例守的是请求形状、翻页、以及「没权限时要说清楚该怎么办」。
func testStore(t *testing.T, endpoint string) *Store {
t.Helper()
return New(&config.Config{
S3Endpoint: endpoint, S3Region: "auto", S3Bucket: "rond",
S3AccessKey: "ak", S3SecretKey: "sk",
})
}
// listXML 拼一份最小的 ListObjectsV2 响应。
func listXML(keys []string, truncated bool, token string) string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?><ListBucketResult><Name>rond</Name>`)
for _, k := range keys {
b.WriteString("<Contents><Key>" + k + "</Key><Size>1234</Size></Contents>")
}
fmt.Fprintf(&b, "<IsTruncated>%v</IsTruncated><KeyCount>%d</KeyCount>", truncated, len(keys))
if token != "" {
b.WriteString("<NextContinuationToken>" + token + "</NextContinuationToken>")
}
b.WriteString(`</ListBucketResult>`)
return b.String()
}
func TestListObjectsSignsAndParses(t *testing.T) {
var gotPath, gotQuery, gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotQuery, gotAuth = r.URL.Path, r.URL.RawQuery, r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, listXML([]string{"9/202609/309-abc.webp"}, false, ""))
}))
defer srv.Close()
s := testStore(t, srv.URL)
objs, err := s.ListObjects(context.Background(), "9/")
if err != nil {
t.Fatalf("列出对象失败: %v", err)
}
if len(objs) != 1 || objs[0].Key != "9/202609/309-abc.webp" || objs[0].Size != 1234 {
t.Fatalf("解析结果不对: %+v", objs)
}
// 路径式访问:只到桶,不带对象键
if gotPath != "/rond" {
t.Errorf("请求路径应为 /rond,实际 %q", gotPath)
}
// 查询串必须**先编码再签**:prefix 里的 / 要写成 %2F,签名与发送必须是同一份字节
if gotQuery != "list-type=2&prefix=9%2F" {
t.Errorf("查询串不对: %q", gotQuery)
}
if !strings.HasPrefix(gotAuth, "AWS4-HMAC-SHA256 Credential=ak/") || !strings.Contains(gotAuth, "/auto/s3/aws4_request") {
t.Errorf("签名头不对: %q", gotAuth)
}
}
func TestListObjectsFollowsContinuationToken(t *testing.T) {
var pages int
var seen []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
pages++
q := r.URL.Query()
seen = append(seen, q.Get("continuation-token"))
w.Header().Set("Content-Type", "application/xml")
if pages == 1 {
fmt.Fprint(w, listXML([]string{"9/a.webp"}, true, "tok-1"))
return
}
fmt.Fprint(w, listXML([]string{"9/b.webp"}, false, ""))
}))
defer srv.Close()
s := testStore(t, srv.URL)
objs, err := s.ListObjects(context.Background(), "9/")
if err != nil {
t.Fatalf("列出对象失败: %v", err)
}
if pages != 2 {
t.Errorf("应当翻两页,实际 %d", pages)
}
if len(objs) != 2 || objs[0].Key != "9/a.webp" || objs[1].Key != "9/b.webp" {
t.Errorf("两页结果没合并: %+v", objs)
}
// 第二页要把 token 带上,且同样要编码
if seen[0] != "" || seen[1] != "tok-1" {
t.Errorf("continuation-token 没带上(或顺序不对): %v", seen)
}
}
// TestListObjectsReportsPermission 没权限(403)时要把「需要 s3:ListBucket」说清楚——
// R2 的 API Token 默认只给对象读写,第一次点恢复多半就撞在这里。
func TestListObjectsReportsPermission(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
fmt.Fprint(w, `<Error><Code>AccessDenied</Code></Error>`)
}))
defer srv.Close()
s := testStore(t, srv.URL)
_, err := s.ListObjects(context.Background(), "")
if err == nil {
t.Fatal("403 应当报错")
}
if !strings.Contains(err.Error(), "403") || !strings.Contains(err.Error(), "s3:ListBucket") {
t.Errorf("错误里没说清该怎么办: %v", err)
}
if !strings.Contains(err.Error(), "AccessDenied") {
t.Errorf("错误里没带服务端的响应体(不好排查): %v", err)
}
}
// TestListObjectsRejectsUnconfigured 没配齐凭据时不该发出请求。
func TestListObjectsRejectsUnconfigured(t *testing.T) {
s := New(&config.Config{})
if _, err := s.ListObjects(context.Background(), ""); err == nil {
t.Fatal("没配齐应当报错")
}
}