合并:地点配对只留一份实现(rond.MatchPlaces),合并侧不再自己配一遍

- 抽出 rond.MatchPlaces(stored, backup) PlaceMatch:坐标优先、退到「同名且相距 ≤1km」、
  多值索引 + 认领即标记已用;DiffPlaces 与 web.mergePlaces 共用它。
- mergePlaces 原来是单槽位 byCoord/byName 自己再配一遍:同坐标/同名的多条备份地点全并到同一行
  站内地点,到访被挂到错的分店(实测「茶颜悦色」两家分店相隔 8.3km,到访却指着同一条)。
  现在直接用配对结果,再叠加 plan.AddPlaces 的新插入。
- loadStoredPlaces 与 mergePlaces 的地点查询都加 ORDER BY id:候选多于一个时靠顺序定谁配谁,
  两侧必须一致,否则配对会飘。
- 删掉只写不读的 PlacePatch.CoordKey、ActivityPatch.Key(容易被误当成查找键)。
- 用例:TestMatchPlacesKeepsDuplicateNameGroupsDistinct(同一坐标/邻近同名都要一条配一条)、
  TestMergePlacesMapsSameNameLocationsOneToOne(真库 + 真事务,两条同名备份地点必须映射到两条不同
  地点);两处都做了变异验证(认领不标记已用 / 恢复单槽位映射 → 对应用例变红)。
- 探针库复测(从 dump 恢复、不动本地真库,49 组冲突全「用实机」):到访挂错地点(>1km)0/953 条、
  地点被搬动 >1km 0 个;合并后再比对三类全 0(到访 0/47/0、地点 0/4/0、活动 0/0/0),
  再点合并「按当前的选择没有要写入的改动」。全量验收 293 PASS / 0 FAIL / 0 SKIP。

已知取舍:勾「用实机」但没勾「并进来」时,若该到访在实机上的地点站内没有、且按 1km 规则也配不上,
place_id 会被置空(旧行为是挂到一条远方的同名地点上——那正是这次要去掉的错配)。
This commit is contained in:
tamakyi
2026-09-29 10:46:54 +08:00
parent 370c4285f8
commit 8763b850be
7 changed files with 178 additions and 66 deletions
+44
View File
@@ -430,6 +430,50 @@ func TestDiffPlacesNameFallbackKeepsNearbyCoordFix(t *testing.T) {
}
}
// 多条同名备份地点必须**一条配一条**(各配最近的那条),不能全并到同一条站内地点上:
// 合并执行(mergePlaces)沿用的就是这份配对,并错了到访就会挂到错的分店
// (实测「茶颜悦色」两家分店相隔 8.3km,到访却指向同一条)。
func TestMatchPlacesKeepsDuplicateNameGroupsDistinct(t *testing.T) {
mk := func(pk int, lat, lon float64) StoredPlace {
sp := StoredPlace{SrcPK: pk, Name: "茶颜悦色", Lat: lat, Lon: lon}
sp.Key = PlaceCoordKey(lat, lon)
return sp
}
stored := []StoredPlace{
mk(11, 28.185360, 112.981422), // 与备份 #41 相距约 665m
mk(12, 28.245842, 113.031303), // 与备份 #42 相距约 670m
}
backup := []Location{
{SrcPK: 41, Name: "茶颜悦色", Lat: 28.181954, Lon: 112.986995},
{SrcPK: 42, Name: "茶颜悦色", Lat: 28.242581, Lon: 113.037061},
}
m := MatchPlaces(stored, backup)
if len(m) != 2 {
t.Fatalf("两条都该配上,实际 %d", len(m))
}
if m[41].SrcPK == m[42].SrcPK {
t.Fatalf("两条同名备份地点被并到了同一条站内地点上: 41→%d 42→%d", m[41].SrcPK, m[42].SrcPK)
}
if m[41].SrcPK != 11 || m[42].SrcPK != 12 {
t.Fatalf("应各配最近的一条(41→11、42→12),实际 41→%d 42→%d", m[41].SrcPK, m[42].SrcPK)
}
// 两家分店挨得近(都在 1km 内)时也要一条配一条:坐标键对不上、只能靠名称兜底,
// 若认领了却不标记已用,两条会双双落到第一条上。
near := []StoredPlace{mk(21, 1.000000, 1.000000), mk(22, 1.005000, 1.005000)}
closeBy := []Location{
{SrcPK: 51, Name: "茶颜悦色", Lat: 1.002400, Lon: 1.002400},
{SrcPK: 52, Name: "茶颜悦色", Lat: 1.002600, Lon: 1.002600},
}
m2 := MatchPlaces(near, closeBy)
if len(m2) != 2 {
t.Fatalf("两条都该配上,实际 %d", len(m2))
}
if m2[51].SrcPK == m2[52].SrcPK {
t.Fatalf("两条同名备份地点被并到了同一条站内地点上: 51→%d 52→%d", m2[51].SrcPK, m2[52].SrcPK)
}
}
// 「地点」这一项比的是坐标,只印名称时同名不同坐标会渲染成「X → X」,站长看不出差在哪。
// 名称一样就必须把坐标带出来。
func TestDiffVisitsPlaceConflictShowsCoords(t *testing.T) {