From 8763b850bee41df3d3ddddbd1cc7d9c679741231 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Tue, 29 Sep 2026 10:46:54 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=88=E5=B9=B6=EF=BC=9A=E5=9C=B0=E7=82=B9?= =?UTF-8?q?=E9=85=8D=E5=AF=B9=E5=8F=AA=E7=95=99=E4=B8=80=E4=BB=BD=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=EF=BC=88rond.MatchPlaces=EF=BC=89=EF=BC=8C=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E4=BE=A7=E4=B8=8D=E5=86=8D=E8=87=AA=E5=B7=B1=E9=85=8D?= =?UTF-8?q?=E4=B8=80=E9=81=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 抽出 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 会被置空(旧行为是挂到一条远方的同名地点上——那正是这次要去掉的错配)。 --- internal/rond/diff.go | 63 +++++++++++++++++++++---------- internal/rond/diff_test.go | 44 +++++++++++++++++++++ internal/rond/merge.go | 15 ++++---- internal/rond/merge_clash_test.go | 12 +++--- internal/web/backup_diff.go | 4 +- internal/web/backup_merge.go | 51 ++++++++++--------------- internal/web/backup_merge_test.go | 55 +++++++++++++++++++++++++++ 7 files changed, 178 insertions(+), 66 deletions(-) diff --git a/internal/rond/diff.go b/internal/rond/diff.go index 3078ab2..338f80d 100644 --- a/internal/rond/diff.go +++ b/internal/rond/diff.go @@ -285,13 +285,22 @@ func placeLabel(name string, lat, lon float64) string { return fmt.Sprintf("%.5f, %.5f", lat, lon) } -// DiffPlaces 对齐地点。坐标优先(改名不影响配对),配不上再按名称找一遍 -// (覆盖「站点把坐标纠偏过」这种),两样都对不上才算新增。 +// PlaceMatch 是「备份地点编号 -> 库内地点」的配对结果。 +type PlaceMatch map[int]StoredPlace + +// MatchPlaces 把备份地点配到库内地点:坐标优先(改名不影响配对),配不上再退到 +// 「同名且相距不超过 nameMatchMaxKm」。 // -// 两侧都用**多值**索引:真机一个坐标下会有好几条同名同坐标的地点(实测「柳州站」3 条, -// 备份里这类分组共 10 组),单槽位 map 会让同一坐标最多只配上一条,剩下的一律判成 -// 「备份新增」——于是每次合并都再插一条重复地点、差异永远归不了零。 -func DiffPlaces(stored []StoredPlace, b *Backup) PlaceDiff { +// 内部用**多值**索引:真机一个坐标下会有好几条同名同坐标的地点(实测「柳州站」3 条), +// 单槽位 map 会让同一坐标最多只配上一条,剩下的一律判成「备份新增」——于是每次合并都再插 +// 一条重复地点、差异永远归不了零。认领过的一定要标记已用,否则多条会双双落到同一条上。 +// +// 差异判定(DiffPlaces)与合并执行(internal/web)**必须共用这一份实现**:两处各配一遍就会 +// 漂移——合并侧原来是单槽位 map 自己再配一次,于是同一坐标/同名的多条备份地点全并到同一条 +// 站内地点上,到访被挂到错的分店(实测「茶颜悦色」两家分店相隔 8.3km)。 +// +// 两个入参的顺序都要稳定(库内按 id、备份按解析顺序),否则候选多于一个时结果会飘。 +func MatchPlaces(stored []StoredPlace, backup []Location) PlaceMatch { byCoord := make(map[string][]StoredPlace, len(stored)) byName := make(map[string][]StoredPlace, len(stored)) for _, sp := range stored { @@ -304,7 +313,7 @@ func DiffPlaces(stored []StoredPlace, b *Backup) PlaceDiff { } } used := map[int]bool{} - // take 认领第一条还没被用掉的候选,认领即置 used——同一个站内地点不能被两个备份地点配走。 + // take 认领第一条还没被用掉的候选,认领即置 used——同一条站内地点不能被两个备份地点配走。 take := func(list []StoredPlace) (StoredPlace, bool) { for _, sp := range list { if !used[sp.SrcPK] { @@ -331,27 +340,41 @@ func DiffPlaces(stored []StoredPlace, b *Backup) PlaceDiff { } return StoredPlace{}, false } + + out := make(PlaceMatch, len(backup)) + for _, l := range backup { + if sp, ok := take(byCoord[PlaceCoordKey(l.Lat, l.Lon)]); ok { + out[l.SrcPK] = sp + continue + } + if sp, ok := takeByName(byName[l.Name], l); ok { + out[l.SrcPK] = sp + } + } + return out +} + +// DiffPlaces 对齐地点。配对规则见 MatchPlaces:配上的比字段,配不上的两边分别是 +// 「备份新增」与「仅站内有」。 +func DiffPlaces(stored []StoredPlace, b *Backup) PlaceDiff { + match := MatchPlaces(stored, b.Locations) + matched := map[int]bool{} var d PlaceDiff - classify := func(sp StoredPlace, l Location) { + for _, l := range b.Locations { + sp, ok := match[l.SrcPK] + if !ok { + d.Added = append(d.Added, l) + continue + } + matched[sp.SrcPK] = true if fields := placeFieldChanges(sp, l); len(fields) > 0 { d.Changed = append(d.Changed, PlaceConflict{Backup: l, DB: sp, Fields: fields}) } else { d.Same++ } } - for _, l := range b.Locations { - if sp, ok := take(byCoord[PlaceCoordKey(l.Lat, l.Lon)]); ok { - classify(sp, l) - continue - } - if sp, ok := takeByName(byName[l.Name], l); ok { - classify(sp, l) - continue - } - d.Added = append(d.Added, l) - } for _, sp := range stored { - if !used[sp.SrcPK] { + if !matched[sp.SrcPK] { d.Removed = append(d.Removed, sp) } } diff --git a/internal/rond/diff_test.go b/internal/rond/diff_test.go index 5dc25bf..fee4695 100644 --- a/internal/rond/diff_test.go +++ b/internal/rond/diff_test.go @@ -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) { diff --git a/internal/rond/merge.go b/internal/rond/merge.go index a0d4ff3..b225e2f 100644 --- a/internal/rond/merge.go +++ b/internal/rond/merge.go @@ -46,16 +46,15 @@ type VisitPatch struct { V Visit } -// PlacePatch 同理,键是坐标键。 +// PlacePatch 表示用备份侧的值覆盖库里的那一条。要改哪一条由配对结果决定 +// (DiffPlaces/MatchPlaces 已经配好),所以这里只带备份侧的整条地点。 type PlacePatch struct { - CoordKey string - L Location + L Location } -// ActivityPatch 键是活动 UUID(没有 UUID 时退回名称)。 +// ActivityPatch 同理。 type ActivityPatch struct { - Key string - A Activity + A Activity } // MergePlan 是一次合并要做的全部改动。 @@ -87,7 +86,7 @@ func BuildMergePlan(vd VisitDiff, pd PlaceDiff, ad ActivityDiff, c Choices) Merg } for _, cf := range pd.Changed { if c.placePick(cf.DB.Key) == PickBackup { - p.UpdPlaces = append(p.UpdPlaces, PlacePatch{CoordKey: cf.DB.Key, L: cf.Backup}) + p.UpdPlaces = append(p.UpdPlaces, PlacePatch{L: cf.Backup}) } } for _, sp := range pd.Removed { @@ -101,7 +100,7 @@ func BuildMergePlan(vd VisitDiff, pd PlaceDiff, ad ActivityDiff, c Choices) Merg } for _, cf := range ad.Changed { if c.activityPick(activityKey(cf.DB)) == PickBackup { - p.UpdActs = append(p.UpdActs, ActivityPatch{Key: activityKey(cf.DB), A: cf.Backup}) + p.UpdActs = append(p.UpdActs, ActivityPatch{A: cf.Backup}) } } for _, sa := range ad.Removed { diff --git a/internal/rond/merge_clash_test.go b/internal/rond/merge_clash_test.go index f819378..935a4bd 100644 --- a/internal/rond/merge_clash_test.go +++ b/internal/rond/merge_clash_test.go @@ -19,9 +19,9 @@ func TestBuildMergePlanPlaceClash(t *testing.T) { if len(p.UpdPlaces) != 1 { t.Fatalf("只有选了「用实机」的地点该更新: %+v", p.UpdPlaces) } - // 键必须是库里的坐标键:备份侧那条没有 Key 字段,拿它当键会让处理函数找不到行 - if p.UpdPlaces[0].CoordKey != keyA || p.UpdPlaces[0].L.Name != "备份名A" { - t.Fatalf("应当用库里的坐标键 + 备份的值: %+v", p.UpdPlaces[0]) + // 带的是备份侧那条的整份值;「改库里的哪一行」由配对(MatchPlaces)决定,计划里不再存键 + if p.UpdPlaces[0].L.Name != "备份名A" { + t.Fatalf("应带备份侧的值: %+v", p.UpdPlaces[0]) } if len(p.UpdVisits)+len(p.AddVisits)+len(p.UpdActs)+len(p.AddActs) != 0 { t.Fatal("没勾新增就不该带出到访/活动改动") @@ -40,9 +40,9 @@ func TestBuildMergePlanActivityClash(t *testing.T) { if len(p.UpdActs) != 1 { t.Fatalf("只有选了「用实机」的活动该更新: %+v", p.UpdActs) } - // 改名也能对上:键取 UUID 而不是名称;没 UUID 的才退回 "n:"+名称 - if p.UpdActs[0].Key != uuidC || p.UpdActs[0].A.Name != "散步" { - t.Fatalf("有 UUID 的活动应按 UUID 对上: %+v", p.UpdActs[0]) + // 改名也能对上:选择是按「UUID(没有才退回名称)」提交的,配对早就发生在比对阶段 + if p.UpdActs[0].A.Name != "散步" { + t.Fatalf("应带备份侧的值: %+v", p.UpdActs[0]) } } diff --git a/internal/web/backup_diff.go b/internal/web/backup_diff.go index 73c8fc8..d067e9e 100644 --- a/internal/web/backup_diff.go +++ b/internal/web/backup_diff.go @@ -288,10 +288,12 @@ func (s *Server) loadStoredVisits(ctx context.Context, datasetID int64) ([]rond. return out, rows.Err() } +// loadStoredPlaces 读地点。**必须按 id 排序**:配对的候选多于一个时(同坐标/同名的重复地点) +// 靠顺序决定谁配谁,合并侧(mergePlaces)用的是同一份顺序与同一套规则。 func (s *Server) loadStoredPlaces(ctx context.Context, datasetID int64) ([]rond.StoredPlace, error) { rows, err := s.db.QueryContext(ctx, `SELECT src_pk, COALESCE(name,''), COALESCE(lat,0), COALESCE(lon,0), COALESCE(province,''), COALESCE(city,''), COALESCE(poi_category,''), visit_count - FROM places WHERE dataset_id=$1`, datasetID) + FROM places WHERE dataset_id=$1 ORDER BY id`, datasetID) if err != nil { return nil, err } diff --git a/internal/web/backup_merge.go b/internal/web/backup_merge.go index b15a866..22241ca 100644 --- a/internal/web/backup_merge.go +++ b/internal/web/backup_merge.go @@ -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++ } diff --git a/internal/web/backup_merge_test.go b/internal/web/backup_merge_test.go index eb5a49e..46d3360 100644 --- a/internal/web/backup_merge_test.go +++ b/internal/web/backup_merge_test.go @@ -5,6 +5,8 @@ import ( "net/http/httptest" "strings" "testing" + + "rond-with-you/internal/rond" ) // mergeBackup 走真实的合并处理函数;失败(重定向里带 err=)直接判错。 @@ -363,3 +365,56 @@ func TestBackupMergeRefusesDeletingPlaceInUse(t *testing.T) { t.Errorf("被拒绝后到访数不该变,实际 %d(期望 %d)", got, beforeVisits+1) } } + +// 两条同名备份地点在合并时必须各配一条站内地点。以前这里用**单槽位** map 自己再配一遍, +// 两条同名地点会映射到同一条站内地点上,到访于是被挂到错的分店——实测「茶颜悦色」两家 +// 分店相隔 8.3km,到访却都指向同一条。现在配对直接沿用比对阶段那一份(rond.MatchPlaces)。 +func TestMergePlacesMapsSameNameLocationsOneToOne(t *testing.T) { + f := newMovementFixture(t) + + // 两条同名的站内地点,放在不太可能与真实数据撞车的坐标上 + if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO places (dataset_id, src_pk, name, lat, lon) + VALUES ($1, 920001, '同名测试店', 1.000000, 1.000000), + ($1, 920002, '同名测试店', 1.100000, 1.100000)`, f.dsID); err != nil { + t.Fatalf("插入站内地点失败: %v", err) + } + t.Cleanup(func() { + f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk IN (920001,920002)`, f.dsID) + }) + + // 备份各一条同名地点,分别距上面两条约 700m(属于坐标纠偏,该配上); + // 与另一条相距 15km,远超 1km 上限,不该被配走 + b := &rond.Backup{Locations: []rond.Location{ + {SrcPK: 41, Name: "同名测试店", Lat: 1.004500, Lon: 1.004500}, + {SrcPK: 42, Name: "同名测试店", Lat: 1.095500, Lon: 1.095500}, + }} + + var placeID map[int]int64 + if err := f.srv.withTx(f.ctx, func(ex execer) error { + var err error + placeID, err = f.srv.mergePlaces(f.ctx, ex, f.dsID, b, rond.MergePlan{}, &mergeStats{}) + return err + }); err != nil { + t.Fatalf("mergePlaces 失败: %v", err) + } + if len(placeID) != 2 { + t.Fatalf("两条都该配上,实际 %d 条", len(placeID)) + } + if placeID[41] == placeID[42] { + t.Fatalf("两条同名备份地点被映射到同一条站内地点上: 41→%d 42→%d", placeID[41], placeID[42]) + } + srcOf := func(id int64) int { + t.Helper() + var pk int + if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places WHERE id=$1`, id).Scan(&pk); err != nil { + t.Fatalf("反查 id=%d 失败: %v", id, err) + } + return pk + } + if got := srcOf(placeID[41]); got != 920001 { + t.Errorf("备份 #41 应配到最近的 920001,实际 %d", got) + } + if got := srcOf(placeID[42]); got != 920002 { + t.Errorf("备份 #42 应配到最近的 920002,实际 %d", got) + } +}