From 770e752a7a5dec1375d8baf37a47b84bbf57be85 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Wed, 30 Sep 2026 23:31:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BE=E7=89=87=E6=81=A2=E5=A4=8D=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=AF=B9=E8=B1=A1=E5=AD=98=E5=82=A8=EF=BC=88R2?= =?UTF-8?q?=EF=BC=89=EF=BC=9B=E4=B8=93=E9=A2=98=E4=B9=9F=E6=94=B9=E6=88=90?= =?UTF-8?q?=E5=BD=92=E4=BA=BA=E4=B8=8D=E5=BD=92=E6=95=B0=E6=8D=AE=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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。 --- .workbuddy/memory/2026-09-30.md | 18 ++++ .workbuddy/memory/MEMORY-details.md | 27 ++++++ .workbuddy/memory/MEMORY.md | 4 +- README.md | 13 ++- internal/db/schema.sql | 11 ++- internal/media/media.go | 93 ++++++++++++++++++- internal/media/s3_list_test.go | 134 ++++++++++++++++++++++++++++ internal/web/handlers_public.go | 5 +- internal/web/handlers_topics.go | 65 +++++++------- internal/web/image_recovery.go | 107 ++++++++++++++++++---- internal/web/image_recovery_test.go | 105 ++++++++++++++++++++++ internal/web/server.go | 9 +- internal/web/topics_dataset_test.go | 91 +++++++++++++++++++ 13 files changed, 622 insertions(+), 60 deletions(-) create mode 100644 internal/media/s3_list_test.go create mode 100644 internal/web/topics_dataset_test.go diff --git a/.workbuddy/memory/2026-09-30.md b/.workbuddy/memory/2026-09-30.md index e295dd0..f38272b 100644 --- a/.workbuddy/memory/2026-09-30.md +++ b/.workbuddy/memory/2026-09-30.md @@ -241,3 +241,21 @@ UUID 集合也与换之前逐条一致)。 并给夹具补了收尾清理(外键没了,必须自己清)。 新增用例 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` 导出。 diff --git a/.workbuddy/memory/MEMORY-details.md b/.workbuddy/memory/MEMORY-details.md index c06ab09..14ccbda 100644 --- a/.workbuddy/memory/MEMORY-details.md +++ b/.workbuddy/memory/MEMORY-details.md @@ -632,3 +632,30 @@ Core Data 元数据、索引、持久化历史、`ZRAWVISIT` 采样点、`ZTRIP* 行没了文件还在 ⇒ 按坐标挂回(含预演不写库、幂等、挂回后**页面口径** `placeImagesOf` 看得见)、 早期键布局与 `parseImageKey` 的表驱动、**数据集已删 ⇒ 靠底座库认回**(并在报告里说明来源)、 删数据集不再带走图片行与文件。 + +### 三十四补记:对象存储(R2)侧 + 专题也一起改了(2026-09-30 23:20) + +**R2 上的图怎么救**:行没了就只剩桶里有那些键,不列桶无从得知它们叫什么。 +`internal/media` 加了 `ListObjects(ctx, prefix)`(ListObjectsV2 + SigV4 + 自动翻页 + XML 解析): +- 请求形状是**路径式、只到桶**:`GET /?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)。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index 6d29eda..f26f3ff 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -56,7 +56,9 @@ - 预演按 `VisitUUID`/`ActivityUUID` 对齐、只读;`raw #>> '{ZIDENTIFIER_,$blob}'` 是 base64 须转 hex 比;地点只认坐标(`PlaceCoordKey` 6 位)。 - 附属数据(备注/投票/图片)主键=`place_key` 坐标键(非 dataset+src_pk);Go 与 SQL 两套须逐位一致。 - ⚠️ **附属数据不能挂数据集外键**:`place_images` 曾按 `dataset_id` CASCADE ⇒ 删旧数据集=删图片行、**文件却留在盘上**(读取按坐标键、行按数据集级联,两套口径打架)。已摘掉外键;恢复走后台「图片挂载恢复」,认地点靠旧数据集留下的 `baselines/.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` 前:开发库无症、**新库起不来**)。 - 合并 `backup_merge.go`:整个 `withTx`、不删除;新行 `*_visit_src` 写**库里的 src_pk**;必须 `writeRawRow`;删地点前先查有无到访(CASCADE)。 diff --git a/README.md b/README.md index 448bf04..d2631e7 100644 --- a/README.md +++ b/README.md @@ -161,11 +161,16 @@ go build -o rond . - ⚠️ **早期版本图片记录挂在数据集上**(`place_images.dataset_id` 带 `ON DELETE CASCADE`), 于是「删掉旧数据集」会把图片记录一起删掉,**而文件还留在磁盘上**——读取按坐标键、行却按数据集级联, 两套口径打架,实测丢过一批图。现在外键已摘掉,删数据集不再带走图片;已经丢掉的那批可以恢复: - 后台首页「图片挂载恢复 → 扫描并恢复」,它按坐标把盘上还在的文件挂回当前数据集的地点上 - (只插记录、不动文件、可反复执行)。认地点要用旧数据集留下的底座库(`/baselines/.sqlite`) - 来还原「地点号 → 坐标」,**所以别删那个文件**;底座也没了就认不出来了,报告里会列出来。 + 后台首页「图片挂载恢复 → 扫描并恢复」,它按坐标把还在的对象挂回当前数据集的地点上 + (只插记录、不动对象、可反复执行)。**本地磁盘与对象存储都扫**: + 本地走目录遍历,对象存储走 ListObjectsV2 列桶——行没了就只剩桶里有那些键,不列桶无从得知它们叫什么。 + ⚠️ 列桶需要凭据上有 **`s3:ListBucket`** 权限(R2 的 API Token 默认只给对象读写), + 没权限时本地那半照救,页面会把该开哪个权限说明白。 + 认地点要用旧数据集留下的底座库(`/baselines/.sqlite`)来还原「地点号 → 坐标」, + **所以别删那个文件**;底座也没了就认不出来了,报告里会列出来。 - **删除数据集**只清掉从备份导来的记录与原始行留档;备注、结论、图片按坐标键保留。 - 专题(`topics`)目前仍旧随数据集删除——同一个数据集删掉后要重建。 +- **专题归人不归数据集**:专题是「一段时间 + 一块区域」的定义,换数据集之后照旧成立 + (内容照当前生效数据集算),删掉旧数据集不会再把它带走。 ## 照片导入(补录没有轨迹的时间段) diff --git a/internal/db/schema.sql b/internal/db/schema.sql index 2f2e5ce..0c47bdd 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -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 压缩后的)字节,一个字节都不动。 diff --git a/internal/media/media.go b/internal/media/media.go index dd9f876..9021d11 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -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" diff --git a/internal/media/s3_list_test.go b/internal/media/s3_list_test.go new file mode 100644 index 0000000..94727c1 --- /dev/null +++ b/internal/media/s3_list_test.go @@ -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(`rond`) + for _, k := range keys { + b.WriteString("" + k + "1234") + } + fmt.Fprintf(&b, "%v%d", truncated, len(keys)) + if token != "" { + b.WriteString("" + token + "") + } + b.WriteString(``) + 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, `AccessDenied`) + })) + 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("没配齐应当报错") + } +} diff --git a/internal/web/handlers_public.go b/internal/web/handlers_public.go index 28c2c9a..eea6e45 100644 --- a/internal/web/handlers_public.go +++ b/internal/web/handlers_public.go @@ -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 } diff --git a/internal/web/handlers_topics.go b/internal/web/handlers_topics.go index d67ffb7..c4f612e 100644 --- a/internal/web/handlers_topics.go +++ b/internal/web/handlers_topics.go @@ -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= - 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= + 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 { diff --git a/internal/web/image_recovery.go b/internal/web/image_recovery.go index 5656013..0f0d561 100644 --- a/internal/web/image_recovery.go +++ b/internal/web/image_recovery.go @@ -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) } diff --git a/internal/web/image_recovery_test.go b/internal/web/image_recovery_test.go index 8ffa26e..bdec993 100644 --- a/internal/web/image_recovery_test.go +++ b/internal/web/image_recovery_test.go @@ -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, ` + %s4242 + 别的目录/notes.txt9 + false`, 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, `AccessDenied`) + })) + 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) + } + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 443d51b..31e2612 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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 diff --git a/internal/web/topics_dataset_test.go b/internal/web/topics_dataset_test.go new file mode 100644 index 0000000..d50c9af --- /dev/null +++ b/internal/web/topics_dataset_test.go @@ -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) + } +}