备份与迷雾的差异分析、合并执行,附属数据改挂坐标键

备份(.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:
tamakyi
2026-09-19 16:04:00 +08:00
parent 9e01923244
commit 30c502ce7b
43 changed files with 5968 additions and 170 deletions
+390
View File
@@ -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}
}
+293
View File
@@ -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)
}
}
+6 -3
View File
@@ -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[:])
+170
View File
@@ -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) }
+76
View File
@@ -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)
}
}
+36
View File
@@ -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"}