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 }