Files
rond-with-you/internal/web/image_recovery.go
T
tamakyi 770e752a7a 图片恢复支持对象存储(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。
2026-09-30 23:31:17 +08:00

479 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"context"
"database/sql"
"errors"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"rond-with-you/internal/media"
"rond-with-you/internal/rond"
)
// 图片挂载恢复:把**盘上还在、但库里已经没有行**的地点图片按坐标重新挂回地点。
//
// 为什么会有这种孤儿:`place_images` 原本在 `dataset_id` 上带 ON DELETE CASCADE,
// 而读取用的是坐标键(换数据集之后同一个地点本就该看到同一张图)。
// 于是「删掉旧数据集」这件事会把图片行一起带走,**文件却留在 media 目录里**——
// 备注、结论早就改成坐标键了,只有图片还挂在数据集上,删数据集就它倒霉。
// 2026-09-30 实测:数据集 9 被删后 place_images 从 N 行变成 0 行,4 个 .webp 文件好端端躺在盘上。
//
// 这个动作只**插入**行:不删文件、不改文件、不碰已有行,可重复执行(幂等)。
// 真正的防复发在 schema 里:外键已去掉,删数据集不再带走图片行。
// coordRef 是一个地点在**某个数据集**里的坐标与名字。
type coordRef struct {
Lat, Lon float64
Name string
}
// coordSource 是「旧数据集编号 → 坐标表」的一次解析结果,顺带说明这份数据是从哪来的。
type coordSource struct {
refs map[int]coordRef
note string
}
type recoveredPlace struct {
Key string
Place string
Count int
}
type unresolvedImage struct {
ObjectKey string
Reason string
}
type imageRecoverReport struct {
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 从对象键里取出 (数据集编号, 地点编号)。
//
// 键有两种形状,历史原因并存,都要认:
//
// <datasetID>/<YYYYMM>/<srcPK>-<内容哈希>.webp ← 现在的写法(见 savePlaceImage)
// <datasetID>/<srcPK>/<内容哈希>.webp ← 早期写法:中间那层目录名就是地点编号
//
// 判据不能只看「中间那层是不是 6 位数字」:地点编号也可能刚好是 6 位。
// 先看文件名里有没有 `<数字>-` 前缀(现在的写法),没有再把中间那层当地点编号。
func parseImageKey(key string) (datasetID, srcPK int, ok bool) {
parts := strings.Split(key, "/")
if len(parts) != 3 {
return 0, 0, false
}
dsID, err := strconv.Atoi(parts[0])
if err != nil || dsID <= 0 {
return 0, 0, false
}
base := strings.TrimSuffix(parts[2], filepath.Ext(parts[2]))
if i := strings.Index(base, "-"); i > 0 {
if pk, err := strconv.Atoi(base[:i]); err == nil && pk > 0 {
return dsID, pk, true
}
}
if pk, err := strconv.Atoi(parts[1]); err == nil && pk > 0 {
return dsID, pk, true
}
return 0, 0, false
}
// mediaKeys 列出本地媒体目录里的全部 .webp(正斜杠相对路径 = 对象键)。
func (s *Server) mediaKeys() ([]string, error) {
root := s.cfg.MediaDir()
var out []string
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err // 目录读不动就报出来,别静默漏文件
}
if d.IsDir() || !strings.EqualFold(filepath.Ext(p), ".webp") {
return nil
}
rel, err := filepath.Rel(root, p)
if err != nil {
return nil
}
out = append(out, filepath.ToSlash(rel))
return nil
})
if errors.Is(err, fs.ErrNotExist) {
return nil, nil // 还没用过本地图片
}
sort.Strings(out)
return out, err
}
// attachedImageKeys 读出库里已经挂着的对象键,用来跳过(幂等)。
func (s *Server) attachedImageKeys(ctx context.Context) (map[string]bool, error) {
rows, err := s.db.QueryContext(ctx, `SELECT object_key FROM place_images`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
return nil, err
}
out[k] = true
}
return out, rows.Err()
}
// currentPlaceIndex 把当前数据集的地点按坐标键索引起来:图片就是靠它认回地点的。
func (s *Server) currentPlaceIndex(ctx context.Context, datasetID int64) (map[string]PlaceRef, error) {
rows, err := s.db.QueryContext(ctx, `SELECT src_pk, COALESCE(name,''), COALESCE(lat,0), COALESCE(lon,0)
FROM places WHERE dataset_id=$1`, datasetID)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]PlaceRef{}
for rows.Next() {
var r PlaceRef
if err := rows.Scan(&r.SrcPK, &r.Name, &r.Lat, &r.Lon); err != nil {
return nil, err
}
if r.Lat == 0 && r.Lon == 0 {
continue
}
out[rond.PlaceCoordKey(r.Lat, r.Lon)] = r
}
return out, rows.Err()
}
// PlaceRef 是恢复时用到的最小地点信息。
type PlaceRef struct {
SrcPK int
Name string
Lat, Lon float64
}
// srcPKCoords 取某个数据集里「地点编号 → 坐标 + 名字」。
//
// 两条来源,按可靠性排:
// 1. 该数据集还在库里 ⇒ 直接查它的 places 表(最准:站点改过坐标也认)。
// 2. 数据集已经删了 ⇒ 退到它留下的**底座库**(`baselines/<id>.sqlite`)。
// 删数据集不会删底座文件,这正是这些图片还能救回来的原因;底座也被清掉就只能报「认不出」。
func (s *Server) srcPKCoords(ctx context.Context, datasetID int64) (coordSource, error) {
var exists bool
if err := s.db.QueryRowContext(ctx, `SELECT true FROM datasets WHERE id=$1`, datasetID).Scan(&exists); err == nil {
rows, err := s.db.QueryContext(ctx, `SELECT src_pk, COALESCE(lat,0), COALESCE(lon,0), COALESCE(name,'')
FROM places WHERE dataset_id=$1`, datasetID)
if err != nil {
return coordSource{}, err
}
defer rows.Close()
refs := map[int]coordRef{}
for rows.Next() {
var pk int
var r coordRef
if err := rows.Scan(&pk, &r.Lat, &r.Lon, &r.Name); err != nil {
return coordSource{}, err
}
refs[pk] = r
}
if err := rows.Err(); err != nil {
return coordSource{}, err
}
return coordSource{refs: refs, note: fmt.Sprintf("#%d:库里的 places 表", datasetID)}, nil
}
// 数据集没了:找它留下的底座库(换基线留下的 .bak 也算,按时间新的优先)
base := s.baselinePath(datasetID)
cands := []string{base}
if baks, _ := filepath.Glob(base + ".bak-*"); len(baks) > 0 {
sort.Sort(sort.Reverse(sort.StringSlice(baks)))
cands = append(cands, baks...)
}
for _, p := range cands {
refs, err := readLocationCoords(ctx, p)
if err != nil {
continue
}
return coordSource{refs: refs, note: fmt.Sprintf("#%d:底座库 %s", datasetID, filepath.Base(p))}, nil
}
return coordSource{}, fmt.Errorf("数据集 #%d 已删除,也没找到它留下的底座库(%s)", datasetID, base)
}
// readLocationCoords 从底座库里读「地点编号 → 坐标 + 名字」(Core Data 的 ZLOCATION)。
func readLocationCoords(ctx context.Context, path string) (map[int]coordRef, error) {
sdb, err := openBaselineRO(path)
if err != nil {
return nil, err
}
defer sdb.Close()
rows, err := sdb.QueryContext(ctx, `SELECT Z_PK, ZLATITUDE, ZLONGITUDE, COALESCE(ZNAME_,'')
FROM ZLOCATION`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int]coordRef{}
for rows.Next() {
var pk int
var r coordRef
var lat, lon sql.NullFloat64
if err := rows.Scan(&pk, &lat, &lon, &r.Name); err != nil {
return nil, err
}
if !lat.Valid || !lon.Valid {
continue
}
r.Lat, r.Lon = lat.Float64, lon.Float64
out[pk] = r
}
return out, rows.Err()
}
// recoverPlaceImages 扫描媒体目录,把库里没有行的图片挂回当前数据集的地点上。
// apply=false 只统计不写库。
func (s *Server) recoverPlaceImages(ctx context.Context, apply bool) (imageRecoverReport, error) {
var rep imageRecoverReport
ds, err := s.datasetFor(ctx)
if err != nil {
return rep, err
}
if ds == nil {
return rep, errors.New("还没有生效的数据集,先导入一份备份")
}
objs, warns, err := s.collectOrphanObjects(ctx)
if err != nil {
return rep, err
}
rep.Warnings = warns
attached, err := s.attachedImageKeys(ctx)
if err != nil {
return rep, err
}
cur, err := s.currentPlaceIndex(ctx, ds.ID)
if err != nil {
return rep, err
}
byPlace := map[string]*recoveredPlace{}
cache := map[int]coordSource{} // 每个旧数据集只解析一次坐标表
failed := map[int]string{} // 解析不过的记下原因,避免每个文件各报一遍
for _, obj := range objs {
key := obj.Key
rep.Scanned++
if obj.Storage == media.BackendS3 {
rep.Remote++
}
if attached[key] {
rep.Already++
continue
}
dsID, srcPK, ok := parseImageKey(key)
if !ok {
rep.Unresolved = append(rep.Unresolved, unresolvedImage{key, "对象键里看不出数据集/地点编号"})
continue
}
src, ok := cache[dsID]
if !ok {
if msg, bad := failed[dsID]; bad {
rep.Unresolved = append(rep.Unresolved, unresolvedImage{key, msg})
continue
}
src, err = s.srcPKCoords(ctx, int64(dsID))
if err != nil {
msg := fmt.Sprintf("认不出这张图属于哪个地点:%v", err)
failed[dsID] = msg
rep.Unresolved = append(rep.Unresolved, unresolvedImage{key, msg})
continue
}
cache[dsID] = src
rep.Notes = append(rep.Notes, src.note)
}
ref, ok := src.refs[srcPK]
if !ok {
rep.Unresolved = append(rep.Unresolved, unresolvedImage{key,
fmt.Sprintf("数据集 #%d 里没有编号 %d 的地点", dsID, srcPK)})
continue
}
pkey := rond.PlaceCoordKey(ref.Lat, ref.Lon)
place, ok := cur[pkey]
if !ok {
rep.Unresolved = append(rep.Unresolved, unresolvedImage{key,
fmt.Sprintf("当前数据集里没有坐标 %s 的地点(旧地点「%s」)", pkey, ref.Name)})
continue
}
entry := byPlace[pkey]
if entry == nil {
entry = &recoveredPlace{Key: pkey, Place: place.Name}
byPlace[pkey] = entry
}
entry.Count++
rep.Attached++
if !apply {
continue
}
// 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,$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, obj.Storage, key, obj.Size); err != nil {
return rep, fmt.Errorf("挂回 %s 失败:%w", key, err)
}
attached[key] = true
}
for _, e := range byPlace {
rep.Recovered = append(rep.Recovered, *e)
}
sort.Slice(rep.Recovered, func(i, j int) bool {
if rep.Recovered[i].Count != rep.Recovered[j].Count {
return rep.Recovered[i].Count > rep.Recovered[j].Count
}
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
}
func dedupStrings(in []string) []string {
seen := map[string]bool{}
var out []string
for _, s := range in {
if seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
}
// 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
}
// 扫描没扫全(比如对象存储没权限)时要显眼,否则站长会以为「恢复完了」而实际只救回本地那半
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 个):新挂回 %d 张", rep.Scanned, rep.Remote, rep.Attached)
if len(rep.Recovered) > 0 {
var parts []string
for i, e := range rep.Recovered {
if i == 3 {
parts = append(parts, fmt.Sprintf("等 %d 个地点", len(rep.Recovered)))
break
}
name := e.Place
if name == "" {
name = e.Key
}
parts = append(parts, fmt.Sprintf("%s ×%d", name, e.Count))
}
msg += "(" + strings.Join(parts, "、") + ")"
}
if rep.Already > 0 {
msg += fmt.Sprintf(";已在库 %d 张", rep.Already)
}
if len(rep.Unresolved) > 0 {
msg += fmt.Sprintf(";认不出 %d 张", len(rep.Unresolved))
for i, u := range rep.Unresolved {
if i == 2 {
msg += fmt.Sprintf(",另有 %d 张", len(rep.Unresolved)-i)
break
}
msg += fmt.Sprintf("。%s:%s", u.ObjectKey, u.Reason)
}
log.Printf("图片恢复:%d 张认不出,例:%s(%s)", len(rep.Unresolved),
rep.Unresolved[0].ObjectKey, rep.Unresolved[0].Reason)
}
s.adminFlash(w, r, kind, msg+tail)
}