合并:按名称兜底配对加 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:
+45
-2
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package rond
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -389,3 +390,74 @@ func TestDiffVisitsActivityWithoutUUIDFallsBackToName(t *testing.T) {
|
||||
t.Fatalf("没有 UUID 时应退回按名称比: %+v", d.Changed)
|
||||
}
|
||||
}
|
||||
|
||||
// 名称兜底配对必须有距离上限:连锁店同名(中国体育彩票、茶颜悦色…)时,坐标配不上的
|
||||
// 两条相隔几百公里的同名地点绝不能配成一对——一勾「用实机」就把站内那条搬到另一座城市。
|
||||
// 实测踩过:佛山的「中国体育彩票」(22.927572,113.222052) 被配给贺州那条
|
||||
// (24.408376,111.554626)(直线 236.8km),合并后站内地点从贺州搬到了佛山。
|
||||
func TestDiffPlacesNameFallbackNeedsNearby(t *testing.T) {
|
||||
b := &Backup{Locations: []Location{
|
||||
{SrcPK: 41, Name: "中国体育彩票", Lat: 24.408376, Lon: 111.554626, City: "贺州市"},
|
||||
}}
|
||||
stored := []StoredPlace{
|
||||
{SrcPK: 11, Key: PlaceCoordKey(22.927572, 113.222052), Name: "中国体育彩票",
|
||||
Lat: 22.927572, Lon: 113.222052, City: "佛山市"},
|
||||
}
|
||||
d := DiffPlaces(stored, b)
|
||||
if len(d.Changed) != 0 {
|
||||
t.Fatalf("相隔 236.8km 的同名地点不该被配对: %+v", d.Changed[0].Fields)
|
||||
}
|
||||
if len(d.Added) != 1 || len(d.Removed) != 1 {
|
||||
t.Fatalf("应报成「备份新增 + 仅站内有」: +%d -%d", len(d.Added), len(d.Removed))
|
||||
}
|
||||
}
|
||||
|
||||
// 但坐标纠偏那种(同一地点,两边差几百米)仍要配上——名称兜底本来就是为它准备的。
|
||||
func TestDiffPlacesNameFallbackKeepsNearbyCoordFix(t *testing.T) {
|
||||
b := &Backup{Locations: []Location{
|
||||
{SrcPK: 41, Name: "茶颜悦色", Lat: 28.181954, Lon: 112.986995},
|
||||
}}
|
||||
stored := []StoredPlace{
|
||||
{SrcPK: 11, Key: PlaceCoordKey(28.185360, 112.981422), Name: "茶颜悦色",
|
||||
Lat: 28.185360, Lon: 112.981422},
|
||||
}
|
||||
d := DiffPlaces(stored, b)
|
||||
if len(d.Added) != 0 || len(d.Removed) != 0 {
|
||||
t.Fatalf("相距 665m 的同名地点应配上: +%d -%d", len(d.Added), len(d.Removed))
|
||||
}
|
||||
if len(d.Changed) != 1 || d.Changed[0].Fields[0].Label != "纬度" {
|
||||
t.Fatalf("应报出坐标差异: %+v", d.Changed)
|
||||
}
|
||||
}
|
||||
|
||||
// 「地点」这一项比的是坐标,只印名称时同名不同坐标会渲染成「X → X」,站长看不出差在哪。
|
||||
// 名称一样就必须把坐标带出来。
|
||||
func TestDiffVisitsPlaceConflictShowsCoords(t *testing.T) {
|
||||
b := &Backup{
|
||||
Visits: []Visit{{SrcPK: 5, Arrival: tp("2026-09-01 10:00"), LocationSrcPK: ip(3)}},
|
||||
Locations: []Location{{SrcPK: 3, Name: "长沙南站", Lat: 28.141772, Lon: 113.067569}},
|
||||
VisitUUID: map[int]string{5: uuidA},
|
||||
}
|
||||
stored := []StoredVisit{{
|
||||
SrcPK: 1, UUID: uuidA, Arrival: tp("2026-09-01 10:00"),
|
||||
PlaceName: "长沙南站", PlaceKey: PlaceCoordKey(28.146535, 113.062164),
|
||||
}}
|
||||
d := DiffVisits(stored, b)
|
||||
if len(d.Changed) != 1 {
|
||||
t.Fatalf("坐标不同应算冲突,实际 %d 条", len(d.Changed))
|
||||
}
|
||||
f := d.Changed[0].Fields
|
||||
if len(f) != 1 || f[0].Label != "地点" {
|
||||
t.Fatalf("应只报一项地点差异: %+v", f)
|
||||
}
|
||||
for _, want := range []string{"长沙南站", "28.146535", "113.062164"} {
|
||||
if !strings.Contains(f[0].From, want) {
|
||||
t.Fatalf("站内那侧 %q 应含 %q(否则这一行看着像没变化)", f[0].From, want)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"长沙南站", "28.141772", "113.067569"} {
|
||||
if !strings.Contains(f[0].To, want) {
|
||||
t.Fatalf("备份那侧 %q 应含 %q", f[0].To, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user