Files
rond-with-you/internal/web/export_align_test.go
T
tamakyi d5a011259d 导出:按身份把编号对齐到基线(app 只能靠编号解引用地点/活动,编号漂移就「看不到数据」)
背景: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。

注意:对齐的目标是**这份数据集的基线**。要让导出与手机编号一致,数据集的基线就得是手机包
(把手机导出作为新数据集上传、再把旧站点导出包合并进去,然后从它导出)。
2026-09-29 16:57:29 +08:00

146 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}