合并:地点配对只留一份实现(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
+20 -31
View File
@@ -200,30 +200,34 @@ func (s *Server) applyMergePlan(ctx context.Context, ds *Dataset, b *rond.Backup
return st, nil
}
// mergePlaces 先把备份地点映射到库里的 places.id(已存在的直接映射),
// 再处理冲突更新与新增,返回「备份地点编号 -> 库 id」。
// mergePlaces 先把备份地点映射到库里的 places.id,再处理冲突更新与新增,
// 返回「备份地点编号 -> 库 id」。
//
// 配对直接沿用比对阶段那一份(rond.MatchPlaces):**这里绝不能自己再配一遍**。原先用
// 单槽位 map 配,同坐标/同名的多条备份地点会全部映射到同一条站内地点上,到访于是被挂到
// 错的分店(实测「茶颜悦色」两家分店相隔 8.3km,到访却指向同一条)。
func (s *Server) mergePlaces(ctx context.Context, ex execer, dsID int64, b *rond.Backup,
plan rond.MergePlan, st *mergeStats) (map[int]int64, error) {
byCoord, byName := map[string]int64{}, map[string]int64{}
rows, err := ex.QueryContext(ctx, `SELECT id, COALESCE(name,''), COALESCE(lat,0), COALESCE(lon,0)
FROM places WHERE dataset_id=$1`, dsID)
// 顺序必须稳定(按 id)且与 loadStoredPlaces 一致,否则候选多于一个时配对会飘
stored := make([]rond.StoredPlace, 0, 512)
bySrcPK := map[int]int64{}
rows, err := ex.QueryContext(ctx, `SELECT id, src_pk, COALESCE(name,''), COALESCE(lat,0), COALESCE(lon,0)
FROM places WHERE dataset_id=$1 ORDER BY id`, dsID)
if err != nil {
return nil, err
}
for rows.Next() {
var id int64
var name string
var lat, lon float64
if err := rows.Scan(&id, &name, &lat, &lon); err != nil {
var sp rond.StoredPlace
if err := rows.Scan(&id, &sp.SrcPK, &sp.Name, &sp.Lat, &sp.Lon); err != nil {
rows.Close()
return nil, err
}
if lat != 0 || lon != 0 {
byCoord[rond.PlaceCoordKey(lat, lon)] = id
}
if _, dup := byName[name]; !dup && name != "" {
byName[name] = id
if sp.Lat != 0 || sp.Lon != 0 {
sp.Key = rond.PlaceCoordKey(sp.Lat, sp.Lon)
}
stored = append(stored, sp)
bySrcPK[sp.SrcPK] = id
}
rows.Close()
if err := rows.Err(); err != nil {
@@ -231,28 +235,14 @@ func (s *Server) mergePlaces(ctx context.Context, ex execer, dsID int64, b *rond
}
placeID := map[int]int64{}
for _, l := range b.Locations {
if l.Lat != 0 || l.Lon != 0 {
if id, ok := byCoord[rond.PlaceCoordKey(l.Lat, l.Lon)]; ok {
placeID[l.SrcPK] = id
continue
}
}
if l.Name != "" {
if id, ok := byName[l.Name]; ok {
placeID[l.SrcPK] = id
}
for srcPK, sp := range rond.MatchPlaces(stored, b.Locations) {
if id, ok := bySrcPK[sp.SrcPK]; ok {
placeID[srcPK] = id
}
}
for _, up := range plan.UpdPlaces {
id := placeID[up.L.SrcPK]
if id == 0 {
id = byCoord[up.CoordKey]
}
if id == 0 {
id = byName[up.L.Name]
}
if id == 0 {
return nil, fmt.Errorf("找不到要更新的地点(%s)", up.L.Name)
}
@@ -264,7 +254,6 @@ func (s *Server) mergePlaces(ctx context.Context, ex execer, dsID int64, b *rond
nullStr(up.L.Sublocality), nullStr(up.L.Thoroughfare), nullStr(up.L.Timezone), id); err != nil {
return nil, fmt.Errorf("更新地点: %w", err)
}
placeID[up.L.SrcPK] = id
st.UpdPlaces++
}