图片恢复支持对象存储(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:
@@ -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` 导出。
|
||||
|
||||
@@ -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 <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)。
|
||||
|
||||
@@ -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/<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` 前:开发库无症、**新库起不来**)。
|
||||
- 合并 `backup_merge.go`:整个 `withTx`、不删除;新行 `*_visit_src` 写**库里的 src_pk**;必须 `writeRawRow`;删地点前先查有无到访(CASCADE)。
|
||||
|
||||
|
||||
@@ -161,11 +161,16 @@ go build -o rond .
|
||||
- ⚠️ **早期版本图片记录挂在数据集上**(`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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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("没配齐应当报错")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user