导出/合并:交通方式编号空间串了,导出会吐出两条同名方式(真机整包拒收)

继上一轮修「活动颜色为 NULL」之后,生产重新导出 `data/20260929.rondbackup` 依旧导不回 rond。
这次拿**最后一次被真机接受的包**(20260920-fixed-A-数据修复)当参照逐项比对:SQLite 头、31 表 /
47 索引 / 建表语句 / 列定义、`Z_METADATA.Z_PLIST` 与 `Z_MODELCACHE`(逐字节)、值类型分布、文本合法
字节、BLOB 长度、UUID 唯一性、关系完整性、`Z_OPT`、行级时间不变量——全部一致;唯一差异是
**`ZTRANSPORT` 里出现了两条同名「公交」(Z_PK 2 与 4),而真机与可导入的 A 包都只有一条**。

根因:`mergeVisits` 把实机的 `ZTRANSPORT` 编号原样写进 `movements.transport_src`。站点没有交通方式表,
方式只活在位移的 transport_* 四列里,两套编号空间同一个数字含义不同(实测 src=4:站点=电🐔、实机=公交,
src 3/5/6 同样撞车)。于是同一个 src 带上两种含义:导出按 src 聚合只能取一个名字 → 另一种含义的位移挂错
方式;更糟的是吐出两条同名方式,真机那边是模型唯一约束级别的违规 → 整包拒收。

修复两处:
1. 合并侧 `mergeVisits`:按名称把实机方式归一到站点编号(新增 `(*Server).transportsByName`:同名沿用
   站点已有的编号,没有才 `nextSrcPK(transportSeq)` 新发一个)。
2. 导出侧 `queryTransports`:改为按**名称**归组(名称就是身份),用量最多的名称优先认领编号,其余编号的
   位移改挂到它上面;并清掉「基线里同名但编号已被归并掉」的旧行——`keepMissing` 会把它原样带回,于是又是
   两条同名。这段清理必须排在所有托管表写完之后,否则兜底判据看到的还是基线里未重写的 ZMOVEMENT。

真实数据效果(本地库,与生产同一形状):方式恢复成站点原本那套 1火车/2公交/3高铁/4电🐔/5地铁/6步行、
无重名;位移各挂回自己的方式(电🐔 336、地铁 25、高铁 23、公交 19、火车 9、步行 3——修复前只有 6 条能对上)。

用例:`TestExportMergesDuplicateTransportNames`(同名归并 + 位移挂回自己那条 + 无悬空引用)、
`TestMergeKeepsTransportNumberingConsistent`(合并后同一 transport_src 不许有两种名称,附「位移确实被补
回来了」的哨兵断言避免写成空用例)。三处都做了变异验证:去掉改挂 / 去掉旧行清理 / 合并照抄实机编号 →
对应用例分别变红。

全量验收 295 PASS / 0 FAIL / 0 SKIP。
This commit is contained in:
tamakyi
2026-09-29 13:25:50 +08:00
parent f26537b1e9
commit 9c1413bb39
4 changed files with 389 additions and 26 deletions
+51
View File
@@ -418,3 +418,54 @@ func TestMergePlacesMapsSameNameLocationsOneToOne(t *testing.T) {
t.Errorf("备份 #42 应配到最近的 920002,实际 %d", got)
}
}
// 交通方式的编号是**两套空间**:站点用自己的 transport_src 编号,而备份里的 m.TransportSrc 是
// 实机的编号。合并时照抄实机编号,会让同一个 src 带上两种含义(实测 src=4 既是「电🐔」又是
// 「公交」)——导出时按名称归并前会吐出两条同名方式,真机整包拒收。合并必须按名称把实机的方式
// 归一到站点的编号上。
func TestMergeKeepsTransportNumberingConsistent(t *testing.T) {
f := newMovementFixture(t)
ctx, pg, dsID := f.ctx, f.pg, f.dsID
movCount := func() int {
return f.scalar(t, `SELECT count(*) FROM movements WHERE dataset_id=$1`, dsID)
}
before := movCount()
// 把站点所有方式改名(编辑页允许改方式名,这就是真实操作):于是站点的「1 号」与实机的
// 「1 号」含义不同——两套编号空间撞车的形状
if _, err := pg.ExecContext(ctx, `UPDATE movements SET transport_name = transport_name || '(站内)'
WHERE dataset_id=$1 AND transport_src IS NOT NULL`, dsID); err != nil {
t.Fatalf("预置改名失败: %v", err)
}
// 删掉一条**带交通方式**的位移的端点到访,好让合并把它连同位移一起补回来
// (没有新到访就不会插入任何位移,用例会变成空的)
var pk int
if err := pg.QueryRowContext(ctx, `SELECT from_visit_src FROM movements
WHERE dataset_id=$1 AND transport_src IS NOT NULL AND from_visit_src IS NOT NULL
ORDER BY id LIMIT 1`, dsID).Scan(&pk); err != nil {
t.Fatalf("挑端点到访失败: %v", err)
}
for _, q := range []string{
`DELETE FROM movements WHERE dataset_id=$1 AND (from_visit_src=$2 OR to_visit_src=$2)`,
`UPDATE weather SET visit_src=NULL WHERE dataset_id=$1 AND visit_src=$2`,
`DELETE FROM visits WHERE dataset_id=$1 AND src_pk=$2`,
} {
if _, err := pg.ExecContext(ctx, q, dsID, pk); err != nil {
t.Fatalf("预置删除失败: %v", err)
}
}
token, _ := previewBackupToken(t, f, f.src)
mergeBackup(t, f, token, "add_new=1")
if got := movCount(); got < before {
t.Fatalf("合并没有把删掉的位移补回来(%d → %d),这条用例会变成空的", before, got)
}
n := f.scalar(t, `SELECT count(*) FROM (SELECT transport_src FROM movements
WHERE dataset_id=$1 AND transport_src IS NOT NULL
GROUP BY transport_src HAVING count(DISTINCT transport_name) > 1) x`, dsID)
if n != 0 {
t.Fatalf("%d 个交通方式编号带上了两种名称(实机编号被照抄进站点的编号空间了)", n)
}
}