图片恢复支持对象存储(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
+18
View File
@@ -241,3 +241,21 @@ UUID 集合也与换之前逐条一致)。
并给夹具补了收尾清理(外键没了,必须自己清)。 并给夹具补了收尾清理(外键没了,必须自己清)。
新增用例 4 条(`image_recovery_test.go`)。 新增用例 4 条(`image_recovery_test.go`)。
### 补记(23:20):对象存储上的图 + 专题一并改
用户:「一并做,而且有部分资源在 R2 上,怎么办?」
**R2 侧**:行没了就只剩桶里有那些键,不列桶无从得知它们叫什么 ⇒ 给 `internal/media` 加了
`ListObjects`(ListObjectsV2 + SigV4 + 翻页 + XML)。两个坑:查询串必须**先编码再签**
(`canonicalQuery` 按原样取 RawQuery,prefix 里的 `/` 得写成 `%2F`,否则签名与发送不是同一份字节);
R2 的 Token 默认没有 `s3:ListBucket` ⇒ 403。列不动时**不让整个恢复失败**:本地那半照救,
原因进 `Warnings`(页面红色提示条)。另外「挂回了却没配 `s3_public_base`」会让行有了、页面一片空白,
比「没恢复」更难懂,也写进 warning。
**专题**:按用户拍板做成归人不归数据集。外键摘掉,所有查询 `dataset_id` → `user_id`
(`loadTopics`/`topicPage`/保存/删除/开关/`HasTopics`);**内容仍照生效数据集算**(`topicContent` 不动)。
`Dataset` 补 `UserID`(`datasetFor` 多查一列)供前台按属主定位用户级数据。
用例除「删数据集后专题还在」外,还直接查 `pg_constraint` 确认外键确实不在——只看行为可能被别的原因弄绿。
**导出链路没碰**:`place_images` / `topics` / `Dataset.UserID` 都不参与 `.rondbackup` 导出。
+27
View File
@@ -632,3 +632,30 @@ Core Data 元数据、索引、持久化历史、`ZRAWVISIT` 采样点、`ZTRIP*
行没了文件还在 ⇒ 按坐标挂回(含预演不写库、幂等、挂回后**页面口径** `placeImagesOf` 看得见)、 行没了文件还在 ⇒ 按坐标挂回(含预演不写库、幂等、挂回后**页面口径** `placeImagesOf` 看得见)、
早期键布局与 `parseImageKey` 的表驱动、**数据集已删 ⇒ 靠底座库认回**(并在报告里说明来源)、 早期键布局与 `parseImageKey` 的表驱动、**数据集已删 ⇒ 靠底座库认回**(并在报告里说明来源)、
删数据集不再带走图片行与文件。 删数据集不再带走图片行与文件。
### 三十四补记:对象存储(R2)侧 + 专题也一起改了(2026-09-30 23:20)
**R2 上的图怎么救**:行没了就只剩桶里有那些键,不列桶无从得知它们叫什么。
`internal/media` 加了 `ListObjects(ctx, prefix)`(ListObjectsV2 + SigV4 + 自动翻页 + XML 解析):
- 请求形状是**路径式、只到桶**:`GET <endpoint>/<bucket>?list-type=2&prefix=…`,
查询串必须**先百分号编码再签名**(`canonicalQuery` 按原样取 `RawQuery`,两边得是同一份字节,
prefix 里的 `/` 要写成 `%2F`)。翻页靠 `continuation-token`。
- ⚠️ R2 的 API Token 默认只给对象读写,**没有 `s3:ListBucket` 就列不出来**(403 AccessDenied)。
列桶失败**不能让整个恢复失败**:本地那半照救,原因作为 `report.Warnings` 带出去,
页面上用红色提示条显示(否则站长会以为恢复完了)。
- 候选对象 = 本地目录遍历 ∪ 桶里 `.webp`,同键以本地为准;行的 `storage` 按「对象实际在哪」写,
`bytes` 用桶里报的 Size。⚠️ 挂回了却没配 `s3_public_base` ⇒ 行有了、页面一片空白
(`placeImagesOf` 会跳过取不到址的行)——这个比「没恢复」更难懂,也要写进 warning。
**专题也改成归人不归数据集**(用户拍板「一并做」):
- `topics.dataset_id` 同样带 CASCADE ⇒ 删数据集带走专题,而专题**只存在这张表里**,没有恢复来源。
- 改法:外键摘掉(`ALTER TABLE topics DROP CONSTRAINT IF EXISTS topics_dataset_id_fkey`),
所有查询从 `dataset_id` 换成 `user_id`(`loadTopics`、`topicPage`、保存/删除/开关、`pageBase.HasTopics`)。
**专题内容仍照当前生效数据集算**(`topicContent` 的 dataset_id 不动)——变的是「这份定义归谁」。
- 附带:`Dataset` 补了 `UserID`(`datasetFor` 多查一列),前台与后台都能直接按属主定位用户级数据
(专题/备注/结论都是这一类);`topicMutate` 不再要求有生效数据集。
- 用例盯住迁移本身:直接查 `pg_constraint` 里 `topics_dataset_id_fkey` 必须不存在
(只看「删数据集没删专题」可能被别的原因弄绿)。
**不影响导出**:这轮动的 `place_images` / `topics` / `Dataset.UserID` 都不参与 `.rondbackup` 导出
(导出只读 datasets/places/visits/movements/weather/entity_raw)。
+3 -1
View File
@@ -56,7 +56,9 @@
- 预演按 `VisitUUID`/`ActivityUUID` 对齐、只读;`raw #>> '{ZIDENTIFIER_,$blob}'` 是 base64 须转 hex 比;地点只认坐标(`PlaceCoordKey` 6 位)。 - 预演按 `VisitUUID`/`ActivityUUID` 对齐、只读;`raw #>> '{ZIDENTIFIER_,$blob}'` 是 base64 须转 hex 比;地点只认坐标(`PlaceCoordKey` 6 位)。
- 附属数据(备注/投票/图片)主键=`place_key` 坐标键(非 dataset+src_pk);Go 与 SQL 两套须逐位一致。 - 附属数据(备注/投票/图片)主键=`place_key` 坐标键(非 dataset+src_pk);Go 与 SQL 两套须逐位一致。
- ⚠️ **附属数据不能挂数据集外键**:`place_images` 曾按 `dataset_id` CASCADE ⇒ 删旧数据集=删图片行、**文件却留在盘上**(读取按坐标键、行按数据集级联,两套口径打架)。已摘掉外键;恢复走后台「图片挂载恢复」,认地点靠旧数据集留下的 `baselines/<id>.sqlite`(**别清 baselines 目录**)。`image_recovery_test.go`(details 卅四) - ⚠️ **附属数据不能挂数据集外键**:`place_images` 曾按 `dataset_id` CASCADE ⇒ 删旧数据集=删图片行、**文件却留在盘上**(读取按坐标键、行按数据集级联,两套口径打架)。已摘掉外键;恢复走后台「图片挂载恢复」,认地点靠旧数据集留下的 `baselines/<id>.sqlite`(**别清 baselines 目录**)。`image_recovery_test.go`(details 卅四)
- ⚠️ `topics` **仍**带 CASCADE(专题随数据集删掉、无恢复来源)——同类陷阱,待拍板。 - ⚠️ 恢复**同时扫对象存储**(R2/S3):`media.ListObjects`(ListObjectsV2 + SigV4,查询串先编码再签),
需 `s3:ListBucket` 权限;列不动时本地照救 + warning。**专题同理**:`topics` 已改成归人不归数据集
(`user_id` 过滤,内容仍照生效数据集算)。用户级数据靠 `Dataset.UserID` 定位。(details 卅四)
- ⚠️ `schema.sql` 迁移块必须收敛且排在依赖表之后(插在 `place_images` 前:开发库无症、**新库起不来**)。 - ⚠️ `schema.sql` 迁移块必须收敛且排在依赖表之后(插在 `place_images` 前:开发库无症、**新库起不来**)。
- 合并 `backup_merge.go`:整个 `withTx`、不删除;新行 `*_visit_src` 写**库里的 src_pk**;必须 `writeRawRow`;删地点前先查有无到访(CASCADE)。 - 合并 `backup_merge.go`:整个 `withTx`、不删除;新行 `*_visit_src` 写**库里的 src_pk**;必须 `writeRawRow`;删地点前先查有无到访(CASCADE)。
+9 -4
View File
@@ -161,11 +161,16 @@ go build -o rond .
- ⚠️ **早期版本图片记录挂在数据集上**(`place_images.dataset_id` 带 `ON DELETE CASCADE`), - ⚠️ **早期版本图片记录挂在数据集上**(`place_images.dataset_id` 带 `ON DELETE CASCADE`),
于是「删掉旧数据集」会把图片记录一起删掉,**而文件还留在磁盘上**——读取按坐标键、行却按数据集级联, 于是「删掉旧数据集」会把图片记录一起删掉,**而文件还留在磁盘上**——读取按坐标键、行却按数据集级联,
两套口径打架,实测丢过一批图。现在外键已摘掉,删数据集不再带走图片;已经丢掉的那批可以恢复: 两套口径打架,实测丢过一批图。现在外键已摘掉,删数据集不再带走图片;已经丢掉的那批可以恢复:
后台首页「图片挂载恢复 → 扫描并恢复」,它按坐标把盘上还在的文件挂回当前数据集的地点上 后台首页「图片挂载恢复 → 扫描并恢复」,它按坐标把还在的对象挂回当前数据集的地点上
(只插记录、不动文件、可反复执行)。认地点要用旧数据集留下的底座库(`<data_dir>/baselines/<id>.sqlite`) (只插记录、不动对象、可反复执行)。**本地磁盘与对象存储都扫**:
来还原「地点号 → 坐标」,**所以别删那个文件**;底座也没了就认不出来了,报告里会列出来。 本地走目录遍历,对象存储走 ListObjectsV2 列桶——行没了就只剩桶里有那些键,不列桶无从得知它们叫什么。
⚠️ 列桶需要凭据上有 **`s3:ListBucket`** 权限(R2 的 API Token 默认只给对象读写),
没权限时本地那半照救,页面会把该开哪个权限说明白。
认地点要用旧数据集留下的底座库(`<data_dir>/baselines/<id>.sqlite`)来还原「地点号 → 坐标」,
**所以别删那个文件**;底座也没了就认不出来了,报告里会列出来。
- **删除数据集**只清掉从备份导来的记录与原始行留档;备注、结论、图片按坐标键保留。 - **删除数据集**只清掉从备份导来的记录与原始行留档;备注、结论、图片按坐标键保留。
专题(`topics`)目前仍旧随数据集删除——同一个数据集删掉后要重建。 - **专题归人不归数据集**:专题是「一段时间 + 一块区域」的定义,换数据集之后照旧成立
(内容照当前生效数据集算),删掉旧数据集不会再把它带走。
## 照片导入(补录没有轨迹的时间段) ## 照片导入(补录没有轨迹的时间段)
+10 -1
View File
@@ -373,10 +373,15 @@ END $$;
-- 专题:把某段时间内、某个区域里的足迹(地点 + 迷雾)打包成一个可对外展示的页面。 -- 专题:把某段时间内、某个区域里的足迹(地点 + 迷雾)打包成一个可对外展示的页面。
-- 区域用「中心点 + 半径」,半径为 0 表示不限区域(时间范围内的地点画到哪算哪)。 -- 区域用「中心点 + 半径」,半径为 0 表示不限区域(时间范围内的地点画到哪算哪)。
--
-- ⚠️ dataset_id **故意不带外键**(早期版本带 ON DELETE CASCADE)。
-- 专题是「一段时间 + 一块区域」的**定义**,不是备份里的数据:换数据集之后它照旧成立
-- (内容照当前生效数据集算),删掉旧数据集不该把它带走。带 CASCADE 时实测丢过一批专题,
-- 而且没有恢复来源(专题只存在这张表里)。dataset_id 退化为「当初在哪份数据上建的」。
CREATE TABLE IF NOT EXISTS topics ( CREATE TABLE IF NOT EXISTS topics (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, 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, title TEXT NOT NULL,
subtitle TEXT NOT NULL DEFAULT '', subtitle TEXT NOT NULL DEFAULT '',
description 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 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/#)。 -- fog of world 快照里「非已探索层」的原始瓦片文件(Model/~ 与 Model/#)。
-- 站点只用已探索层渲染,但导出必须原样写回这两层,否则重导出的快照会丢掉约 55% 的内容。 -- 站点只用已探索层渲染,但导出必须原样写回这两层,否则重导出的快照会丢掉约 55% 的内容。
-- data 存的是原始(zlib 压缩后的)字节,一个字节都不动。 -- data 存的是原始(zlib 压缩后的)字节,一个字节都不动。
+92 -1
View File
@@ -11,6 +11,7 @@ import (
"crypto/hmac" "crypto/hmac"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/xml"
"errors" "errors"
"fmt" "fmt"
"io" "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 { func (s *Store) putS3(ctx context.Context, key string, data []byte, contentType string) error {
if contentType == "" { if contentType == "" {
contentType = "application/octet-stream" 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 { if ds == nil {
return p, nil return p, nil
} }
// 有专题才在导航里露出入口(含未对外展示的:站长要能从导航进管理页) // 有专题才在导航里露出入口(含未对外展示的:站长要能从导航进管理页)。
// 专题归**人**(换了数据集也还是同一批),所以按属主判,不是按数据集。
if err := s.db.QueryRowContext(ctx, 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 return p, err
} }
+35 -30
View File
@@ -44,15 +44,19 @@ func parseTopicTime(raw string) (time.Time, bool) {
// ---------- 查询 ---------- // ---------- 查询 ----------
// loadTopics 读专题列表。onlyEnabled 时只取对外展示的(前台用)。 // 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, q := `SELECT id, title, subtitle, description, start_at, end_at, center_lat, center_lon,
radius_km, show_fog, enabled, sort_order radius_km, show_fog, enabled, sort_order
FROM topics WHERE dataset_id=$1` FROM topics WHERE user_id=$1`
if onlyEnabled { if onlyEnabled {
q += ` AND enabled` q += ` AND enabled`
} }
q += ` ORDER BY sort_order, id` 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 { if err != nil {
return nil, err return nil, err
} }
@@ -282,28 +286,31 @@ func (s *Server) topicsAdminPage(w http.ResponseWriter, r *http.Request) {
p.Flatpickr = true p.Flatpickr = true
d := TopicAdminData{Page: p} d := TopicAdminData{Page: p}
d.Flash, d.Err = r.URL.Query().Get("ok"), r.URL.Query().Get("err") 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) ds, err := s.datasetFor(ctx)
if err != nil { if err != nil {
s.serverError(w, r, err) s.serverError(w, r, err)
return return
} }
if ds != nil { 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 { if err := s.fillTopicStatsBatch(ctx, list, ds.ID, nil, false); err != nil {
log.Printf("专题统计失败: %v", err) log.Printf("专题统计失败: %v", err)
} }
d.Topics = list }
// 编辑模式:?edit=<id> d.Topics = list
if id := atoiOr(r.URL.Query().Get("edit"), 0); id > 0 { // 编辑模式:?edit=<id>
for _, t := range list { if id := atoiOr(r.URL.Query().Get("edit"), 0); id > 0 {
if t.ID == int64(id) { for _, t := range list {
d.Form = t 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, 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, start_at=$6, end_at=$7, center_lat=$8, center_lon=$9, radius_km=$10,
show_fog=$11, enabled=$12, sort_order=$13 show_fog=$11, enabled=$12, sort_order=$13
WHERE id=$1 AND dataset_id=$2`, WHERE id=$1 AND user_id=$2`,
id, ds.ID, t.Title, t.Subtitle, t.Description, t.StartAt, t.EndAt, 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 { t.CenterLat, t.CenterLon, t.RadiusKm, t.ShowFog, t.Enabled, t.SortOrder); err != nil {
s.serverError(w, r, err) s.serverError(w, r, err)
return return
@@ -350,6 +357,8 @@ func (s *Server) adminTopicSave(w http.ResponseWriter, r *http.Request) {
return return
} }
var newID int64 var newID int64
// dataset_id 照写不动,但只当**出处**记:这一份专题是在哪份数据上建的。
// 认它归谁、对外怎么展示都与它无关(外键已摘,见 schema.sql)。
if err := s.db.QueryRowContext(ctx, `INSERT INTO topics if err := s.db.QueryRowContext(ctx, `INSERT INTO topics
(user_id, dataset_id, title, subtitle, description, start_at, end_at, (user_id, dataset_id, title, subtitle, description, start_at, end_at,
center_lat, center_lon, radius_km, show_fog, enabled, sort_order) 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) { func (s *Server) adminTopicDelete(w http.ResponseWriter, r *http.Request) {
s.topicMutate(w, r, func(ctx context.Context, dsID int64, id int) error { 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 dataset_id=$2`, id, dsID) _, err := s.db.ExecContext(ctx, `DELETE FROM topics WHERE id=$1 AND user_id=$2`, id, userID)
return err return err
}, "专题已删除") }, "专题已删除")
} }
func (s *Server) adminTopicToggle(w http.ResponseWriter, r *http.Request) { func (s *Server) adminTopicToggle(w http.ResponseWriter, r *http.Request) {
s.topicMutate(w, r, func(ctx context.Context, dsID int64, id int) error { 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 dataset_id=$2`, id, dsID) _, err := s.db.ExecContext(ctx, `UPDATE topics SET enabled = NOT enabled WHERE id=$1 AND user_id=$2`, id, userID)
return err return err
}, "展示状态已切换") }, "展示状态已切换")
} }
// topicMutate 收敛删除 / 开关两个动作里重复的取参与错误处理。 // topicMutate 收敛删除 / 开关两个动作里重复的取参与错误处理。
// 按属主收口(专题是用户级的),**不要求有生效数据集**——没有数据时照样该能整理专题。
func (s *Server) topicMutate(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64, int) error, okMsg string) { func (s *Server) topicMutate(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64, int) error, okMsg string) {
ctx := r.Context() ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
redirectTopics(w, r, "err", "还没有可编辑的数据集")
return
}
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
s.serverError(w, r, err) s.serverError(w, r, err)
return return
@@ -393,7 +398,7 @@ func (s *Server) topicMutate(w http.ResponseWriter, r *http.Request, fn func(con
redirectTopics(w, r, "err", "专题标识无效") redirectTopics(w, r, "err", "专题标识无效")
return 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) s.serverError(w, r, err)
return return
} }
@@ -411,7 +416,7 @@ func (s *Server) topicsListPage(w http.ResponseWriter, r *http.Request) {
} }
d := TopicListData{Page: p} d := TopicListData{Page: p}
if p.Dataset != nil { 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 { if err != nil {
s.serverError(w, r, err) s.serverError(w, r, err)
return return
@@ -441,7 +446,7 @@ func (s *Server) topicPage(w http.ResponseWriter, r *http.Request) {
var t Topic var t Topic
err = s.db.QueryRowContext(ctx, `SELECT id, title, subtitle, description, start_at, end_at, 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 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, 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) &t.CenterLat, &t.CenterLon, &t.RadiusKm, &t.ShowFog, &t.Enabled, &t.SortOrder)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
+89 -18
View File
@@ -9,11 +9,13 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"path"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"rond-with-you/internal/media"
"rond-with-you/internal/rond" "rond-with-you/internal/rond"
) )
@@ -52,15 +54,69 @@ type unresolvedImage struct {
} }
type imageRecoverReport struct { type imageRecoverReport struct {
Scanned int // 盘上扫到的图片文件数 Scanned int // 扫到的图片对象数(本地盘 + 对象存储)
Remote int // 其中来自对象存储(R2/S3)的
Attached int // 本次新挂回的行数(apply=false 时是「可挂回」) Attached int // 本次新挂回的行数(apply=false 时是「可挂回」)
Already int // 库里已经有行、跳过的 Already int // 库里已经有行、跳过的
Recovered []recoveredPlace // 按地点汇总(新挂回/可挂回的) Recovered []recoveredPlace // 按地点汇总(新挂回/可挂回的)
Unresolved []unresolvedImage Unresolved []unresolvedImage
Notes []string // 坐标来源说明,便于核对「为什么是这么认的」 Notes []string // 坐标来源说明,便于核对「为什么是这么认的」
Warnings []string // 非致命的问题(例如对象存储没扫成),要在页面上说出来
Applied bool 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 从对象键里取出 (数据集编号, 地点编号)。 // parseImageKey 从对象键里取出 (数据集编号, 地点编号)。
// //
// 键有两种形状,历史原因并存,都要认: // 键有两种形状,历史原因并存,都要认:
@@ -251,10 +307,11 @@ func (s *Server) recoverPlaceImages(ctx context.Context, apply bool) (imageRecov
if ds == nil { if ds == nil {
return rep, errors.New("还没有生效的数据集,先导入一份备份") return rep, errors.New("还没有生效的数据集,先导入一份备份")
} }
keys, err := s.mediaKeys() objs, warns, err := s.collectOrphanObjects(ctx)
if err != nil { if err != nil {
return rep, fmt.Errorf("扫描图片目录失败:%w", err) return rep, err
} }
rep.Warnings = warns
attached, err := s.attachedImageKeys(ctx) attached, err := s.attachedImageKeys(ctx)
if err != nil { if err != nil {
return rep, err return rep, err
@@ -267,8 +324,12 @@ func (s *Server) recoverPlaceImages(ctx context.Context, apply bool) (imageRecov
cache := map[int]coordSource{} // 每个旧数据集只解析一次坐标表 cache := map[int]coordSource{} // 每个旧数据集只解析一次坐标表
failed := map[int]string{} // 解析不过的记下原因,避免每个文件各报一遍 failed := map[int]string{} // 解析不过的记下原因,避免每个文件各报一遍
for _, key := range keys { for _, obj := range objs {
key := obj.Key
rep.Scanned++ rep.Scanned++
if obj.Storage == media.BackendS3 {
rep.Remote++
}
if attached[key] { if attached[key] {
rep.Already++ rep.Already++
continue continue
@@ -317,19 +378,14 @@ func (s *Server) recoverPlaceImages(ctx context.Context, apply bool) (imageRecov
if !apply { if !apply {
continue continue
} }
fi, err := os.Stat(filepath.Join(s.cfg.MediaDir(), filepath.FromSlash(key))) // storage 取「对象实际在哪」:本地扫到的就是 local,只剩桶里有的就是 s3。
var size int64 // width/height 无从得知(页面只用 URL,留 0 不影响渲染),bytes 用对象大小。
if err == nil {
size = fi.Size()
}
// storage 固定 local:能在这个目录里扫到,就说明它是本地后端的对象。
// width/height 无从得知(页面只用到 URL,留 0 不影响渲染),bytes 用文件大小。
if _, err := s.db.ExecContext(ctx, `INSERT INTO place_images if _, err := s.db.ExecContext(ctx, `INSERT INTO place_images
(dataset_id, src_pk, place_key, storage, object_key, width, height, bytes, position) (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)) COALESCE((SELECT max(position)+1 FROM place_images WHERE place_key=$3), 0))
ON CONFLICT (dataset_id, src_pk, object_key) DO NOTHING`, 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) return rep, fmt.Errorf("挂回 %s 失败:%w", key, err)
} }
attached[key] = true 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 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.Notes = dedupStrings(rep.Notes)
rep.Applied = apply rep.Applied = apply
return rep, nil return rep, nil
@@ -361,18 +423,27 @@ func dedupStrings(in []string) []string {
return out return out
} }
// adminImagesRecover 是「扫描并恢复」按钮的处理函数。只插行、不删文件,可反复点。 // adminImagesRecover 是「扫描并恢复」按钮的处理函数。只插行、不删对象,可反复点。
func (s *Server) adminImagesRecover(w http.ResponseWriter, r *http.Request) { func (s *Server) adminImagesRecover(w http.ResponseWriter, r *http.Request) {
rep, err := s.recoverPlaceImages(r.Context(), true) rep, err := s.recoverPlaceImages(r.Context(), true)
if err != nil { if err != nil {
s.adminFlash(w, r, "err", "图片恢复失败:"+err.Error()) s.adminFlash(w, r, "err", "图片恢复失败:"+err.Error())
return 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 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 { if len(rep.Recovered) > 0 {
var parts []string var parts []string
for i, e := range rep.Recovered { 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), log.Printf("图片恢复:%d 张认不出,例:%s(%s)", len(rep.Unresolved),
rep.Unresolved[0].ObjectKey, rep.Unresolved[0].Reason) 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 ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"net/http"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
@@ -350,3 +352,106 @@ func writeGhostBaseline(t *testing.T, path string, srcPK int, lat, lon float64)
func placeKeyOfCoords(lat, lon float64) string { func placeKeyOfCoords(lat, lon float64) string {
return rond.PlaceCoordKey(lat, lon) 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。 // datasetFor 取当前生效的数据集;无数据时返回 nil。
func (s *Server) datasetFor(ctx context.Context) (*Dataset, error) { func (s *Server) datasetFor(ctx context.Context) (*Dataset, error) {
var d Dataset 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 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`). 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) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, nil
} }
@@ -553,7 +553,10 @@ func (s *Server) datasetFor(ctx context.Context) (*Dataset, error) {
} }
type Dataset struct { type Dataset struct {
ID int64 ID int64
// UserID 是这份数据集的属主。专题、备注、结论这类**用户级**数据要靠它定位——
// 它们不属于某个数据集,只是当初挂在那一份上;换了数据集之后还是同一个人的。
UserID int64
Name string Name string
UploadedAt time.Time UploadedAt time.Time
Visits int 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)
}
}