From 370c4285f82c6ba56e32c0a14d582195e692bf9d Mon Sep 17 00:00:00 2001 From: tamakyi Date: Tue, 29 Sep 2026 10:18:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=88=E5=B9=B6=EF=BC=9A=E6=8C=89=E5=90=8D?= =?UTF-8?q?=E7=A7=B0=E5=85=9C=E5=BA=95=E9=85=8D=E5=AF=B9=E5=8A=A0=201km=20?= =?UTF-8?q?=E4=B8=8A=E9=99=90=EF=BC=8C=E5=86=B2=E7=AA=81=E8=A1=8C=E5=9C=A8?= =?UTF-8?q?=E5=90=8D=E7=A7=B0=E7=9B=B8=E5=90=8C=E6=97=B6=E6=8A=8A=E5=9D=90?= =?UTF-8?q?=E6=A0=87=E5=8D=B0=E5=87=BA=E6=9D=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 偏差。 --- internal/rond/diff.go | 47 +++++++++++++++++++++++-- internal/rond/diff_test.go | 72 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/internal/rond/diff.go b/internal/rond/diff.go index 6de775a..3078ab2 100644 --- a/internal/rond/diff.go +++ b/internal/rond/diff.go @@ -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 { diff --git a/internal/rond/diff_test.go b/internal/rond/diff_test.go index 4c737b3..5dc25bf 100644 --- a/internal/rond/diff_test.go +++ b/internal/rond/diff_test.go @@ -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) + } + } +}