Files
rond-with-you/internal/rond/parse.go
T
tamakyi b4c2a8452f 审计修复:后端(安全 / 数据一致性 / 性能)
audit-2026-09-21.md 里的后端条目落地。每条都配了回归守卫,并逐条做过变异验证
(注入原 bug → 确认按正确理由变红 → 还原 → 变绿)。

安全
- CSRF 只加同源 Origin/Referer 守卫(csrf.go),不动模板
- 会话 cookie 的 Secure 按请求判断;两步验证两闸(loginGuard + 验证码一次性)
- 跳转收口到 safeInternalPath;clientIP 只认 XFF 最后一段
- GPX 按属主圈定且查 RowsAffected;下载名走 downloadDisposition
- 上传口统一 MaxBytesReader;落盘后的失败路径清掉半成品

数据一致性
- 区域判定两端各过一遍再 AND(apiTrack 与 movementsWhere 对齐)
- Filter.Verdict 全域一致(下推到 visitsWhere);聚合刷新必须返回 error
- 附属数据改挂坐标键,Go 与 SQL 两套表达式逐位一致
- 脏备份的伪时间/越界坐标不再进领域模型;rows 循环统一 drainRows
- 合并与导出:writeRawRow 上抛错误、悬空引用清理、原生列补全

性能
- placeCols 相关子查询带 dataset_id 前导列;findSourceArchive 先按大小筛
- 批量勾选改 ANY($3::bigint[]);fillupCache 加上限;同一查询不再查两遍

另:新增「全新库」冒烟测试 —— 空库跑完 Migrate 再用应用侧读查询真打一遍,
迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
2026-09-24 20:37:16 +08:00

