Files
rond-with-you/internal/rond/export_align.go
T
tamakyi d5a011259d 导出:按身份把编号对齐到基线(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。

注意:对齐的目标是**这份数据集的基线**。要让导出与手机编号一致,数据集的基线就得是手机包
(把手机导出作为新数据集上传、再把旧站点导出包合并进去,然后从它导出)。
2026-09-29 16:57:29 +08:00

460 lines
12 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 (
"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
}