图片恢复支持对象存储(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:
+92
-1
@@ -11,6 +11,7 @@ import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -136,7 +137,97 @@ func (s *Store) pruneEmptyDirs(dir string) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- S3 兼容对象存储:只实现 PUT / DELETE + SigV4 签名 ---------- */
|
||||
/* ---------- S3 兼容对象存储:只实现 PUT / DELETE / LIST + SigV4 签名 ---------- */
|
||||
|
||||
// Object 是对象存储里的一个对象(列表用,只要键与大小)。
|
||||
type Object struct {
|
||||
Key string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// listBucketResult 是 ListObjectsV2 的响应里我们关心的部分。
|
||||
type listBucketResult struct {
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
NextContinuationToken string `xml:"NextContinuationToken"`
|
||||
Contents []struct {
|
||||
Key string `xml:"Key"`
|
||||
Size int64 `xml:"Size"`
|
||||
} `xml:"Contents"`
|
||||
}
|
||||
|
||||
// ListObjects 列出某个前缀下的对象(S3 ListObjectsV2,自动翻页)。
|
||||
//
|
||||
// 为什么需要它:「图片挂载恢复」要从对象存储里找回**库里已经没有记录**的图片——
|
||||
// 那些对象的键只剩下桶里有(上传时落的那行被级联删了),不列桶就无从得知它们叫什么。
|
||||
//
|
||||
// ⚠️ 需要凭据上有 s3:ListBucket 权限(R2 的 API Token 默认只给对象读写)。
|
||||
// 没权限时报错会把 HTTP 状态与响应体前一段带出来,照着开权限即可。
|
||||
func (s *Store) ListObjects(ctx context.Context, prefix string) ([]Object, error) {
|
||||
if !s.cfg.S3Ready() {
|
||||
return nil, errors.New("对象存储未配置齐全(需要 s3_endpoint / s3_bucket / s3_access_key / s3_secret_key)")
|
||||
}
|
||||
var out []Object
|
||||
token := ""
|
||||
for page := 0; ; page++ {
|
||||
// 防御:桶里对象极多(或服务端一直说「还有下一页」)时别把内存与时间耗光
|
||||
if page >= 200 {
|
||||
return out, fmt.Errorf("对象列表翻页过多(已取 %d 个对象)", len(out))
|
||||
}
|
||||
q := "list-type=2&prefix=" + escape(prefix)
|
||||
if token != "" {
|
||||
q += "&continuation-token=" + escape(token)
|
||||
}
|
||||
req, err := s.s3ListRequest(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.sign(req, nil)
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, rerr := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
tail := strings.TrimSpace(string(body))
|
||||
if len(tail) > 300 {
|
||||
tail = tail[:300] + "…"
|
||||
}
|
||||
return nil, fmt.Errorf("列出对象失败:HTTP %d %s(对象存储凭据需要 s3:ListBucket 权限)",
|
||||
resp.StatusCode, tail)
|
||||
}
|
||||
if rerr != nil {
|
||||
return nil, rerr
|
||||
}
|
||||
var parsed listBucketResult
|
||||
if err := xml.Unmarshal(body, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("解析对象列表失败:%w", err)
|
||||
}
|
||||
for _, c := range parsed.Contents {
|
||||
if c.Key == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, Object{Key: c.Key, Size: c.Size})
|
||||
}
|
||||
if !parsed.IsTruncated || parsed.NextContinuationToken == "" {
|
||||
return out, nil
|
||||
}
|
||||
token = parsed.NextContinuationToken
|
||||
}
|
||||
}
|
||||
|
||||
// s3ListRequest 拼「列桶」请求:路径只到桶(不带对象键),查询串自己编码好交给签名。
|
||||
// 查询串必须**先编码再签**:canonicalQuery 是按原样取 RawQuery 的,两边得是同一份字节。
|
||||
func (s *Store) s3ListRequest(ctx context.Context, rawQuery string) (*http.Request, error) {
|
||||
u := s.cfg.S3Endpoint + "/" + escapePath(s.cfg.S3Bucket) + "?" + rawQuery
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("x-amz-content-sha256", sha256Hex(nil))
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (s *Store) putS3(ctx context.Context, key string, data []byte, contentType string) error {
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
|
||||
Reference in New Issue
Block a user