Files
rond-with-you/internal/rond/export.go
T
tamakyi d99c63fa4f 导出:把 ZVISIT.ZRAW 夹到包内采样行数以内(越界会让真机整包拒收)
ZVISIT.ZRAW 是「这次到访由多少个原始采样点归并而来」的缓存计数,而采样点表 ZRAWVISIT 是
**不托管实体**(只从基线原样带过来)。合并进来的到访带着源包的计数、源包的那些采样行站点
没留下 ⇒ 计数会超过包里实际有的采样行数。

2026-09-30 逐行二分定位:生产导出被真机拒收(「备份恢复失败,你的当前数据未被修改」),
罪魁是单条 `ZVISIT #1000`——它声称 2305 个采样点,而包里只有 2246 行 ZRAWVISIT;
把这一列改成 1421 后同一份包立刻能导入。旁证:被真机接受过的三份包(09-14 基线、
09-20 A 包、验收纯净往返包)的 ZRAW 最大值都恰好 ≤ 采样行数,而它们 ZRAW 为 NULL/0 的行
本来就有 39~139 条 ⇒ 往下夹是安全的、夹取只降不升。

顺带:`readDupBaseline` 先 os.Stat 再开库——sql.Open 默认 READWRITE|CREATE,路径不存在时
会静默建一个空库,报出来的是「no such table」而不是「没有基线」,很难查。

用例 `TestExportClampsRawSampleCount`:造一条声称 999999 个采样点的到访,导出后断言
「任何一行的 ZRAW 都不超过包内采样行数」且该行仍在(只改计数、不丢记录);变异验证:
关掉夹取立刻红。
2026-09-30 17:35:59 +08:00

