导出:按身份把编号对齐到基线(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。

注意:对齐的目标是**这份数据集的基线**。要让导出与手机编号一致,数据集的基线就得是手机包
(把手机导出作为新数据集上传、再把旧站点导出包合并进去,然后从它导出)。
This commit is contained in:
tamakyi
2026-09-29 16:57:29 +08:00
parent d5e96edbea
commit d5a011259d
3 changed files with 685 additions and 30 deletions
+81 -30
View File
@@ -39,7 +39,8 @@ var managedCols = map[string][]string{
"ZTAG": {"ZNAME_", "ZCOLOR_", "ZGROUP", "ZLASTARRIVALDATE_"},
"ZTRANSPORT": {"ZNAME_", "ZCOLOR_", "ZICON_"},
"ZVISIT": {"ZLOCATION", "ZACTIVITY_", "ZARRIVALDATE_", "ZDEPARTUREDATE_",
"ZBOOKMARKED", "ZUSERADDED", "ZREMARK_", "ZEMOJINAME_", "ZWEATHERSYMBOL_", "ZIDENTIFIER_"},
"ZBOOKMARKED", "ZUSERADDED", "ZREMARK_", "ZEMOJINAME_", "ZWEATHERSYMBOL_", "ZIDENTIFIER_",
"ZMERGEDTO", "ZPARENT"},
"ZMOVEMENT": {"ZTRANSPORT_", "ZVISITFROM_", "ZVISITTO_", "ZSTART_", "ZEND_", "ZTYPE_"},
"ZHOURLYWEATHER": {"ZVISIT", "ZDATE_", "ZISDAYLIGHT", "ZTEMPERATURE_", "ZAPPARENTTEMPERATURE_", "ZHUMIDITY_",
"ZPRECIPITATIONAMOUNT_", "ZPRECIPITATIONCHANCE_", "ZWINDSPEED_", "ZVISIBILITY_", "ZUVINDEXVALUE_",
@@ -241,7 +242,13 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st
return err
}
loc := w("ZLOCATION", "Location")
// 编号对齐(见 export_align.go):把站点编号映射到基线的编号空间
placeAlign, err := alignPlaces(ctx, tx, places)
if err != nil {
return fmt.Errorf("对齐地点编号: %w", err)
}
for _, p := range places {
outPK := placeAlign[p.SrcPK]
placeSrcByID[int64(p.ID)] = p.SrcPK
raw := raws["ZLOCATION"][p.SrcPK]
// 地点详情的「最近活跃时间」= 该地点最晚一次到访的到达时间,没有到访就不写
@@ -253,13 +260,13 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st
// 有留档就用真机那份(它才是真正的原始定位,哪怕用户后来挪过点);
// 站点新建的地点没有留档,用显示坐标反算一个,否则这列会空着。
var rawLat, rawLon any
if p.Lat.Valid && p.Lon.Valid && !known["ZLOCATION"][p.SrcPK] {
if p.Lat.Valid && p.Lon.Valid && !known["ZLOCATION"][outPK] {
rawLat, rawLon = geo.GCJ02ToWGS84(p.Lat.Float64, p.Lon.Float64)
}
// 国家码写成 NULL,**不要**补空串:2026-09-29 实测——只把这一列从 NULL 改成 ''
// 就让真机从「能导入」变成「备份恢复失败」(同一份包、同一台设备,唯一差异就是这列)。
// 站点新建的地点这一项本来就是 NULL,保持原样最安全。
if err := loc.write(p.SrcPK, []any{ns(p.Name), ns(p.Category), nf(p.Lat), nf(p.Lon),
if err := loc.write(outPK, []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),
lastArrival, rawLat, rawLon},
raw); err != nil {
@@ -277,7 +284,7 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st
var pks []any
for _, p := range places {
if p.CoordSource.Valid && p.CoordSource.String == "photo" {
pks = append(pks, p.SrcPK)
pks = append(pks, placeAlign[p.SrcPK])
}
}
for len(pks) > 0 {
@@ -306,7 +313,12 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st
return err
}
act := w("ZACTIVITY", "Activity")
actAlign, err := alignUUIDs(ctx, db, tx, datasetID, "ZACTIVITY", "ZUID_", actsPKs(activities))
if err != nil {
return fmt.Errorf("对齐活动编号: %w", err)
}
for _, a := range activities {
outPK := actAlign[a.SrcPK]
actSrcByID[int64(a.ID)] = a.SrcPK
raw := raws["ZACTIVITY"][a.SrcPK]
// 站点没有图标入口,新建的活动在 rond 里会是空图标;站点自己渲染时缺省用 📍
@@ -326,9 +338,9 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st
if t, ok := actLast[int64(a.ID)]; ok {
last = appleSeconds(t)
}
if err := act.write(a.SrcPK, []any{ns(a.Name), color, icon,
if err := act.write(outPK, []any{ns(a.Name), color, icon,
bint(a.Home), bint(a.Work), bint(a.Excluded), bint(a.Archived), last,
coreDataUUID(!known["ZACTIVITY"][a.SrcPK], "ZACTIVITY", a.SrcPK)},
coreDataUUID(!known["ZACTIVITY"][outPK], "ZACTIVITY", a.SrcPK)},
raw); err != nil {
return fmt.Errorf("写入活动类型失败: %w", err)
}
@@ -385,6 +397,10 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st
return err
}
tag := w("ZTAG", "Tag")
tagAlign, err := alignTags(ctx, tx, tags)
if err != nil {
return fmt.Errorf("对齐标签编号: %w", err)
}
tagLast, err := queryTagMaxArrival(ctx, db, datasetID)
if err != nil {
return err
@@ -402,7 +418,7 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st
if tm, ok := tagLast[int64(t.SrcPK)]; ok {
last = appleSeconds(tm)
}
if err := tag.write(t.SrcPK, []any{ns(t.Name), ns(t.Color), grp, last},
if err := tag.write(tagAlign[t.SrcPK], []any{ns(t.Name), ns(t.Color), grp, last},
raws["ZTAG"][t.SrcPK]); err != nil {
return fmt.Errorf("写入标签失败: %w", err)
}
@@ -412,13 +428,17 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st
}
// 6) 交通方式 ZTRANSPORT(从位移里聚合而来)
transports, transByName, err := queryTransports(ctx, db, datasetID, maxPKOf(known["ZTRANSPORT"], nil)+1)
transports, transByNameSite, err := queryTransports(ctx, db, datasetID, maxPKOf(known["ZTRANSPORT"], nil)+1)
if err != nil {
return err
}
tr := w("ZTRANSPORT", "Transport")
// 没有任何行程引用的方式保持基线原样,不当成「已删除」
tr.keepMissing = true
transAlign, err := alignTransports(ctx, tx, transports)
if err != nil {
return fmt.Errorf("对齐交通方式编号: %w", err)
}
for _, t := range transports {
name, color, icon := ns(t.Name), ns(t.Color), ns(t.Icon)
// 真机这三列从来没出现过空值(NULL 和空串都没有),而站点不单独维护交通方式表、
@@ -426,7 +446,7 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st
// 否则会把 NULL 写进包里——rond 拿到的是它自己从来不会产生的形状
// (2026-09-20:新建的「步行」就是这样导出的,与新建活动补 blue/📍 同一套做法)。
// 基线里已有的行不碰:那种情况交给 keepNilCols 保留真机原值。
if !known["ZTRANSPORT"][t.SrcPK] {
if !known["ZTRANSPORT"][transAlign[t.SrcPK]] {
if name == nil {
name = "未命名"
}
@@ -437,7 +457,7 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st
icon = transportPlaceholderIcon
}
}
if err := tr.write(t.SrcPK, []any{name, color, icon}, raws["ZTRANSPORT"][t.SrcPK]); err != nil {
if err := tr.write(transAlign[t.SrcPK], []any{name, color, icon}, raws["ZTRANSPORT"][t.SrcPK]); err != nil {
return fmt.Errorf("写入交通方式失败: %w", err)
}
}
@@ -451,21 +471,30 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st
return err
}
visit := w("ZVISIT", "Visit")
visitAlign, err := alignUUIDs(ctx, db, tx, datasetID, "ZVISIT", "ZIDENTIFIER_", visitsPKs(visits))
if err != nil {
return fmt.Errorf("对齐到访编号: %w", err)
}
for _, v := range visits {
outPK := visitAlign[v.SrcPK]
var locPK, actPK any
if v.PlaceID.Valid {
if sp, ok := placeSrcByID[v.PlaceID.Int64]; ok {
locPK = sp
locPK = placeAlign[sp]
}
}
if v.ActivityID.Valid {
if sp, ok := actSrcByID[v.ActivityID.Int64]; ok {
actPK = sp
actPK = actAlign[sp]
}
}
if err := visit.write(v.SrcPK, []any{locPK, actPK, appleSeconds(v.Arrival), nt(v.Departure),
// ZMERGEDTO / ZPARENT 是自引用:留档里存的是站点编号,对齐后必须跟着改挂,
// 否则会指到别的到访、甚至不存在的行。
mergedTo := alignedVisitRef(raws["ZVISIT"][v.SrcPK], "ZMERGEDTO", visitAlign)
parent := alignedVisitRef(raws["ZVISIT"][v.SrcPK], "ZPARENT", visitAlign)
if err := visit.write(outPK, []any{locPK, actPK, appleSeconds(v.Arrival), nt(v.Departure),
bint(v.Bookmarked), bint(v.UserAdded), ns(v.Remark), ns(v.Emoji), ns(v.WeatherSymbol),
coreDataUUID(!known["ZVISIT"][v.SrcPK], "ZVISIT", v.SrcPK)},
coreDataUUID(!known["ZVISIT"][outPK], "ZVISIT", v.SrcPK), mergedTo, parent},
raws["ZVISIT"][v.SrcPK]); err != nil {
return fmt.Errorf("写入访问记录失败: %w", err)
}
@@ -517,29 +546,40 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st
return err
}
mov := w("ZMOVEMENT", "Movement")
movVisit := map[int]int{}
for _, m := range movements {
if m.FromVisit.Valid {
movVisit[m.SrcPK] = visitAlign[int(m.FromVisit.Int64)]
}
}
movAlign, err := alignByVisitRef(ctx, tx, "ZMOVEMENT", "ZVISITFROM_", movsPKs(movements), movVisit)
if err != nil {
return fmt.Errorf("对齐位移编号: %w", err)
}
for _, m := range movements {
outPK := movAlign[m.SrcPK]
var trv, fromv, tov any
if m.Transport.Valid {
// 方式按**这条位移自己的名称**归并(见 queryTransports):同一个 src 在站点里可能
// 带着两种含义,只有当条的名称才能认出它到底是哪一种,否则会挂到错的方式上。
if x, ok := transByName[m.TransportName]; ok {
trv = x
if x, ok := transByNameSite[m.TransportName]; ok {
trv = transAlign[x]
} else {
trv = m.Transport.Int64
trv = transAlign[int(m.Transport.Int64)]
}
}
if m.FromVisit.Valid {
fromv = m.FromVisit.Int64
fromv = visitAlign[int(m.FromVisit.Int64)]
}
if m.ToVisit.Valid {
tov = m.ToVisit.Int64
tov = visitAlign[int(m.ToVisit.Int64)]
}
// ZTYPE_ 只有站点新建的行才补(已在基线里的行保持真机原值,见 keepNilCols)
var mvType any
if !known["ZMOVEMENT"][m.SrcPK] {
if !known["ZMOVEMENT"][outPK] {
mvType = movementTypeDefault(m.Transport.Valid)
}
if err := mov.write(m.SrcPK, []any{trv, fromv, tov, nt(m.Start), nt(m.End), mvType},
if err := mov.write(outPK, []any{trv, fromv, tov, nt(m.Start), nt(m.End), mvType},
raws["ZMOVEMENT"][m.SrcPK]); err != nil {
return fmt.Errorf("写入位移失败: %w", err)
}
@@ -554,12 +594,23 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st
return err
}
wx := w("ZHOURLYWEATHER", "HourlyWeather")
wxVisit := map[int]int{}
for _, row := range weather {
if row.VisitSrc.Valid {
wxVisit[row.SrcPK] = visitAlign[int(row.VisitSrc.Int64)]
}
}
wxAlign, err := alignByVisitRef(ctx, tx, "ZHOURLYWEATHER", "ZVISIT", wxPKs(weather), wxVisit)
if err != nil {
return fmt.Errorf("对齐天气编号: %w", err)
}
for _, row := range weather {
outPK := wxAlign[row.SrcPK]
var vsrc any
if row.VisitSrc.Valid {
vsrc = row.VisitSrc.Int64
vsrc = visitAlign[int(row.VisitSrc.Int64)]
}
if err := wx.write(row.SrcPK, []any{vsrc, nt(row.At), bint(row.Daylight),
if err := wx.write(outPK, []any{vsrc, nt(row.At), bint(row.Daylight),
nf(row.Temp), nf(row.App), nf(row.Hum), nf(row.Pa), nf(row.Pc), nf(row.Ws), nf(row.Vis), ni(row.UV),
ns(row.Cond), ns(row.Symbol), ns(row.UVCat), ns(row.WindDir)},
raws["ZHOURLYWEATHER"][row.SrcPK]); err != nil {
@@ -619,13 +670,13 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st
// 11) 回写 Z_PRIMARYKEY.Z_MAX:只上调,避免 rond 下次插入撞键
bumps := map[string]int{
"Location": maxPKOf(known["ZLOCATION"], placesPKs(places)),
"Activity": maxPKOf(known["ZACTIVITY"], actsPKs(activities)),
"Tag": maxPKOf(known["ZTAG"], tagsPKs(tags)),
"Transport": maxPKOf(known["ZTRANSPORT"], transPKs(transports)),
"Visit": maxPKOf(known["ZVISIT"], visitsPKs(visits)),
"Movement": maxPKOf(known["ZMOVEMENT"], movsPKs(movements)),
"HourlyWeather": maxPKOf(known["ZHOURLYWEATHER"], wxPKs(weather)),
"Location": maxPKOf(known["ZLOCATION"], mapValues(placeAlign)),
"Activity": maxPKOf(known["ZACTIVITY"], mapValues(actAlign)),
"Tag": maxPKOf(known["ZTAG"], mapValues(tagAlign)),
"Transport": maxPKOf(known["ZTRANSPORT"], mapValues(transAlign)),
"Visit": maxPKOf(known["ZVISIT"], mapValues(visitAlign)),
"Movement": maxPKOf(known["ZMOVEMENT"], mapValues(movAlign)),
"HourlyWeather": maxPKOf(known["ZHOURLYWEATHER"], mapValues(wxAlign)),
"TagGroup": maxGroupPK,
}
for name, mx := range bumps {
+459
View File
@@ -0,0 +1,459 @@
package rond
import (
"context"
"database/sql"
"encoding/base64"
"fmt"
"os"
"sort"
"strings"
"rond-with-you/internal/geo"
)
// 导出时的**编号对齐**。
//
// 背景:Core Data 的 store 里**地点与活动没有 UUID**,app 只能靠 Z_PK 解引用
// (`ZVISIT.ZLOCATION` / `ZVISIT.ZACTIVITY_`)。而站点的 `src_pk` 与手机 store 的 `Z_PK`
// 并不是同一套编号——站点的基线本身可能是「导出包」,编号会在导入-导出的循环里漂移。
// 导出若把漂移过的编号原样写回,手机那边每条到访都会指向错的地点/活动(或指向不存在的行)
// ⇒ 导入后「落不进去 / 看不见数据」。
//
// 2026-09-29 实测(真机 09-28 包 vs 站点导出,按到访 UUID 对齐):地点引用 920/920 不同、
// 活动引用 903/920 不同,而引用到的地点按「名称 + 坐标」完全一致——语义相同、编号不同。
// 真机自己导出的包永远没问题(编号自洽)。
//
// 做法:把托管表的行**按身份**重新编号到「基线的编号空间」(基线就是这份数据集从手机那儿
// 来的那个 store,所以对齐基线 = 对齐手机):
//
// 地点:坐标键优先(多值索引),退到「同名且相距 ≤1km」
// 活动 / 到访:UUID
// 标签 / 交通方式:名称
// 位移 / 天气:所属到访(已对齐)
//
// 基线里找不到身份对应的(站点新建的)行,取「基线最大编号之上」的新号;分配顺序固定,
// 保证同一份数据每次导出编号一致。
//
// 设 ROND_ALIGN_DEBUG=1 会在 stderr 打出每张表的映射规模,便于排查。
// pkAlloc 按「基线最大编号 + 1」起逐个发号,避免与基线的行相撞。
type pkAlloc struct {
next int
used map[int]bool
cache map[int]int
}
func newPKAlloc(baseMax int) *pkAlloc {
return &pkAlloc{next: baseMax + 1, used: map[int]bool{}, cache: map[int]int{}}
}
func (al *pkAlloc) take(srcPK int, reserved map[int]bool) int {
if v, ok := al.cache[srcPK]; ok {
return v
}
for al.used[al.next] || reserved[al.next] {
al.next++
}
al.used[al.next] = true
al.cache[srcPK] = al.next
al.next++
return al.cache[srcPK]
}
func alignDebug(kind string, base, site, rows, matched int) {
if os.Getenv("ROND_ALIGN_DEBUG") == "" {
return
}
fmt.Fprintf(os.Stderr, "ALIGN %-12s 基线 %4d / 站点留档 %4d / 待写 %4d / 身份匹配 %4d\n",
kind, base, site, rows, matched)
}
// alignPlaces 地点:坐标键优先(多值索引),退到同名且 ≤ nameMatchMaxKm。
func alignPlaces(ctx context.Context, tx *sql.Tx, places []pgPlace) (map[int]int, error) {
byCoord, byName := map[string][]int{}, map[string][]int{}
base := map[int]StoredPlace{}
maxPK := 0
// 多值索引:真机一个坐标下有多条同坐标同名的地点(实测「柳州站」3 条),
// 单槽位会让其余行永远配不上,引用就会错位。ORDER BY 保证结果确定。
rows, err := tx.QueryContext(ctx, `SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZLATITUDE,0), COALESCE(ZLONGITUDE,0)
FROM ZLOCATION ORDER BY Z_PK`)
if err != nil {
return nil, err
}
for rows.Next() {
var sp StoredPlace
if err := rows.Scan(&sp.SrcPK, &sp.Name, &sp.Lat, &sp.Lon); err != nil {
rows.Close()
return nil, err
}
if sp.Lat != 0 || sp.Lon != 0 {
sp.Key = PlaceCoordKey(sp.Lat, sp.Lon)
byCoord[sp.Key] = append(byCoord[sp.Key], sp.SrcPK)
}
if sp.Name != "" {
byName[sp.Name] = append(byName[sp.Name], sp.SrcPK)
}
base[sp.SrcPK] = sp
if sp.SrcPK > maxPK {
maxPK = sp.SrcPK
}
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
out := make(map[int]int, len(places))
used := map[int]bool{}
alloc := newPKAlloc(maxPK)
var leftover []pgPlace
matched := 0
for _, p := range places {
key := ""
if p.Lat.Valid && p.Lon.Valid && (p.Lat.Float64 != 0 || p.Lon.Float64 != 0) {
key = PlaceCoordKey(p.Lat.Float64, p.Lon.Float64)
}
picked := 0
for _, id := range byCoord[key] {
if !used[id] {
picked = id
break
}
}
if picked != 0 {
used[picked] = true
out[p.SrcPK] = picked
matched++
continue
}
leftover = append(leftover, p)
}
for _, p := range leftover {
if id, ok := matchPlaceByName(byName, base, used, p); ok {
used[id] = true
out[p.SrcPK] = id
matched++
continue
}
if !used[p.SrcPK] {
used[p.SrcPK] = true
out[p.SrcPK] = p.SrcPK
continue
}
out[p.SrcPK] = alloc.take(p.SrcPK, used)
}
alignDebug("ZLOCATION", maxPK, len(places), len(places), matched)
return out, nil
}
func matchPlaceByName(byName map[string][]int, base map[int]StoredPlace, used map[int]bool, p pgPlace) (int, bool) {
if !p.Name.Valid || p.Name.String == "" {
return 0, false
}
for _, id := range byName[p.Name.String] {
if used[id] {
continue
}
sp, ok := base[id]
if !ok {
continue
}
if p.Lat.Valid && p.Lon.Valid && (sp.Lat != 0 || sp.Lon != 0) {
if geo.Haversine(sp.Lat, sp.Lon, p.Lat.Float64, p.Lon.Float64) > nameMatchMaxKm {
continue
}
}
return id, true
}
return 0, false
}
// alignUUIDs 活动 / 到访:按留档里的 UUID 对齐到基线同一 UUID 的那一行。
func alignUUIDs(ctx context.Context, db *sql.DB, tx *sql.Tx, datasetID int64,
entity, col string, srcPKs []int) (map[int]int, error) {
base, maxPK, err := baselineUUIDMap(ctx, tx, entity, col)
if err != nil {
return nil, err
}
site, err := siteUUIDMap(ctx, db, datasetID, entity, col)
if err != nil {
return nil, err
}
out := make(map[int]int, len(srcPKs))
used := map[int]bool{}
alloc := newPKAlloc(maxPK)
matched := 0
for _, pk := range srcPKs {
if u, ok := site[pk]; ok {
if id, ok := base[u]; ok && !used[id] {
used[id] = true
out[pk] = id
matched++
continue
}
}
if !used[pk] {
used[pk] = true
out[pk] = pk
continue
}
out[pk] = alloc.take(pk, used)
}
alignDebug(entity, len(base), len(site), len(srcPKs), matched)
return out, nil
}
func baselineUUIDMap(ctx context.Context, tx *sql.Tx, table, col string) (map[string]int, int, error) {
out := map[string]int{}
maxPK := 0
q := fmt.Sprintf(`SELECT Z_PK, hex(%s) FROM %s WHERE %s IS NOT NULL ORDER BY Z_PK`, col, table, col)
rows, err := tx.QueryContext(ctx, q)
if err != nil {
return nil, 0, err
}
defer rows.Close()
for rows.Next() {
var pk int
var hex string
if err := rows.Scan(&pk, &hex); err != nil {
return nil, 0, err
}
if hex != "" {
out[strings.ToLower(hex)] = pk
}
if pk > maxPK {
maxPK = pk
}
}
return out, maxPK, rows.Err()
}
// siteUUIDMap 读站点留档里的「src_pk -> UUID(hex)」。站点新建的行没有留档 ⇒ 不在结果里,
// 那些行本来就该拿新编号。留档里的 UUID 是 base64 的 16 字节 blob(见 loadStoredVisits)。
func siteUUIDMap(ctx context.Context, db *sql.DB, datasetID int64, entity, col string) (map[int]string, error) {
out := map[int]string{}
q := fmt.Sprintf(`SELECT src_pk, raw #>> '{%s,$blob}' FROM entity_raw
WHERE dataset_id=$1 AND entity=$2 AND (raw #>> '{%s,$blob}') IS NOT NULL`, col, col)
rows, err := db.QueryContext(ctx, q, datasetID, entity)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var pk int
var b64 string
if err := rows.Scan(&pk, &b64); err != nil {
return nil, err
}
if h := base64ToHex(b64); h != "" {
out[pk] = h
}
}
return out, rows.Err()
}
// base64ToHex 把留档里的 base64 blob 转成小写 hex(与基线 `hex(ZIDENTIFIER_)` 同一形式)。
func base64ToHex(s string) string {
b, err := base64.StdEncoding.DecodeString(s)
if err != nil || len(b) == 0 {
return ""
}
return strings.ToLower(fmt.Sprintf("%x", b))
}
// alignTags 标签:按名称(同名取基线上编号最小的那条)。
func alignTags(ctx context.Context, tx *sql.Tx, tags []pgTag) (map[int]int, error) {
base := map[string]int{}
maxPK := 0
rows, err := tx.QueryContext(ctx, `SELECT Z_PK, COALESCE(ZNAME_,'') FROM ZTAG ORDER BY Z_PK`)
if err != nil {
return nil, err
}
for rows.Next() {
var pk int
var name string
if err := rows.Scan(&pk, &name); err != nil {
rows.Close()
return nil, err
}
if name != "" {
if id, ok := base[name]; !ok || pk < id {
base[name] = pk
}
}
if pk > maxPK {
maxPK = pk
}
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
out := make(map[int]int, len(tags))
used := map[int]bool{}
alloc := newPKAlloc(maxPK)
matched := 0
for _, t := range tags {
if t.Name.Valid && t.Name.String != "" {
if id, ok := base[t.Name.String]; ok && !used[id] {
used[id] = true
out[t.SrcPK] = id
matched++
continue
}
}
if !used[t.SrcPK] {
used[t.SrcPK] = true
out[t.SrcPK] = t.SrcPK
continue
}
out[t.SrcPK] = alloc.take(t.SrcPK, used)
}
alignDebug("ZTAG", maxPK, len(base), len(tags), matched)
return out, nil
}
// alignTransports 交通方式:按名称对齐到基线(基线同名方式的编号)。
func alignTransports(ctx context.Context, tx *sql.Tx, transports []pgTransport) (map[int]int, error) {
base := map[string]int{}
maxPK := 0
rows, err := tx.QueryContext(ctx, `SELECT Z_PK, COALESCE(ZNAME_,'') FROM ZTRANSPORT ORDER BY Z_PK`)
if err != nil {
return nil, err
}
for rows.Next() {
var pk int
var name string
if err := rows.Scan(&pk, &name); err != nil {
rows.Close()
return nil, err
}
if name != "" {
if id, ok := base[name]; !ok || pk < id {
base[name] = pk
}
}
if pk > maxPK {
maxPK = pk
}
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
out := make(map[int]int, len(transports))
used := map[int]bool{}
alloc := newPKAlloc(maxPK)
matched := 0
for _, t := range transports {
if t.Name.Valid && t.Name.String != "" {
if id, ok := base[t.Name.String]; ok && !used[id] {
used[id] = true
out[t.SrcPK] = id
matched++
continue
}
}
if !used[t.SrcPK] {
used[t.SrcPK] = true
out[t.SrcPK] = t.SrcPK
continue
}
out[t.SrcPK] = alloc.take(t.SrcPK, used)
}
alignDebug("ZTRANSPORT", maxPK, len(base), len(transports), matched)
return out, nil
}
// alignByVisitRef 位移 / 天气:按「所属到访(已对齐后的编号)」在基线里找没被占用的行。
// 同一到访下有多条时按编号顺序依次认领,保证确定性。
func alignByVisitRef(ctx context.Context, tx *sql.Tx, table, visitCol string,
srcPKs []int, visitOf map[int]int) (map[int]int, error) {
base := map[int][]int{}
maxPK := 0
q := fmt.Sprintf(`SELECT Z_PK, COALESCE(%s,0) FROM %s ORDER BY Z_PK`, visitCol, table)
rows, err := tx.QueryContext(ctx, q)
if err != nil {
return nil, err
}
for rows.Next() {
var pk, v int
if err := rows.Scan(&pk, &v); err != nil {
rows.Close()
return nil, err
}
base[v] = append(base[v], pk)
if pk > maxPK {
maxPK = pk
}
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
out := make(map[int]int, len(srcPKs))
used := map[int]bool{}
alloc := newPKAlloc(maxPK)
matched := 0
for _, pk := range srcPKs {
for _, id := range base[visitOf[pk]] {
if !used[id] {
used[id] = true
out[pk] = id
matched++
break
}
}
if _, ok := out[pk]; !ok {
if !used[pk] {
used[pk] = true
out[pk] = pk
} else {
out[pk] = alloc.take(pk, used)
}
}
}
alignDebug(table, maxPK, len(base), len(srcPKs), matched)
return out, nil
}
// alignedVisitRef 把留档里某个「指向到访的列」改挂到对齐后的编号:留档存的是站点编号,
// 对齐之后直接写回会指到别的到访(甚至不存在的行)。空值仍写 NULL。
func alignedVisitRef(raw map[string]any, col string, visitAlign map[int]int) any {
if raw == nil {
return nil
}
v, ok := raw[col]
if !ok || v == nil {
return nil
}
var pk int
switch t := v.(type) {
case float64:
pk = int(t)
case int64:
pk = int(t)
case int:
pk = t
default:
return nil
}
if pk <= 0 {
return nil
}
if out, ok := visitAlign[pk]; ok {
return out
}
return nil // 找不到对应(那条到访已不在站点数据里)——宁可置空也不要指错
}
// mapValues 取映射的值集合,给 Z_PRIMARYKEY 的 Z_MAX 用(要覆盖实际写入的编号)。
func mapValues(m map[int]int) []int {
out := make([]int, 0, len(m))
for _, v := range m {
out = append(out, v)
}
sort.Ints(out)
return out
}
+145
View File
@@ -0,0 +1,145 @@
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)
}
}
}