图片恢复:坐标来源全部合并,认不出时说清已查过哪些来源

真数据实测(扫描 40 个对象、只挂回 7 张)暴露了第一版的漏洞:
报错「数据集 #1 里没有编号 389 的地点」——来源能读出来,却不是上传那会儿的编号空间。

根因:srcPKCoords 是「第一个能读到的来源就返回」,而**换基线会覆盖 baselines/<id>.sqlite**,
当前那份未必是当年上传时用的编号;旧编号往往还躺在同一份底座的 .bak 留档里。
教训很直白:**「能读出数据」不等于「读对了那一版」**。

改了三处:
1. 来源全部读入合并(先读的优先):库里 places → entity_raw 里 ZLOCATION 的整行留档
   (站点上删掉的地点,places 没了、留档可能还在)→ baselines/<id>.sqlite 及其每一份 .bak-*。
   读不动的文件跳过换下一个,不再「第一份成功就返回」。
2. 同一个编号保留多组候选坐标,逐个试到能在当前数据集里对上为止
   (站点纠过偏、或不同来源算出的坐标不一样,只认第一组会白白认不出)。
3. 认不出要能排查:理由写明「已查来源 …」与试过的坐标;提示条按**数据集归并**上报
   (「数据集 #1:31 张(编号 389、390…),已查来源 …」),不再刷「另有 31 张」;
   完整清单(前 30 条)进服务端日志。

新增用例:当前底座缺编号、换基线留档里才有(正是用户遇到的形状)⇒ 必须救得回;
以及「真的认不出」时要带出已查来源。全量验收 PASS=332 FAIL=0 SKIP=0。
This commit is contained in:
tamakyi
2026-10-01 00:11:04 +08:00
parent 770e752a7a
commit 6b665b6056
6 changed files with 333 additions and 56 deletions
+18
View File
@@ -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/<id>.sqlite`** ⇒
当前那份未必是上传那会儿的编号空间,旧编号很可能还在同一份底座的 `.bak` 留档里。
改了三处:
1. 来源**全部读入合并**:库里 places → `entity_raw` 里 ZLOCATION 的整行留档 → `baselines/<id>.sqlite`
及**每一份 `.bak-*`**(读不动的跳过,不再第一份成功就返回)。
2. 同一编号**保留多组候选坐标**,逐个试到能在当前数据集里对上(站点纠过偏/来源不一致的情况)。
3. 认不出要能排查:理由写明「已查来源 …」与试过的坐标;提示条**按数据集归并**上报,
不再刷「另有 31 张」;完整清单(前 30 条)进服务端日志。
可复用的教训:从「可能被覆盖过的留档」里还原编号/坐标时,必须把历史版本一起纳入,
并在失败信息里列出已查过的来源——否则用户只看到「认不出」,无从下手。
+22
View File
@@ -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/<id>.sqlite`**,
当前那份未必是当年上传时用的编号;旧编号往往还躺在同一份底座的 `.bak` 留档里。
改成三件事:
1. **所有来源全部读进来合并**(先读的优先):库里 `places` → `entity_raw` 里 ZLOCATION 的整行留档
(站点上删掉的地点,places 没了、留档可能还在)→ `baselines/<id>.sqlite` 及其**每一份 `.bak-*`**。
读不动的文件跳过换下一个,不再「第一份成功就返回」。
2. **同一个编号保留多组候选坐标**,逐个试到能在当前数据集里对上为止:
站点纠过偏、或不同来源算出的坐标不一样,只认第一组会白白认不出。
3. **认不出要能排查**:`unresolvedImage` 带上 `Dataset / SrcPK / Note`,理由里写明
「已查来源:库里 places / 原始行留档 / 155.sqlite / …」与试过的坐标;
提示条按**数据集归并**上报(「数据集 #1:31 张(编号 389、390…),已查来源 …」),
不再刷「另有 31 张」;完整清单(前 30 条)进服务端日志。
教训(可复用):**「能读出数据」不等于「读对了那一版」**。凡是从「可能被覆盖过的留档」里还原编号/坐标,
都要把历史版本一起纳入,并且在失败信息里列出已查过的来源 —— 否则用户只能看到「认不出」,无从下手。
+1 -1
View File
@@ -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/<id>.sqlite`(**别清 baselines 目录**)。`image_recovery_test.go`(details 卅四)
- ⚠️ **附属数据不能挂数据集外键**:`place_images` 曾按 `dataset_id` CASCADE ⇒ 删旧数据集=删图片行、**文件却留在盘上**(读取按坐标键、行按数据集级联,两套口径打架)。已摘掉外键;恢复走后台「图片挂载恢复」,认地点靠旧数据集留下的 `baselines/<id>.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 卅四)
+2 -1
View File
@@ -167,7 +167,8 @@ go build -o rond .
⚠️ 列桶需要凭据上有 **`s3:ListBucket`** 权限(R2 的 API Token 默认只给对象读写),
没权限时本地那半照救,页面会把该开哪个权限说明白。
认地点要用旧数据集留下的底座库(`<data_dir>/baselines/<id>.sqlite`)来还原「地点号 → 坐标」,
**所以别删那个文件**;底座也没了就认不出来了,报告里会列出来。
**所以别删那个文件**,换基线留下的 `.bak-*` 留档也别清——换过的底座未必还是上传那会儿的编号空间,
旧编号往往只在留档里(来源会全部读进来合并,逐个试到对上为止)。都没有才认不出,报告里会列出来。
- **删除数据集**只清掉从备份导来的记录与原始行留档;备注、结论、图片按坐标键保留。
- **专题归人不归数据集**:专题是「一段时间 + 一块区域」的定义,换数据集之后照旧成立
(内容照当前生效数据集算),删掉旧数据集不会再把它带走。
+197 -43
View File
@@ -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/<id>.sqlite`)。
// 删数据集不会删底座文件,这正是这些图片还能救回来的原因;底座也被清掉就只能报「认不出」。
// ⚠️ 不能「第一个能读的就算了」——实测 33 张认不出来就是这么来的:
// 换基线会**覆盖** baselines/<id>.sqlite,当前那份未必是上传那会儿的编号空间
// (报错长这样:「数据集 #1 里没有编号 389 的地点」:能读出 ZLOCATION,却没有当年那几个编号)。
// 而换基线留下的 .bak 里很可能还留着旧编号。所以下面几种来源**全部**读一遍,
// 按可靠性依次填表,缺哪个编号再用后面的来源补:
// 1. 库里该数据集的 places(最准,站点改过坐标也认)
// 2. entity_raw 里 ZLOCATION 的整行留档(站点上删掉的地点,places 没了、留档可能还在)
// 3. baselines/<id>.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 {
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)
}
}
}
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 coordSource{}, err
return nil, err
}
defer rows.Close()
refs := map[int]coordRef{}
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 coordSource{}, err
return nil, err
}
refs[pk] = r
out[pk] = r
}
if err := rows.Err(); err != nil {
return coordSource{}, err
}
return coordSource{refs: refs, note: fmt.Sprintf("#%d:库里的 places 表", datasetID)}, nil
return out, rows.Err()
}
// 数据集没了:找它留下的底座库(换基线留下的 .bak 也算,按时间新的优先)
// 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)
}
+84 -2
View File
@@ -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/<id>.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))
}
}