Files
rond-with-you/internal/rond/diff.go
T
tamakyi 10d23e03cf 合并:重复地点不再反复判「备份新增」,到访的活动改按身份比
- DiffPlaces 的 byCoord/byName 改为多值索引 + take() 逐条认领未用项:真机一个坐标下会有
  多条同名同坐标的地点(实测「柳州站」3 条),单槽位时同一坐标最多只配上 2 条,第 3 条
  永远判「备份新增」⇒ 每次合并都再插一条重复地点、差异永远归不了零(实测 368→438→439)。
  修完:地点新增 70→67、合并后再比对归零、再点合并直接「没有要写入的改动」。
- 到访的「活动」由按名称比改为按身份(ZUID_)比,两边都缺 UUID 才退回名称:16 个活动改名
  曾把 355 条到访全变成同因冲突,且逐条选「用实机」只写回 activity_id(映射回同一个活动)
  改不掉名字。loadStoredVisits 为此多 JOIN 一次 entity_raw 取 ZACTIVITY.ZUID_。
- 回归用例 4 条(重复地点一一配上、活动改名不算冲突、换活动仍要报、缺 UUID 退回名称)+
  「合并后地点差异归零」断言 3 处;两处改动都做过变异验证。
- 实测(09-28 备份并进 09-14 数据集):到访冲突 253→6,合并两次地点数 438→439 变为 435→435。
2026-09-28 19:31:21 +08:00

437 lines
14 KiB
Go

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
ActivityUUID 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, b.ActivityUUID); 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, actUUID 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})
}
// 活动按**身份**判,不按名称:站内与真机对同一个活动改名属于活动层的差异,
// 挂在它名下的每条到访都跟着报一遍就会把真差异淹掉(实测 16 个活动改名 → 355 条假冲突),
// 而且逐条选「用实机」也改不掉活动名,冲突永远收敛不了。
// 两边都有 UUID 才比 UUID;缺 UUID 的老数据退回按名称比。
bl, bu := "", ""
if v.ActivitySrcPK != nil {
bl, bu = actName[*v.ActivitySrcPK], actUUID[*v.ActivitySrcPK]
}
if sv.ActivityUUID != "" && bu != "" {
if sv.ActivityUUID != bu {
add("活动", sv.ActivityName, bl)
}
} else {
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 对齐地点。坐标优先(改名不影响配对),配不上再按名称找一遍
// (覆盖「站点把坐标纠偏过」这种),两样都对不上才算新增。
//
// 两侧都用**多值**索引:真机一个坐标下会有好几条同名同坐标的地点(实测「柳州站」3 条,
// 备份里这类分组共 10 组),单槽位 map 会让同一坐标最多只配上一条,剩下的一律判成
// 「备份新增」——于是每次合并都再插一条重复地点、差异永远归不了零。
func DiffPlaces(stored []StoredPlace, b *Backup) PlaceDiff {
byCoord := make(map[string][]StoredPlace, len(stored))
byName := make(map[string][]StoredPlace, len(stored))
for _, sp := range stored {
if sp.Lat != 0 || sp.Lon != 0 {
k := PlaceCoordKey(sp.Lat, sp.Lon)
byCoord[k] = append(byCoord[k], sp)
}
if sp.Name != "" {
byName[sp.Name] = append(byName[sp.Name], sp)
}
}
used := map[int]bool{}
// take 认领第一条还没被用掉的候选,认领即置 used——同一个站内地点不能被两个备份地点配走。
take := func(list []StoredPlace) (StoredPlace, bool) {
for _, sp := range list {
if !used[sp.SrcPK] {
used[sp.SrcPK] = true
return sp, true
}
}
return StoredPlace{}, false
}
var d PlaceDiff
classify := func(sp StoredPlace, l Location) {
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 := take(byCoord[PlaceCoordKey(l.Lat, l.Lon)]); ok {
classify(sp, l)
continue
}
if sp, ok := take(byName[l.Name]); ok {
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 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}
}