From 6b665b60563f78551718d1c4eaae21a0a5a53973 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Thu, 1 Oct 2026 00:11:04 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BE=E7=89=87=E6=81=A2=E5=A4=8D=EF=BC=9A?= =?UTF-8?q?=E5=9D=90=E6=A0=87=E6=9D=A5=E6=BA=90=E5=85=A8=E9=83=A8=E5=90=88?= =?UTF-8?q?=E5=B9=B6=EF=BC=8C=E8=AE=A4=E4=B8=8D=E5=87=BA=E6=97=B6=E8=AF=B4?= =?UTF-8?q?=E6=B8=85=E5=B7=B2=E6=9F=A5=E8=BF=87=E5=93=AA=E4=BA=9B=E6=9D=A5?= =?UTF-8?q?=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真数据实测(扫描 40 个对象、只挂回 7 张)暴露了第一版的漏洞: 报错「数据集 #1 里没有编号 389 的地点」——来源能读出来,却不是上传那会儿的编号空间。 根因:srcPKCoords 是「第一个能读到的来源就返回」,而**换基线会覆盖 baselines/.sqlite**, 当前那份未必是当年上传时用的编号;旧编号往往还躺在同一份底座的 .bak 留档里。 教训很直白:**「能读出数据」不等于「读对了那一版」**。 改了三处: 1. 来源全部读入合并(先读的优先):库里 places → entity_raw 里 ZLOCATION 的整行留档 (站点上删掉的地点,places 没了、留档可能还在)→ baselines/.sqlite 及其每一份 .bak-*。 读不动的文件跳过换下一个,不再「第一份成功就返回」。 2. 同一个编号保留多组候选坐标,逐个试到能在当前数据集里对上为止 (站点纠过偏、或不同来源算出的坐标不一样,只认第一组会白白认不出)。 3. 认不出要能排查:理由写明「已查来源 …」与试过的坐标;提示条按**数据集归并**上报 (「数据集 #1:31 张(编号 389、390…),已查来源 …」),不再刷「另有 31 张」; 完整清单(前 30 条)进服务端日志。 新增用例:当前底座缺编号、换基线留档里才有(正是用户遇到的形状)⇒ 必须救得回; 以及「真的认不出」时要带出已查来源。全量验收 PASS=332 FAIL=0 SKIP=0。 --- .workbuddy/memory/2026-09-30.md | 18 ++ .workbuddy/memory/MEMORY-details.md | 22 +++ .workbuddy/memory/MEMORY.md | 2 +- README.md | 3 +- internal/web/image_recovery.go | 258 ++++++++++++++++++++++------ internal/web/image_recovery_test.go | 86 +++++++++- 6 files changed, 333 insertions(+), 56 deletions(-) diff --git a/.workbuddy/memory/2026-09-30.md b/.workbuddy/memory/2026-09-30.md index f38272b..be1945b 100644 --- a/.workbuddy/memory/2026-09-30.md +++ b/.workbuddy/memory/2026-09-30.md @@ -259,3 +259,21 @@ R2 的 Token 默认没有 `s3:ListBucket` ⇒ 403。列不动时**不让整个 用例除「删数据集后专题还在」外,还直接查 `pg_constraint` 确认外键确实不在——只看行为可能被别的原因弄绿。 **导出链路没碰**:`place_images` / `topics` / `Dataset.UserID` 都不参与 `.rondbackup` 导出。 + +### 再补(23:50):认不出 33 张 —— 「能读出数据」≠「读对了那一版」 + +用户跑到真数据上:扫描 40 个对象(对象存储 19),只挂回 7 张,**认不出 33 张**, +报错「数据集 #1 里没有编号 389 的地点」。 + +根因:我的 `srcPKCoords` 是「第一个能读到的来源就返回」,而**换基线会覆盖 `baselines/.sqlite`** ⇒ +当前那份未必是上传那会儿的编号空间,旧编号很可能还在同一份底座的 `.bak` 留档里。 + +改了三处: +1. 来源**全部读入合并**:库里 places → `entity_raw` 里 ZLOCATION 的整行留档 → `baselines/.sqlite` + 及**每一份 `.bak-*`**(读不动的跳过,不再第一份成功就返回)。 +2. 同一编号**保留多组候选坐标**,逐个试到能在当前数据集里对上(站点纠过偏/来源不一致的情况)。 +3. 认不出要能排查:理由写明「已查来源 …」与试过的坐标;提示条**按数据集归并**上报, + 不再刷「另有 31 张」;完整清单(前 30 条)进服务端日志。 + +可复用的教训:从「可能被覆盖过的留档」里还原编号/坐标时,必须把历史版本一起纳入, +并在失败信息里列出已查过的来源——否则用户只看到「认不出」,无从下手。 diff --git a/.workbuddy/memory/MEMORY-details.md b/.workbuddy/memory/MEMORY-details.md index 14ccbda..8320a41 100644 --- a/.workbuddy/memory/MEMORY-details.md +++ b/.workbuddy/memory/MEMORY-details.md @@ -659,3 +659,25 @@ Core Data 元数据、索引、持久化历史、`ZRAWVISIT` 采样点、`ZTRIP* **不影响导出**:这轮动的 `place_images` / `topics` / `Dataset.UserID` 都不参与 `.rondbackup` 导出 (导出只读 datasets/places/visits/movements/weather/entity_raw)。 + +### ⚠️ 三十四再补:坐标来源不能「第一个能读的就算了」(2026-09-30 23:50,用户实测) + +第一版上线后用戶反馈:**扫描 40 个对象,只挂回 7 张,认不出 33 张**, +报错是「数据集 #1 里没有编号 389 的地点」——即来源能读出来,但**不是上传那会儿的编号空间**。 + +根因:`srcPKCoords` 是「第一个能读到的来源就返回」,而 **换基线会覆盖 `baselines/.sqlite`**, +当前那份未必是当年上传时用的编号;旧编号往往还躺在同一份底座的 `.bak` 留档里。 + +改成三件事: +1. **所有来源全部读进来合并**(先读的优先):库里 `places` → `entity_raw` 里 ZLOCATION 的整行留档 + (站点上删掉的地点,places 没了、留档可能还在)→ `baselines/.sqlite` 及其**每一份 `.bak-*`**。 + 读不动的文件跳过换下一个,不再「第一份成功就返回」。 +2. **同一个编号保留多组候选坐标**,逐个试到能在当前数据集里对上为止: + 站点纠过偏、或不同来源算出的坐标不一样,只认第一组会白白认不出。 +3. **认不出要能排查**:`unresolvedImage` 带上 `Dataset / SrcPK / Note`,理由里写明 + 「已查来源:库里 places / 原始行留档 / 155.sqlite / …」与试过的坐标; + 提示条按**数据集归并**上报(「数据集 #1:31 张(编号 389、390…),已查来源 …」), + 不再刷「另有 31 张」;完整清单(前 30 条)进服务端日志。 + +教训(可复用):**「能读出数据」不等于「读对了那一版」**。凡是从「可能被覆盖过的留档」里还原编号/坐标, +都要把历史版本一起纳入,并且在失败信息里列出已查过的来源 —— 否则用户只能看到「认不出」,无从下手。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index f26f3ff..24115e0 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -55,7 +55,7 @@ ## 跨备份 / 附属数据(details 十九~廿一、卅四) - 预演按 `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 卅四) +- ⚠️ **附属数据不能挂数据集外键**:`place_images` 曾按 `dataset_id` CASCADE ⇒ 删旧数据集=删图片行、**文件却留在盘上**(读取按坐标键、行按数据集级联,两套口径打架)。已摘掉外键;恢复走后台「图片挂载恢复」,认地点靠旧数据集留下的 `baselines/.sqlite`(**别清 baselines 目录**);⚠️ 来源必须**全部读进来合并**(places → entity_raw 留档 → 该 sqlite 及**每份 `.bak`**)——换基线会覆盖底座文件,只看当前那份会整片认不出(实测 40 张只挂回 7 张)。`image_recovery_test.go`(details 卅四) - ⚠️ 恢复**同时扫对象存储**(R2/S3):`media.ListObjects`(ListObjectsV2 + SigV4,查询串先编码再签), 需 `s3:ListBucket` 权限;列不动时本地照救 + warning。**专题同理**:`topics` 已改成归人不归数据集 (`user_id` 过滤,内容仍照生效数据集算)。用户级数据靠 `Dataset.UserID` 定位。(details 卅四) diff --git a/README.md b/README.md index d2631e7..41af7d3 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,8 @@ go build -o rond . ⚠️ 列桶需要凭据上有 **`s3:ListBucket`** 权限(R2 的 API Token 默认只给对象读写), 没权限时本地那半照救,页面会把该开哪个权限说明白。 认地点要用旧数据集留下的底座库(`/baselines/.sqlite`)来还原「地点号 → 坐标」, - **所以别删那个文件**;底座也没了就认不出来了,报告里会列出来。 + **所以别删那个文件**,换基线留下的 `.bak-*` 留档也别清——换过的底座未必还是上传那会儿的编号空间, + 旧编号往往只在留档里(来源会全部读进来合并,逐个试到对上为止)。都没有才认不出,报告里会列出来。 - **删除数据集**只清掉从备份导来的记录与原始行留档;备注、结论、图片按坐标键保留。 - **专题归人不归数据集**:专题是「一段时间 + 一块区域」的定义,换数据集之后照旧成立 (内容照当前生效数据集算),删掉旧数据集不会再把它带走。 diff --git a/internal/web/image_recovery.go b/internal/web/image_recovery.go index 0f0d561..510b07b 100644 --- a/internal/web/image_recovery.go +++ b/internal/web/image_recovery.go @@ -36,9 +36,12 @@ type coordRef struct { Name string } -// coordSource 是「旧数据集编号 → 坐标表」的一次解析结果,顺带说明这份数据是从哪来的。 +// coordSource 是「旧数据集编号 → 坐标」的一次解析结果,顺带说明这份数据是从哪来的。 +// +// refs 里同一个编号可能有**多组候选坐标**:站点上纠过偏、或者按来源不同算出的坐标不一样 +// (实测「站点把坐标纠偏过」与「手机那侧改过」都会出现)。逐个试到能在当前数据集里对上为止。 type coordSource struct { - refs map[int]coordRef + refs map[int][]coordRef note string } @@ -48,9 +51,23 @@ type recoveredPlace struct { Count int } +// unresolvedImage 是一张认不出来的图。带 Dataset / SrcPK / Note 是为了**按数据集归并**上报: +// 一份包缺编号往往是整片整片的,逐条刷「另有 31 张」没用,得说清是哪个数据集、缺哪些编号、 +// 已经查过哪些来源,站长才判断得出该去找哪份包。 type unresolvedImage struct { ObjectKey string Reason string + Dataset int + SrcPK int + Note string // 已查过的坐标来源 +} + +// unresolvedGroup 是按数据集归并后的认不出清单。 +type unresolvedGroup struct { + Dataset int + Count int + SrcPKs []int + Note string } type imageRecoverReport struct { @@ -219,51 +236,117 @@ type PlaceRef struct { Lat, Lon float64 } -// srcPKCoords 取某个数据集里「地点编号 → 坐标 + 名字」。 +// srcPKCoords 汇总某个数据集里「地点编号 → 坐标」的**全部来源**并合并(先读的优先)。 // -// 两条来源,按可靠性排: -// 1. 该数据集还在库里 ⇒ 直接查它的 places 表(最准:站点改过坐标也认)。 -// 2. 数据集已经删了 ⇒ 退到它留下的**底座库**(`baselines/.sqlite`)。 -// 删数据集不会删底座文件,这正是这些图片还能救回来的原因;底座也被清掉就只能报「认不出」。 +// ⚠️ 不能「第一个能读的就算了」——实测 33 张认不出来就是这么来的: +// 换基线会**覆盖** baselines/.sqlite,当前那份未必是上传那会儿的编号空间 +// (报错长这样:「数据集 #1 里没有编号 389 的地点」:能读出 ZLOCATION,却没有当年那几个编号)。 +// 而换基线留下的 .bak 里很可能还留着旧编号。所以下面几种来源**全部**读一遍, +// 按可靠性依次填表,缺哪个编号再用后面的来源补: +// 1. 库里该数据集的 places(最准,站点改过坐标也认) +// 2. entity_raw 里 ZLOCATION 的整行留档(站点上删掉的地点,places 没了、留档可能还在) +// 3. baselines/.sqlite 以及换基线留下的每一份 .bak-* func (s *Server) srcPKCoords(ctx context.Context, datasetID int64) (coordSource, error) { - var exists bool - if err := s.db.QueryRowContext(ctx, `SELECT true FROM datasets WHERE id=$1`, datasetID).Scan(&exists); err == nil { - rows, err := s.db.QueryContext(ctx, `SELECT src_pk, COALESCE(lat,0), COALESCE(lon,0), COALESCE(name,'') - FROM places WHERE dataset_id=$1`, datasetID) - if err != nil { - return coordSource{}, err - } - defer rows.Close() - refs := map[int]coordRef{} - for rows.Next() { - var pk int - var r coordRef - if err := rows.Scan(&pk, &r.Lat, &r.Lon, &r.Name); err != nil { - return coordSource{}, err + refs := map[int][]coordRef{} + var notes []string + // 按可靠性依次追加(先来的候选排前面);不同来源给出不同坐标时全都留着,重复的跳过 + add := func(m map[int]coordRef) { + for k, v := range m { + if !hasCoord(refs[k], v) { + refs[k] = append(refs[k], v) } - refs[pk] = r } - if err := rows.Err(); err != nil { - return coordSource{}, err - } - return coordSource{refs: refs, note: fmt.Sprintf("#%d:库里的 places 表", datasetID)}, nil } - // 数据集没了:找它留下的底座库(换基线留下的 .bak 也算,按时间新的优先) + if m, err := s.placesCoords(ctx, datasetID); err == nil && len(m) > 0 { + add(m) + notes = append(notes, "库里 places") + } + if m, err := s.rawCoords(ctx, datasetID); err == nil && len(m) > 0 { + add(m) + notes = append(notes, "原始行留档") + } + for _, p := range s.baselineCandidates(datasetID) { + m, err := readLocationCoords(ctx, p) + if err != nil { + continue // 读不动(文件坏了 / 不是那套表)就跳过,换下一个来源 + } + add(m) + notes = append(notes, filepath.Base(p)) + } + if len(refs) == 0 { + return coordSource{}, fmt.Errorf( + "数据集 #%d 找不到任何「地点编号 → 坐标」的来源(已试:库里的 places、原始行留档、baselines/%d.sqlite 及其 .bak)", + datasetID, datasetID) + } + return coordSource{refs: refs, note: strings.Join(notes, " / ")}, nil +} + +// hasCoord 判断这组候选坐标里已经有没有同一个(去重用)。 +func hasCoord(list []coordRef, want coordRef) bool { + for _, v := range list { + if v == want { + return true + } + } + return false +} + +// placesCoords 读某数据集在库里的地点(只有数据集还在时才查得到)。 +func (s *Server) placesCoords(ctx context.Context, datasetID int64) (map[int]coordRef, error) { + rows, err := s.db.QueryContext(ctx, `SELECT src_pk, COALESCE(lat,0), COALESCE(lon,0), COALESCE(name,'') + FROM places WHERE dataset_id=$1`, datasetID) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int]coordRef{} + for rows.Next() { + var pk int + var r coordRef + if err := rows.Scan(&pk, &r.Lat, &r.Lon, &r.Name); err != nil { + return nil, err + } + out[pk] = r + } + return out, rows.Err() +} + +// rawCoords 从原始行留档里读地点坐标。站点上删掉地点后 places 里就没了, +// 但 entity_raw 可能还留着当年那一整行(导出也靠它),正好能补上这段编号。 +func (s *Server) rawCoords(ctx context.Context, datasetID int64) (map[int]coordRef, error) { + rows, err := s.db.QueryContext(ctx, `SELECT (raw->>'Z_PK')::int, + NULLIF(raw->>'ZLATITUDE','')::float8, NULLIF(raw->>'ZLONGITUDE','')::float8, + COALESCE(raw->>'ZNAME_','') + FROM entity_raw + WHERE dataset_id=$1 AND entity='ZLOCATION' + AND (raw->>'Z_PK') ~ '^[0-9]+$' + AND COALESCE(raw->>'ZLATITUDE','') <> '' AND COALESCE(raw->>'ZLONGITUDE','') <> ''`, datasetID) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int]coordRef{} + for rows.Next() { + var pk int + var r coordRef + if err := rows.Scan(&pk, &r.Lat, &r.Lon, &r.Name); err != nil { + return nil, err + } + out[pk] = r + } + return out, rows.Err() +} + +// baselineCandidates 列出某数据集可用的底座库:当前那份在先,换基线留下的 .bak 新的在后。 +func (s *Server) baselineCandidates(datasetID int64) []string { base := s.baselinePath(datasetID) cands := []string{base} if baks, _ := filepath.Glob(base + ".bak-*"); len(baks) > 0 { sort.Sort(sort.Reverse(sort.StringSlice(baks))) cands = append(cands, baks...) } - for _, p := range cands { - refs, err := readLocationCoords(ctx, p) - if err != nil { - continue - } - return coordSource{refs: refs, note: fmt.Sprintf("#%d:底座库 %s", datasetID, filepath.Base(p))}, nil - } - return coordSource{}, fmt.Errorf("数据集 #%d 已删除,也没找到它留下的底座库(%s)", datasetID, base) + return cands } // readLocationCoords 从底座库里读「地点编号 → 坐标 + 名字」(Core Data 的 ZLOCATION)。 @@ -336,36 +419,58 @@ func (s *Server) recoverPlaceImages(ctx context.Context, apply bool) (imageRecov } dsID, srcPK, ok := parseImageKey(key) if !ok { - rep.Unresolved = append(rep.Unresolved, unresolvedImage{key, "对象键里看不出数据集/地点编号"}) + rep.Unresolved = append(rep.Unresolved, unresolvedImage{ObjectKey: key, + Reason: "对象键里看不出数据集/地点编号"}) continue } src, ok := cache[dsID] if !ok { if msg, bad := failed[dsID]; bad { - rep.Unresolved = append(rep.Unresolved, unresolvedImage{key, msg}) + rep.Unresolved = append(rep.Unresolved, unresolvedImage{ObjectKey: key, Dataset: dsID, + SrcPK: srcPK, Reason: msg}) continue } src, err = s.srcPKCoords(ctx, int64(dsID)) if err != nil { msg := fmt.Sprintf("认不出这张图属于哪个地点:%v", err) failed[dsID] = msg - rep.Unresolved = append(rep.Unresolved, unresolvedImage{key, msg}) + rep.Unresolved = append(rep.Unresolved, unresolvedImage{ObjectKey: key, Dataset: dsID, + SrcPK: srcPK, Reason: msg}) continue } cache[dsID] = src rep.Notes = append(rep.Notes, src.note) } - ref, ok := src.refs[srcPK] - if !ok { - rep.Unresolved = append(rep.Unresolved, unresolvedImage{key, - fmt.Sprintf("数据集 #%d 里没有编号 %d 的地点", dsID, srcPK)}) + cands, ok := src.refs[srcPK] + if !ok || len(cands) == 0 { + rep.Unresolved = append(rep.Unresolved, unresolvedImage{ObjectKey: key, Dataset: dsID, SrcPK: srcPK, + Note: src.note, + Reason: fmt.Sprintf("数据集 #%d 里没有编号 %d 的地点(已查来源:%s)", dsID, srcPK, src.note)}) continue } - pkey := rond.PlaceCoordKey(ref.Lat, ref.Lon) - place, ok := cur[pkey] + // 同一个编号可能有多组候选坐标(站点纠过偏、来源不同),逐个试到能对上为止 + place, ok := PlaceRef{}, false + var pkey string + var tried []string + for _, ref := range cands { + k := rond.PlaceCoordKey(ref.Lat, ref.Lon) + if p, hit := cur[k]; hit { + place, pkey, ok = p, k, true + break + } + tried = append(tried, k) + } if !ok { - rep.Unresolved = append(rep.Unresolved, unresolvedImage{key, - fmt.Sprintf("当前数据集里没有坐标 %s 的地点(旧地点「%s」)", pkey, ref.Name)}) + // 编号找得到、坐标也算出来了,只是当前这份数据里没有这些坐标的地点了 + // (多半是在 App 里被合并/删掉,或坐标在 App 那侧改了)。要把试过的坐标说出来才排查得动。 + why := fmt.Sprintf("当前数据集里没有编号 %d 对应的地点(旧地点「%s」", srcPK, cands[0].Name) + if len(tried) > 0 { + why += fmt.Sprintf(";试过坐标 %s", strings.Join(tried, "、")) + } + why += fmt.Sprintf(";已查来源:%s)", src.note) + rep.Unresolved = append(rep.Unresolved, unresolvedImage{ObjectKey: key, Dataset: dsID, SrcPK: srcPK, + Note: src.note, + Reason: why}) continue } entry := byPlace[pkey] @@ -410,6 +515,40 @@ func (s *Server) recoverPlaceImages(ctx context.Context, apply bool) (imageRecov return rep, nil } +// groupUnresolved 按数据集归并(保持首次出现顺序)。 +func groupUnresolved(list []unresolvedImage) []unresolvedGroup { + var out []unresolvedGroup + idx := map[int]int{} + for _, u := range list { + if u.Dataset == 0 { + continue + } + i, ok := idx[u.Dataset] + if !ok { + idx[u.Dataset] = len(out) + out = append(out, unresolvedGroup{Dataset: u.Dataset, Note: u.Note}) + i = len(out) - 1 + } + out[i].Count++ + if u.SrcPK > 0 && !containsInt(out[i].SrcPKs, u.SrcPK) { + out[i].SrcPKs = append(out[i].SrcPKs, u.SrcPK) + } + } + for i := range out { + sort.Ints(out[i].SrcPKs) + } + return out +} + +func containsInt(list []int, want int) bool { + for _, v := range list { + if v == want { + return true + } + } + return false +} + func dedupStrings(in []string) []string { seen := map[string]bool{} var out []string @@ -464,15 +603,30 @@ func (s *Server) adminImagesRecover(w http.ResponseWriter, r *http.Request) { } if len(rep.Unresolved) > 0 { msg += fmt.Sprintf(";认不出 %d 张", len(rep.Unresolved)) - for i, u := range rep.Unresolved { - if i == 2 { - msg += fmt.Sprintf(",另有 %d 张", len(rep.Unresolved)-i) + // 按数据集归并:一份包缺编号往往是整片整片的,逐条刷「另有 31 张」看不出问题在哪, + // 得说清「哪个数据集缺哪些编号、已经查过哪些来源」。 + groups := groupUnresolved(rep.Unresolved) + for i, g := range groups { + if i == 3 { + msg += fmt.Sprintf(",另有 %d 个数据集", len(groups)-i) break } - msg += fmt.Sprintf("。%s:%s", u.ObjectKey, u.Reason) + pks := g.SrcPKs + if len(pks) > 6 { + pks = pks[:6] + } + msg += fmt.Sprintf("。数据集 #%d:%d 张(编号 %v…)", g.Dataset, g.Count, pks) + if g.Note != "" { + msg += fmt.Sprintf(",已查来源 %s", g.Note) + } + } + // 提示条装不下全部,服务端日志里留一份(够排查) + for i, u := range rep.Unresolved { + if i >= 30 { + break + } + log.Printf("图片恢复:认不出 %s —— %s", u.ObjectKey, u.Reason) } - log.Printf("图片恢复:%d 张认不出,例:%s(%s)", len(rep.Unresolved), - rep.Unresolved[0].ObjectKey, rep.Unresolved[0].Reason) } s.adminFlash(w, r, kind, msg+tail) } diff --git a/internal/web/image_recovery_test.go b/internal/web/image_recovery_test.go index bdec993..68d347c 100644 --- a/internal/web/image_recovery_test.go +++ b/internal/web/image_recovery_test.go @@ -301,8 +301,9 @@ func TestRecoverPlaceImagesFallsBackToBaseline(t *testing.T) { if gotKey != placeKeyOfCoords(lat, lon) { t.Errorf("认出的地点不对:%q", gotKey) } - if !strings.Contains(strings.Join(rep.Notes, " "), "底座库") { - t.Errorf("应当说明这份坐标是从底座库来的,实际 %+v", rep) + // 报告要写明这份坐标是从哪个底座库认出来的(换基线会覆盖底座文件,来源说不清没法排查) + if !strings.Contains(strings.Join(rep.Notes, " "), fmt.Sprintf("%d.sqlite", ghost)) { + t.Errorf("应当说明这份坐标是从底座库 %d.sqlite 认出来的,实际 %+v", ghost, rep) } } @@ -455,3 +456,84 @@ func TestRecoverPlaceImagesReportsObjectStorageFailure(t *testing.T) { } } } + +// TestRecoverPlaceImagesFallsBackToSwappedBaselineBackup 钉住「换了底座就认不出编号」这个真实场景。 +// +// 换基线会**覆盖** baselines/.sqlite,当前那份未必是上传那会儿的编号空间。 +// 用户的实测反馈就是这样:能读出 ZLOCATION,却报「数据集 #1 里没有编号 389 的地点」, +// 而旧编号很可能还在换基线留下的 `.bak` 留档里。所以坐标来源必须**全部读进来合并**, +// 不能「第一个能读的就算了」。 +func TestRecoverPlaceImagesFallsBackToSwappedBaselineBackup(t *testing.T) { + f := newMovementFixture(t) + ctx, srv := f.ctx, f.srv + srcPK, _, lat, lon := pickPlaceWithCoords(t, f) + isolateImageState(t, f, lat, lon) + + const ghost = 987655 + base := srv.baselinePath(ghost) + if err := os.MkdirAll(filepath.Dir(base), 0o755); err != nil { + t.Fatal(err) + } + // 当前底座:换了别的一套编号,里面**没有**我们这个编号 + writeGhostBaseline(t, base, 1001, 0.1, 0.1) + // 换基线留下的留档:才是上传那会儿的编号空间 + bak := base + ".bak-20260101-000000" + writeGhostBaseline(t, bak, srcPK, lat, lon) + t.Cleanup(func() { os.Remove(base); os.Remove(bak) }) + + put := func(name string, ds int, pk int) string { + key := filepath.ToSlash(filepath.Join(strconv.Itoa(ds), strconv.Itoa(pk), + fmt.Sprintf("%s-%d.webp", name, time.Now().UnixNano()))) + dst := filepath.Join(f.cfg.MediaDir(), filepath.FromSlash(key)) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, fakeWebP(0x33, 64), 0o644); err != nil { + t.Fatal(err) + } + return key + } + want := put("swapped", ghost, srcPK) + // 另一个数据集连留档都没有:应当认不出,但要把「已查过哪些来源」说出来 + const none = 987656 + nobase := srv.baselinePath(none) + writeGhostBaseline(t, nobase, 2002, 0.2, 0.2) + t.Cleanup(func() { os.Remove(nobase) }) + nope := put("unknown", none, srcPK) + + rep, err := srv.recoverPlaceImages(ctx, true) + if err != nil { + t.Fatalf("恢复失败: %v", err) + } + if imageRows(t, f, want) != 1 { + t.Fatalf("没从底座留档里认回编号(认不出 %d 张:%+v)", len(rep.Unresolved), rep.Unresolved) + } + var gotKey string + if err := f.pg.QueryRowContext(ctx, `SELECT place_key FROM place_images WHERE object_key=$1`, want). + Scan(&gotKey); err != nil { + t.Fatal(err) + } + if gotKey != rond.PlaceCoordKey(lat, lon) { + t.Errorf("认出的地点不对:%q", gotKey) + } + if !strings.Contains(strings.Join(rep.Notes, " "), filepath.Base(bak)) { + t.Errorf("报告里应当写明用到了留档 %s,实际 %v", filepath.Base(bak), rep.Notes) + } + + // 认不出的那张:理由要带上「已查来源」,否则站长判断不出该去找哪份包 + var reason string + for _, u := range rep.Unresolved { + if u.ObjectKey == nope { + reason = u.Reason + } + } + if reason == "" { + t.Fatalf("这张本该认不出,实际没有出现在清单里:%+v", rep.Unresolved) + } + if !strings.Contains(reason, "已查来源") { + t.Errorf("认不出的理由里没写已查过哪些来源(没法排查):%q", reason) + } + if !containsStr(f.imageURLs(t, srcPK), "/media/"+want) { + t.Errorf("页面口径看不到这张图:%v", f.imageURLs(t, srcPK)) + } +}