合并:按名称兜底配对加 1km 上限,冲突行在名称相同时把坐标印出来

- DiffPlaces 的 takeByName 只在两点相距 ≤1km 时才认领:名称不是唯一键,连锁店同名是常态
  (真机里「中国体育彩票」3 条、「长沙南站」2 条、「茶颜悦色」2 条)。没有上限时,坐标配不上
  的两条同名地点会被强行配成一对——实测把佛山的「中国体育彩票」配给了贺州那条(直线 236.8km),
  站长勾「用实机」就把站内那条地点的坐标搬到另一座城市(实测 src_pk=71 从贺州搬到佛山)。
  注意 geo.Haversine 返回的是公里,常量按公里写。
- visitFieldChanges 的「地点」在两边名称相同时用 coordSuffix 把坐标印出来:这一项比的是坐标,
  只印名称会渲染成「X → X」,站长看不出差在哪(连锁店重名、同一地点两边坐标略有出入都会走到这里)。
- 新增用例 3 条(相隔 236.8km 不该配对、665m 仍要配对、名称相同时必须带坐标)+ 变异验证。
- 复测(回滚后按浏览器默认全选「用实机」重跑):被搬动超过 1km 的地点从 1 个变 0 个;
  合并后再比对 到访 0/47/1、地点 0/4/1、活动 0/0/0;953 条 UUID 到访全命中、到达时间 0 偏差。
This commit is contained in:
tamakyi
2026-09-29 10:18:22 +08:00
parent 10d23e03cf
commit 370c4285f8
2 changed files with 117 additions and 2 deletions
+45 -2
View File
@@ -3,7 +3,10 @@ package rond
import (
"fmt"
"sort"
"strings"
"time"
"rond-with-you/internal/geo"
)
// 两份数据的比对。
@@ -109,6 +112,15 @@ 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 表达式,
@@ -236,7 +248,13 @@ func visitFieldChanges(sv StoredVisit, v Visit, placeKey, placeName, actName, ac
}
// 地点按坐标判、显示名称:名称被「补全地址」反查改过,不算换了地点
if sv.PlaceKey != bp {
out = append(out, FieldChange{Label: "地点", From: sv.PlaceName, To: bn})
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 条假冲突),
@@ -296,6 +314,23 @@ func DiffPlaces(stored []StoredPlace, b *Backup) PlaceDiff {
}
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
}
var d PlaceDiff
classify := func(sp StoredPlace, l Location) {
if fields := placeFieldChanges(sp, l); len(fields) > 0 {
@@ -309,7 +344,7 @@ func DiffPlaces(stored []StoredPlace, b *Backup) PlaceDiff {
classify(sp, l)
continue
}
if sp, ok := take(byName[l.Name]); ok {
if sp, ok := takeByName(byName[l.Name], l); ok {
classify(sp, l)
continue
}
@@ -326,6 +361,14 @@ func DiffPlaces(stored []StoredPlace, b *Backup) PlaceDiff {
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 {