备份与迷雾的差异分析、合并执行,附属数据改挂坐标键
备份(.rondbackup)与迷雾(.fwss)各加一层「先看差异再动手」: - 迷雾:上传先出差异页,说清叠加能补多少、整体替换会丢多少,并在真实底图上 用绿/灰/红标出会新增 / 不变 / 会丢的区域 - 备份:与当前数据集按 UUID 逐条比对(rond 每次导出都会重排 Z_PK,编号认不出人), 新增合入、冲突逐条选、仅站内有逐条勾选删除,全在一个事务里执行; 合入的行写回 entity_raw,保证再传同一份备份时差异归零 - 地点按坐标对齐:站点的「补全地址」会把地点改名,拿名称当键会让一整个地点的 到访全变成假冲突 - 附属数据(备注/结论/访客票/图片)从 dataset_id+src_pk 换成坐标键,换数据集不再失联; 访客票没有 dataset_id,回填按活跃数据集认领 - 修正 contentHash 拿 Z_PK 当指纹的隐患,以及两处「src_pk 跨快照稳定」的错误注释 导出格式(.rondbackup / .fwss)保持原样,往返测试通过。
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
package rond
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 两份数据的比对。
|
||||
//
|
||||
// 对齐键的选择是这个文件的全部要点:rond 每次导出/同步都会重排 Z_PK(实测两份备份里
|
||||
// 822 条到访的 UUID 完全一致、Z_PK 100% 不同),所以 src_pk 只能在一个数据集内部使用。
|
||||
// 跨备份对齐一律走真机写的 UUID;地点/标签/交通方式没有 UUID,退到「名称 + 坐标」。
|
||||
//
|
||||
// 这里只做判定,不碰数据库、也不写任何东西。
|
||||
|
||||
// StoredVisit 是库里的一条到访,外加从 entity_raw 留档取出的对齐键。
|
||||
type StoredVisit struct {
|
||||
SrcPK int
|
||||
UUID string // 空 = 站点新建的行(真机不会给它 UUID)
|
||||
Arrival *time.Time
|
||||
Departure *time.Time
|
||||
Remark string
|
||||
Emoji string
|
||||
Bookmarked bool
|
||||
UserAdded bool
|
||||
PlaceKey string // 地点对齐键,空 = 该到访没有地点
|
||||
PlaceName string // 仅用于展示
|
||||
ActivityName string
|
||||
}
|
||||
|
||||
// StoredPlace 是库里的一个地点。
|
||||
type StoredPlace struct {
|
||||
SrcPK int
|
||||
Key string
|
||||
Name string
|
||||
Lat, Lon float64
|
||||
Province string
|
||||
City string
|
||||
POICategory string
|
||||
VisitCount int
|
||||
}
|
||||
|
||||
// StoredActivity 是库里的一个活动类型。
|
||||
type StoredActivity struct {
|
||||
SrcPK int
|
||||
Name string
|
||||
UUID string
|
||||
}
|
||||
|
||||
// FieldChange 是冲突里的一项字段差异。From 是库里当前的值,To 是备份里的值。
|
||||
type FieldChange struct {
|
||||
Label string
|
||||
From string
|
||||
To string
|
||||
}
|
||||
|
||||
// VisitConflict 是同一条到访(UUID 相同)两边字段不一致。
|
||||
type VisitConflict struct {
|
||||
Backup Visit
|
||||
DB StoredVisit
|
||||
Fields []FieldChange
|
||||
}
|
||||
|
||||
// VisitDiff 是到访的差异画像。
|
||||
type VisitDiff struct {
|
||||
Added []Visit // 备份有、库里没有
|
||||
Removed []StoredVisit // 库里有、备份里没有
|
||||
Changed []VisitConflict
|
||||
Same int // 对齐上且完全一致
|
||||
}
|
||||
|
||||
// PlaceDiff 是地点的差异画像。
|
||||
type PlaceDiff struct {
|
||||
Added []Location
|
||||
Removed []StoredPlace
|
||||
Changed []PlaceConflict
|
||||
Same int
|
||||
}
|
||||
|
||||
type PlaceConflict struct {
|
||||
Backup Location
|
||||
DB StoredPlace
|
||||
Fields []FieldChange
|
||||
}
|
||||
|
||||
// ActivityDiff 是活动类型的差异画像(按 UUID 对齐,退不到就按名称)。
|
||||
type ActivityDiff struct {
|
||||
Added []Activity
|
||||
Removed []StoredActivity
|
||||
Changed []ActivityConflict
|
||||
Same int
|
||||
}
|
||||
|
||||
type ActivityConflict struct {
|
||||
Backup Activity
|
||||
DB StoredActivity
|
||||
Fields []FieldChange
|
||||
}
|
||||
|
||||
// PlaceCoordKey 是地点的跨备份对齐键:**只认坐标,不认名称**。
|
||||
//
|
||||
// 地点没有 UUID,名称又会被站点自己的「补全缺失地址」改掉(实测:站内把
|
||||
// 「体育馆美食街」反查成了「河池市国民体质监测中心」,坐标一字未动,于是 17 条
|
||||
// 到访全被误报成「换了地点」)。坐标取 6 位小数(约 0.1m)——足以吸收浮点往返误差,
|
||||
// 又不会把挨着的两个地点并成一个。
|
||||
func PlaceCoordKey(lat, lon float64) string {
|
||||
return fmt.Sprintf("%.6f|%.6f", lat, lon)
|
||||
}
|
||||
|
||||
func coordStr(v float64) string { return fmt.Sprintf("%.6f", v) }
|
||||
|
||||
// DiffVisits 按 UUID 对齐两边的到访。
|
||||
//
|
||||
// 真机偶尔有条目没写 UUID(实测 824 行到访里有 2 行),只按 UUID 配对会让同一条既算
|
||||
// 「新增」又算「仅站内有」;所以没有 UUID 的行退到「到达时间 + 地点」兜底配对。
|
||||
// 库里那些连兜底也配不上的(站点新建的行)一律算「库里有、备份没有」。
|
||||
func DiffVisits(stored []StoredVisit, b *Backup) VisitDiff {
|
||||
placeKey, placeName := map[int]string{}, map[int]string{}
|
||||
for _, l := range b.Locations {
|
||||
// 没有坐标的地点不参与坐标对齐,否则它们会共用 "0.000000|0.000000" 互相认领
|
||||
if l.Lat != 0 || l.Lon != 0 {
|
||||
placeKey[l.SrcPK] = PlaceCoordKey(l.Lat, l.Lon)
|
||||
}
|
||||
placeName[l.SrcPK] = placeLabel(l.Name, l.Lat, l.Lon)
|
||||
}
|
||||
actName := map[int]string{}
|
||||
for _, a := range b.Activities {
|
||||
actName[a.SrcPK] = a.Name
|
||||
}
|
||||
|
||||
used := make([]bool, len(stored))
|
||||
byUUID := make(map[string]int, len(stored))
|
||||
byTime := map[string][]int{}
|
||||
for i, sv := range stored {
|
||||
if sv.UUID != "" {
|
||||
byUUID[sv.UUID] = i
|
||||
continue
|
||||
}
|
||||
if sv.Arrival != nil {
|
||||
k := timeKey(*sv.Arrival, sv.PlaceKey)
|
||||
byTime[k] = append(byTime[k], i)
|
||||
}
|
||||
}
|
||||
|
||||
var d VisitDiff
|
||||
classify := func(i int, v Visit) {
|
||||
used[i] = true
|
||||
sv := stored[i]
|
||||
if fields := visitFieldChanges(sv, v, placeKey, placeName, actName); len(fields) > 0 {
|
||||
d.Changed = append(d.Changed, VisitConflict{Backup: v, DB: sv, Fields: fields})
|
||||
} else {
|
||||
d.Same++
|
||||
}
|
||||
}
|
||||
|
||||
for _, v := range b.Visits {
|
||||
if v.Arrival == nil {
|
||||
continue // 没有到达时间的行导入时就被跳过了,不该被算成「新增」
|
||||
}
|
||||
if uuid := b.VisitUUID[v.SrcPK]; uuid != "" {
|
||||
if i, ok := byUUID[uuid]; ok {
|
||||
classify(i, v)
|
||||
continue
|
||||
}
|
||||
d.Added = append(d.Added, v)
|
||||
continue
|
||||
}
|
||||
bpk := ""
|
||||
if v.LocationSrcPK != nil {
|
||||
bpk = placeKey[*v.LocationSrcPK]
|
||||
}
|
||||
if list := byTime[timeKey(*v.Arrival, bpk)]; len(list) > 0 {
|
||||
byTime[timeKey(*v.Arrival, bpk)] = list[1:]
|
||||
classify(list[0], v)
|
||||
continue
|
||||
}
|
||||
d.Added = append(d.Added, v)
|
||||
}
|
||||
for i, sv := range stored {
|
||||
if !used[i] {
|
||||
d.Removed = append(d.Removed, sv)
|
||||
}
|
||||
}
|
||||
sortVisits(d)
|
||||
return d
|
||||
}
|
||||
|
||||
// timeKey 是没有 UUID 时的兜底配对键。只到秒:苹果时间戳转过来是浮点秒,
|
||||
// 而 PG 存的是微秒,逐位比会永远配不上。
|
||||
func timeKey(t time.Time, place string) string {
|
||||
return fmt.Sprintf("%d|%s", t.Unix(), place)
|
||||
}
|
||||
|
||||
func visitFieldChanges(sv StoredVisit, v Visit, placeKey, placeName, actName map[int]string) []FieldChange {
|
||||
var out []FieldChange
|
||||
add := func(label, from, to string) {
|
||||
if from != to {
|
||||
out = append(out, FieldChange{Label: label, From: from, To: to})
|
||||
}
|
||||
}
|
||||
if !sameTime(sv.Arrival, v.Arrival) {
|
||||
add("到达时间", fmtTime(sv.Arrival), fmtTime(v.Arrival))
|
||||
}
|
||||
if !sameTime(sv.Departure, v.Departure) {
|
||||
add("离开时间", fmtTime(sv.Departure), fmtTime(v.Departure))
|
||||
}
|
||||
// 地点按坐标判、显示名称;活动按名称判。都不能比 src_pk——两边编号空间不同。
|
||||
bp, bn := "", ""
|
||||
if v.LocationSrcPK != nil {
|
||||
bp, bn = placeKey[*v.LocationSrcPK], placeName[*v.LocationSrcPK]
|
||||
}
|
||||
// 地点按坐标判、显示名称:名称被「补全地址」反查改过,不算换了地点
|
||||
if sv.PlaceKey != bp {
|
||||
out = append(out, FieldChange{Label: "地点", From: sv.PlaceName, To: bn})
|
||||
}
|
||||
bl := ""
|
||||
if v.ActivitySrcPK != nil {
|
||||
bl = actName[*v.ActivitySrcPK]
|
||||
}
|
||||
add("活动", sv.ActivityName, bl)
|
||||
add("备注", sv.Remark, v.Remark)
|
||||
add("表情", sv.Emoji, v.Emoji)
|
||||
add("收藏", fmtBool(sv.Bookmarked), fmtBool(v.Bookmarked))
|
||||
return out
|
||||
}
|
||||
|
||||
// placeLabel 给没有名字的地点一个可读的占位。
|
||||
func placeLabel(name string, lat, lon float64) string {
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
return fmt.Sprintf("%.5f, %.5f", lat, lon)
|
||||
}
|
||||
|
||||
// DiffPlaces 对齐地点。坐标优先(改名不影响配对),配不上再按名称找一遍
|
||||
// (覆盖「站点把坐标纠偏过」这种),两样都对不上才算新增。
|
||||
func DiffPlaces(stored []StoredPlace, b *Backup) PlaceDiff {
|
||||
byCoord := make(map[string]StoredPlace, len(stored))
|
||||
byName := map[string]StoredPlace{}
|
||||
for _, sp := range stored {
|
||||
if sp.Lat != 0 || sp.Lon != 0 {
|
||||
byCoord[PlaceCoordKey(sp.Lat, sp.Lon)] = sp
|
||||
}
|
||||
if _, dup := byName[sp.Name]; !dup && sp.Name != "" {
|
||||
byName[sp.Name] = sp
|
||||
}
|
||||
}
|
||||
used := map[int]bool{}
|
||||
var d PlaceDiff
|
||||
classify := func(sp StoredPlace, l Location) {
|
||||
used[sp.SrcPK] = true
|
||||
if fields := placeFieldChanges(sp, l); len(fields) > 0 {
|
||||
d.Changed = append(d.Changed, PlaceConflict{Backup: l, DB: sp, Fields: fields})
|
||||
} else {
|
||||
d.Same++
|
||||
}
|
||||
}
|
||||
for _, l := range b.Locations {
|
||||
if sp, ok := byCoord[PlaceCoordKey(l.Lat, l.Lon)]; ok && !used[sp.SrcPK] {
|
||||
classify(sp, l)
|
||||
continue
|
||||
}
|
||||
if sp, ok := byName[l.Name]; ok && !used[sp.SrcPK] {
|
||||
classify(sp, l)
|
||||
continue
|
||||
}
|
||||
d.Added = append(d.Added, l)
|
||||
}
|
||||
for _, sp := range stored {
|
||||
if !used[sp.SrcPK] {
|
||||
d.Removed = append(d.Removed, sp)
|
||||
}
|
||||
}
|
||||
sort.Slice(d.Added, func(i, j int) bool { return d.Added[i].Name < d.Added[j].Name })
|
||||
sort.Slice(d.Removed, func(i, j int) bool { return d.Removed[i].Name < d.Removed[j].Name })
|
||||
sort.Slice(d.Changed, func(i, j int) bool { return d.Changed[i].DB.Name < d.Changed[j].DB.Name })
|
||||
return d
|
||||
}
|
||||
|
||||
// placeFieldChanges 比地点上除对齐键之外的字段。名称也算——按坐标配上之后,
|
||||
// 「名字不一样」正是站长最想看到的那类差异。
|
||||
func placeFieldChanges(sp StoredPlace, l Location) []FieldChange {
|
||||
var out []FieldChange
|
||||
add := func(label, from, to string) {
|
||||
if from != to {
|
||||
out = append(out, FieldChange{Label: label, From: from, To: to})
|
||||
}
|
||||
}
|
||||
add("名称", sp.Name, l.Name)
|
||||
add("纬度", coordStr(sp.Lat), coordStr(l.Lat))
|
||||
add("经度", coordStr(sp.Lon), coordStr(l.Lon))
|
||||
add("省份", sp.Province, l.Province)
|
||||
add("城市", sp.City, l.City)
|
||||
add("分类", sp.POICategory, l.POICategory)
|
||||
return out
|
||||
}
|
||||
|
||||
// DiffActivities 优先按 UUID 对齐;库里没有 UUID 的(站点新建)退回名称匹配,
|
||||
// 匹配不上就算「库里有、备份没有」。
|
||||
func DiffActivities(stored []StoredActivity, b *Backup) ActivityDiff {
|
||||
byUUID := map[string]StoredActivity{}
|
||||
byName := map[string]StoredActivity{}
|
||||
for _, sa := range stored {
|
||||
if sa.UUID != "" {
|
||||
byUUID[sa.UUID] = sa
|
||||
}
|
||||
if _, dup := byName[sa.Name]; !dup {
|
||||
byName[sa.Name] = sa
|
||||
}
|
||||
}
|
||||
var d ActivityDiff
|
||||
matched := map[int]bool{}
|
||||
for _, a := range b.Activities {
|
||||
sa, ok := byUUID[b.ActivityUUID[a.SrcPK]]
|
||||
if !ok {
|
||||
if sa, ok = byName[a.Name]; !ok {
|
||||
d.Added = append(d.Added, a)
|
||||
continue
|
||||
}
|
||||
}
|
||||
matched[sa.SrcPK] = true
|
||||
if sa.Name != a.Name {
|
||||
d.Changed = append(d.Changed, ActivityConflict{Backup: a, DB: sa,
|
||||
Fields: []FieldChange{{Label: "名称", From: sa.Name, To: a.Name}}})
|
||||
} else {
|
||||
d.Same++
|
||||
}
|
||||
}
|
||||
for _, sa := range stored {
|
||||
if !matched[sa.SrcPK] {
|
||||
d.Removed = append(d.Removed, sa)
|
||||
}
|
||||
}
|
||||
sort.Slice(d.Added, func(i, j int) bool { return d.Added[i].Name < d.Added[j].Name })
|
||||
sort.Slice(d.Removed, func(i, j int) bool { return d.Removed[i].Name < d.Removed[j].Name })
|
||||
return d
|
||||
}
|
||||
|
||||
func sortVisits(d VisitDiff) {
|
||||
tv := func(t *time.Time) int64 {
|
||||
if t == nil {
|
||||
return 0
|
||||
}
|
||||
return t.Unix()
|
||||
}
|
||||
sort.Slice(d.Added, func(i, j int) bool { return tv(d.Added[i].Arrival) < tv(d.Added[j].Arrival) })
|
||||
sort.Slice(d.Removed, func(i, j int) bool { return tv(d.Removed[i].Arrival) < tv(d.Removed[j].Arrival) })
|
||||
sort.Slice(d.Changed, func(i, j int) bool { return tv(d.Changed[i].Backup.Arrival) < tv(d.Changed[j].Backup.Arrival) })
|
||||
}
|
||||
|
||||
func sameTime(a, b *time.Time) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return a.Equal(*b)
|
||||
}
|
||||
|
||||
func fmtTime(t *time.Time) string {
|
||||
if t == nil {
|
||||
return "—"
|
||||
}
|
||||
return t.Local().Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
func fmtBool(v bool) string {
|
||||
if v {
|
||||
return "是"
|
||||
}
|
||||
return "否"
|
||||
}
|
||||
|
||||
// Counts 汇总各实体的三类条数,供概览展示。
|
||||
type Counts struct {
|
||||
Added, Removed, Changed, Same int
|
||||
}
|
||||
|
||||
func (c Counts) Total() int { return c.Added + c.Removed + c.Changed + c.Same }
|
||||
|
||||
func VisitCounts(d VisitDiff) Counts {
|
||||
return Counts{len(d.Added), len(d.Removed), len(d.Changed), d.Same}
|
||||
}
|
||||
|
||||
func PlaceCounts(d PlaceDiff) Counts {
|
||||
return Counts{len(d.Added), len(d.Removed), len(d.Changed), d.Same}
|
||||
}
|
||||
|
||||
func ActivityCounts(d ActivityDiff) Counts {
|
||||
return Counts{len(d.Added), len(d.Removed), len(d.Changed), d.Same}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package rond
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func tp(s string) *time.Time {
|
||||
t, err := time.Parse("2006-01-02 15:04", s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return &t
|
||||
}
|
||||
|
||||
func ip(v int) *int { return &v }
|
||||
|
||||
// 两组编号刻意完全不同:rond 每次导出都会重排 Z_PK,对齐只能靠 UUID。
|
||||
const (
|
||||
uuidA = "11111111111111111111111111111111"
|
||||
uuidB = "22222222222222222222222222222222"
|
||||
uuidC = "33333333333333333333333333333333"
|
||||
)
|
||||
|
||||
func sampleBackup() *Backup {
|
||||
return &Backup{
|
||||
Visits: []Visit{
|
||||
{SrcPK: 555, Arrival: tp("2026-09-01 10:00"), Departure: tp("2026-09-01 11:00"),
|
||||
LocationSrcPK: ip(3), ActivitySrcPK: ip(7)},
|
||||
{SrcPK: 556, Arrival: tp("2026-09-02 10:00"), LocationSrcPK: ip(3)},
|
||||
},
|
||||
Locations: []Location{
|
||||
{SrcPK: 3, Name: "家", Lat: 24.1, Lon: 109.1, City: "柳州市"},
|
||||
{SrcPK: 4, Name: "公司", Lat: 24.2, Lon: 109.2, City: "柳州市"},
|
||||
},
|
||||
Activities: []Activity{
|
||||
{SrcPK: 7, Name: "工作"},
|
||||
{SrcPK: 8, Name: "散步"},
|
||||
},
|
||||
VisitUUID: map[int]string{555: uuidA, 556: uuidB},
|
||||
ActivityUUID: map[int]string{7: uuidC},
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffVisitsAlignsByUUIDNotPK(t *testing.T) {
|
||||
stored := []StoredVisit{
|
||||
{SrcPK: 1, UUID: uuidA, Arrival: tp("2026-09-01 10:00"), Departure: tp("2026-09-01 11:00"),
|
||||
PlaceKey: PlaceCoordKey(24.1, 109.1), ActivityName: "工作"},
|
||||
{SrcPK: 2, UUID: uuidB, Arrival: tp("2026-09-02 10:00"),
|
||||
PlaceKey: PlaceCoordKey(24.1, 109.1)},
|
||||
}
|
||||
d := DiffVisits(stored, sampleBackup())
|
||||
if len(d.Added) != 0 || len(d.Removed) != 0 || len(d.Changed) != 0 {
|
||||
t.Fatalf("两边一致时不该有差异: +%d -%d ~%d", len(d.Added), len(d.Removed), len(d.Changed))
|
||||
}
|
||||
if d.Same != 2 {
|
||||
t.Fatalf("应配上 2 条,实际 %d", d.Same)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffVisitsFieldChanges(t *testing.T) {
|
||||
stored := []StoredVisit{{
|
||||
SrcPK: 1, UUID: uuidA, Arrival: tp("2026-09-01 09:00"), Departure: tp("2026-09-01 11:00"),
|
||||
PlaceKey: PlaceCoordKey(24.1, 109.1), ActivityName: "工作", Remark: "站点备注",
|
||||
}}
|
||||
d := DiffVisits(stored, sampleBackup())
|
||||
if len(d.Changed) != 1 {
|
||||
t.Fatalf("到达时间不同应算冲突,实际 %d 条", len(d.Changed))
|
||||
}
|
||||
f := d.Changed[0].Fields
|
||||
if len(f) != 2 {
|
||||
t.Fatalf("应报出到达时间与备注两项,实际 %v", f)
|
||||
}
|
||||
if f[0].Label != "到达时间" || f[1].Label != "备注" {
|
||||
t.Fatalf("字段名不对: %v", f)
|
||||
}
|
||||
if f[1].From != "站点备注" || f[1].To != "" {
|
||||
t.Fatalf("方向搞反了(From 应是库里的值): %+v", f[1])
|
||||
}
|
||||
}
|
||||
|
||||
// 站点新建的到访没有 UUID,而且它的 src_pk 很可能与真机新行的编号撞上——
|
||||
// 绝不能因为编号相同就当成同一条。
|
||||
func TestDiffVisitsIgnoresPKCollision(t *testing.T) {
|
||||
stored := []StoredVisit{{
|
||||
SrcPK: 555, UUID: "", Arrival: tp("2026-08-01 08:00"), UserAdded: true,
|
||||
PlaceKey: PlaceCoordKey(30.0, 120.0),
|
||||
}}
|
||||
d := DiffVisits(stored, sampleBackup())
|
||||
if len(d.Added) != 2 {
|
||||
t.Fatalf("备份里两条都是新增,实际 %d", len(d.Added))
|
||||
}
|
||||
if len(d.Removed) != 1 {
|
||||
t.Fatalf("站点补录那条应算「仅库里有」,实际 %d", len(d.Removed))
|
||||
}
|
||||
if !d.Removed[0].UserAdded {
|
||||
t.Error("站点补录的标记要带上,页面上要区分开")
|
||||
}
|
||||
}
|
||||
|
||||
// 真机偶尔有不带 UUID 的到访,只按 UUID 配对会让同一条既算新增又算仅站内有。
|
||||
func TestDiffVisitsFallsBackWhenUUIDMissing(t *testing.T) {
|
||||
b := &Backup{
|
||||
Visits: []Visit{
|
||||
{SrcPK: 900, Arrival: tp("2026-09-01 10:00"), LocationSrcPK: ip(3)},
|
||||
{SrcPK: 901, Arrival: tp("2026-09-02 10:00"), LocationSrcPK: ip(3)},
|
||||
},
|
||||
Locations: []Location{{SrcPK: 3, Name: "家", Lat: 24.1, Lon: 109.1}},
|
||||
VisitUUID: map[int]string{}, // 一条都没有 UUID
|
||||
ActivityUUID: map[int]string{},
|
||||
}
|
||||
stored := []StoredVisit{
|
||||
{SrcPK: 1, Arrival: tp("2026-09-01 10:00"), PlaceKey: PlaceCoordKey(24.1, 109.1)},
|
||||
{SrcPK: 2, Arrival: tp("2026-09-02 10:00"), PlaceKey: PlaceCoordKey(24.1, 109.1)},
|
||||
}
|
||||
d := DiffVisits(stored, b)
|
||||
if len(d.Added) != 0 || len(d.Removed) != 0 {
|
||||
t.Fatalf("没有 UUID 时应按「到达时间 + 地点」兜底配对: +%d -%d", len(d.Added), len(d.Removed))
|
||||
}
|
||||
if d.Same != 2 {
|
||||
t.Fatalf("两条都该配上,实际 %d", d.Same)
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底配对要认得出「同一条但时间被改过」——时间不同就配不上,此时按新增 + 仅站内有报出,
|
||||
// 总比静默当成同一条好。
|
||||
func TestDiffVisitsFallbackNeedsSameTime(t *testing.T) {
|
||||
b := &Backup{
|
||||
Visits: []Visit{{SrcPK: 900, Arrival: tp("2026-09-01 10:00"), LocationSrcPK: ip(3)}},
|
||||
Locations: []Location{{SrcPK: 3, Name: "家", Lat: 24.1, Lon: 109.1}},
|
||||
VisitUUID: map[int]string{},
|
||||
}
|
||||
stored := []StoredVisit{{SrcPK: 1, Arrival: tp("2026-09-01 11:00"), PlaceKey: PlaceCoordKey(24.1, 109.1)}}
|
||||
d := DiffVisits(stored, b)
|
||||
if len(d.Added) != 1 || len(d.Removed) != 1 {
|
||||
t.Fatalf("时间对不上时该分别报出: +%d -%d", len(d.Added), len(d.Removed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffVisitsSkipsRowsWithoutArrival(t *testing.T) {
|
||||
b := sampleBackup()
|
||||
b.Visits = append(b.Visits, Visit{SrcPK: 557, LocationSrcPK: ip(3)})
|
||||
d := DiffVisits(nil, b)
|
||||
if len(d.Added) != 2 {
|
||||
t.Fatalf("没有到达时间的行导入时就被跳过,不该算新增,实际 %d", len(d.Added))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffPlacesByKey(t *testing.T) {
|
||||
b := sampleBackup()
|
||||
stored := []StoredPlace{
|
||||
{SrcPK: 10, Key: PlaceCoordKey(24.1, 109.1), Name: "家", Lat: 24.1, Lon: 109.1, City: "柳州市"},
|
||||
}
|
||||
d := DiffPlaces(stored, b)
|
||||
if len(d.Added) != 1 || d.Added[0].Name != "公司" {
|
||||
t.Fatalf("应只有「公司」是新增: %+v", d.Added)
|
||||
}
|
||||
if len(d.Removed) != 0 || len(d.Changed) != 0 || d.Same != 1 {
|
||||
t.Fatalf("「家」应配上且无差异: same=%d changed=%d removed=%d", d.Same, len(d.Changed), len(d.Removed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffPlacesReportsFieldChanges(t *testing.T) {
|
||||
b := sampleBackup()
|
||||
stored := []StoredPlace{
|
||||
{SrcPK: 10, Key: PlaceCoordKey(24.1, 109.1), Name: "家", Lat: 24.1, Lon: 109.1, City: "桂林市"},
|
||||
}
|
||||
d := DiffPlaces(stored, b)
|
||||
if len(d.Changed) != 1 {
|
||||
t.Fatalf("城市不同应算冲突,实际 %d", len(d.Changed))
|
||||
}
|
||||
if f := d.Changed[0].Fields; len(f) != 1 || f[0].Label != "城市" || f[0].From != "桂林市" || f[0].To != "柳州市" {
|
||||
t.Fatalf("字段差异不对: %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffActivities(t *testing.T) {
|
||||
b := sampleBackup()
|
||||
stored := []StoredActivity{
|
||||
{SrcPK: 20, Name: "工作", UUID: uuidC},
|
||||
{SrcPK: 21, Name: "站点自建"},
|
||||
}
|
||||
d := DiffActivities(stored, b)
|
||||
if len(d.Added) != 1 || d.Added[0].Name != "散步" {
|
||||
t.Fatalf("「散步」应是新增: %+v", d.Added)
|
||||
}
|
||||
if len(d.Removed) != 1 || d.Removed[0].Name != "站点自建" {
|
||||
t.Fatalf("站点自建的活动应算仅库里有: %+v", d.Removed)
|
||||
}
|
||||
if d.Same != 1 {
|
||||
t.Fatalf("「工作」应配上,实际 same=%d", d.Same)
|
||||
}
|
||||
}
|
||||
|
||||
// 活动改了名:UUID 还在,就该认出来是同一个,只报名称变化,而不是「删一个加一个」。
|
||||
func TestDiffActivitiesRenameByUUID(t *testing.T) {
|
||||
b := sampleBackup()
|
||||
stored := []StoredActivity{{SrcPK: 20, Name: "上班", UUID: uuidC}}
|
||||
d := DiffActivities(stored, b)
|
||||
if len(d.Added) != 1 || len(d.Removed) != 0 {
|
||||
t.Fatalf("只该多出「散步」,「上班」应认出是同一个: +%d -%d", len(d.Added), len(d.Removed))
|
||||
}
|
||||
if len(d.Changed) != 1 || d.Changed[0].Fields[0].From != "上班" || d.Changed[0].Fields[0].To != "工作" {
|
||||
t.Fatalf("应按 UUID 认出重命名: %+v", d.Changed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceCoordKeyStable(t *testing.T) {
|
||||
a := PlaceCoordKey(24.1, 109.1)
|
||||
if a != PlaceCoordKey(24.1, 109.1) {
|
||||
t.Fatal("同样的输入应得到同样的键")
|
||||
}
|
||||
if a == PlaceCoordKey(24.1, 109.2) {
|
||||
t.Fatal("坐标不同不该算同一个地点")
|
||||
}
|
||||
}
|
||||
|
||||
// 站点把地点反查改名(坐标没动)时,到访层面不该报「换了地点」——
|
||||
// 实测里这一条会让 17 条到访全变成假冲突。
|
||||
func TestDiffVisitsIgnoresPlaceRename(t *testing.T) {
|
||||
b := &Backup{
|
||||
Visits: []Visit{{SrcPK: 5, Arrival: tp("2026-09-01 10:00"), LocationSrcPK: ip(3)}},
|
||||
Locations: []Location{{SrcPK: 3, Name: "河池市国民体质监测中心", Lat: 24.691827, Lon: 108.035268}},
|
||||
VisitUUID: map[int]string{5: uuidA},
|
||||
}
|
||||
stored := []StoredVisit{{
|
||||
SrcPK: 1, UUID: uuidA, Arrival: tp("2026-09-01 10:00"),
|
||||
PlaceKey: PlaceCoordKey(24.691827, 108.035268), PlaceName: "体育馆美食街",
|
||||
}}
|
||||
d := DiffVisits(stored, b)
|
||||
if len(d.Changed) != 0 {
|
||||
t.Fatalf("只改了地点名不该算到访冲突: %+v", d.Changed[0].Fields)
|
||||
}
|
||||
if d.Same != 1 {
|
||||
t.Fatalf("应算作一致,实际 same=%d", d.Same)
|
||||
}
|
||||
}
|
||||
|
||||
// 反之,地点层面要把改名报出来。
|
||||
func TestDiffPlacesReportsRename(t *testing.T) {
|
||||
b := &Backup{Locations: []Location{
|
||||
{SrcPK: 3, Name: "河池市国民体质监测中心", Lat: 24.691827, Lon: 108.035268, City: "河池市"},
|
||||
}}
|
||||
stored := []StoredPlace{{
|
||||
SrcPK: 10, Key: PlaceCoordKey(24.691827, 108.035268), Name: "体育馆美食街",
|
||||
Lat: 24.691827, Lon: 108.035268, City: "河池市",
|
||||
}}
|
||||
d := DiffPlaces(stored, b)
|
||||
if len(d.Added) != 0 || len(d.Removed) != 0 {
|
||||
t.Fatalf("坐标一样就是同一个地点: +%d -%d", len(d.Added), len(d.Removed))
|
||||
}
|
||||
if len(d.Changed) != 1 {
|
||||
t.Fatalf("改名应报成一条冲突,实际 %d", len(d.Changed))
|
||||
}
|
||||
f := d.Changed[0].Fields
|
||||
if len(f) != 1 || f[0].Label != "名称" || f[0].From != "体育馆美食街" || f[0].To != "河池市国民体质监测中心" {
|
||||
t.Fatalf("字段差异不对: %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
// 没有坐标的地点不能都落到同一个键上,否则两个不同地点会互相认领。
|
||||
func TestDiffPlacesWithoutCoordDoesNotCollide(t *testing.T) {
|
||||
b := &Backup{Locations: []Location{{SrcPK: 3, Name: "甲地"}}}
|
||||
stored := []StoredPlace{
|
||||
{SrcPK: 10, Name: "甲地"},
|
||||
{SrcPK: 11, Name: "乙地"},
|
||||
}
|
||||
d := DiffPlaces(stored, b)
|
||||
if len(d.Added) != 0 || len(d.Removed) != 1 {
|
||||
t.Fatalf("应只配上同名的「甲地」: +%d -%d", len(d.Added), len(d.Removed))
|
||||
}
|
||||
if d.Removed[0].Name != "乙地" {
|
||||
t.Fatalf("没配上的应是「乙地」,实际 %q", d.Removed[0].Name)
|
||||
}
|
||||
if d.Same != 1 {
|
||||
t.Fatalf("「甲地」应算一致,实际 %d", d.Same)
|
||||
}
|
||||
}
|
||||
|
||||
// 站点把坐标纠偏过(名称没变)也要认得出是同一个地点。
|
||||
func TestDiffPlacesMatchesByNameWhenCoordMoved(t *testing.T) {
|
||||
b := &Backup{Locations: []Location{{SrcPK: 3, Name: "家", Lat: 24.100001, Lon: 109.100001}}}
|
||||
stored := []StoredPlace{{
|
||||
SrcPK: 10, Key: PlaceCoordKey(24.1, 109.1), Name: "家", Lat: 24.1, Lon: 109.1,
|
||||
}}
|
||||
d := DiffPlaces(stored, b)
|
||||
if len(d.Added) != 0 || len(d.Removed) != 0 {
|
||||
t.Fatalf("名称一样就该配上: +%d -%d", len(d.Added), len(d.Removed))
|
||||
}
|
||||
if len(d.Changed) != 1 || d.Changed[0].Fields[0].Label != "纬度" {
|
||||
t.Fatalf("应报出坐标差异: %+v", d.Changed)
|
||||
}
|
||||
}
|
||||
@@ -1332,9 +1332,12 @@ var uuidNamespace = [16]byte{0x6b, 0x1c, 0x9a, 0x2e, 0x42, 0x7d, 0x4f, 0x16,
|
||||
// uuidV5 由命名空间与名字生成 RFC 4122 版本 5 的 UUID(16 字节原始形式)。
|
||||
//
|
||||
// rond 给每条到访、每个活动都存一个 16 字节 UUID(ZIDENTIFIER_ / ZUID_,真机里全是 v4),
|
||||
// 站点这边没有对应字段。这里用确定性算法补:名字只取表名 + src_pk(src_pk 跨快照稳定),
|
||||
// 于是同一条记录每次导出拿到的都是同一个 UUID——若改成随机,每次导出的包都会让 rond
|
||||
// 把同一批记录当成新的。
|
||||
// 站点新建的行没有真机 UUID 可继承。这里用确定性算法补:名字取表名 + src_pk
|
||||
// (src_pk 在**同一个数据集内**稳定,够用了),于是同一条记录每次导出拿到的都是同一个
|
||||
// UUID——若改成随机,每次导出的包都会让 rond 把同一批记录当成新的。
|
||||
//
|
||||
// 注意这只保证「同一数据集内幂等」,不是跨备份的稳定标识:rond 那边每次导入都会重排
|
||||
// Z_PK,真正的跨备份对齐键是它原生的 ZIDENTIFIER_(见 diff.go)。
|
||||
func uuidV5(name string) []byte {
|
||||
h := sha1.New()
|
||||
h.Write(uuidNamespace[:])
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package rond
|
||||
|
||||
// 合并计划:把「差异画像 + 站长在页面上做的选择」翻译成一份可执行的改动清单。
|
||||
// 这里只做判定,不碰数据库、也不写任何东西。
|
||||
|
||||
// 站长对一条冲突的处理方式。
|
||||
const (
|
||||
PickBackup = "backup" // 用备份(实机)的值覆盖站内
|
||||
PickDB = "db" // 保留站内的值
|
||||
PickSkip = "skip" // 这条不动
|
||||
)
|
||||
|
||||
// Choices 是差异页提交上来的选择。冲突是逐条选的,键分别是到访 UUID、
|
||||
// 地点坐标键、活动 UUID。
|
||||
type Choices struct {
|
||||
AddVisits bool // 把备份里多出来的到访并进来
|
||||
AddPlaces bool
|
||||
AddActs bool
|
||||
|
||||
VisitClash map[string]string
|
||||
PlaceClash map[string]string
|
||||
ActClash map[string]string
|
||||
|
||||
// 删除:「仅站内有」的记录按库里编号勾选,默认一个都不删。
|
||||
// 键是 src_pk 而不是 UUID——站点补录的行压根没有 UUID。
|
||||
DelVisits map[int]bool
|
||||
DelPlaces map[int]bool
|
||||
DelActs map[int]bool
|
||||
}
|
||||
|
||||
func (c Choices) visitPick(key string) string { return pickOf(c.VisitClash, key) }
|
||||
func (c Choices) placePick(key string) string { return pickOf(c.PlaceClash, key) }
|
||||
func (c Choices) activityPick(key string) string { return pickOf(c.ActClash, key) }
|
||||
|
||||
func pickOf(m map[string]string, key string) string {
|
||||
switch m[key] {
|
||||
case PickBackup, PickDB, PickSkip:
|
||||
return m[key]
|
||||
}
|
||||
return PickDB // 没提交或提交了怪值:默认保留站内的,不误伤
|
||||
}
|
||||
|
||||
// VisitPatch 表示用备份侧的值覆盖库里的那一条。
|
||||
type VisitPatch struct {
|
||||
UUID string // 对齐键
|
||||
V Visit
|
||||
}
|
||||
|
||||
// PlacePatch 同理,键是坐标键。
|
||||
type PlacePatch struct {
|
||||
CoordKey string
|
||||
L Location
|
||||
}
|
||||
|
||||
// ActivityPatch 键是活动 UUID(没有 UUID 时退回名称)。
|
||||
type ActivityPatch struct {
|
||||
Key string
|
||||
A Activity
|
||||
}
|
||||
|
||||
// MergePlan 是一次合并要做的全部改动。
|
||||
type MergePlan struct {
|
||||
AddPlaces []Location
|
||||
UpdPlaces []PlacePatch
|
||||
DelPlaces []StoredPlace
|
||||
AddActs []Activity
|
||||
UpdActs []ActivityPatch
|
||||
DelActs []StoredActivity
|
||||
AddVisits []Visit
|
||||
UpdVisits []VisitPatch
|
||||
DelVisits []StoredVisit
|
||||
}
|
||||
|
||||
// Empty 报告这份计划什么事都不用做。
|
||||
func (p MergePlan) Empty() bool {
|
||||
return len(p.AddPlaces)+len(p.UpdPlaces)+len(p.DelPlaces)+
|
||||
len(p.AddActs)+len(p.UpdActs)+len(p.DelActs)+
|
||||
len(p.AddVisits)+len(p.UpdVisits)+len(p.DelVisits) == 0
|
||||
}
|
||||
|
||||
// BuildMergePlan 按选择把差异筛成改动清单。
|
||||
func BuildMergePlan(vd VisitDiff, pd PlaceDiff, ad ActivityDiff, c Choices) MergePlan {
|
||||
var p MergePlan
|
||||
|
||||
if c.AddPlaces {
|
||||
p.AddPlaces = pd.Added
|
||||
}
|
||||
for _, cf := range pd.Changed {
|
||||
if c.placePick(cf.DB.Key) == PickBackup {
|
||||
p.UpdPlaces = append(p.UpdPlaces, PlacePatch{CoordKey: cf.DB.Key, L: cf.Backup})
|
||||
}
|
||||
}
|
||||
for _, sp := range pd.Removed {
|
||||
if c.DelPlaces[sp.SrcPK] {
|
||||
p.DelPlaces = append(p.DelPlaces, sp)
|
||||
}
|
||||
}
|
||||
|
||||
if c.AddActs {
|
||||
p.AddActs = ad.Added
|
||||
}
|
||||
for _, cf := range ad.Changed {
|
||||
if c.activityPick(activityKey(cf.DB)) == PickBackup {
|
||||
p.UpdActs = append(p.UpdActs, ActivityPatch{Key: activityKey(cf.DB), A: cf.Backup})
|
||||
}
|
||||
}
|
||||
for _, sa := range ad.Removed {
|
||||
if c.DelActs[sa.SrcPK] {
|
||||
p.DelActs = append(p.DelActs, sa)
|
||||
}
|
||||
}
|
||||
|
||||
if c.AddVisits {
|
||||
p.AddVisits = vd.Added
|
||||
// 新到访可能挂在一个「备份里新出现、库里还没有」的地点/活动上:
|
||||
// 不把它们一起建出来,到访的 place_id / activity_id 就无从映射,只能整条丢掉。
|
||||
p.AddPlaces = appendMissingPlaces(p.AddPlaces, pd.Added)
|
||||
p.AddActs = appendMissingActs(p.AddActs, ad.Added)
|
||||
}
|
||||
for _, cf := range vd.Changed {
|
||||
if c.visitPick(cf.DB.UUID) == PickBackup {
|
||||
p.UpdVisits = append(p.UpdVisits, VisitPatch{UUID: cf.DB.UUID, V: cf.Backup})
|
||||
}
|
||||
}
|
||||
for _, sv := range vd.Removed {
|
||||
if c.DelVisits[sv.SrcPK] {
|
||||
p.DelVisits = append(p.DelVisits, sv)
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func appendMissingPlaces(have, more []Location) []Location {
|
||||
seen := make(map[int]bool, len(have))
|
||||
for _, l := range have {
|
||||
seen[l.SrcPK] = true
|
||||
}
|
||||
for _, l := range more {
|
||||
if !seen[l.SrcPK] {
|
||||
have = append(have, l)
|
||||
seen[l.SrcPK] = true
|
||||
}
|
||||
}
|
||||
return have
|
||||
}
|
||||
|
||||
func appendMissingActs(have, more []Activity) []Activity {
|
||||
seen := make(map[int]bool, len(have))
|
||||
for _, a := range have {
|
||||
seen[a.SrcPK] = true
|
||||
}
|
||||
for _, a := range more {
|
||||
if !seen[a.SrcPK] {
|
||||
have = append(have, a)
|
||||
seen[a.SrcPK] = true
|
||||
}
|
||||
}
|
||||
return have
|
||||
}
|
||||
|
||||
// activityKey 是活动冲突在页面上的标识:优先 UUID(改名也能对上),没有才用名称。
|
||||
func activityKey(sa StoredActivity) string {
|
||||
if sa.UUID != "" {
|
||||
return sa.UUID
|
||||
}
|
||||
return "n:" + sa.Name
|
||||
}
|
||||
|
||||
// ActivityKey 供页面与处理函数共用同一套键。
|
||||
func ActivityKey(sa StoredActivity) string { return activityKey(sa) }
|
||||
@@ -0,0 +1,76 @@
|
||||
package rond
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildMergePlanPicks(t *testing.T) {
|
||||
vd := VisitDiff{
|
||||
Added: []Visit{{SrcPK: 900, Arrival: tp("2026-09-09 10:00")}},
|
||||
Removed: []StoredVisit{{SrcPK: 5, UUID: uuidA}},
|
||||
Changed: []VisitConflict{
|
||||
{Backup: Visit{SrcPK: 901, Arrival: tp("2026-09-01 12:00")}, DB: StoredVisit{SrcPK: 1, UUID: uuidA}},
|
||||
{Backup: Visit{SrcPK: 902, Arrival: tp("2026-09-02 12:00")}, DB: StoredVisit{SrcPK: 2, UUID: uuidB}},
|
||||
},
|
||||
}
|
||||
pd := PlaceDiff{Added: []Location{{SrcPK: 3, Name: "新地点"}}}
|
||||
ad := ActivityDiff{Added: []Activity{{SrcPK: 8, Name: "散步"}}}
|
||||
|
||||
p := BuildMergePlan(vd, pd, ad, Choices{
|
||||
AddVisits: true,
|
||||
AddPlaces: true,
|
||||
AddActs: false,
|
||||
VisitClash: map[string]string{uuidA: PickBackup, uuidB: PickDB},
|
||||
})
|
||||
if len(p.AddVisits) != 1 || p.AddVisits[0].SrcPK != 900 {
|
||||
t.Fatalf("新增到访应合入一条: %+v", p.AddVisits)
|
||||
}
|
||||
if len(p.UpdVisits) != 1 || p.UpdVisits[0].UUID != uuidA {
|
||||
t.Fatalf("只有选了「用实机」的那条才该更新: %+v", p.UpdVisits)
|
||||
}
|
||||
if len(p.AddPlaces) != 1 {
|
||||
t.Fatalf("勾了「并进来」就该带上新增地点: %d", len(p.AddPlaces))
|
||||
}
|
||||
// 即使没单独勾活动,合入到访时也要把它可能挂着的「库里还没有的活动」带上,
|
||||
// 否则那条到访的 activity_id 无从映射,只能整条丢掉。
|
||||
if len(p.AddActs) != 1 || p.AddActs[0].Name != "散步" {
|
||||
t.Fatalf("合入到访应连带它需要的活动: %+v", p.AddActs)
|
||||
}
|
||||
// 「仅站内有」永远不进计划:删除是单独的事,绝不顺手做
|
||||
if len(p.UpdPlaces)+len(p.UpdActs) != 0 {
|
||||
t.Fatal("不该有地点/活动更新")
|
||||
}
|
||||
}
|
||||
|
||||
// 没提交的键、或提交了怪值,一律当成「保留站内」——不误伤。
|
||||
func TestBuildMergePlanDefaultsToKeepDB(t *testing.T) {
|
||||
vd := VisitDiff{Changed: []VisitConflict{
|
||||
{Backup: Visit{SrcPK: 901}, DB: StoredVisit{SrcPK: 1, UUID: uuidA}},
|
||||
}}
|
||||
p := BuildMergePlan(vd, PlaceDiff{}, ActivityDiff{}, Choices{VisitClash: map[string]string{uuidA: "wat"}})
|
||||
if len(p.UpdVisits) != 0 {
|
||||
t.Fatalf("怪值不该被当成「用实机」: %+v", p.UpdVisits)
|
||||
}
|
||||
if !p.Empty() {
|
||||
t.Fatal("什么都没选,计划应当是空的")
|
||||
}
|
||||
}
|
||||
|
||||
// 冲突选了「跳过」也不动。
|
||||
func TestBuildMergePlanSkip(t *testing.T) {
|
||||
vd := VisitDiff{Changed: []VisitConflict{
|
||||
{Backup: Visit{SrcPK: 901}, DB: StoredVisit{SrcPK: 1, UUID: uuidA}},
|
||||
}}
|
||||
p := BuildMergePlan(vd, PlaceDiff{}, ActivityDiff{}, Choices{VisitClash: map[string]string{uuidA: PickSkip}})
|
||||
if !p.Empty() {
|
||||
t.Fatalf("跳过就不该产生改动: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// 活动改了名也要能用 UUID 当键对得上。
|
||||
func TestActivityKeyPrefersUUID(t *testing.T) {
|
||||
if got := ActivityKey(StoredActivity{SrcPK: 1, Name: "上班", UUID: uuidC}); got != uuidC {
|
||||
t.Fatalf("有 UUID 就用 UUID,实际 %q", got)
|
||||
}
|
||||
if got := ActivityKey(StoredActivity{SrcPK: 1, Name: "上班"}); got != "n:上班" {
|
||||
t.Fatalf("没有 UUID 退回名称,实际 %q", got)
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,13 @@ type Backup struct {
|
||||
// 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
|
||||
}
|
||||
|
||||
// Open 解压备份包到 dir,返回可用的 SQLite 连接。
|
||||
@@ -465,9 +472,38 @@ func Parse(db *sql.DB) (*Backup, error) {
|
||||
} 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"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user