图片恢复支持对象存储(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
+10 -1
View File
@@ -373,10 +373,15 @@ END $$;
-- 专题:把某段时间内、某个区域里的足迹(地点 + 迷雾)打包成一个可对外展示的页面。
-- 区域用「中心点 + 半径」,半径为 0 表示不限区域(时间范围内的地点画到哪算哪)。
--
-- ⚠️ dataset_id **故意不带外键**(早期版本带 ON DELETE CASCADE)。
-- 专题是「一段时间 + 一块区域」的**定义**,不是备份里的数据:换数据集之后它照旧成立
-- (内容照当前生效数据集算),删掉旧数据集不该把它带走。带 CASCADE 时实测丢过一批专题,
-- 而且没有恢复来源(专题只存在这张表里)。dataset_id 退化为「当初在哪份数据上建的」。
CREATE TABLE IF NOT EXISTS topics (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
dataset_id BIGINT NOT NULL,
title TEXT NOT NULL,
subtitle TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
@@ -393,6 +398,10 @@ CREATE TABLE IF NOT EXISTS topics (
);
CREATE INDEX IF NOT EXISTS idx_topics_user ON topics(user_id, sort_order, id);
-- 老库摘掉那根外键(新库上面的 CREATE 里就没有)。幂等:约束不在时什么都不做。
-- 摘掉之后删数据集不再带走专题——它们只存在这张表里,删了就没有别的恢复来源了。
ALTER TABLE topics DROP CONSTRAINT IF EXISTS topics_dataset_id_fkey;
-- fog of world 快照里「非已探索层」的原始瓦片文件(Model/~ 与 Model/#)。
-- 站点只用已探索层渲染,但导出必须原样写回这两层,否则重导出的快照会丢掉约 55% 的内容。
-- data 存的是原始(zlib 压缩后的)字节,一个字节都不动。
+92 -1
View File
@@ -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"
+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("没配齐应当报错")
}
}
+3 -2
View File
@@ -44,9 +44,10 @@ func (s *Server) pageBase(r *http.Request, nav, title string) (Page, error) {
if ds == nil {
return p, nil
}
// 有专题才在导航里露出入口(含未对外展示的:站长要能从导航进管理页)
// 有专题才在导航里露出入口(含未对外展示的:站长要能从导航进管理页)。
// 专题归**人**(换了数据集也还是同一批),所以按属主判,不是按数据集。
if err := s.db.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM topics WHERE dataset_id=$1)`, ds.ID).Scan(&p.HasTopics); err != nil {
`SELECT EXISTS(SELECT 1 FROM topics WHERE user_id=$1)`, ds.UserID).Scan(&p.HasTopics); err != nil {
return p, err
}
+35 -30
View File
@@ -44,15 +44,19 @@ func parseTopicTime(raw string) (time.Time, bool) {
// ---------- 查询 ----------
// loadTopics 读专题列表。onlyEnabled 时只取对外展示的(前台用)。
func (s *Server) loadTopics(ctx context.Context, datasetID int64, onlyEnabled bool) ([]Topic, error) {
//
// 按**属主**取而不是按数据集:专题是「一段时间 + 一块区域」的定义,本不该随数据集生灭。
// 早期按 dataset_id 过滤 ⇒ 删掉旧数据集时专题被级联删掉(实测丢过一批,且没有恢复来源)。
// 专题的**内容**仍然照当前生效数据集算(见 topicContent),变的是「这份定义归谁」。
func (s *Server) loadTopics(ctx context.Context, ownerID int64, onlyEnabled bool) ([]Topic, error) {
q := `SELECT id, title, subtitle, description, start_at, end_at, center_lat, center_lon,
radius_km, show_fog, enabled, sort_order
FROM topics WHERE dataset_id=$1`
FROM topics WHERE user_id=$1`
if onlyEnabled {
q += ` AND enabled`
}
q += ` ORDER BY sort_order, id`
rows, err := s.db.QueryContext(ctx, q, datasetID)
rows, err := s.db.QueryContext(ctx, q, ownerID)
if err != nil {
return nil, err
}
@@ -282,28 +286,31 @@ func (s *Server) topicsAdminPage(w http.ResponseWriter, r *http.Request) {
p.Flatpickr = true
d := TopicAdminData{Page: p}
d.Flash, d.Err = r.URL.Query().Get("ok"), r.URL.Query().Get("err")
u := userFrom(ctx)
// 专题归**人**不归数据集,所以没有生效数据集时也能看、也能改(只是统计不出来)。
// 早期版本这里要 ds != nil 才加载,删数据集期间专题就整个不见了。
list, err := s.loadTopics(ctx, u.ID, false)
if err != nil {
s.serverError(w, r, err)
return
}
ds, err := s.datasetFor(ctx)
if err != nil {
s.serverError(w, r, err)
return
}
if ds != nil {
list, err := s.loadTopics(ctx, ds.ID, false)
if err != nil {
s.serverError(w, r, err)
return
}
// 后台管理页:站长看全部
// 后台管理页:站长看全部;统计照当前生效数据集算
if err := s.fillTopicStatsBatch(ctx, list, ds.ID, nil, false); err != nil {
log.Printf("专题统计失败: %v", err)
}
d.Topics = list
// 编辑模式:?edit=<id>
if id := atoiOr(r.URL.Query().Get("edit"), 0); id > 0 {
for _, t := range list {
if t.ID == int64(id) {
d.Form = t
}
}
d.Topics = list
// 编辑模式:?edit=<id>
if id := atoiOr(r.URL.Query().Get("edit"), 0); id > 0 {
for _, t := range list {
if t.ID == int64(id) {
d.Form = t
}
}
}
@@ -340,8 +347,8 @@ func (s *Server) adminTopicSave(w http.ResponseWriter, r *http.Request) {
if _, err := s.db.ExecContext(ctx, `UPDATE topics SET title=$3, subtitle=$4, description=$5,
start_at=$6, end_at=$7, center_lat=$8, center_lon=$9, radius_km=$10,
show_fog=$11, enabled=$12, sort_order=$13
WHERE id=$1 AND dataset_id=$2`,
id, ds.ID, t.Title, t.Subtitle, t.Description, t.StartAt, t.EndAt,
WHERE id=$1 AND user_id=$2`,
id, u.ID, t.Title, t.Subtitle, t.Description, t.StartAt, t.EndAt,
t.CenterLat, t.CenterLon, t.RadiusKm, t.ShowFog, t.Enabled, t.SortOrder); err != nil {
s.serverError(w, r, err)
return
@@ -350,6 +357,8 @@ func (s *Server) adminTopicSave(w http.ResponseWriter, r *http.Request) {
return
}
var newID int64
// dataset_id 照写不动,但只当**出处**记:这一份专题是在哪份数据上建的。
// 认它归谁、对外怎么展示都与它无关(外键已摘,见 schema.sql)。
if err := s.db.QueryRowContext(ctx, `INSERT INTO topics
(user_id, dataset_id, title, subtitle, description, start_at, end_at,
center_lat, center_lon, radius_km, show_fog, enabled, sort_order)
@@ -363,27 +372,23 @@ func (s *Server) adminTopicSave(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) adminTopicDelete(w http.ResponseWriter, r *http.Request) {
s.topicMutate(w, r, func(ctx context.Context, dsID int64, id int) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM topics WHERE id=$1 AND dataset_id=$2`, id, dsID)
s.topicMutate(w, r, func(ctx context.Context, userID int64, id int) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM topics WHERE id=$1 AND user_id=$2`, id, userID)
return err
}, "专题已删除")
}
func (s *Server) adminTopicToggle(w http.ResponseWriter, r *http.Request) {
s.topicMutate(w, r, func(ctx context.Context, dsID int64, id int) error {
_, err := s.db.ExecContext(ctx, `UPDATE topics SET enabled = NOT enabled WHERE id=$1 AND dataset_id=$2`, id, dsID)
s.topicMutate(w, r, func(ctx context.Context, userID int64, id int) error {
_, err := s.db.ExecContext(ctx, `UPDATE topics SET enabled = NOT enabled WHERE id=$1 AND user_id=$2`, id, userID)
return err
}, "展示状态已切换")
}
// topicMutate 收敛删除 / 开关两个动作里重复的取参与错误处理。
// 按属主收口(专题是用户级的),**不要求有生效数据集**——没有数据时照样该能整理专题。
func (s *Server) topicMutate(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64, int) error, okMsg string) {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
redirectTopics(w, r, "err", "还没有可编辑的数据集")
return
}
if err := r.ParseForm(); err != nil {
s.serverError(w, r, err)
return
@@ -393,7 +398,7 @@ func (s *Server) topicMutate(w http.ResponseWriter, r *http.Request, fn func(con
redirectTopics(w, r, "err", "专题标识无效")
return
}
if err := fn(ctx, ds.ID, id); err != nil {
if err := fn(ctx, userFrom(ctx).ID, id); err != nil {
s.serverError(w, r, err)
return
}
@@ -411,7 +416,7 @@ func (s *Server) topicsListPage(w http.ResponseWriter, r *http.Request) {
}
d := TopicListData{Page: p}
if p.Dataset != nil {
list, err := s.loadTopics(ctx, p.Dataset.ID, !p.IsAdmin())
list, err := s.loadTopics(ctx, p.Dataset.UserID, !p.IsAdmin())
if err != nil {
s.serverError(w, r, err)
return
@@ -441,7 +446,7 @@ func (s *Server) topicPage(w http.ResponseWriter, r *http.Request) {
var t Topic
err = s.db.QueryRowContext(ctx, `SELECT id, title, subtitle, description, start_at, end_at,
center_lat, center_lon, radius_km, show_fog, enabled, sort_order
FROM topics WHERE id=$1 AND dataset_id=$2`, id, p.Dataset.ID).
FROM topics WHERE id=$1 AND user_id=$2`, id, p.Dataset.UserID).
Scan(&t.ID, &t.Title, &t.Subtitle, &t.Description, &t.StartAt, &t.EndAt,
&t.CenterLat, &t.CenterLon, &t.RadiusKm, &t.ShowFog, &t.Enabled, &t.SortOrder)
if err == sql.ErrNoRows {
+89 -18
View File
@@ -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)
}
+105
View File
@@ -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)
}
}
}
+6 -3
View File
@@ -539,10 +539,10 @@ func parseInt64s(vals []string) []int64 {
// datasetFor 取当前生效的数据集;无数据时返回 nil。
func (s *Server) datasetFor(ctx context.Context) (*Dataset, error) {
var d Dataset
err := s.db.QueryRowContext(ctx, `SELECT id, original_name, uploaded_at, visit_count, place_count,
err := s.db.QueryRowContext(ctx, `SELECT id, user_id, original_name, uploaded_at, visit_count, place_count,
raw_visit_count, first_visit_at, last_visit_at, size_bytes
FROM datasets WHERE is_active AND status='done' ORDER BY uploaded_at DESC LIMIT 1`).
Scan(&d.ID, &d.Name, &d.UploadedAt, &d.Visits, &d.Places, &d.RawVisits, &d.First, &d.Last, &d.Size)
Scan(&d.ID, &d.UserID, &d.Name, &d.UploadedAt, &d.Visits, &d.Places, &d.RawVisits, &d.First, &d.Last, &d.Size)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
@@ -553,7 +553,10 @@ func (s *Server) datasetFor(ctx context.Context) (*Dataset, error) {
}
type Dataset struct {
ID int64
ID int64
// UserID 是这份数据集的属主。专题、备注、结论这类**用户级**数据要靠它定位——
// 它们不属于某个数据集,只是当初挂在那一份上;换了数据集之后还是同一个人的。
UserID int64
Name string
UploadedAt time.Time
Visits int
+91
View File
@@ -0,0 +1,91 @@
package web
import (
"testing"
)
// ---------- 专题:归人不归数据集 ----------
//
// 专题是「一段时间 + 一块区域」的定义,不是备份里的数据:换数据集之后它照旧成立
// (内容照当前生效数据集算),删掉旧数据集不该把它带走。
// 早期版本 `topics.dataset_id` 带 ON DELETE CASCADE,实测丢过一批专题,而且**只存在这张表里**,
// 没有别的恢复来源。这两条用例守的就是「删数据集之后专题还在、还列得出来」。
// insertTopic 直接插一条专题(与页面同一张表,省去构造表单)。
func insertTopic(t *testing.T, f *movementFixture, title string) int64 {
t.Helper()
uid := userFrom(f.ctx).ID
var id int64
if err := f.pg.QueryRowContext(f.ctx, `INSERT INTO topics
(user_id, dataset_id, title, subtitle, description, start_at, end_at,
center_lat, center_lon, radius_km, show_fog, enabled, sort_order)
VALUES ($1,$2,$3,'','',now() - interval '30 days', now(), 0,0,0, true, true, 0)
RETURNING id`, uid, f.dsID, title).Scan(&id); err != nil {
t.Fatalf("插专题失败: %v", err)
}
return id
}
func topicIDs(t *testing.T, f *movementFixture, uid int64) []int64 {
t.Helper()
list, err := f.srv.loadTopics(f.ctx, uid, false)
if err != nil {
t.Fatalf("读专题失败: %v", err)
}
var out []int64
for _, tp := range list {
out = append(out, tp.ID)
}
return out
}
// TestTopicsSurviveDatasetDelete 删数据集不再带走专题,删完还能按属主列出来。
func TestTopicsSurviveDatasetDelete(t *testing.T) {
f := newMovementFixture(t)
uid := userFrom(f.ctx).ID
want := insertTopic(t, f, "2026 国庆")
if got := topicIDs(t, f, uid); len(got) != 1 || got[0] != want {
t.Fatalf("建完之后应当能按属主列出这一条,实际 %v", got)
}
// 外键已摘:删数据集不级联到专题
if _, err := f.pg.ExecContext(f.ctx, `DELETE FROM datasets WHERE id=$1`, f.dsID); err != nil {
t.Fatalf("删数据集失败: %v", err)
}
var n int
if err := f.pg.QueryRowContext(f.ctx, `SELECT count(*) FROM topics WHERE id=$1`, want).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("删数据集把专题带走了(外键没摘干净?)")
}
if got := topicIDs(t, f, uid); len(got) != 1 || got[0] != want {
t.Errorf("没有生效数据集时也该列得出来(专题归人),实际 %v", got)
}
}
// TestTopicsForeignKeyDropped 直接盯住迁移本身:外键约束必须不在。
// 只看「删数据集没删专题」有可能被别的原因弄绿(比如根本没建数据集约束),
// 这里把判据钉在 `pg_constraint` 上。
func TestTopicsForeignKeyDropped(t *testing.T) {
f := newMovementFixture(t)
var del string
err := f.pg.QueryRowContext(f.ctx, `SELECT confdeltype FROM pg_constraint
WHERE conname='topics_dataset_id_fkey'`).Scan(&del)
if err == nil {
t.Errorf("topics 上还有 datasets 的外键(删除行为=%s):删数据集会把专题一起带走", del)
}
// 专题只该跟着**用户**走:删用户时它必须一起没,不能留下孤儿
uid := userFrom(f.ctx).ID
insertTopic(t, f, "会被用户带走")
if _, err := f.pg.ExecContext(f.ctx, `DELETE FROM users WHERE id=$1`, uid); err != nil {
t.Fatalf("删用户失败: %v", err)
}
var left int
if err := f.pg.QueryRowContext(f.ctx, `SELECT count(*) FROM topics WHERE user_id=$1`, uid).Scan(&left); err != nil {
t.Fatal(err)
}
if left != 0 {
t.Errorf("删用户后专题还留着 %d 条", left)
}
}