背景:Core Data 里**地点与活动没有 UUID**,app 只能靠 Z_PK 解引用(ZVISIT.ZLOCATION /
ZVISIT.ZACTIVITY_)。站点这边的 src_pk 是从「导入→导出」循环里滚出来的,与手机 store 的 Z_PK
不是一套。2026-09-29 实测(真机 09-28 包 vs 站点导出,按到访 UUID 对齐):地点引用 920/920 不同、
活动引用 903/920 不同,而引用到的地点按「名称+坐标」完全一致——语义相同、编号不同。结果就是
「导入成功但看不到数据」(每条到访都指到手机那边的别的地点/活动或不存在的行)。
做法(新增 internal/rond/export_align.go):导出的每张托管表**先按身份建映射**,再按映射后的编号写:
地点:坐标键优先(多值索引),退到「同名且相距 ≤1km」
活动/到访:UUID(读留档 entity_raw 里的 ZUID_ / ZIDENTIFIER_)
标签/交通方式:名称;位移/天气:所属到访(已对齐)
基线里没有身份对应的行 → 取基线最大编号之上的新号;而**身份匹配不上、原编号又没被别的身份占用的行
保持原编号**(这样「基线就是源包」的往返导出一行都不动;被占用时才另取号,避免覆盖基线的别的记录)。
配套改动:引用全部改挂(含自引用 ZMERGEDTO / ZPARENT,它们原本从留档原样带出、对齐后会指错或悬空,
已加入托管列并按映射写回);Z_PRIMARYKEY.Z_MAX 改用实际写入的编号;基线身份查询都 ORDER BY Z_PK 保证
确定性;siteUUIDMap 改成读 base64 后在 Go 里解码(与 web 侧 loadStoredVisits 同一手法)。
验证:
- 真实数据(本地数据集 + 真机 09-28 当基线):按 UUID 对齐的 953 条共同到访里地点引用一致 933、
活动一致 951(改前 0 / 7),悬空引用 0;余下 20 条是基线自己重名重复行的归属歧义。
- 用例 TestExportAlignsPKsToBaseline:把库内编号整体平移 500000 再导出,断言按 UUID 对齐后**地点/
活动的编号**与基线一致率 ≥95% + 7 类引用无悬空 ⇒ 822/822。⚠️ 第一版只比「名称+坐标」,变异验证
不会变红(编号不一致时引用依然自洽),必须比编号本身。
- 往返不变量 TestExportRoundtrip 通过(「匹配不上则保持原编号」这条规则就是为它加的:之前唯一的
未匹配行是那条没有 UUID 的「全空到访」,被改号成 825 才暴露出来)。
- 全量验收 297 PASS / 0 FAIL / 0 SKIP。
注意:对齐的目标是**这份数据集的基线**。要让导出与手机编号一致,数据集的基线就得是手机包
(把手机导出作为新数据集上传、再把旧站点导出包合并进去,然后从它导出)。
146 lines
5.8 KiB
Go
146 lines
5.8 KiB
Go
package web
|
||
|
||
import (
|
||
"database/sql"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
|
||
"rond-with-you/internal/rond"
|
||
)
|
||
|
||
// 导出的**编号必须与基线对齐**。
|
||
//
|
||
// 为什么:Core Data 的 store 里地点与活动**没有 UUID**,app 只能靠 Z_PK 解引用
|
||
// (`ZVISIT.ZLOCATION` / `ZVISIT.ZACTIVITY_`)。站点这边的编号是从「导入→导出」循环里滚出来的,
|
||
// 与手机 store 的编号不是一套——2026-09-29 实测:同一批到访里 920/920 的地点引用、903/920 的
|
||
// 活动引用与真机包不同(引用到的地点按名称+坐标完全一致),结果是「导入成功但看不到数据」。
|
||
//
|
||
// 这里把库里的编号整体平移(模拟「数据来自编号完全不同的备份」),再导出,要求导出结果回到基线的编号。
|
||
func TestExportAlignsPKsToBaseline(t *testing.T) {
|
||
f := newMovementFixture(t)
|
||
f.shiftSrcPKs(t, 500000) // 库内编号整体平移 ⇒ 与基线完全不同
|
||
|
||
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() })
|
||
base := f.openSource(t) // 基线 = 导入用的那份包
|
||
|
||
type ref struct {
|
||
placePK, actPK sql.NullInt64
|
||
}
|
||
load := func(db *sql.DB) map[string]ref {
|
||
rows, err := db.Query(`SELECT hex(ZIDENTIFIER_), ZLOCATION, ZACTIVITY_ FROM ZVISIT
|
||
WHERE ZIDENTIFIER_ IS NOT NULL`)
|
||
if err != nil {
|
||
t.Fatalf("读到访失败: %v", err)
|
||
}
|
||
defer rows.Close()
|
||
m := map[string]ref{}
|
||
for rows.Next() {
|
||
var u string
|
||
var r ref
|
||
if err := rows.Scan(&u, &r.placePK, &r.actPK); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
m[u] = r
|
||
}
|
||
return m
|
||
}
|
||
describe := func(db *sql.DB, placePK, actPK sql.NullInt64) (string, string) {
|
||
p := "<空>"
|
||
if placePK.Valid {
|
||
var n string
|
||
var la, lo float64
|
||
if err := db.QueryRow(`SELECT COALESCE(ZNAME_,''), COALESCE(ZLATITUDE,0), COALESCE(ZLONGITUDE,0)
|
||
FROM ZLOCATION WHERE Z_PK=?`, placePK.Int64).Scan(&n, &la, &lo); err != nil {
|
||
return "<地点不存在>", ""
|
||
}
|
||
p = fmt.Sprintf("%s %.5f,%.5f", n, la, lo)
|
||
}
|
||
a := "<空>"
|
||
if actPK.Valid {
|
||
var n string
|
||
if err := db.QueryRow(`SELECT COALESCE(ZNAME_,'') FROM ZACTIVITY WHERE Z_PK=?`, actPK.Int64).Scan(&n); err != nil {
|
||
return p, "<活动不存在>"
|
||
}
|
||
a = n
|
||
}
|
||
return p, a
|
||
}
|
||
|
||
baseVisits, expVisits := load(base), load(edb)
|
||
if len(baseVisits) == 0 || len(expVisits) == 0 {
|
||
t.Fatalf("包里的到访为空:基线 %d 条 / 导出 %d 条", len(baseVisits), len(expVisits))
|
||
}
|
||
common, placeOK, actOK := 0, 0, 0
|
||
for u, br := range baseVisits {
|
||
er, ok := expVisits[u]
|
||
if !ok {
|
||
continue
|
||
}
|
||
common++
|
||
bp, ba := describe(base, br.placePK, br.actPK)
|
||
ep, ea := describe(edb, er.placePK, er.actPK)
|
||
// 关键:**编号本身**要和基线一致(app 就是拿这个编号解引用的)。只看「名称+坐标」
|
||
// 查不出问题——编号不一致时引用依然自洽,但手机那边解不出来。
|
||
samePK := br.placePK.Valid == er.placePK.Valid &&
|
||
(!br.placePK.Valid || br.placePK.Int64 == er.placePK.Int64)
|
||
if samePK {
|
||
placeOK++
|
||
} else if placeOK+3 > common { // 只记前几条,避免刷屏
|
||
// 少数不一致是正常的:真机自己就有重名的重复行(如同名车站两条),
|
||
// 地点没有 UUID,1:1 归属只能任选其一。所以例子只记日志,断言看比例。
|
||
t.Logf("到访 %s 的地点编号不同:基线 #%v(%q) / 导出 #%v(%q)",
|
||
u[:8], br.placePK.Int64, bp, er.placePK.Int64, ep)
|
||
}
|
||
sameAct := br.actPK.Valid == er.actPK.Valid &&
|
||
(!br.actPK.Valid || br.actPK.Int64 == er.actPK.Int64)
|
||
if sameAct {
|
||
actOK++
|
||
} else if actOK+3 > common {
|
||
t.Logf("到访 %s 的活动编号不同:基线 #%v(%q) / 导出 #%v(%q)",
|
||
u[:8], br.actPK.Int64, ba, er.actPK.Int64, ea)
|
||
}
|
||
}
|
||
if common == 0 {
|
||
t.Fatal("基线与导出没有可对齐的到访")
|
||
}
|
||
if placeOK*100 < common*95 {
|
||
t.Errorf("地点引用与基线一致率过低:%d/%d(应 ≥95%%)", placeOK, common)
|
||
}
|
||
if actOK*100 < common*95 {
|
||
t.Errorf("活动引用与基线一致率过低:%d/%d(应 ≥95%%)", actOK, common)
|
||
}
|
||
t.Logf("按 UUID 对齐共同到访 %d 条:地点引用一致 %d、活动引用一致 %d", common, placeOK, actOK)
|
||
|
||
// 对齐之后不许出现悬空引用(含自引用 ZMERGEDTO / ZPARENT)
|
||
for _, q := range []struct{ name, sql string }{
|
||
{"visit→location", `SELECT count(*) FROM ZVISIT a WHERE a.ZLOCATION IS NOT NULL AND a.ZLOCATION<>0
|
||
AND NOT EXISTS (SELECT 1 FROM ZLOCATION b WHERE b.Z_PK=a.ZLOCATION)`},
|
||
{"visit→activity", `SELECT count(*) FROM ZVISIT a WHERE a.ZACTIVITY_ IS NOT NULL AND a.ZACTIVITY_<>0
|
||
AND NOT EXISTS (SELECT 1 FROM ZACTIVITY b WHERE b.Z_PK=a.ZACTIVITY_)`},
|
||
{"visit→mergedTo", `SELECT count(*) FROM ZVISIT a WHERE a.ZMERGEDTO IS NOT NULL AND a.ZMERGEDTO<>0
|
||
AND NOT EXISTS (SELECT 1 FROM ZVISIT b WHERE b.Z_PK=a.ZMERGEDTO)`},
|
||
{"visit→parent", `SELECT count(*) FROM ZVISIT a WHERE a.ZPARENT IS NOT NULL AND a.ZPARENT<>0
|
||
AND NOT EXISTS (SELECT 1 FROM ZVISIT b WHERE b.Z_PK=a.ZPARENT)`},
|
||
{"movement→visit", `SELECT count(*) FROM ZMOVEMENT a WHERE a.ZVISITFROM_ IS NOT NULL AND a.ZVISITFROM_<>0
|
||
AND NOT EXISTS (SELECT 1 FROM ZVISIT b WHERE b.Z_PK=a.ZVISITFROM_)`},
|
||
{"movement→transport", `SELECT count(*) FROM ZMOVEMENT a WHERE a.ZTRANSPORT_ IS NOT NULL AND a.ZTRANSPORT_<>0
|
||
AND NOT EXISTS (SELECT 1 FROM ZTRANSPORT b WHERE b.Z_PK=a.ZTRANSPORT_)`},
|
||
{"weather→visit", `SELECT count(*) FROM ZHOURLYWEATHER a WHERE a.ZVISIT IS NOT NULL AND a.ZVISIT<>0
|
||
AND NOT EXISTS (SELECT 1 FROM ZVISIT b WHERE b.Z_PK=a.ZVISIT)`},
|
||
} {
|
||
if n := countNull(t, edb, q.sql); n != 0 {
|
||
t.Errorf("编号对齐后出现 %d 条悬空引用:%s", n, q.name)
|
||
}
|
||
}
|
||
}
|