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