package rond import ( "fmt" "sort" "strings" "time" "rond-with-you/internal/geo" ) // 两份数据的比对。 // // 对齐键的选择是这个文件的全部要点: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) } // nameMatchMaxKm 是「按名称兜底配对」允许的最大距离(公里)。 // // 名称不是唯一键:连锁店同名是常态(实测真机里「中国体育彩票」3 条、「长沙南站」2 条、 // 「茶颜悦色」2 条)。没有距离上限时,坐标配不上的两条同名地点会被强行配成一对—— // 实测把佛山的「中国体育彩票」配给了贺州那条(直线 236.8km),站长一勾「用实机」就把 // 站内那条地点的坐标搬到另一座城市、名下到访集体跳城。1km 足够覆盖「站点把坐标纠偏过」 // (实测那批偏移 660~750m),又不会跨城配对。 const nameMatchMaxKm = 1.0 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 { from, to := sv.PlaceName, bn if from == to { // 名称一样就必须把坐标摊开:这一项比的是坐标,只印名称会渲染成「X → X」, // 站长看不出差在哪(连锁店重名、同一地点两边坐标略有出入都会走到这里)。 from, to = coordSuffix(from, sv.PlaceKey), coordSuffix(bn, bp) } out = append(out, FieldChange{Label: "地点", From: from, To: to}) } // 活动按**身份**判,不按名称:站内与真机对同一个活动改名属于活动层的差异, // 挂在它名下的每条到访都跟着报一遍就会把真差异淹掉(实测 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) } // PlaceMatch 是「备份地点编号 -> 库内地点」的配对结果。 type PlaceMatch map[int]StoredPlace // MatchPlaces 把备份地点配到库内地点:坐标优先(改名不影响配对),配不上再退到 // 「同名且相距不超过 nameMatchMaxKm」。 // // 内部用**多值**索引:真机一个坐标下会有好几条同名同坐标的地点(实测「柳州站」3 条), // 单槽位 map 会让同一坐标最多只配上一条,剩下的一律判成「备份新增」——于是每次合并都再插 // 一条重复地点、差异永远归不了零。认领过的一定要标记已用,否则多条会双双落到同一条上。 // // 差异判定(DiffPlaces)与合并执行(internal/web)**必须共用这一份实现**:两处各配一遍就会 // 漂移——合并侧原来是单槽位 map 自己再配一次,于是同一坐标/同名的多条备份地点全并到同一条 // 站内地点上,到访被挂到错的分店(实测「茶颜悦色」两家分店相隔 8.3km)。 // // 两个入参的顺序都要稳定(库内按 id、备份按解析顺序),否则候选多于一个时结果会飘。 func MatchPlaces(stored []StoredPlace, backup []Location) PlaceMatch { 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 } // takeByName 只在「够近」时认领:两边都有坐标却离得太远,那是两家同名的店,不是同一个地点。 // 任一侧没有坐标就没法判距离,按旧行为直接认领。 takeByName := func(list []StoredPlace, l Location) (StoredPlace, bool) { for _, sp := range list { if used[sp.SrcPK] { continue } if (sp.Lat != 0 || sp.Lon != 0) && (l.Lat != 0 || l.Lon != 0) { if geo.Haversine(sp.Lat, sp.Lon, l.Lat, l.Lon) > nameMatchMaxKm { continue } } used[sp.SrcPK] = true return sp, true } return StoredPlace{}, false } out := make(PlaceMatch, len(backup)) for _, l := range backup { if sp, ok := take(byCoord[PlaceCoordKey(l.Lat, l.Lon)]); ok { out[l.SrcPK] = sp continue } if sp, ok := takeByName(byName[l.Name], l); ok { out[l.SrcPK] = sp } } return out } // DiffPlaces 对齐地点。配对规则见 MatchPlaces:配上的比字段,配不上的两边分别是 // 「备份新增」与「仅站内有」。 func DiffPlaces(stored []StoredPlace, b *Backup) PlaceDiff { match := MatchPlaces(stored, b.Locations) matched := map[int]bool{} var d PlaceDiff for _, l := range b.Locations { sp, ok := match[l.SrcPK] if !ok { d.Added = append(d.Added, l) continue } matched[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 _, sp := range stored { if !matched[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 } // coordSuffix 给「地点」这一项补上坐标键("lat|lon")里的人类可读尾巴。 func coordSuffix(name, key string) string { if key == "" { return name + "(无坐标)" } return name + " (" + strings.Replace(key, "|", ", ", 1) + ")" } // 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} }