图片恢复支持对象存储(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:
@@ -9,11 +9,13 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"rond-with-you/internal/media"
|
||||
"rond-with-you/internal/rond"
|
||||
)
|
||||
|
||||
@@ -52,15 +54,69 @@ type unresolvedImage struct {
|
||||
}
|
||||
|
||||
type imageRecoverReport struct {
|
||||
Scanned int // 盘上扫到的图片文件数
|
||||
Scanned int // 扫到的图片对象数(本地盘 + 对象存储)
|
||||
Remote int // 其中来自对象存储(R2/S3)的
|
||||
Attached int // 本次新挂回的行数(apply=false 时是「可挂回」)
|
||||
Already int // 库里已经有行、跳过的
|
||||
Recovered []recoveredPlace // 按地点汇总(新挂回/可挂回的)
|
||||
Unresolved []unresolvedImage
|
||||
Notes []string // 坐标来源说明,便于核对「为什么是这么认的」
|
||||
Warnings []string // 非致命的问题(例如对象存储没扫成),要在页面上说出来
|
||||
Applied bool
|
||||
}
|
||||
|
||||
// orphanObject 是一个候选图片对象:本地盘上的文件,或对象存储里的键。
|
||||
// Storage 决定恢复出来的行该记哪个后端——**行被删了就没有这一列可查**,
|
||||
// 只能按「对象实际在哪」判断。
|
||||
type orphanObject struct {
|
||||
Key string
|
||||
Storage string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// collectOrphanObjects 把「盘上」与「桶里」的候选对象汇总起来(本地优先,同键不重复)。
|
||||
//
|
||||
// 对象存储扫不动时**不整体失败**:本地那部分照样能救回来,把原因作为 warning 带出去即可。
|
||||
// 最常见的原因是凭据没有 s3:ListBucket 权限(R2 的 API Token 默认只给对象读写)。
|
||||
func (s *Server) collectOrphanObjects(ctx context.Context) (objs []orphanObject, warnings []string, err error) {
|
||||
locals, err := s.mediaKeys()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("扫描图片目录失败:%w", err)
|
||||
}
|
||||
seen := make(map[string]int, len(locals)) // key -> objs 下标
|
||||
root := s.cfg.MediaDir()
|
||||
for _, k := range locals {
|
||||
var size int64
|
||||
if fi, err := os.Stat(filepath.Join(root, filepath.FromSlash(k))); err == nil {
|
||||
size = fi.Size()
|
||||
}
|
||||
seen[k] = len(objs)
|
||||
objs = append(objs, orphanObject{Key: k, Storage: media.BackendLocal, Size: size})
|
||||
}
|
||||
|
||||
if !s.media.S3Ready() {
|
||||
return objs, warnings, nil
|
||||
}
|
||||
remote, rerr := s.media.ListObjects(ctx, "")
|
||||
if rerr != nil {
|
||||
warnings = append(warnings, fmt.Sprintf(
|
||||
"对象存储没扫成(只恢复了本地磁盘上的图):%v", rerr))
|
||||
return objs, warnings, nil
|
||||
}
|
||||
for _, o := range remote {
|
||||
if !strings.EqualFold(path.Ext(o.Key), ".webp") {
|
||||
continue // 桶里可能有别的东西,不是我们放的图
|
||||
}
|
||||
key := strings.TrimPrefix(o.Key, "/")
|
||||
if _, dup := seen[key]; dup {
|
||||
continue // 本地已经有同一个键:以本地那份为准(文件确实在)
|
||||
}
|
||||
seen[key] = len(objs)
|
||||
objs = append(objs, orphanObject{Key: key, Storage: media.BackendS3, Size: o.Size})
|
||||
}
|
||||
return objs, warnings, nil
|
||||
}
|
||||
|
||||
// parseImageKey 从对象键里取出 (数据集编号, 地点编号)。
|
||||
//
|
||||
// 键有两种形状,历史原因并存,都要认:
|
||||
@@ -251,10 +307,11 @@ func (s *Server) recoverPlaceImages(ctx context.Context, apply bool) (imageRecov
|
||||
if ds == nil {
|
||||
return rep, errors.New("还没有生效的数据集,先导入一份备份")
|
||||
}
|
||||
keys, err := s.mediaKeys()
|
||||
objs, warns, err := s.collectOrphanObjects(ctx)
|
||||
if err != nil {
|
||||
return rep, fmt.Errorf("扫描图片目录失败:%w", err)
|
||||
return rep, err
|
||||
}
|
||||
rep.Warnings = warns
|
||||
attached, err := s.attachedImageKeys(ctx)
|
||||
if err != nil {
|
||||
return rep, err
|
||||
@@ -267,8 +324,12 @@ func (s *Server) recoverPlaceImages(ctx context.Context, apply bool) (imageRecov
|
||||
cache := map[int]coordSource{} // 每个旧数据集只解析一次坐标表
|
||||
failed := map[int]string{} // 解析不过的记下原因,避免每个文件各报一遍
|
||||
|
||||
for _, key := range keys {
|
||||
for _, obj := range objs {
|
||||
key := obj.Key
|
||||
rep.Scanned++
|
||||
if obj.Storage == media.BackendS3 {
|
||||
rep.Remote++
|
||||
}
|
||||
if attached[key] {
|
||||
rep.Already++
|
||||
continue
|
||||
@@ -317,19 +378,14 @@ func (s *Server) recoverPlaceImages(ctx context.Context, apply bool) (imageRecov
|
||||
if !apply {
|
||||
continue
|
||||
}
|
||||
fi, err := os.Stat(filepath.Join(s.cfg.MediaDir(), filepath.FromSlash(key)))
|
||||
var size int64
|
||||
if err == nil {
|
||||
size = fi.Size()
|
||||
}
|
||||
// storage 固定 local:能在这个目录里扫到,就说明它是本地后端的对象。
|
||||
// width/height 无从得知(页面只用到 URL,留 0 不影响渲染),bytes 用文件大小。
|
||||
// storage 取「对象实际在哪」:本地扫到的就是 local,只剩桶里有的就是 s3。
|
||||
// width/height 无从得知(页面只用 URL,留 0 不影响渲染),bytes 用对象大小。
|
||||
if _, err := s.db.ExecContext(ctx, `INSERT INTO place_images
|
||||
(dataset_id, src_pk, place_key, storage, object_key, width, height, bytes, position)
|
||||
VALUES ($1,$2,$3,'local',$4,0,0,$5,
|
||||
VALUES ($1,$2,$3,$4,$5,0,0,$6,
|
||||
COALESCE((SELECT max(position)+1 FROM place_images WHERE place_key=$3), 0))
|
||||
ON CONFLICT (dataset_id, src_pk, object_key) DO NOTHING`,
|
||||
ds.ID, place.SrcPK, pkey, key, size); err != nil {
|
||||
ds.ID, place.SrcPK, pkey, obj.Storage, key, obj.Size); err != nil {
|
||||
return rep, fmt.Errorf("挂回 %s 失败:%w", key, err)
|
||||
}
|
||||
attached[key] = true
|
||||
@@ -343,6 +399,12 @@ func (s *Server) recoverPlaceImages(ctx context.Context, apply bool) (imageRecov
|
||||
}
|
||||
return rep.Recovered[i].Key < rep.Recovered[j].Key
|
||||
})
|
||||
// 对象存储上的图挂回来了、却取不到公开地址时,行是有了页面却一片空白(placeImagesOf 会跳过
|
||||
// 取不到址的行)。这个比「没恢复」更难懂,必须报出来。
|
||||
if rep.Remote > 0 && s.cfg.S3PublicBase == "" {
|
||||
rep.Warnings = append(rep.Warnings, "对象存储上的图片已挂回,但没配 s3_public_base,"+
|
||||
"浏览器取不到地址,页面会当作没图:请在 conf/app.ini 里补上公开域名")
|
||||
}
|
||||
rep.Notes = dedupStrings(rep.Notes)
|
||||
rep.Applied = apply
|
||||
return rep, nil
|
||||
@@ -361,18 +423,27 @@ func dedupStrings(in []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// adminImagesRecover 是「扫描并恢复」按钮的处理函数。只插行、不删文件,可反复点。
|
||||
// adminImagesRecover 是「扫描并恢复」按钮的处理函数。只插行、不删对象,可反复点。
|
||||
func (s *Server) adminImagesRecover(w http.ResponseWriter, r *http.Request) {
|
||||
rep, err := s.recoverPlaceImages(r.Context(), true)
|
||||
if err != nil {
|
||||
s.adminFlash(w, r, "err", "图片恢复失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
if rep.Attached == 0 && len(rep.Unresolved) == 0 {
|
||||
s.adminFlash(w, r, "ok", fmt.Sprintf("盘上 %d 个图片文件都已在库里,无需恢复。", rep.Scanned))
|
||||
// 扫描没扫全(比如对象存储没权限)时要显眼,否则站长会以为「恢复完了」而实际只救回本地那半
|
||||
kind := "ok"
|
||||
if len(rep.Warnings) > 0 {
|
||||
kind = "err"
|
||||
}
|
||||
tail := ""
|
||||
for _, w := range rep.Warnings {
|
||||
tail += " " + w
|
||||
}
|
||||
if rep.Attached == 0 && len(rep.Unresolved) == 0 && len(rep.Warnings) == 0 {
|
||||
s.adminFlash(w, r, "ok", fmt.Sprintf("图片对象共 %d 个,都已在库里,无需恢复。", rep.Scanned))
|
||||
return
|
||||
}
|
||||
msg := fmt.Sprintf("扫描 %d 个文件:新挂回 %d 张", rep.Scanned, rep.Attached)
|
||||
msg := fmt.Sprintf("扫描 %d 个图片对象(其中对象存储 %d 个):新挂回 %d 张", rep.Scanned, rep.Remote, rep.Attached)
|
||||
if len(rep.Recovered) > 0 {
|
||||
var parts []string
|
||||
for i, e := range rep.Recovered {
|
||||
@@ -403,5 +474,5 @@ func (s *Server) adminImagesRecover(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("图片恢复:%d 张认不出,例:%s(%s)", len(rep.Unresolved),
|
||||
rep.Unresolved[0].ObjectKey, rep.Unresolved[0].Reason)
|
||||
}
|
||||
s.adminFlash(w, r, "ok", msg)
|
||||
s.adminFlash(w, r, kind, msg+tail)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user