图片恢复:坐标来源全部合并,认不出时说清已查过哪些来源
真数据实测(扫描 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:
@@ -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 条)进服务端日志。
|
||||
|
||||
可复用的教训:从「可能被覆盖过的留档」里还原编号/坐标时,必须把历史版本一起纳入,
|
||||
并在失败信息里列出已查过的来源——否则用户只看到「认不出」,无从下手。
|
||||
|
||||
@@ -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 条)进服务端日志。
|
||||
|
||||
教训(可复用):**「能读出数据」不等于「读对了那一版」**。凡是从「可能被覆盖过的留档」里还原编号/坐标,
|
||||
都要把历史版本一起纳入,并且在失败信息里列出已查过的来源 —— 否则用户只能看到「认不出」,无从下手。
|
||||
|
||||
@@ -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 卅四)
|
||||
|
||||
@@ -167,7 +167,8 @@ go build -o rond .
|
||||
⚠️ 列桶需要凭据上有 **`s3:ListBucket`** 权限(R2 的 API Token 默认只给对象读写),
|
||||
没权限时本地那半照救,页面会把该开哪个权限说明白。
|
||||
认地点要用旧数据集留下的底座库(`<data_dir>/baselines/<id>.sqlite`)来还原「地点号 → 坐标」,
|
||||
**所以别删那个文件**;底座也没了就认不出来了,报告里会列出来。
|
||||
**所以别删那个文件**,换基线留下的 `.bak-*` 留档也别清——换过的底座未必还是上传那会儿的编号空间,
|
||||
旧编号往往只在留档里(来源会全部读进来合并,逐个试到对上为止)。都没有才认不出,报告里会列出来。
|
||||
- **删除数据集**只清掉从备份导来的记录与原始行留档;备注、结论、图片按坐标键保留。
|
||||
- **专题归人不归数据集**:专题是「一段时间 + 一块区域」的定义,换数据集之后照旧成立
|
||||
(内容照当前生效数据集算),删掉旧数据集不会再把它带走。
|
||||
|
||||
+197
-43
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user