808 lines
24 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 负责解析 rond(iOS 应用 LifeEasy)导出的 .rondbackup 备份包。
//
// 备份包本质是 zip,内含 Core Data 的 SQLite 库 LifeEasy.sqlite 及同名 -wal / -shm。
// 数据主要在 WAL 里,因此必须三个文件一起解出来再打开,否则会丢失绝大部分记录。
package rond
import (
"archive/zip"
"context"
"database/sql"
"encoding/base64"
"fmt"
"io"
"log"
"math"
"os"
"path/filepath"
"strings"
"time"
_ "modernc.org/sqlite"
)
// Core Data 时间戳基准:2001-01-01 UTC
var appleEpoch = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
// 允许的时间窗口 [1970, 2100)。脏备份里会出现 NaN / ±Inf / 量级离谱的值,
// 而 int64 转换对超范围值是回绕而非饱和——实测 1e30 恰好回绕成 0(=基准点 2001)、
// 1e12 变成 2123 年,都是「看着正常」的伪时间,再被当成 first/last_visit_at
// 污染整条时间轴。必须先卡输入秒数:回绕后的结果可能又落回合法区间,只查结果挡不住。
var (
appleMinSec = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC).Sub(appleEpoch).Seconds()
appleMaxSec = time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC).Sub(appleEpoch).Seconds()
)
func appleTime(v sql.NullFloat64) *time.Time {
if !v.Valid || math.IsNaN(v.Float64) || math.IsInf(v.Float64, 0) {
return nil
}
sec := math.Floor(v.Float64)
if sec < appleMinSec || sec > appleMaxSec {
return nil
}
// 秒的整数部分和小数部分分开算:8 亿量级的秒乘 1e6/1e9 已超出 float64 的
// 精确整数范围(2^53),一次性相乘再取整会系统性吃掉最后 1 微秒,
// 往返导出后时间戳就对不上了。
us := int64(sec)*1_000_000 + int64(math.Round((v.Float64-sec)*1e6))
t := appleEpoch.Add(time.Duration(us) * time.Microsecond).Local()
return &t
}
// validCoord 判定坐标是否可用。SQLite 的 REAL 列可能塞进 NaN,备份也可能带出
// ±90 / ±180 之外的值;这类值会让地图与迷雾索引的网格算出 NaN 或越界格。
// 无效就按「无坐标」处理——与管线里 COALESCE(NULL,0) 的既有约定一致。
func validCoord(lat, lon float64) bool {
if math.IsNaN(lat) || math.IsInf(lat, 0) || lat < -90 || lat > 90 {
return false
}
if math.IsNaN(lon) || math.IsInf(lon, 0) || lon < -180 || lon > 180 {
return false
}
return true
}
type Activity struct {
SrcPK int
Name string
Color string
Icon string
IsHome bool
IsWork bool
IsExcluded bool
IsArchived bool
}
type Tag struct {
SrcPK int
Name string
Color string
GroupName string
}
type Location struct {
SrcPK int
Name string
POICategory string
Lat float64
Lon float64
CountryCode string
Province string
City string
District string
Sublocality string
Thoroughfare string
SubThoroughfare string
Timezone string
}
type Visit struct {
SrcPK int
LocationSrcPK *int
ActivitySrcPK *int
Arrival *time.Time
Departure *time.Time
Bookmarked bool
UserAdded bool
Remark string
Emoji string
WeatherSymbol string
TagSrcPKs []int
}
type Movement struct {
SrcPK int
TransportSrc *int
FromVisitSrc *int
ToVisitSrc *int
Start *time.Time
End *time.Time
}
type Weather struct {
SrcPK int
VisitSrc *int
At *time.Time
IsDaylight bool
TempC *float64
ApparentC *float64
Humidity *float64
PrecipAmt *float64
PrecipPct *float64
WindSpeed *float64
Visibility *float64
UVIndex *int
Condition string
Symbol string
UVCategory string
WindDir string
}
type Transport struct {
SrcPK int
Name string
Color string
Icon string
}
// CoreDataMeta 记录备份包里 Core Data 库的结构信息(实体编号、建表 DDL、关联表),
// 作为数据集的结构快照留档;导出 rondbackup 已改为以「基线库」为底覆盖写入,不再依赖它。
type CoreDataMeta struct {
// 实体名(ZPRIMARYKEY.Z_NAME) -> 实体编号(Z_ENT) 与当前最大主键(Z_MAX)
Entities map[string]EntityInfo `json:"entities"`
// 表名 -> CREATE TABLE 语句(含全部列与默认值,导出只 INSERT 其中部分列)
DDL map[string]string `json:"ddl"`
// 访问↔标签多对多关联表(Core Data 自动生成的 Z_<tagEnt>VISITS_)
JoinTable JoinTableInfo `json:"join_visit_tag"`
}
type EntityInfo struct {
Ent int `json:"ent"`
Max int `json:"max"`
}
type JoinTableInfo struct {
Name string `json:"name"`
VisitCol string `json:"visit_col"`
TagCol string `json:"tag_col"`
}
type Backup struct {
Activities []Activity
Tags []Tag
Transports []Transport
Locations []Location
Visits []Visit
Movements []Movement
Weather []Weather
RawVisitNum int
TagGroups map[int]string
Meta *CoreDataMeta
// RawRows 是各实体在 Core Data 里的完整原始行:「表名 -> Z_PK -> 列名 -> 值」。
// 站点只映射了其中一部分字段,留档让导出能逐列还原,不必依赖基线库补剩下的列。
RawRows map[string]map[int]map[string]any
// VisitUUID / ActivityUUID 是真机给每条到访、每个活动存的 16 字节 UUID(小写十六进制)。
// **这是跨备份唯一稳定的对齐键**:rond 每次导出/同步都会重排 Z_PK——实测两份备份里
// 822 条到访的 UUID 完全一致、Z_PK 100% 不同——所以「这份备份」与「库里的那一份」
// 之间只能按它对齐。地点/标签/交通方式没有 UUID,只能退到名称与坐标。
VisitUUID map[int]string
ActivityUUID map[int]string
}
// maxDBBytes 是备份包内单个 SQLite 文件解压后的上限。真机的库是几十 MB 量级,
// 2 GiB 足够宽松,但足以挡住 zip bomb——压缩比上千时几 MB 的包能解出 TB 级。
const maxDBBytes = 2 << 30
// copyLimited 把 r 拷进 w,超过 limit 就报错。
// zip 头里的 UncompressedSize 是攻击者可控字段,不能只信声明,必须在读的时候掐住。
func copyLimited(w io.Writer, r io.Reader, limit int64) error {
n, err := io.Copy(w, io.LimitReader(r, limit+1))
if err != nil {
return err
}
if n > limit {
return fmt.Errorf("解压后超过 %d 字节,疑似异常备份包", limit)
}
return nil
}
// Open 解压备份包到 dir,返回可用的 SQLite 连接。
// 调用方负责 Close 与删除 dir。
func Open(archivePath, dir string) (*sql.DB, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
zr, err := zip.OpenReader(archivePath)
if err != nil {
return nil, fmt.Errorf("备份包不是有效的 zip: %w", err)
}
defer zr.Close()
dbPath, err := unpackArchive(&zr.Reader, dir, maxDBBytes)
if err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(dbPath)+"?_pragma=busy_timeout(5000)")
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
// 把 WAL 合并回主库,避免后续读取时依赖 -shm 状态
if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
db.Close()
return nil, fmt.Errorf("合并 WAL 失败: %w", err)
}
return db, nil
}
// unpackArchive 把包里的 Core Data 主库及其 WAL / SHM 解压到 dir,返回主库路径。
// limit 是单个条目解压后的字节上限;抽成参数是为了让上限本身可测
// (真上限 2 GiB,测试里没法造出一个 2 GiB 的 zip bomb)。
func unpackArchive(zr *zip.Reader, dir string, limit int64) (string, error) {
var dbPath string
for _, f := range zr.File {
base := filepath.Base(f.Name)
if f.FileInfo().IsDir() || base == "" {
continue
}
// 只取 Core Data 主库及其 WAL / SHM
low := strings.ToLower(base)
if !strings.HasSuffix(low, ".sqlite") && !strings.HasSuffix(low, ".sqlite-wal") && !strings.HasSuffix(low, ".sqlite-shm") {
continue
}
dst := filepath.Join(dir, base)
rc, err := f.Open()
if err != nil {
return "", err
}
out, err := os.Create(dst)
if err != nil {
rc.Close()
return "", err
}
cerr := copyLimited(out, rc, limit)
rc.Close()
out.Close()
if cerr != nil {
os.Remove(dst)
return "", fmt.Errorf("解压 %s: %w", base, cerr)
}
if strings.HasSuffix(low, ".sqlite") {
dbPath = dst
}
}
if dbPath == "" {
return "", fmt.Errorf("备份包内未找到 .sqlite 数据库文件")
}
return dbPath, nil
}
// drainRows 收尾 rows 循环。只 Close 不查 Err:驱动或连接中途出错时 Next() 只会
// 返回 false、从不报错,半张表就被当成完整的交出去——数据静默变少,日志一片空白。
func drainRows(rows *sql.Rows, what string) error {
err := rows.Err()
rows.Close()
if err != nil {
return fmt.Errorf("%s: %w", what, err)
}
return nil
}
// Parse 读取 Core Data 库,转换为领域模型。
func Parse(db *sql.DB) (*Backup, error) {
b := &Backup{TagGroups: map[int]string{}}
var err error
// 标签分组
rows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,'') FROM ZTAGGROUP`)
if err != nil {
return nil, fmt.Errorf("读取标签分组: %w", err)
}
for rows.Next() {
var pk int
var name string
if err = rows.Scan(&pk, &name); err != nil {
rows.Close()
return nil, err
}
b.TagGroups[pk] = name
}
if err = drainRows(rows, "读取标签分组"); err != nil {
return nil, err
}
// 标签(含所属分组关系 Z_11TAGS_ 的逆向:ZTAG.ZGROUP -> ZTAGGROUP)
tagGroup := map[int]int{}
grows, err := db.Query(`SELECT Z_PK, COALESCE(ZGROUP,0) FROM ZTAG`)
if err != nil {
return nil, fmt.Errorf("读取标签: %w", err)
}
for grows.Next() {
var pk, g int
if err = grows.Scan(&pk, &g); err != nil {
grows.Close()
return nil, err
}
tagGroup[pk] = g
}
if err = drainRows(grows, "读取标签分组关系"); err != nil {
return nil, err
}
trows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,'') FROM ZTAG`)
if err != nil {
return nil, fmt.Errorf("读取标签: %w", err)
}
for trows.Next() {
var t Tag
if err = trows.Scan(&t.SrcPK, &t.Name, &t.Color); err != nil {
trows.Close()
return nil, err
}
t.GroupName = b.TagGroups[tagGroup[t.SrcPK]]
b.Tags = append(b.Tags, t)
}
if err = drainRows(trows, "读取标签"); err != nil {
return nil, err
}
// 类型 / 活动
arows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,''), COALESCE(ZICON_,''),
COALESCE(ZISHOME,0), COALESCE(ZISWORK,0), COALESCE(ZISEXCLUDED,0), COALESCE(ZISARCHIVED,0) FROM ZACTIVITY`)
if err != nil {
return nil, fmt.Errorf("读取活动类型: %w", err)
}
for arows.Next() {
var a Activity
var h, w, e, ar int
if err = arows.Scan(&a.SrcPK, &a.Name, &a.Color, &a.Icon, &h, &w, &e, &ar); err != nil {
arows.Close()
return nil, err
}
a.IsHome, a.IsWork, a.IsExcluded, a.IsArchived = h == 1, w == 1, e == 1, ar == 1
b.Activities = append(b.Activities, a)
}
if err = drainRows(arows, "读取活动类型"); err != nil {
return nil, err
}
// 交通方式
trrows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,''), COALESCE(ZICON_,'') FROM ZTRANSPORT`)
if err != nil {
return nil, fmt.Errorf("读取交通方式: %w", err)
}
for trrows.Next() {
var t Transport
if err = trrows.Scan(&t.SrcPK, &t.Name, &t.Color, &t.Icon); err != nil {
trrows.Close()
return nil, err
}
b.Transports = append(b.Transports, t)
}
if err = drainRows(trrows, "读取交通方式"); err != nil {
return nil, err
}
// 地点
lrows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCATEGORY_,''),
COALESCE(ZLATITUDE,0), COALESCE(ZLONGITUDE,0),
COALESCE(ZISOCOUNTRYCODE,''), COALESCE(ZADMINISTRATIVEAREA,''), COALESCE(ZLOCALITY,''),
COALESCE(ZSUBLOCALITY,''), COALESCE(ZTHOROUGHFARE,''), COALESCE(ZSUBTHOROUGHFARE,''),
COALESCE(ZTIMEZONE,'') FROM ZLOCATION`)
if err != nil {
return nil, fmt.Errorf("读取地点: %w", err)
}
for lrows.Next() {
var l Location
if err = lrows.Scan(&l.SrcPK, &l.Name, &l.POICategory, &l.Lat, &l.Lon,
&l.CountryCode, &l.Province, &l.City, &l.Sublocality, &l.Thoroughfare,
&l.SubThoroughfare, &l.Timezone); err != nil {
lrows.Close()
return nil, err
}
if !validCoord(l.Lat, l.Lon) {
l.Lat, l.Lon = 0, 0
}
b.Locations = append(b.Locations, l)
}
if err = drainRows(lrows, "读取地点"); err != nil {
return nil, err
}
// 访问记录
vrows, err := db.Query(`SELECT Z_PK, ZLOCATION, ZACTIVITY_, ZARRIVALDATE_, ZDEPARTUREDATE_,
COALESCE(ZBOOKMARKED,0), COALESCE(ZUSERADDED,0), COALESCE(ZREMARK_,''),
COALESCE(ZEMOJINAME_,''), COALESCE(ZWEATHERSYMBOL_,'') FROM ZVISIT`)
if err != nil {
return nil, fmt.Errorf("读取访问记录: %w", err)
}
visitIdx := map[int]int{}
for vrows.Next() {
var v Visit
var loc, act sql.NullInt64
var arr, dep sql.NullFloat64
var bm, ua int
if err = vrows.Scan(&v.SrcPK, &loc, &act, &arr, &dep, &bm, &ua,
&v.Remark, &v.Emoji, &v.WeatherSymbol); err != nil {
vrows.Close()
return nil, err
}
if loc.Valid {
pk := int(loc.Int64)
v.LocationSrcPK = &pk
}
if act.Valid {
pk := int(act.Int64)
v.ActivitySrcPK = &pk
}
v.Arrival = appleTime(arr)
v.Departure = appleTime(dep)
v.Bookmarked, v.UserAdded = bm == 1, ua == 1
visitIdx[v.SrcPK] = len(b.Visits)
b.Visits = append(b.Visits, v)
}
if err = drainRows(vrows, "读取访问记录"); err != nil {
return nil, err
}
// 访问 → 标签
vtrows, err := db.Query(`SELECT Z_18VISITS_, Z_11TAGS_5 FROM Z_11VISITS_`)
if err != nil {
return nil, fmt.Errorf("读取访问标签: %w", err)
}
for vtrows.Next() {
var vid, tid int
if err = vtrows.Scan(&vid, &tid); err != nil {
vtrows.Close()
return nil, err
}
if i, ok := visitIdx[vid]; ok {
b.Visits[i].TagSrcPKs = append(b.Visits[i].TagSrcPKs, tid)
}
}
if err = drainRows(vtrows, "读取访问标签"); err != nil {
return nil, err
}
// 位移
mrows, err := db.Query(`SELECT Z_PK, ZTRANSPORT_, ZVISITFROM_, ZVISITTO_, ZSTART_, ZEND_ FROM ZMOVEMENT`)
if err != nil {
return nil, fmt.Errorf("读取位移记录: %w", err)
}
for mrows.Next() {
var m Movement
var tr, from, to sql.NullInt64
var st, en sql.NullFloat64
if err = mrows.Scan(&m.SrcPK, &tr, &from, &to, &st, &en); err != nil {
mrows.Close()
return nil, err
}
if tr.Valid {
pk := int(tr.Int64)
m.TransportSrc = &pk
}
if from.Valid {
pk := int(from.Int64)
m.FromVisitSrc = &pk
}
if to.Valid {
pk := int(to.Int64)
m.ToVisitSrc = &pk
}
m.Start = appleTime(st)
m.End = appleTime(en)
b.Movements = append(b.Movements, m)
}
if err = drainRows(mrows, "读取位移记录"); err != nil {
return nil, err
}
// 小时级天气
wrows, err := db.Query(`SELECT Z_PK, ZVISIT, ZDATE_, COALESCE(ZISDAYLIGHT,0),
ZTEMPERATURE_, ZAPPARENTTEMPERATURE_, ZHUMIDITY_, ZPRECIPITATIONAMOUNT_,
ZPRECIPITATIONCHANCE_, ZWINDSPEED_, ZVISIBILITY_, ZUVINDEXVALUE_,
COALESCE(ZCONDITION_,''), COALESCE(ZSYMBOLNAME_,''), COALESCE(ZUVINDEXCATEGORY_,''),
COALESCE(ZWINDCOMPASSDIRECTION_,'') FROM ZHOURLYWEATHER`)
if err != nil {
return nil, fmt.Errorf("读取天气记录: %w", err)
}
for wrows.Next() {
var w Weather
var vis sql.NullInt64
var at sql.NullFloat64
var dl int
var temp, app, hum, pa, pc, ws, vv sql.NullFloat64
var uv sql.NullInt64
if err = wrows.Scan(&w.SrcPK, &vis, &at, &dl, &temp, &app, &hum, &pa, &pc, &ws, &vv, &uv,
&w.Condition, &w.Symbol, &w.UVCategory, &w.WindDir); err != nil {
wrows.Close()
return nil, err
}
if vis.Valid {
pk := int(vis.Int64)
w.VisitSrc = &pk
}
w.At = appleTime(at)
w.IsDaylight = dl == 1
w.TempC = fptr(temp)
w.ApparentC = fptr(app)
w.Humidity = fptr(hum)
w.PrecipAmt = fptr(pa)
w.PrecipPct = fptr(pc)
w.WindSpeed = fptr(ws)
w.Visibility = fptr(vv)
if uv.Valid {
n := int(uv.Int64)
w.UVIndex = &n
}
b.Weather = append(b.Weather, w)
}
if err = drainRows(wrows, "读取天气记录"); err != nil {
return nil, err
}
if err := db.QueryRow(`SELECT count(*) FROM ZRAWVISIT`).Scan(&b.RawVisitNum); err != nil {
b.RawVisitNum = 0
}
// 抓取 Core Data 结构元信息(实体编号 / 建表 DDL / 关联表),作为结构快照留档。
// 失败不致命:旧备份可能没有这些表。
if meta, merr := captureMeta(db); merr == nil {
b.Meta = meta
} else {
log.Printf("抓取 Core Data 元信息失败: %v", merr)
}
// 整行留档:导出时逐列还原,不丢任何 rond 自己写的字段。
if raw, rerr := captureRawRows(db); rerr == nil {
b.RawRows = raw
} else {
log.Printf("抓取原始行留档失败(导出将退回基线补列): %v", rerr)
}
// 对齐键:导出不依赖它,但「两份数据比对」全靠它,缺了就只能放弃逐条对齐
if b.VisitUUID, err = uuidColumn(db, "ZVISIT", "ZIDENTIFIER_"); err != nil {
return nil, fmt.Errorf("读取到访 UUID: %w", err)
}
if b.ActivityUUID, err = uuidColumn(db, "ZACTIVITY", "ZUID_"); err != nil {
return nil, fmt.Errorf("读取活动 UUID: %w", err)
}
return b, nil
}
// uuidColumn 读出某表某一列的 UUID,返回 Z_PK -> 小写十六进制。
// 列不存在(很老的备份)返回空表而不是错误;全零或空的当作没有。
func uuidColumn(db *sql.DB, table, col string) (map[int]string, error) {
rows, err := db.Query(fmt.Sprintf(`SELECT Z_PK, hex(%s) FROM %s`, col, table))
if err != nil {
return nil, nil
}
defer rows.Close()
out := map[int]string{}
for rows.Next() {
var pk int
var h sql.NullString
if err := rows.Scan(&pk, &h); err != nil {
return nil, err
}
if h.Valid && strings.Trim(h.String, "0") != "" {
out[pk] = strings.ToLower(h.String)
}
}
return out, rows.Err()
}
// rawTables 是需要整行留档的 Core Data 表(站点管理的实体 + 标签分组)。
var rawTables = []string{"ZLOCATION", "ZVISIT", "ZMOVEMENT", "ZHOURLYWEATHER", "ZACTIVITY", "ZTAG", "ZTAGGROUP", "ZTRANSPORT"}
// captureRawRows 读出上表每一行的全部列,供导出逐列还原。
// BLOB 编码成 {"$blob": base64}(JSON 没有字节串类型),时间戳维持 Core Data 浮点秒原值。
func captureRawRows(db *sql.DB) (map[string]map[int]map[string]any, error) {
out := map[string]map[int]map[string]any{}
for _, t := range rawTables {
var n int
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?`, t).Scan(&n); err != nil || n == 0 {
continue // 旧备份可能没这张表
}
cols, err := queryStrings(context.Background(), db, fmt.Sprintf("SELECT name FROM pragma_table_info('%s')", t))
if err != nil || len(cols) == 0 {
continue
}
quoted := make([]string, len(cols))
for i, c := range cols {
quoted[i] = `"` + strings.ReplaceAll(c, `"`, `""`) + `"`
}
rows, err := db.Query(fmt.Sprintf("SELECT %s FROM %s", strings.Join(quoted, ","), t))
if err != nil {
return nil, fmt.Errorf("读取 %s 原始行: %w", t, err)
}
vals := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
m := map[int]map[string]any{}
for rows.Next() {
if err := rows.Scan(ptrs...); err != nil {
rows.Close()
return nil, err
}
var pk int
row := make(map[string]any, len(cols))
for i, c := range cols {
if c == "Z_PK" {
if v, ok := vals[i].(int64); ok {
pk = int(v)
}
}
row[c] = jsonSafe(vals[i])
}
if pk > 0 {
m[pk] = row
}
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
out[t] = m
}
return out, nil
}
// jsonSafe 把驱动值换成能被 JSON 无损表达的形式。
func jsonSafe(v any) any {
if b, ok := v.([]byte); ok {
return map[string]any{"$blob": base64.StdEncoding.EncodeToString(b)}
}
return v
}
// WriteBaseline 把备份包内的 Core Data 库另存为一份「单文件基线」,供导出时作为底稿。
//
// 为什么要有基线:rond 是 Core Data 应用,库里有 Z_METADATA(模型版本哈希)、
// Z_MODELCACHE、持久化历史表(ACHANGE/ATRANSACTION*)、大量索引,以及我们并不落库的实体
// (ZRAWVISIT 原始 GPS 采样点、ZKEYWORD、ZTRIP* 等)。仅凭抓到的建表语句重建只能得到
// 一个「空壳」,Core Data 会判定与模型不兼容而拒绝打开。以基线为底、只覆盖我们管理的
// 那几张实体表,上述内容就都能原样保留。
//
// 备份包里数据主要在 WAL 中,所以这里先按 Open 的方式解包并合并 WAL,再用
// VACUUM INTO 导出一份自洽的单文件副本(不依赖 -wal/-shm)。
func WriteBaseline(archivePath, dst string) error {
dir, err := os.MkdirTemp("", "rond-base-")
if err != nil {
return err
}
defer os.RemoveAll(dir)
db, err := Open(archivePath, dir) // Open 内部已做 wal_checkpoint(TRUNCATE)
if err != nil {
return err
}
defer db.Close()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
_ = os.Remove(dst)
// VACUUM INTO 需要字面量路径(不用参数绑定),单引号按 SQL 规则转义
lit := strings.ReplaceAll(filepath.ToSlash(dst), "'", "''")
if _, err := db.Exec("VACUUM INTO '" + lit + "'"); err != nil {
// 退路:主库在 Open 里已合并过 WAL,直接复制也自洽
src := filepath.Join(dir, "LifeEasy.sqlite")
if cerr := copyFile(src, dst); cerr != nil {
return fmt.Errorf("生成基线库失败: %v(VACUUM INTO 亦失败: %w)", cerr, err)
}
}
return nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}
// captureMeta 读取 ZPRIMARYKEY(实体编号与最大主键)与所需的 Z* 建表 DDL,
// 并定位「访问↔标签」关联表及其两列。作为数据集上的 Core Data 结构快照留档。
func captureMeta(db *sql.DB) (*CoreDataMeta, error) {
m := &CoreDataMeta{Entities: map[string]EntityInfo{}, DDL: map[string]string{}}
rows, err := db.Query(`SELECT COALESCE(Z_ENT,0), COALESCE(Z_NAME,''), COALESCE(Z_MAX,0) FROM Z_PRIMARYKEY`)
if err != nil {
return nil, fmt.Errorf("读取 Z_PRIMARYKEY: %w", err)
}
for rows.Next() {
var ent, max int
var name string
if err := rows.Scan(&ent, &name, &max); err != nil {
rows.Close()
return nil, err
}
if name != "" {
m.Entities[name] = EntityInfo{Ent: ent, Max: max}
}
}
if err = drainRows(rows, "读取 Z_PRIMARYKEY"); err != nil {
return nil, err
}
drows, err := db.Query(`SELECT name, sql FROM sqlite_master WHERE type='table' AND name LIKE 'Z%' AND sql IS NOT NULL`)
if err != nil {
return nil, fmt.Errorf("读取建表 DDL: %w", err)
}
for drows.Next() {
var name, ddl string
if err := drows.Scan(&name, &ddl); err != nil {
drows.Close()
return nil, err
}
m.DDL[name] = ddl
}
if err = drainRows(drows, "读取建表 DDL"); err != nil {
return nil, err
}
// 访问↔标签关联表:表名形如 Z_<tagEnt>VISITS_,两列分别指向 Visit / Tag 实体。
visitEnt, okV := m.Entities["Visit"]
tagEnt, okT := m.Entities["Tag"]
if okV && okT {
for tname, ddl := range m.DDL {
if !strings.HasPrefix(tname, "Z_") || !strings.HasSuffix(tname, "VISITS_") {
continue
}
_ = ddl
crows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", tname))
if err != nil {
continue
}
var vcol, tcol string
for crows.Next() {
var cid int
var cname, ctype string
var notnull int
var dflt sql.NullString
var pk int
if err := crows.Scan(&cid, &cname, &ctype, &notnull, &dflt, &pk); err != nil {
crows.Close()
continue
}
if strings.Contains(cname, fmt.Sprintf("Z_%dVISITS", visitEnt.Ent)) {
vcol = cname
}
if strings.Contains(cname, fmt.Sprintf("Z_%dTAGS", tagEnt.Ent)) {
tcol = cname
}
}
cerr := crows.Err()
crows.Close()
if cerr != nil {
continue
}
if vcol != "" && tcol != "" {
m.JoinTable = JoinTableInfo{Name: tname, VisitCol: vcol, TagCol: tcol}
break
}
}
}
return m, nil
}
func fptr(v sql.NullFloat64) *float64 {
if !v.Valid {
return nil
}
f := v.Float64
return &f
}