From 9c1413bb390d6b41671e2bd169b9c6a52cf986ca Mon Sep 17 00:00:00 2001 From: tamakyi Date: Tue, 29 Sep 2026 13:25:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=BC=E5=87=BA/=E5=90=88=E5=B9=B6=EF=BC=9A?= =?UTF-8?q?=E4=BA=A4=E9=80=9A=E6=96=B9=E5=BC=8F=E7=BC=96=E5=8F=B7=E7=A9=BA?= =?UTF-8?q?=E9=97=B4=E4=B8=B2=E4=BA=86=EF=BC=8C=E5=AF=BC=E5=87=BA=E4=BC=9A?= =?UTF-8?q?=E5=90=90=E5=87=BA=E4=B8=A4=E6=9D=A1=E5=90=8C=E5=90=8D=E6=96=B9?= =?UTF-8?q?=E5=BC=8F=EF=BC=88=E7=9C=9F=E6=9C=BA=E6=95=B4=E5=8C=85=E6=8B=92?= =?UTF-8?q?=E6=94=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 继上一轮修「活动颜色为 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。 --- internal/rond/export.go | 197 ++++++++++++++++++++++---- internal/web/backup_merge.go | 68 ++++++++- internal/web/backup_merge_test.go | 51 +++++++ internal/web/export_transport_test.go | 99 +++++++++++++ 4 files changed, 389 insertions(+), 26 deletions(-) create mode 100644 internal/web/export_transport_test.go diff --git a/internal/rond/export.go b/internal/rond/export.go index 3c10726..0161eee 100644 --- a/internal/rond/export.go +++ b/internal/rond/export.go @@ -12,6 +12,7 @@ import ( "math" "os" "path/filepath" + "sort" "strings" "time" @@ -254,8 +255,15 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st if p.Lat.Valid && p.Lon.Valid && !known["ZLOCATION"][p.SrcPK] { rawLat, rawLon = geo.GCJ02ToWGS84(p.Lat.Float64, p.Lon.Float64) } + // 国家码:真机这列从来不写 NULL(未知时写空串,实测 09-28 那份有 19 行是 ''), + // 而站点新建的地点没有这一项、导入时 '' 也被折叠成了 NULL —— 写回时按真机的形状补空串, + // 别把 NULL 塞回去(那又是它没见过的形状)。 + country := ns(p.Country) + if country == nil { + country = "" + } if err := loc.write(p.SrcPK, []any{ns(p.Name), ns(p.Category), nf(p.Lat), nf(p.Lon), - ns(p.Country), ns(p.Province), ns(p.City), ns(p.Sublocality), ns(p.Thoroughfare), ns(p.Timezone), + country, ns(p.Province), ns(p.City), ns(p.Sublocality), ns(p.Thoroughfare), ns(p.Timezone), lastArrival, rawLat, rawLon}, raw); err != nil { return fmt.Errorf("写入地点失败: %w", err) @@ -407,7 +415,7 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st } // 6) 交通方式 ZTRANSPORT(从位移里聚合而来) - transports, err := queryTransports(ctx, db, datasetID) + transports, transByName, err := queryTransports(ctx, db, datasetID, maxPKOf(known["ZTRANSPORT"], nil)+1) if err != nil { return err } @@ -515,7 +523,13 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st for _, m := range movements { var trv, fromv, tov any if m.Transport.Valid { - trv = m.Transport.Int64 + // 方式按**这条位移自己的名称**归并(见 queryTransports):同一个 src 在站点里可能 + // 带着两种含义,只有当条的名称才能认出它到底是哪一种,否则会挂到错的方式上。 + if x, ok := transByName[m.TransportName]; ok { + trv = x + } else { + trv = m.Transport.Int64 + } } if m.FromVisit.Valid { fromv = m.FromVisit.Int64 @@ -559,6 +573,53 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st return err } + // 10.5) 清掉被归并掉的交通方式旧行 + // 基线里「同名但编号已被归并掉」的旧行必须删掉:keepMissing 会把没写到的基线行原样留下, + // 于是包里又出现两条同名方式(正是 2026-09-29 生产导出被拒的形状)。名称没有被任何新行认领的 + // 基线方式仍然保留——站点侧「删掉某方式的最后一条行程」不等于用户想删掉这个方式。 + canonical := map[string]int{} + for _, t := range transports { + if t.Name.Valid && t.Name.String != "" { + canonical[t.Name.String] = t.SrcPK + } + } + if len(canonical) > 0 { + var stale []int + rows, err := tx.QueryContext(ctx, `SELECT Z_PK, COALESCE(ZNAME_,'') FROM ZTRANSPORT`) + if err != nil { + return fmt.Errorf("读取基线的交通方式: %w", err) + } + for rows.Next() { + var pk int + var name string + if err := rows.Scan(&pk, &name); err != nil { + rows.Close() + return err + } + if keep, ok := canonical[name]; ok && keep != pk { + stale = append(stale, pk) + } + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + for _, pk := range stale { + // 兜底:还有位移指着它就不删(宁可有重复,也不要留悬空引用) + var refs int + if err := tx.QueryRowContext(ctx, + `SELECT count(*) FROM ZMOVEMENT WHERE ZTRANSPORT_ = ?`, pk).Scan(&refs); err != nil { + return err + } + if refs > 0 { + continue + } + if _, err := tx.ExecContext(ctx, `DELETE FROM ZTRANSPORT WHERE Z_PK = ?`, pk); err != nil { + return fmt.Errorf("清理被归并的交通方式 %d: %w", pk, err) + } + } + } + // 11) 回写 Z_PRIMARYKEY.Z_MAX:只上调,避免 rond 下次插入撞键 bumps := map[string]int{ "Location": maxPKOf(known["ZLOCATION"], placesPKs(places)), @@ -1028,37 +1089,121 @@ type pgTransport struct { Name, Color, Icon sql.NullString } -// queryTransports 把位移里用到的交通方式聚合成 ZTRANSPORT 的行。 +// queryTransports 把位移里用到的交通方式按**名称**归并成 ZTRANSPORT 的行,并给出 +// 「方式名 -> 编号」——每条位移照自己的 transport_name 改挂过去。 // -// 必须按 transport_src 唯一:DISTINCT 在「同一方式的不同行程取名不一致」时会返回 -// 两行同一个 Z_PK(编辑页允许改方式名,历史数据里也已经有这种脏数据), -// 导出时会往 ZTRANSPORT 插两次同一个主键——新建的方式直接撞唯一约束让整次导出失败。 -// 取名取最近编辑过的那条(id 最大)的非空值。 -func queryTransports(ctx context.Context, db *sql.DB, datasetID int64) ([]pgTransport, error) { - rows, err := db.QueryContext(ctx, `SELECT transport_src, - (array_agg(transport_name ORDER BY id DESC) FILTER (WHERE transport_name IS NOT NULL))[1], +// 为什么不按 transport_src 聚合:站点没有交通方式表,方式只活在位移的 transport_* 四列里, +// 而 transport_src 是**两套编号空间**——把实机备份合并进来时实机的编号被写进了同名列,于是同一个 +// src 带着两种含义(实测 src=4 既是「电🐔」又是「公交」)。按 src 聚合只能给一个 src 取一个名字, +// 另一种含义的位移就被挂到错的方式上;更糟的是会吐出两条同名方式——同名在真机那边是模型唯一约束 +// 级别的违规,整包会被拒收(2026-09-29 生产导出踩的第二个坑)。 +// +// 归一规则:**名称就是身份**。按各名称的位移数从多到少依次认领编号——优先用它原本用得最多、 +// 且还没被别的名称认领的那个;都被占了就新发一个。实测这套规则会把编号恢复成站点原本那一套, +// 每条位移也都能挂回自己真正的方式上。 +// +// floor 是「新编号至少要从多少开始」,用来避开基线里已有的编号(避免把新行当成基线行改写)。 +func queryTransports(ctx context.Context, db *sql.DB, datasetID int64, floor int) ([]pgTransport, map[string]int, error) { + type cand struct{ src, n int } + byName := map[string][]cand{} + total := map[string]int{} + rows, err := db.QueryContext(ctx, `SELECT COALESCE(transport_name,''), transport_src, count(*) + FROM movements WHERE dataset_id=$1 AND transport_src IS NOT NULL + GROUP BY 1,2`, datasetID) + if err != nil { + return nil, nil, fmt.Errorf("读取交通方式: %w", err) + } + maxSrc := 0 + for rows.Next() { + var name string + var src, n int + if err := rows.Scan(&name, &src, &n); err != nil { + rows.Close() + return nil, nil, err + } + byName[name] = append(byName[name], cand{src, n}) + total[name] += n + if src > maxSrc { + maxSrc = src + } + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, nil, err + } + + // 颜色/图标:每个名称取最近编辑过的那条位移上的非空值(真机这两列从来不空,空值在写回时补占位) + colorIcon := map[string][2]sql.NullString{} + rows, err = db.QueryContext(ctx, `SELECT COALESCE(transport_name,''), (array_agg(transport_color ORDER BY id DESC) FILTER (WHERE transport_color IS NOT NULL))[1], (array_agg(transport_icon ORDER BY id DESC) FILTER (WHERE transport_icon IS NOT NULL))[1] FROM movements WHERE dataset_id=$1 AND transport_src IS NOT NULL - GROUP BY transport_src ORDER BY transport_src`, datasetID) + GROUP BY 1`, datasetID) if err != nil { - return nil, fmt.Errorf("读取交通方式: %w", err) + return nil, nil, fmt.Errorf("读取交通方式颜色: %w", err) } - defer rows.Close() - var out []pgTransport for rows.Next() { - var t pgTransport - var ts sql.NullInt64 - if err := rows.Scan(&ts, &t.Name, &t.Color, &t.Icon); err != nil { - return nil, err + var name string + var color, icon sql.NullString + if err := rows.Scan(&name, &color, &icon); err != nil { + rows.Close() + return nil, nil, err } - if !ts.Valid { - continue + colorIcon[name] = [2]sql.NullString{color, icon} + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, nil, err + } + + names := make([]string, 0, len(byName)) + for name := range byName { + names = append(names, name) + } + // 用量多的先认领编号;数量相同按名称排,保证结果与遍历顺序无关 + sort.Slice(names, func(i, j int) bool { + if total[names[i]] != total[names[j]] { + return total[names[i]] > total[names[j]] + } + return names[i] < names[j] + }) + + next := maxSrc + 1 + if next < floor { + next = floor + } + claimed := map[int]bool{} + transByName := map[string]int{} + out := make([]pgTransport, 0, len(names)) + for _, name := range names { + list := byName[name] + sort.Slice(list, func(i, j int) bool { + if list[i].n != list[j].n { + return list[i].n > list[j].n + } + return list[i].src < list[j].src + }) + src := 0 + for _, c := range list { + if !claimed[c.src] { + src = c.src + break + } + } + if src == 0 { + src = next + next++ + } + claimed[src] = true + transByName[name] = src + t := pgTransport{SrcPK: src, Color: colorIcon[name][0], Icon: colorIcon[name][1]} + if name != "" { + t.Name = sql.NullString{String: name, Valid: true} } - t.SrcPK = int(ts.Int64) out = append(out, t) } - return out, rows.Err() + sort.Slice(out, func(i, j int) bool { return out[i].SrcPK < out[j].SrcPK }) + return out, transByName, nil } type pgVisit struct { @@ -1096,10 +1241,12 @@ type pgMovement struct { SrcPK int Transport, FromVisit, ToVisit sql.NullInt64 Start, End sql.NullTime + TransportName string // 该条位移自己的方式名(归并用,见 queryTransports) } func queryMovements(ctx context.Context, db *sql.DB, datasetID int64) ([]pgMovement, error) { - rows, err := db.QueryContext(ctx, `SELECT src_pk, transport_src, from_visit_src, to_visit_src, started_at, ended_at + rows, err := db.QueryContext(ctx, `SELECT src_pk, transport_src, from_visit_src, to_visit_src, started_at, ended_at, + COALESCE(transport_name,'') FROM movements WHERE dataset_id=$1 ORDER BY id`, datasetID) if err != nil { return nil, fmt.Errorf("读取位移: %w", err) @@ -1108,7 +1255,7 @@ func queryMovements(ctx context.Context, db *sql.DB, datasetID int64) ([]pgMovem var out []pgMovement for rows.Next() { var m pgMovement - if err := rows.Scan(&m.SrcPK, &m.Transport, &m.FromVisit, &m.ToVisit, &m.Start, &m.End); err != nil { + if err := rows.Scan(&m.SrcPK, &m.Transport, &m.FromVisit, &m.ToVisit, &m.Start, &m.End, &m.TransportName); err != nil { return nil, err } out = append(out, m) diff --git a/internal/web/backup_merge.go b/internal/web/backup_merge.go index 22241ca..0a16ec5 100644 --- a/internal/web/backup_merge.go +++ b/internal/web/backup_merge.go @@ -516,6 +516,28 @@ func (s *Server) mergeVisits(ctx context.Context, ex execer, dsID int64, b *rond for _, t := range b.Transports { transName[t.SrcPK], transColor[t.SrcPK], transIcon[t.SrcPK] = t.Name, t.Color, t.Icon } + // 交通方式是**两套编号空间**:站点的 movements.transport_src 用自己的编号(站点没有交通方式表, + // 方式只活在位移的 transport_* 四列里),而 m.TransportSrc 是实机的编号。同一个数字两边含义 + // 常常不同(实测实机 4=公交、站点 4=电🐔),照抄会把一个 src 变成两种含义——导出按 src 聚合时 + // 会吐出两条同名方式,真机那边(以及 Core Data 的唯一约束)会判成损坏数据、整包拒收。 + // 按**名称**归一:站点已有同名方式就沿用它的编号,没有才新发一个。 + siteTrans, transNext, err := s.transportsByName(ctx, ex, dsID) + if err != nil { + return err + } + transSrc := map[int]int{} + for _, t := range b.Transports { + if t.Name == "" { + continue + } + if src, ok := siteTrans[t.Name]; ok { + transSrc[t.SrcPK] = src + continue + } + siteTrans[t.Name] = transNext + transSrc[t.SrcPK] = transNext + transNext++ + } coordOf := map[int][2]float64{} for _, l := range b.Locations { coordOf[l.SrcPK] = [2]float64{l.Lat, l.Lon} @@ -570,11 +592,21 @@ func (s *Server) mergeVisits(ctx context.Context, ex execer, dsID int64, b *rond if err != nil { return err } + // transport_src 写**站点编号空间**里的号(见上面 transSrc 的说明):照抄实机编号会让 + // 同一个 src 出现两种含义,导出时吐出两条同名方式、整包被真机拒收。 + var transFK any + if m.TransportSrc != nil { + if x, ok := transSrc[*m.TransportSrc]; ok { + transFK = x + } else { + transFK = *m.TransportSrc + } + } if _, err := ex.ExecContext(ctx, `INSERT INTO movements (dataset_id, src_pk, transport_src, transport_name, transport_color, transport_icon, from_visit_src, to_visit_src, from_place_id, to_place_id, started_at, ended_at, duration_min, distance_km) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT DO NOTHING`, - dsID, pk, m.TransportSrc, tname, tcolor, ticon, fsrc, tsrc, + dsID, pk, transFK, tname, tcolor, ticon, fsrc, tsrc, fromPID, toPID, m.Start, m.End, dur, dist); err != nil { return fmt.Errorf("新增位移: %w", err) } @@ -627,6 +659,40 @@ func (s *Server) mergeVisits(ctx context.Context, ex execer, dsID int64, b *rond return nil } +// transportsByName 读站点现有的交通方式,按**名称**归并成一个「名称 -> 编号」表(同名取用得多 +// 的那个编号,并列取小),并给出下一个可用编号。站点没有交通方式表,方式只活在位移的 +// transport_src / transport_name 里,所以这里从 movements 里聚合。 +func (s *Server) transportsByName(ctx context.Context, ex execer, dsID int64) (map[string]int, int, error) { + rows, err := ex.QueryContext(ctx, `SELECT transport_src, transport_name, count(*) + FROM movements + WHERE dataset_id=$1 AND transport_src IS NOT NULL AND COALESCE(transport_name,'') <> '' + GROUP BY 1,2`, dsID) + if err != nil { + return nil, 0, err + } + defer rows.Close() + count, best := map[string]int{}, map[string]int{} + for rows.Next() { + var src, n int + var name string + if err := rows.Scan(&src, &name, &n); err != nil { + return nil, 0, err + } + cur, seen := count[name] + if !seen || n > cur || (n == cur && src < best[name]) { + count[name], best[name] = n, src + } + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + next, err := s.nextSrcPK(ctx, ex, dsID, transportSeq) + if err != nil { + return nil, 0, err + } + return best, next, nil +} + // applyDeletions 处理「仅站内有」里被勾选删除的记录。顺序是到访 → 活动 → 地点: // 删地点会级联删掉它名下的全部到访(外键 ON DELETE CASCADE),所以反过来会误删; // 而且**有到访引用时直接拒绝**,让站长先处理到访,而不是悄悄带走一批。 diff --git a/internal/web/backup_merge_test.go b/internal/web/backup_merge_test.go index 46d3360..fe469a6 100644 --- a/internal/web/backup_merge_test.go +++ b/internal/web/backup_merge_test.go @@ -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) + } +} diff --git a/internal/web/export_transport_test.go b/internal/web/export_transport_test.go new file mode 100644 index 0000000..eb38784 --- /dev/null +++ b/internal/web/export_transport_test.go @@ -0,0 +1,99 @@ +package web + +import ( + "os" + "path/filepath" + "testing" + + "rond-with-you/internal/rond" +) + +// 交通方式是**全局表、以名称区分**:同名两条会被真机当成损坏数据,整包拒收 +// (2026-09-29 生产导出就是这么被拒的:`ZTRANSPORT` 里两条「公交」)。 +// +// 站点的 transport_src 是另一套编号空间——把实机备份合并进来时,同一个 src 会带上两种含义 +// (实测 src=4 既是「电🐔」又是「公交」)。导出必须按**名称**归并,并且把每条位移挂回 +// 它自己那个名称对应的方式上;只按 src 取一个名字会让另一种含义的位移挂错方式。 +func TestExportMergesDuplicateTransportNames(t *testing.T) { + f := newMovementFixture(t) + ctx, pg, dsID := f.ctx, f.pg, f.dsID + + // 造出「同一个 src、两种名称」的形状:正是合并实机备份留下的样子 + var k1, k2 int + if err := pg.QueryRowContext(ctx, `SELECT + (SELECT src_pk FROM movements WHERE dataset_id=$1 AND transport_src IS NOT NULL ORDER BY id LIMIT 1), + (SELECT src_pk FROM movements WHERE dataset_id=$1 AND transport_src IS NOT NULL ORDER BY id OFFSET 1 LIMIT 1)`, + dsID).Scan(&k1, &k2); err != nil { + t.Fatalf("挑位移失败: %v", err) + } + for _, v := range []struct { + pk int + name string + color string + icon string + }{{k1, "电🐔", "purple", "moped.fill"}, {k2, "公交", "red", "bus.fill"}} { + if _, err := pg.ExecContext(ctx, `UPDATE movements SET transport_src=900, transport_name=$3, + transport_color=$4, transport_icon=$5 WHERE dataset_id=$1 AND src_pk=$2`, + dsID, v.pk, v.name, v.color, v.icon); err != nil { + t.Fatalf("预置失败: %v", err) + } + } + t.Cleanup(func() { + pg.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1 AND transport_src=900`, dsID) + }) + // 再把站点原本那条「公交」(src=2)改名,于是「公交」这个名字只剩注入的那条: + // 归并后基线里 src=2 那行就成了「同名但编号已被归并掉」的旧行,必须被删掉, + // 否则 keepMissing 会把它原样留下 → 包里又是两条同名方式 + if _, err := pg.ExecContext(ctx, `UPDATE movements SET transport_name='巴士' + WHERE dataset_id=$1 AND transport_src=2`, dsID); err != nil { + t.Fatalf("预置改名失败: %v", err) + } + + out := filepath.Join(t.TempDir(), "export.rondbackup") + if err := os.WriteFile(out, f.export(t), 0o644); err != nil { + t.Fatal(err) + } + edb, err := rond.Open(out, t.TempDir()) + if err != nil { + t.Fatalf("打开导出包失败: %v", err) + } + t.Cleanup(func() { edb.Close() }) + + // 1) 同名不许出现两条 + rows, err := edb.Query(`SELECT ZNAME_ FROM ZTRANSPORT WHERE ZNAME_ IS NOT NULL + GROUP BY ZNAME_ HAVING count(*) > 1`) + if err != nil { + t.Fatalf("读 ZTRANSPORT 失败: %v", err) + } + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + t.Fatal(err) + } + t.Errorf("导出包里有两条同名交通方式 %q:真机会判成损坏数据、整包拒收", name) + } + rows.Close() + if err := rows.Err(); err != nil { + t.Fatal(err) + } + + // 2) 每条位移必须挂回自己名称对应的那条方式 + for _, want := range []struct { + pk int + name string + }{{k1, "电🐔"}, {k2, "公交"}} { + var got string + if err := edb.QueryRow(`SELECT t.ZNAME_ FROM ZMOVEMENT m JOIN ZTRANSPORT t ON t.Z_PK = m.ZTRANSPORT_ + WHERE m.Z_PK = ?`, want.pk).Scan(&got); err != nil { + t.Fatalf("位移 #%d 的方式查不到: %v", want.pk, err) + } + if got != want.name { + t.Errorf("位移 #%d 应挂到 %q,实际挂到了 %q(归并后要按位移自己的名称改挂)", want.pk, want.name, got) + } + } + // 3) 归并后不许留下悬空的 ZTRANSPORT_ 引用 + if n := countNull(t, edb, `SELECT count(*) FROM ZMOVEMENT m WHERE m.ZTRANSPORT_ IS NOT NULL + AND m.ZTRANSPORT_ <> 0 AND NOT EXISTS (SELECT 1 FROM ZTRANSPORT t WHERE t.Z_PK = m.ZTRANSPORT_)`); n != 0 { + t.Errorf("归并后有 %d 条位移指向不存在的方式", n) + } +}