1663 lines
58 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 rond
import (
"archive/zip"
"context"
"crypto/sha1"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"hash/crc32"
"io"
"math"
"os"
"path/filepath"
"sort"
"strings"
"time"
"rond-with-you/internal/geo"
_ "modernc.org/sqlite"
)
// managedTables 是「由 PostgreSQL 全量重建」的实体表。
// 其余表一律保持基线原样——包括 Z_METADATA / Z_MODELCACHE / 持久化历史
// (ACHANGE/ATRANSACTION*)、全部索引、以及我们不落库的实体
// (ZRAWVISIT 原始 GPS 采样点、ZKEYWORD、ZPOICATEGORY、ZTRIP* 等)。
var managedTables = []string{"ZVISIT", "ZMOVEMENT", "ZHOURLYWEATHER", "ZTRANSPORT", "ZLOCATION", "ZACTIVITY", "ZTAG"}
// managedCols 是「由 PostgreSQL 负责」的列。这些列之外的字段只能从原始行留档还原
// (留档缺失时再退回基线里的原值),否则会像早期版本那样把 rond 自己写的字段洗成 NULL。
var managedCols = map[string][]string{
"ZLOCATION": {"ZNAME_", "ZCATEGORY_", "ZLATITUDE", "ZLONGITUDE", "ZISOCOUNTRYCODE",
"ZADMINISTRATIVEAREA", "ZLOCALITY", "ZSUBLOCALITY", "ZTHOROUGHFARE", "ZTIMEZONE",
"ZLASTARRIVALDATE_", "ZRAWLATITUDE", "ZRAWLONGITUDE"},
"ZACTIVITY": {"ZNAME_", "ZCOLOR_", "ZICON_", "ZISHOME", "ZISWORK", "ZISEXCLUDED", "ZISARCHIVED",
"ZLASTARRIVALDATE_", "ZUID_"},
"ZTAG": {"ZNAME_", "ZCOLOR_", "ZGROUP", "ZLASTARRIVALDATE_"},
"ZTRANSPORT": {"ZNAME_", "ZCOLOR_", "ZICON_"},
"ZVISIT": {"ZLOCATION", "ZACTIVITY_", "ZARRIVALDATE_", "ZDEPARTUREDATE_",
"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_",
"ZCONDITION_", "ZSYMBOLNAME_", "ZUVINDEXCATEGORY_", "ZWINDCOMPASSDIRECTION_"},
}
// newRowDefaults 是「编辑页新建、没有原始留档」的行要补的值,照真机包里的取值分布抄。
// 置 NULL 会让 rond 把地点显示成「固定」这类特殊状态。
var newRowDefaults = map[string]map[string]any{
"ZLOCATION": {"ZBOOKMARKED": 0, "ZISPHOTOIMPORT": 0, "ZNEEDREFRESH": 0, "ZPINSTYLE_": 0,
"ZTYPE_": 0, "ZUSERADDED": 0, "ZUSERIGNORED": 0, "ZCOUNTDOWNINSECONDS": 0.0, "ZRATING": 0.0,
"ZRADIUS": 100.0},
"ZACTIVITY": {"ZISCALENDAREXCLUDED": 0, "ZISDEFAULT": 0, "ZPINSTYLE_": 0,
"ZCA_": 0.0, "ZCB_": 0.0, "ZCG_": 0.0, "ZCR_": 0.0, "ZG_": 0.0},
"ZTAG": {"ZISARCHIVED": 0, "ZISAUTOMARK": 0, "ZPINSTYLE_": 0},
"ZTRANSPORT": {"ZISDEFAULT": 0},
"ZVISIT": {"ZASSOCIATIONBITMASK_": 0, "ZISACTIVITYTAGEXCLUDED": 0, "ZISPHOTOIMPORT": 0, "ZISPLACETAGEXCLUDED": 0, "ZUSERIGNORED": 0},
"ZMOVEMENT": {"ZISMODIFIED": 0},
}
// keepNilCols 是「PG 侧算不出值就保持基线原值」的管理列——它们要么是 rond 的派生缓存列,
// 要么是原生包里有、站点这边没有对应字段的列。都只在站点确实有值可写时才动,否则原样保留:
//
// - ZLOCATION/ZACTIVITY/ZTAG 的 ZLASTARRIVALDATE_(各页面里的「最近活跃时间」):值为该
// 地点/活动/标签最晚一次到访的到达时间。新建的行不写,rond 里就永远显示「无数据」;
// 但从没被用过的那几个,app 写的是 distantPast 哨兵(真机包里 ZLOCATION 有 38 个),
// 站点复现不出来,就别用 NULL 去覆盖。
// - ZLOCATION.ZRAWLATITUDE/ZRAWLONGITUDE:真机存的是原始 GPS(WGS-84),而
// ZLATITUDE/ZLONGITUDE 是它经 GCJ-02 偏移后的显示坐标(实测 293/323 精确满足)。
// 站点新建的地点没有原始 GPS 可写时,才用显示坐标反算一个补上。
// - ZVISIT.ZIDENTIFIER_ / ZACTIVITY.ZUID_:真机给每条记录存一个 16 字节 UUID v4
// (822/824、30/30 全有),站点这边没有对应字段,新建的行需要补一个确定性 UUID。
// - ZMOVEMENT.ZTYPE_:真机 791/791 都有值,取值集中在 2(无交通方式,实测中位速度
// 5.0km/h ≈ 步行)与 5(有交通方式,中位 17km/h),另有 23 条 4、1 条 0。
// 站点复现不了 rond 自己的判定,只给新建的行补一个(见 patchSQLite),
// 已有行一律保持真机原值。
// - ZTRANSPORT 的名称/颜色/图标:站点不单独维护交通方式表,导出时从位移里聚合。
// 聚合不到(用同一方式的所有行程这几列都为空)就保留基线原值,
// 否则会把真机的模式名洗成 NULL,rond 里变成一个没有名字的方式。
var keepNilCols = map[string][]string{
"ZLOCATION": {"ZLASTARRIVALDATE_", "ZRAWLATITUDE", "ZRAWLONGITUDE"},
"ZACTIVITY": {"ZLASTARRIVALDATE_", "ZUID_"},
"ZTAG": {"ZLASTARRIVALDATE_"},
"ZVISIT": {"ZIDENTIFIER_"},
"ZMOVEMENT": {"ZTYPE_"},
"ZTRANSPORT": {"ZNAME_", "ZCOLOR_", "ZICON_"},
}
// 新建行没选颜色/图标时的占位值。真机这几列从来不空(NULL 和空串都没有),写 NULL 是
// 它没见过的形状;颜色取 rond 系统色名,图标取一个必定存在的 SF Symbol。
const (
transportPlaceholderColor = "gray"
transportPlaceholderIcon = "circle.fill"
activityPlaceholderColor = "gray"
)
// movementTypeDefault 给站点新建的位移补 ZTYPE_:真机里这条规律覆盖 757/791 行
// (有交通方式→5、没有→2),剩下的是用户在 app 里手改过的不规则取值。
func movementTypeDefault(transportSet bool) int {
if transportSet {
return 5
}
return 2
}
// ExportRondbackup 以导入时留下的基线库为底,把 PostgreSQL 里的当前数据覆盖写回,
// 再打包成与 rond 原始格式一致的 .rondbackup(LifeEasy.sqlite / -wal / -shm)。
//
// 坐标是 GCJ-02,与 rond 的 ZLOCATION 同系,原样写回、无需任何坐标变换。
func ExportRondbackup(ctx context.Context, db *sql.DB, datasetID int64, baselinePath string, dst io.Writer) error {
if _, err := os.Stat(baselinePath); err != nil {
return fmt.Errorf("缺少基线库(%s):请重新上传一次原始 .rondbackup,以便重建基线后导出", filepath.Base(baselinePath))
}
work, err := os.MkdirTemp("", "rond-export-")
if err != nil {
return err
}
defer os.RemoveAll(work)
sqlitePath := filepath.Join(work, "LifeEasy.sqlite")
if err := copyFile(baselinePath, sqlitePath); err != nil {
return fmt.Errorf("复制基线库失败: %w", err)
}
if err := patchSQLite(ctx, db, datasetID, sqlitePath); err != nil {
return err
}
return zipBackup(sqlitePath, dst)
}
// patchSQLite 在基线库副本上覆盖写入 PostgreSQL 的当前数据。
//
// 对基线里已存在的行走 UPDATE、只动我们管理的列——rond 自己写的其余列
// (ZLOCATION.ZPINSTYLE_/ZRADIUS、ZVISIT.ZRAW/ZIDENTIFIER_、ZMOVEMENT.ZTYPE_ 等)
// 原样保留;此前用 DELETE+INSERT 全量重写会把它们统统置 NULL,rond 里新建的地点
// 会因此显示成「固定」这类特殊状态。真正新增的行才 INSERT,并补上 rond 自己
// 会写的标志位默认值。
func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath string) error {
sdb, err := sql.Open("sqlite", "file:"+filepath.ToSlash(sqlitePath))
if err != nil {
return fmt.Errorf("打开基线库失败: %w", err)
}
defer sdb.Close()
sdb.SetMaxOpenConns(1)
if _, err := sdb.ExecContext(ctx, "PRAGMA journal_mode=WAL"); err != nil {
return fmt.Errorf("切换 WAL 模式失败: %w", err)
}
if _, err := sdb.ExecContext(ctx, "PRAGMA foreign_keys=OFF"); err != nil {
return fmt.Errorf("关闭外键检查失败: %w", err)
}
// 1) 实体编号与关联表都从基线里读,导出不再依赖 coredata_meta
ents, err := loadEntities(ctx, sdb)
if err != nil {
return err
}
ent := func(name string) any {
if e, ok := ents[name]; ok {
return e
}
return nil
}
join, err := discoverVisitTagJoin(ctx, sdb, ents)
if err != nil {
return err
}
has, err := tableExists(ctx, sdb)
if err != nil {
return err
}
// 基线必须齐全,否则后半程的写入会以「no such table/实体编号为空」这种难懂的方式失败。
for _, t := range managedTables {
if !has[t] {
return fmt.Errorf("基线库缺少表 %s,无法导出:请重新上传一次原始 .rondbackup 以重建基线", t)
}
}
for _, name := range []string{"Location", "Activity", "Tag", "TagGroup", "Transport", "Visit", "Movement", "HourlyWeather"} {
if _, ok := ents[name]; !ok {
return fmt.Errorf("基线库的 Z_PRIMARYKEY 缺少实体 %s,无法导出:请重新上传一次原始 .rondbackup", name)
}
}
// 原始行留档:有留档就逐列还原整行,不依赖基线去补那些站点没映射的字段。
// 取不到(老数据集、表还没建)时 raws[表] 为空,写入器会自动退回原有行为。
raws := map[string]map[int]map[string]any{}
skippedRaw := map[string]map[int]bool{}
for _, t := range managedTables {
m, sk, err := queryEntityRaw(ctx, db, datasetID, t)
if err != nil {
return err
}
raws[t], skippedRaw[t] = m, sk
}
// 基线表里除主键与管理列之外的列——这些列的值只能从原始行留档还原。
// 必须在 BeginTx 之前读:事务会独占 sdb 仅有的那条连接,再用 sdb 查询会死锁。
others := map[string][]pgCol{}
alls := map[string][]pgCol{}
for _, t := range managedTables {
all, other, err := otherColumns(ctx, sdb, t, managedCols[t])
if err != nil {
return err
}
alls[t], others[t] = all, other
}
tx, err := sdb.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// 基线里各表已存在的主键,用于区分 UPDATE 与 INSERT。必须在写之前读,
// 且只能走 tx——sdb 仅有的连接已被事务占用,再查会死锁。
known := map[string]map[int]bool{}
for _, t := range append(append([]string{}, managedTables...), "ZTAGGROUP") {
if !has[t] {
continue
}
k, err := knownPKs(ctx, tx, t)
if err != nil {
return err
}
known[t] = k
}
// w 是各表写入器的便捷构造;Z_ENT 来自基线的 Z_PRIMARYKEY,
// 列清单与新增行默认值查 managedCols / newRowDefaults,留档从 PostgreSQL 读。
w := func(table, entity string) *tableWriter {
return &tableWriter{ctx: ctx, tx: tx, table: table, entID: ent(entity),
cols: managedCols[table], defs: newRowDefaults[table], other: others[table],
all: alls[table], skipped: skippedRaw[table], raws: raws[table],
known: known[table], seen: map[int]bool{}, keep: keepNilSet(keepNilCols[table])}
}
// 2) 地点 ZLOCATION
placeSrcByID := map[int64]int{}
places, err := queryPlaces(ctx, db, datasetID)
if err != nil {
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]
// 地点详情的「最近活跃时间」= 该地点最晚一次到访的到达时间,没有到访就不写
var lastArrival any
if p.LastVisitAt.Valid {
lastArrival = appleSeconds(p.LastVisitAt.Time)
}
// 原始 GPS 列:真机存的是 WGS-84,站点存的是 GCJ-02 显示坐标。
// 有留档就用真机那份(它才是真正的原始定位,哪怕用户后来挪过点);
// 站点新建的地点没有留档,用显示坐标反算一个,否则这列会空着。
var rawLat, rawLon any
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(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 {
return fmt.Errorf("写入地点失败: %w", err)
}
}
if err := loc.finish(); err != nil {
return err
}
// ZISPHOTOIMPORT:rond 靠这一位区分「照片导入」与「手工新增」,站点这边没有对应字段,
// 但来源留档说得清清楚楚,比一律写默认值 0 准。统一在这里补一次:新建行与
// 「有合成留档、走整行还原」的行都覆盖得到,而真机行(coord_source=rond)不碰。
if loc.typeOf("ZISPHOTOIMPORT") != "" {
var pks []any
for _, p := range places {
if p.CoordSource.Valid && p.CoordSource.String == "photo" {
pks = append(pks, placeAlign[p.SrcPK])
}
}
for len(pks) > 0 {
n := len(pks)
if n > 500 {
n = 500
}
chunk := pks[:n]
pks = pks[n:]
ph := strings.TrimSuffix(strings.Repeat("?,", len(chunk)), ",")
if _, err := tx.ExecContext(ctx,
"UPDATE ZLOCATION SET ZISPHOTOIMPORT=1 WHERE Z_PK IN ("+ph+")", chunk...); err != nil {
return fmt.Errorf("写 ZISPHOTOIMPORT 失败: %w", err)
}
}
}
// 3) 活动类型 ZACTIVITY
actSrcByID := map[int64]int{}
activities, err := queryActivities(ctx, db, datasetID)
if err != nil {
return err
}
actLast, err := queryPlaceLikeMaxArrival(ctx, db, datasetID, "activity_id")
if err != nil {
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 里会是空图标;站点自己渲染时缺省用 📍
icon := ns(a.Icon)
if icon == nil {
icon = "📍"
}
// 颜色同样要兜底:ns() 把空串也当空,而「地点补全」新建的活动 color 就是空串
// (见 fillup.go),不兜底这里会写出 ZCOLOR_=NULL。真机这列从来不空——2026-09-29
// 生产导出就是因为这一格被 rond 判成损坏数据,提示「备份恢复失败,你的当前数据未被更改」。
color := ns(a.Color)
if color == nil {
color = activityPlaceholderColor
}
// 活动列表里的「最近活跃时间」:同 ZLOCATION,取该活动最晚一次到访
var last any
if t, ok := actLast[int64(a.ID)]; ok {
last = appleSeconds(t)
}
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"][outPK], "ZACTIVITY", a.SrcPK)},
raw); err != nil {
return fmt.Errorf("写入活动类型失败: %w", err)
}
}
if err := act.finish(); err != nil {
return err
}
// 4) 标签分组 ZTAGGROUP:沿用基线上已有的主键(按名字匹配),只补新增的分组,
// 这样 ZTAG.ZGROUP 仍指向原有分组,分组本身也不会被换掉主键。
groupPK := map[string]int{}
{
grows, err := tx.QueryContext(ctx, `SELECT Z_PK, COALESCE(ZNAME_,'') FROM ZTAGGROUP`)
if err != nil {
return fmt.Errorf("读取标签分组: %w", err)
}
for grows.Next() {
var pk int
var name string
if err := grows.Scan(&pk, &name); err != nil {
grows.Close()
return err
}
groupPK[name] = pk
}
grows.Close()
}
needGroups, err := queryStrings(ctx, db, `SELECT DISTINCT COALESCE(group_name,'') FROM tags WHERE dataset_id=$1 AND group_name<>''`, datasetID)
if err != nil {
return fmt.Errorf("读取标签分组: %w", err)
}
maxGroupPK := 0
for pk := range known["ZTAGGROUP"] {
if pk > maxGroupPK {
maxGroupPK = pk
}
}
for _, g := range needGroups {
if _, ok := groupPK[g]; ok {
continue
}
maxGroupPK++
groupPK[g] = maxGroupPK
if _, err := tx.ExecContext(ctx, `INSERT INTO ZTAGGROUP (Z_PK, Z_ENT, Z_OPT, ZNAME_) VALUES (?,?,?,?)`,
maxGroupPK, ent("TagGroup"), 1, g); err != nil {
return fmt.Errorf("写入标签分组失败: %w", err)
}
}
// 5) 标签 ZTAG
tagSrcByID := map[int64]int{}
tags, err := queryTags(ctx, db, datasetID)
if err != nil {
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
}
for _, t := range tags {
tagSrcByID[int64(t.SrcPK)] = t.SrcPK
var grp any
if t.Group.Valid && t.Group.String != "" {
if gpk, ok := groupPK[t.Group.String]; ok {
grp = gpk
}
}
// 标签的「最近活跃时间」:该标签下最晚一次到访(同 ZLOCATION / ZACTIVITY)
var last any
if tm, ok := tagLast[int64(t.SrcPK)]; ok {
last = appleSeconds(tm)
}
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)
}
}
if err := tag.finish(); err != nil {
return err
}
// 6) 交通方式 ZTRANSPORT(从位移里聚合而来)
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 和空串都没有),而站点不单独维护交通方式表、
// 颜色/图标是从位移上聚合的,用户建方式时没选就是空。**新建的行**必须补占位,
// 否则会把 NULL 写进包里——rond 拿到的是它自己从来不会产生的形状
// (2026-09-20:新建的「步行」就是这样导出的,与新建活动补 blue/📍 同一套做法)。
// 基线里已有的行不碰:那种情况交给 keepNilCols 保留真机原值。
if !known["ZTRANSPORT"][transAlign[t.SrcPK]] {
if name == nil {
name = "未命名"
}
if color == nil {
color = transportPlaceholderColor
}
if icon == nil {
icon = transportPlaceholderIcon
}
}
if err := tr.write(transAlign[t.SrcPK], []any{name, color, icon}, raws["ZTRANSPORT"][t.SrcPK]); err != nil {
return fmt.Errorf("写入交通方式失败: %w", err)
}
}
if err := tr.finish(); err != nil {
return err
}
// 7) 访问记录 ZVISIT
visits, err := queryVisits(ctx, db, datasetID)
if err != nil {
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 = placeAlign[sp]
}
}
if v.ActivityID.Valid {
if sp, ok := actSrcByID[v.ActivityID.Int64]; ok {
actPK = actAlign[sp]
}
}
// 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"][outPK], "ZVISIT", v.SrcPK), mergedTo, parent},
raws["ZVISIT"][v.SrcPK]); err != nil {
return fmt.Errorf("写入访问记录失败: %w", err)
}
}
if err := visit.finish(); err != nil {
return err
}
// 站点删掉到访之后,它的原始采样点会失去目标。ZRAWVISIT 是不托管实体(原样保留),
// 而 Core Data 的关系由它自己维护、SQLite 里没有外键约束,所以这种悬空引用不会报错,
// 但它是**真机自己绝不会产生的形状**——真机删到访时采样点跟着级联删。
// 留着还有副作用:app 会把「没归并的采样点」重新推成一条到访,删掉的记录又回来了。
if _, err := tx.ExecContext(ctx, `DELETE FROM ZRAWVISIT WHERE ZVISIT IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM ZVISIT v WHERE v.Z_PK = ZRAWVISIT.ZVISIT)`); err != nil {
return fmt.Errorf("清理失去目标的原始采样点失败: %w", err)
}
// ZVISIT.ZRAW 是「这次到访由多少个原始采样点归并而来」的计数,而采样点表 ZRAWVISIT 是
// **不托管实体**(原样从基线带过来)。合并进来的到访带着源包的计数,可源包里的那些采样行
// 站点没留下 ⇒ 计数会超过包里实际有的采样行数。2026-09-30 实测:真机会因此**整包拒收**
// (「备份恢复失败,你的当前数据未被修改」)——#1000 声称 2305 个采样点而包里只有 2246 行,
// 把它夹到 2246 以内立刻就能导入。被真机接受过的包里 ZRAW 为 NULL/0 的行本来就有 39~139 条,
// 所以往下夹是安全的(夹取只降不升)。
if _, err := tx.ExecContext(ctx, `UPDATE ZVISIT SET ZRAW = (SELECT count(*) FROM ZRAWVISIT)
WHERE ZRAW IS NOT NULL AND ZRAW > (SELECT count(*) FROM ZRAWVISIT)`); err != nil {
return fmt.Errorf("夹原始采样点数失败: %w", err)
}
// 8) 访问↔标签关联(列名从基线探测得到;纯两列关系,全量重写即可)
if join.Name != "" && join.VisitCol != "" && join.TagCol != "" {
if _, err := tx.ExecContext(ctx, "DELETE FROM "+join.Name); err != nil {
return fmt.Errorf("清空 %s 失败: %w", join.Name, err)
}
vt, err := db.QueryContext(ctx, `SELECT v.src_pk, t.src_pk FROM visit_tags vt
JOIN visits v ON v.id=vt.visit_id JOIN tags t ON t.id=vt.tag_id
WHERE v.dataset_id=$1`, datasetID)
if err != nil {
return fmt.Errorf("读取访问标签: %w", err)
}
for vt.Next() {
var vsrc, tsrc int
if err := vt.Scan(&vsrc, &tsrc); err != nil {
vt.Close()
return err
}
if _, err := tx.ExecContext(ctx, fmt.Sprintf("INSERT INTO %s (%s, %s) VALUES (?,?)",
join.Name, join.VisitCol, join.TagCol), vsrc, tsrc); err != nil {
vt.Close()
return fmt.Errorf("写入访问标签关联失败: %w", err)
}
}
vt.Close()
if err := vt.Err(); err != nil {
return err
}
}
// 9) 位移 ZMOVEMENT
movements, err := queryMovements(ctx, db, datasetID)
if err != nil {
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 := transByNameSite[m.TransportName]; ok {
trv = transAlign[x]
} else {
trv = transAlign[int(m.Transport.Int64)]
}
}
if m.FromVisit.Valid {
fromv = visitAlign[int(m.FromVisit.Int64)]
}
if m.ToVisit.Valid {
tov = visitAlign[int(m.ToVisit.Int64)]
}
// ZTYPE_ 只有站点新建的行才补(已在基线里的行保持真机原值,见 keepNilCols)
var mvType any
if !known["ZMOVEMENT"][outPK] {
mvType = movementTypeDefault(m.Transport.Valid)
}
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)
}
}
if err := mov.finish(); err != nil {
return err
}
// 10) 小时级天气 ZHOURLYWEATHER
weather, err := queryWeather(ctx, db, datasetID)
if err != nil {
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 = visitAlign[int(row.VisitSrc.Int64)]
}
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 {
return fmt.Errorf("写入天气失败: %w", err)
}
}
if err := wx.finish(); err != nil {
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"], 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 {
if _, err := tx.ExecContext(ctx, `UPDATE Z_PRIMARYKEY SET Z_MAX=? WHERE Z_NAME=? AND Z_MAX<?`, mx, name, mx); err != nil {
return fmt.Errorf("更新 Z_PRIMARYKEY %s 失败: %w", name, err)
}
}
if err := tx.Commit(); err != nil {
return err
}
if _, err := sdb.ExecContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
return fmt.Errorf("合并 WAL 失败: %w", err)
}
return nil
}
// tableWriter 把一行数据写进基线库:主键已存在走 UPDATE(只动 cols,rond 写的其余列不动),
// 不存在走 INSERT(补 defs 里的 rond 默认值)。finish() 负责清掉 PG 里已删除的行。
type tableWriter struct {
ctx context.Context
tx *sql.Tx
table string
entID any // 该表在 Z_PRIMARYKEY 里的实体编号(INSERT 时写 Z_ENT)
cols []string
defs map[string]any
other []pgCol // 非管理列,值只能从留档还原
all []pgCol // 除 Z_PK 外的全部列,仅供整行还原时取类型
// 导入时因缺必填字段而没进 PostgreSQL 的行,导出要按留档原样补回去。
skipped map[int]bool
raws map[int]map[string]any
known map[int]bool
seen map[int]bool
// keep 里的列在值为空时保持基线/留档原值(见 keepNilCols)。
keep map[string]bool
// keepMissing 为真时,基线里存在但 PostgreSQL 没写到的行不删除。
// 只有 ZTRANSPORT 用:交通方式在站点侧没有独立的表,导出时从行程里聚合,
// 「删掉某方式的最后一条行程」不等于用户想删掉这个方式——留着才是保真。
keepMissing bool
}
// keepNilSet 把 keepNilCols 的列名列表转成集合(空列表返回 nil,读 nil map 恒为 false)。
func keepNilSet(cols []string) map[string]bool {
if len(cols) == 0 {
return nil
}
m := make(map[string]bool, len(cols))
for _, c := range cols {
m[c] = true
}
return m
}
// typeOf 取某列在基线里的声明类型,用于把留档值还原成驱动认识的形式。
func (w *tableWriter) typeOf(col string) string {
for _, c := range w.all {
if c.Name == col {
return c.Type
}
}
return ""
}
// write 写一行。raw 非空(导入时留过整行档案)时用 INSERT OR REPLACE 写全列:
// 管理列取站点当前值,其余列取留档原值——rond 自己写的字段因此一个都不会丢。
func (w *tableWriter) write(pk int, vals []any, raw map[string]any) error {
if len(vals) != len(w.cols) {
return fmt.Errorf("%s: 值个数 %d 与列数 %d 不一致", w.table, len(vals), len(w.cols))
}
w.seen[pk] = true
if len(raw) > 0 {
return w.writeFull(pk, vals, raw)
}
if w.known[pk] {
sets := make([]string, 0, len(w.cols))
args := make([]any, 0, len(w.cols)+1)
for i, c := range w.cols {
// 派生缓存列没有新值就整列不动,交给基线里的原值
if w.keep[c] && vals[i] == nil {
continue
}
sets = append(sets, c+"=?")
args = append(args, vals[i])
}
if len(sets) == 0 {
return nil
}
args = append(args, pk)
_, err := w.tx.ExecContext(w.ctx, "UPDATE "+w.table+" SET "+strings.Join(sets, ",")+" WHERE Z_PK=?", args...)
return err
}
names := []string{"Z_PK", "Z_ENT", "Z_OPT"}
args := []any{pk, w.entID, 1}
for i, c := range w.cols {
names = append(names, c)
args = append(args, vals[i])
}
for c, v := range w.defs {
if containsStr(w.cols, c) {
continue
}
names = append(names, c)
args = append(args, v)
}
ph := strings.TrimSuffix(strings.Repeat("?,", len(names)), ",")
_, err := w.tx.ExecContext(w.ctx,
"INSERT INTO "+w.table+" ("+strings.Join(names, ",")+") VALUES ("+ph+")", args...)
return err
}
// keepEmptyString 保住「原本是空串」的文本列。
// 站点把空串当未填写存成 NULL,导出时就会把这些列写成 NULL;
// 对 rond 来说空串和 NULL 不是一回事,往返一趟就对不上了。
func keepEmptyString(v any, raw any) any {
if v != nil {
return v
}
if s, ok := raw.(string); ok && s == "" {
return ""
}
return v
}
// entID 是该表在 Z_PRIMARYKEY 里的实体编号(INSERT 时写 Z_ENT)。
// writeFull 按留档还原整行:管理列用站点当前值,其余列用原始值。
func (w *tableWriter) writeFull(pk int, vals []any, raw map[string]any) error {
names := []string{"Z_PK"}
args := []any{pk}
// 1) 管理列:站点当前值(PG 是这些字段的真值来源)
for i, c := range w.cols {
if w.keep[c] && vals[i] == nil {
// 派生缓存列算不出新值 → 写留档原值,别把 app 写的哨兵洗成 NULL
rv, ok := raw[c]
if !ok {
continue
}
names = append(names, c)
args = append(args, sqliteValue(w.typeOf(c), rv))
continue
}
names = append(names, c)
args = append(args, keepEmptyString(vals[i], raw[c]))
}
// 2) 其余列:留档里的原始值
seen := make(map[string]bool, len(w.cols))
for _, c := range w.cols {
seen[c] = true
}
for _, c := range w.other {
if seen[c.Name] {
continue
}
if c.Name == "Z_ENT" {
names, args = append(names, c.Name), append(args, w.entID)
continue
}
rv, ok := raw[c.Name]
if !ok {
continue
}
names, args = append(names, c.Name), append(args, sqliteValue(c.Type, rv))
}
ph := strings.TrimSuffix(strings.Repeat("?,", len(names)), ",")
_, err := w.tx.ExecContext(w.ctx,
"INSERT OR REPLACE INTO "+w.table+" ("+strings.Join(names, ",")+") VALUES ("+ph+")", args...)
return err
}
// finish 删除基线里 PostgreSQL 已不存在的行,保证导出包与站点数据一致;
// 再把导入时因缺必填字段没能进 PostgreSQL 的行按留档原样补回,
// 否则「导入—导出」一趟就会凭空少数据。
func (w *tableWriter) finish() error {
for pk := range w.known {
if w.seen[pk] || w.keepMissing {
continue
}
if _, err := w.tx.ExecContext(w.ctx, "DELETE FROM "+w.table+" WHERE Z_PK=?", pk); err != nil {
return fmt.Errorf("清理 %s 中已删除的行 %d 失败: %w", w.table, pk, err)
}
}
for pk := range w.skipped {
if w.seen[pk] {
continue
}
raw, ok := w.raws[pk]
if !ok || len(raw) == 0 {
continue
}
if err := w.writeRaw(pk, raw); err != nil {
return fmt.Errorf("还原 %s 中未导入的行 %d 失败: %w", w.table, pk, err)
}
}
return nil
}
// writeRaw 完全按留档写回一整行,所有列(含站点管理列)都取原始值。
func (w *tableWriter) writeRaw(pk int, raw map[string]any) error {
names := []string{"Z_PK"}
args := []any{pk}
for _, c := range w.all {
if c.Name == "Z_ENT" {
names = append(names, c.Name)
args = append(args, w.entID)
continue
}
v, ok := raw[c.Name]
if !ok {
continue
}
names = append(names, c.Name)
args = append(args, sqliteValue(c.Type, v))
}
ph := strings.TrimSuffix(strings.Repeat("?,", len(names)), ",")
_, err := w.tx.ExecContext(w.ctx,
"INSERT OR REPLACE INTO "+w.table+" ("+strings.Join(names, ",")+") VALUES ("+ph+")", args...)
return err
}
// pgCol 是基线表的一列(名字 + 声明类型)。
type pgCol struct {
Name string
Type string
}
// otherColumns 取基线表的列定义:all 是除 Z_PK 外的全部列,other 再排除掉管理列。
func otherColumns(ctx context.Context, sdb *sql.DB, table string, managed []string) (all, other []pgCol, err error) {
rows, err := sdb.QueryContext(ctx,
fmt.Sprintf("SELECT name, COALESCE(type,'') FROM pragma_table_info('%s')", strings.ReplaceAll(table, "'", "''")))
if err != nil {
return nil, nil, fmt.Errorf("读取 %s 列定义: %w", table, err)
}
defer rows.Close()
for rows.Next() {
var c pgCol
if err := rows.Scan(&c.Name, &c.Type); err != nil {
return nil, nil, err
}
if c.Name == "Z_PK" {
continue
}
all = append(all, c)
if containsStr(managed, c.Name) {
continue
}
other = append(other, c)
}
return all, other, rows.Err()
}
// queryEntityRaw 读出某张表在导入时留下的整行档案。
func queryEntityRaw(ctx context.Context, db *sql.DB, datasetID int64, table string) (map[int]map[string]any, map[int]bool, error) {
out := map[int]map[string]any{}
skip := map[int]bool{}
rows, err := db.QueryContext(ctx,
`SELECT src_pk, raw::text, skipped FROM entity_raw WHERE dataset_id=$1 AND entity=$2`, datasetID, table)
if err != nil {
// 表还没建(迁移未跑)时不能算致命,退回无留档模式
return out, skip, nil
}
defer rows.Close()
for rows.Next() {
var pk int
var blob string
var sk bool
if err := rows.Scan(&pk, &blob, &sk); err != nil {
return nil, nil, err
}
var raw map[string]any
if err := json.Unmarshal([]byte(blob), &raw); err != nil {
continue
}
out[pk] = raw
if sk {
skip[pk] = true
}
}
return out, skip, rows.Err()
}
// sqliteValue 把 JSON 里取回的值还原成适合写进 SQLite 的类型:
// JSON 只有数字一种数值类型,整形列要还原成 int64;BLOB 解码 base64。
func sqliteValue(colType string, v any) any {
switch t := v.(type) {
case nil:
return nil
case bool:
return bint(t)
case float64:
if strings.Contains(strings.ToUpper(colType), "INT") && t == math.Trunc(t) {
return int64(t)
}
return t
case string:
return t
case map[string]any:
if b, ok := t["$blob"].(string); ok {
if data, err := base64.StdEncoding.DecodeString(b); err == nil {
return data
}
}
return nil
default:
return v
}
}
// knownPKs 读出基线表里现存的全部主键。
func knownPKs(ctx context.Context, tx *sql.Tx, table string) (map[int]bool, error) {
rows, err := tx.QueryContext(ctx, "SELECT Z_PK FROM "+table)
if err != nil {
return nil, fmt.Errorf("读取 %s 主键: %w", table, err)
}
defer rows.Close()
out := map[int]bool{}
for rows.Next() {
var pk sql.NullInt64
if err := rows.Scan(&pk); err != nil {
return nil, err
}
if pk.Valid {
out[int(pk.Int64)] = true
}
}
return out, rows.Err()
}
// ---------- PostgreSQL 侧的行读取(先取回内存,再统一写 SQLite,避免两库交错) ----------
type pgPlace struct {
ID, SrcPK int
Name, Category, Country, Province, City, District, Sublocality, Thoroughfare, Timezone sql.NullString
Lat, Lon sql.NullFloat64
LastVisitAt sql.NullTime
// CoordSource 记录这个点是哪个入口建的(photo/manual/rond)。只有 ZISPHOTOIMPORT
// 用得上:rond 靠这个位区分「照片导入」与「手工新增」,而站点这边没有对应字段。
CoordSource sql.NullString
}
func queryPlaces(ctx context.Context, db *sql.DB, datasetID int64) ([]pgPlace, error) {
// 区县取 sublocality,缺了再退到 district:站点表单只暴露「区县」,
// 手工补录的地点这两列里只有 district 有值,直接用 sublocality 会把区县丢掉。
rows, err := db.QueryContext(ctx, `SELECT id, src_pk, name, poi_category, lat, lon, country_code, province, city,
COALESCE(NULLIF(sublocality,''), NULLIF(district,'')), thoroughfare, timezone, last_visit_at,
coord_source
FROM places WHERE dataset_id=$1 ORDER BY id`, datasetID)
if err != nil {
return nil, fmt.Errorf("读取地点: %w", err)
}
defer rows.Close()
var out []pgPlace
for rows.Next() {
var p pgPlace
if err := rows.Scan(&p.ID, &p.SrcPK, &p.Name, &p.Category, &p.Lat, &p.Lon, &p.Country,
&p.Province, &p.City, &p.Sublocality, &p.Thoroughfare, &p.Timezone, &p.LastVisitAt,
&p.CoordSource); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
type pgActivity struct {
ID, SrcPK int
Name, Color, Icon sql.NullString
Home, Work, Excluded, Archived bool
}
// queryPlaceLikeMaxArrival 返回「按某个到访外键分组的最晚到达时间」,
// 用来填 ZACTIVITY.ZLASTARRIVALDATE_ 这类缓存列(col 只接受 activity_id)。
func queryPlaceLikeMaxArrival(ctx context.Context, db *sql.DB, datasetID int64, col string) (map[int64]time.Time, error) {
if col != "activity_id" {
return nil, fmt.Errorf("不支持的聚合列 %q", col)
}
rows, err := db.QueryContext(ctx, `SELECT activity_id, max(arrival) FROM visits
WHERE dataset_id=$1 AND activity_id IS NOT NULL GROUP BY activity_id`, datasetID)
if err != nil {
return nil, fmt.Errorf("读取活动最近到访: %w", err)
}
defer rows.Close()
out := map[int64]time.Time{}
for rows.Next() {
var id int64
var t time.Time
if err := rows.Scan(&id, &t); err != nil {
return nil, err
}
out[id] = t
}
return out, rows.Err()
}
// queryTagMaxArrival 返回每个标签下最晚一次到访的到达时间(标签挂在到访上,走关联表)。
func queryTagMaxArrival(ctx context.Context, db *sql.DB, datasetID int64) (map[int64]time.Time, error) {
rows, err := db.QueryContext(ctx, `SELECT vt.tag_id, max(v.arrival)
FROM visits v JOIN visit_tags vt ON vt.visit_id = v.id
WHERE v.dataset_id=$1 GROUP BY vt.tag_id`, datasetID)
if err != nil {
return nil, fmt.Errorf("读取标签最近到访: %w", err)
}
defer rows.Close()
out := map[int64]time.Time{}
for rows.Next() {
var id int64
var t time.Time
if err := rows.Scan(&id, &t); err != nil {
return nil, err
}
out[id] = t
}
return out, rows.Err()
}
func queryActivities(ctx context.Context, db *sql.DB, datasetID int64) ([]pgActivity, error) {
rows, err := db.QueryContext(ctx, `SELECT id, src_pk, name, color, icon, is_home, is_work, is_excluded, is_archived
FROM activities WHERE dataset_id=$1 ORDER BY id`, datasetID)
if err != nil {
return nil, fmt.Errorf("读取活动类型: %w", err)
}
defer rows.Close()
var out []pgActivity
for rows.Next() {
var a pgActivity
var h, w, e, ar bool
if err := rows.Scan(&a.ID, &a.SrcPK, &a.Name, &a.Color, &a.Icon, &h, &w, &e, &ar); err != nil {
return nil, err
}
a.Home, a.Work, a.Excluded, a.Archived = h, w, e, ar
out = append(out, a)
}
return out, rows.Err()
}
type pgTag struct {
SrcPK int
Name, Color, Group sql.NullString
}
func queryTags(ctx context.Context, db *sql.DB, datasetID int64) ([]pgTag, error) {
rows, err := db.QueryContext(ctx, `SELECT src_pk, name, color, COALESCE(group_name,'') FROM tags WHERE dataset_id=$1 ORDER BY id`, datasetID)
if err != nil {
return nil, fmt.Errorf("读取标签: %w", err)
}
defer rows.Close()
var out []pgTag
for rows.Next() {
var t pgTag
if err := rows.Scan(&t.SrcPK, &t.Name, &t.Color, &t.Group); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
type pgTransport struct {
SrcPK int
Name, Color, Icon sql.NullString
}
// queryTransports 把位移里用到的交通方式按**名称**归并成 ZTRANSPORT 的行,并给出
// 「方式名 -> 编号」——每条位移照自己的 transport_name 改挂过去。
//
// 为什么不按 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 1`, datasetID)
if err != nil {
return nil, nil, fmt.Errorf("读取交通方式颜色: %w", err)
}
for rows.Next() {
var name string
var color, icon sql.NullString
if err := rows.Scan(&name, &color, &icon); err != nil {
rows.Close()
return nil, nil, err
}
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}
}
out = append(out, t)
}
sort.Slice(out, func(i, j int) bool { return out[i].SrcPK < out[j].SrcPK })
return out, transByName, nil
}
type pgVisit struct {
SrcPK int
PlaceID, ActivityID sql.NullInt64
Arrival time.Time
Departure sql.NullTime
Bookmarked, UserAdded bool
Remark, Emoji, WeatherSymbol sql.NullString
}
func queryVisits(ctx context.Context, db *sql.DB, datasetID int64) ([]pgVisit, error) {
rows, err := db.QueryContext(ctx, `SELECT src_pk, place_id, activity_id, arrival, departure, bookmarked, is_user_added,
COALESCE(remark,''), COALESCE(emoji,''), COALESCE(weather_symbol,'')
FROM visits WHERE dataset_id=$1 ORDER BY id`, datasetID)
if err != nil {
return nil, fmt.Errorf("读取访问记录: %w", err)
}
defer rows.Close()
var out []pgVisit
for rows.Next() {
var v pgVisit
var bm, ua bool
if err := rows.Scan(&v.SrcPK, &v.PlaceID, &v.ActivityID, &v.Arrival, &v.Departure, &bm, &ua,
&v.Remark, &v.Emoji, &v.WeatherSymbol); err != nil {
return nil, err
}
v.Bookmarked, v.UserAdded = bm, ua
out = append(out, v)
}
return out, rows.Err()
}
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,
COALESCE(transport_name,'')
FROM movements WHERE dataset_id=$1 ORDER BY id`, datasetID)
if err != nil {
return nil, fmt.Errorf("读取位移: %w", err)
}
defer rows.Close()
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, &m.TransportName); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
type pgWeather struct {
SrcPK int
VisitSrc sql.NullInt64
At sql.NullTime
Daylight bool
Temp, App, Hum, Pa, Pc, Ws, Vis sql.NullFloat64
UV sql.NullInt64
Cond, Symbol, UVCat, WindDir sql.NullString
}
func queryWeather(ctx context.Context, db *sql.DB, datasetID int64) ([]pgWeather, error) {
// is_daylight 必须兜底成 false:真机 557/557 都有值,但站点「补录天气」建的行
// 没填昼夜标记(editWeatherCreate 不写这一列),而扫进 bool 遇到 NULL 会让**整包导出失败**。
// 取 0 与导入侧同口径(parse.go 读真机包用的就是 COALESCE(ZISDAYLIGHT,0))。
rows, err := db.QueryContext(ctx, `SELECT src_pk, visit_src, at, COALESCE(is_daylight,false), temperature_c, apparent_c, humidity,
precipitation_amount, precipitation_chance, wind_speed, visibility, uv_index,
COALESCE(condition,''), COALESCE(symbol,''), COALESCE(uv_category,''), COALESCE(wind_direction,'')
FROM weather WHERE dataset_id=$1 ORDER BY id`, datasetID)
if err != nil {
return nil, fmt.Errorf("读取天气: %w", err)
}
defer rows.Close()
var out []pgWeather
for rows.Next() {
var wRow pgWeather
var dl bool
if err := rows.Scan(&wRow.SrcPK, &wRow.VisitSrc, &wRow.At, &dl, &wRow.Temp, &wRow.App, &wRow.Hum, &wRow.Pa, &wRow.Pc,
&wRow.Ws, &wRow.Vis, &wRow.UV, &wRow.Cond, &wRow.Symbol, &wRow.UVCat, &wRow.WindDir); err != nil {
return nil, err
}
wRow.Daylight = dl
out = append(out, wRow)
}
return out, rows.Err()
}
// ---------- Z_PRIMARYKEY.Z_MAX ----------
func maxPKOf(known map[int]bool, written []int) int {
mx := 0
for pk := range known {
if pk > mx {
mx = pk
}
}
for _, pk := range written {
if pk > mx {
mx = pk
}
}
return mx
}
func placesPKs(v []pgPlace) []int {
out := make([]int, len(v))
for i, r := range v {
out[i] = r.SrcPK
}
return out
}
func actsPKs(v []pgActivity) []int {
out := make([]int, len(v))
for i, r := range v {
out[i] = r.SrcPK
}
return out
}
func tagsPKs(v []pgTag) []int {
out := make([]int, len(v))
for i, r := range v {
out[i] = r.SrcPK
}
return out
}
func transPKs(v []pgTransport) []int {
out := make([]int, len(v))
for i, r := range v {
out[i] = r.SrcPK
}
return out
}
func visitsPKs(v []pgVisit) []int {
out := make([]int, len(v))
for i, r := range v {
out[i] = r.SrcPK
}
return out
}
func movsPKs(v []pgMovement) []int {
out := make([]int, len(v))
for i, r := range v {
out[i] = r.SrcPK
}
return out
}
func wxPKs(v []pgWeather) []int {
out := make([]int, len(v))
for i, r := range v {
out[i] = r.SrcPK
}
return out
}
// loadEntities 从基线的 Z_PRIMARYKEY 读「实体名 → 编号」。
func loadEntities(ctx context.Context, sdb *sql.DB) (map[string]int, error) {
ents := map[string]int{}
rows, err := sdb.QueryContext(ctx, `SELECT COALESCE(Z_ENT,0), COALESCE(Z_NAME,'') FROM Z_PRIMARYKEY`)
if err != nil {
return nil, fmt.Errorf("读取 Z_PRIMARYKEY: %w", err)
}
defer rows.Close()
for rows.Next() {
var ent int
var name string
if err := rows.Scan(&ent, &name); err != nil {
return nil, err
}
if name != "" {
ents[name] = ent
}
}
return ents, rows.Err()
}
// discoverVisitTagJoin 在基线里定位「访问↔标签」多对多表及其两列。
func discoverVisitTagJoin(ctx context.Context, sdb *sql.DB, ents map[string]int) (JoinTableInfo, error) {
visitEnt, okV := ents["Visit"]
tagEnt, okT := ents["Tag"]
if !okV || !okT {
return JoinTableInfo{}, nil
}
names, err := queryStrings(ctx, sdb, `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Z\_%' ESCAPE '\'`)
if err != nil {
return JoinTableInfo{}, err
}
for _, n := range names {
if !strings.HasSuffix(n, "VISITS_") {
continue
}
cols, err := queryStrings(ctx, sdb, fmt.Sprintf("SELECT name FROM pragma_table_info('%s')", strings.ReplaceAll(n, "'", "''")))
if err != nil {
continue
}
var vcol, tcol string
for _, c := range cols {
if strings.Contains(c, fmt.Sprintf("Z_%dVISITS", visitEnt)) {
vcol = c
}
if strings.Contains(c, fmt.Sprintf("Z_%dTAGS", tagEnt)) {
tcol = c
}
}
if vcol != "" && tcol != "" {
return JoinTableInfo{Name: n, VisitCol: vcol, TagCol: tcol}, nil
}
}
return JoinTableInfo{}, nil
}
func tableExists(ctx context.Context, sdb *sql.DB) (map[string]bool, error) {
names, err := queryStrings(ctx, sdb, `SELECT name FROM sqlite_master WHERE type='table'`)
if err != nil {
return nil, err
}
out := make(map[string]bool, len(names))
for _, n := range names {
out[n] = true
}
return out, nil
}
func queryStrings(ctx context.Context, q interface {
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
}, query string, args ...any) ([]string, error) {
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
func containsStr(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
// zipBackup 打包三个文件。压缩方式与 rond 一致(Store 不压缩),
// 并且即使 -wal/-shm 为空也一并写入——避免设备上残留的旧 WAL 与新库不匹配。
func zipBackup(sqlitePath string, dst io.Writer) error {
zw := zip.NewWriter(dst)
defer zw.Close()
for _, suffix := range []string{"", "-wal", "-shm"} {
p := sqlitePath + suffix
if suffix != "" {
if _, err := os.Stat(p); err != nil {
f, cerr := os.Create(p)
if cerr != nil {
return cerr
}
f.Close()
}
}
name := "LifeEasy.sqlite" + suffix
// 必须用 CreateRaw 把长度与 CRC 先写进本地头:zip.Writer 默认走流式写入,会把这三项留成 0
// 并在 flag 里置 bit3(data descriptor)、把真值挪到条目尾部——真机的读取器只认本地头里就
// 写着长度的条目,遇到这种包会直接判成损坏(实测 2026-09-16 起每一份 Go 写的导出都被拒,
// 而唯一被接受过的那份恰好是 Python 重新打包的,它的 flags 是 0x0000)。
data, err := os.ReadFile(p)
if err != nil {
return fmt.Errorf("读 %s 失败: %w", name, err)
}
fh := &zip.FileHeader{Name: name, Method: zip.Store}
fh.CRC32 = crc32.ChecksumIEEE(data)
fh.CompressedSize64 = uint64(len(data))
fh.UncompressedSize64 = uint64(len(data))
w, err := zw.CreateRaw(fh)
if err != nil {
return fmt.Errorf("打包 %s 失败: %w", name, err)
}
if _, err := w.Write(data); err != nil {
return fmt.Errorf("打包 %s 失败: %w", name, err)
}
}
return nil
}
// appleSeconds 把时间转成 Core Data 时间戳(自 2001-01-01 UTC 起的秒数,绝对时刻,与时区无关)。
func appleSeconds(t time.Time) float64 {
// 先取整数微秒再除,避免 Duration.Seconds() 的浮点表示误差
// 让时间戳在往返一趟后差出 1 微秒。
return float64(t.UTC().Sub(appleEpoch).Microseconds()) / 1e6
}
// coreDataUUID 给「基线里没有、站点又没这个字段」的新行补一个确定性 UUID(见 uuidV5)。
// 基线里已有的行一律返回 nil(交给 keepNilCols 沿用原值):站点本就没有这个字段,
// 不该反过来改写真机的身份标识——真机里也确实有 2 条到访这一列是空的。
// DerivedUUID 是站点给「新建行」补的确定性 UUID(RFC 4122 v5,名字取「表名/编号」)。
//
// 导出侧用它填 ZVISIT.ZIDENTIFIER_ / ZACTIVITY.ZUID_;它同时是**对账键**:
// 站点补录的行自己没有留档 UUID(entity_raw 里没有那一行),但导出过一次之后,
// 包里那条就是这个名字——这份包再合并回来时必须认得出来,否则同一行会被判成
// 「备份新增」又插一条(实测数据集 9 有 43 对这样的重复:一半是补录行,一半是它的副本)。
func DerivedUUID(table string, pk int) []byte {
return uuidV5(fmt.Sprintf("%s/%d", table, pk))
}
// DerivedVisitUUID 是派生 UUID 的小写十六进制形式,与 Backup.VisitUUID / StoredVisit.UUID 同形。
func DerivedVisitUUID(pk int) string { return fmt.Sprintf("%x", DerivedUUID("ZVISIT", pk)) }
// VisitMatchUUID 返回一条站点到访用来对账的 UUID:留档里有就用留档里的,
// 没有(站点补录的行)就用导出那一刻会补给它的派生 UUID。
func VisitMatchUUID(sv StoredVisit) string {
if sv.UUID != "" {
return sv.UUID
}
if sv.SrcPK <= 0 {
return ""
}
return DerivedVisitUUID(sv.SrcPK)
}
func coreDataUUID(isNew bool, table string, pk int) any {
if !isNew {
return nil
}
return DerivedUUID(table, pk)
}
// uuidNamespace 是本站补 UUID 时用的固定命名空间,取值本身无意义,只要不变即可。
var uuidNamespace = [16]byte{0x6b, 0x1c, 0x9a, 0x2e, 0x42, 0x7d, 0x4f, 0x16,
0x8b, 0x33, 0x5a, 0x0c, 0xd9, 0x71, 0xe4, 0x08}
// uuidV5 由命名空间与名字生成 RFC 4122 版本 5 的 UUID(16 字节原始形式)。
//
// rond 给每条到访、每个活动都存一个 16 字节 UUID(ZIDENTIFIER_ / ZUID_,真机里全是 v4),
// 站点新建的行没有真机 UUID 可继承。这里用确定性算法补:名字取表名 + src_pk
// (src_pk 在**同一个数据集内**稳定,够用了),于是同一条记录每次导出拿到的都是同一个
// UUID——若改成随机,每次导出的包都会让 rond 把同一批记录当成新的。
//
// 注意这只保证「同一数据集内幂等」,不是跨备份的稳定标识:rond 那边每次导入都会重排
// Z_PK,真正的跨备份对齐键是它原生的 ZIDENTIFIER_(见 diff.go)。
func uuidV5(name string) []byte {
h := sha1.New()
h.Write(uuidNamespace[:])
h.Write([]byte(name))
sum := h.Sum(nil)[:16]
sum[6] = (sum[6] & 0x0f) | 0x50 // 版本 5
sum[8] = (sum[8] & 0x3f) | 0x80 // RFC 4122 变体位
return sum
}
func ns(v sql.NullString) any {
if v.Valid && v.String != "" {
return v.String
}
return nil
}
func nf(v sql.NullFloat64) any {
if v.Valid {
return v.Float64
}
return nil
}
func ni(v sql.NullInt64) any {
if v.Valid {
return v.Int64
}
return nil
}
func nt(v sql.NullTime) any {
if v.Valid {
return appleSeconds(v.Time)
}
return nil
}
func bint(b bool) int {
if b {
return 1
}
return 0
}