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) } // RawFloat8 返回「把 entity_raw 里的 JSON 值安全地转成 float8」的 SQL 表达式, // 值不是合法的数字就给 NULL,而不是抛错。 // // 留档是原样存下来的 JSON,值不一定是数字:空串、字符串形式的 "1.2.3"、 // "NaN" 都可能出现。直接 `::float8` 会抛 invalid input syntax,代价是**编辑页 // 整页 500** 或**整次导入失败 / 启动自愈失败**,而且只在特定那一行数据上复现。 // // 两个实现细节都有原因,别简化: // - 写成 CASE 而不是「正则 AND cast」:PG 不保证 AND/OR 各操作数的求值顺序, // 正则没挡住时照样会炸;CASE 只求值命中的那个分支。 // - 正则把**指数限在两位**,于是最大幅度约 1e109,本身就进不了 float8 的溢出区 // (实测 `'1e999'` 这类值在 CASE 里一样会抛 out of range)。这样不依赖 // PG 版本——`pg_input_is_valid` 要 PG 16+,本项目的库版本没有写死。 // 坐标用不上 1e109 以上的写法,也不会小到 1e-100 以下。 // // RawFloatPattern 是同一个正则(不带引号),给「只想判合不合法、不取数值」的地方用: // 光一个 `~` 判断不会抛错,所以那种地方可以直接串它。 const RawFloatPattern = `^[+-]?([0-9]{1,10}(\.[0-9]*)?|\.[0-9]{1,10})([eE][+-]?[0-9]{1,2})?$` func RawFloat8(expr string) string { return `CASE WHEN ` + expr + ` ~ '` + RawFloatPattern + `' THEN (` + expr + `)::float8 END` } // 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} }