备份与迷雾的差异分析、合并执行,附属数据改挂坐标键
备份(.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}
|
||||
}
|
||||
Reference in New Issue
Block a user