导出:按身份把编号对齐到基线(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:
+81
-30
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user