合并:重复地点不再反复判「备份新增」,到访的活动改按身份比

- DiffPlaces 的 byCoord/byName 改为多值索引 + take() 逐条认领未用项:真机一个坐标下会有
  多条同名同坐标的地点(实测「柳州站」3 条),单槽位时同一坐标最多只配上 2 条,第 3 条
  永远判「备份新增」⇒ 每次合并都再插一条重复地点、差异永远归不了零(实测 368→438→439)。
  修完:地点新增 70→67、合并后再比对归零、再点合并直接「没有要写入的改动」。
- 到访的「活动」由按名称比改为按身份(ZUID_)比,两边都缺 UUID 才退回名称:16 个活动改名
  曾把 355 条到访全变成同因冲突,且逐条选「用实机」只写回 activity_id(映射回同一个活动)
  改不掉名字。loadStoredVisits 为此多 JOIN 一次 entity_raw 取 ZACTIVITY.ZUID_。
- 回归用例 4 条(重复地点一一配上、活动改名不算冲突、换活动仍要报、缺 UUID 退回名称)+
  「合并后地点差异归零」断言 3 处;两处改动都做过变异验证。
- 实测(09-28 备份并进 09-14 数据集):到访冲突 253→6,合并两次地点数 438→439 变为 435→435。
This commit is contained in:
tamakyi
2026-09-28 19:31:21 +08:00
parent f2ce6646c4
commit 10d23e03cf
5 changed files with 172 additions and 22 deletions
+39 -14
View File
@@ -27,6 +27,7 @@ type StoredVisit struct {
PlaceKey string // 地点对齐键,空 = 该到访没有地点 PlaceKey string // 地点对齐键,空 = 该到访没有地点
PlaceName string // 仅用于展示 PlaceName string // 仅用于展示
ActivityName string ActivityName string
ActivityUUID string // 该到访所挂活动的对齐键,空 = 站点新建的活动
} }
// StoredPlace 是库里的一个地点。 // StoredPlace 是库里的一个地点。
@@ -170,7 +171,7 @@ func DiffVisits(stored []StoredVisit, b *Backup) VisitDiff {
classify := func(i int, v Visit) { classify := func(i int, v Visit) {
used[i] = true used[i] = true
sv := stored[i] sv := stored[i]
if fields := visitFieldChanges(sv, v, placeKey, placeName, actName); len(fields) > 0 { if fields := visitFieldChanges(sv, v, placeKey, placeName, actName, b.ActivityUUID); len(fields) > 0 {
d.Changed = append(d.Changed, VisitConflict{Backup: v, DB: sv, Fields: fields}) d.Changed = append(d.Changed, VisitConflict{Backup: v, DB: sv, Fields: fields})
} else { } else {
d.Same++ d.Same++
@@ -215,7 +216,7 @@ func timeKey(t time.Time, place string) string {
return fmt.Sprintf("%d|%s", t.Unix(), place) return fmt.Sprintf("%d|%s", t.Unix(), place)
} }
func visitFieldChanges(sv StoredVisit, v Visit, placeKey, placeName, actName map[int]string) []FieldChange { func visitFieldChanges(sv StoredVisit, v Visit, placeKey, placeName, actName, actUUID map[int]string) []FieldChange {
var out []FieldChange var out []FieldChange
add := func(label, from, to string) { add := func(label, from, to string) {
if from != to { if from != to {
@@ -228,7 +229,7 @@ func visitFieldChanges(sv StoredVisit, v Visit, placeKey, placeName, actName map
if !sameTime(sv.Departure, v.Departure) { if !sameTime(sv.Departure, v.Departure) {
add("离开时间", fmtTime(sv.Departure), fmtTime(v.Departure)) add("离开时间", fmtTime(sv.Departure), fmtTime(v.Departure))
} }
// 地点按坐标判、显示名称;活动按名称判。都不能比 src_pk——两边编号空间不同。 // 地点按坐标、活动按身份比对,都不能比 src_pk——两边编号空间不同。
bp, bn := "", "" bp, bn := "", ""
if v.LocationSrcPK != nil { if v.LocationSrcPK != nil {
bp, bn = placeKey[*v.LocationSrcPK], placeName[*v.LocationSrcPK] bp, bn = placeKey[*v.LocationSrcPK], placeName[*v.LocationSrcPK]
@@ -237,11 +238,21 @@ func visitFieldChanges(sv StoredVisit, v Visit, placeKey, placeName, actName map
if sv.PlaceKey != bp { if sv.PlaceKey != bp {
out = append(out, FieldChange{Label: "地点", From: sv.PlaceName, To: bn}) out = append(out, FieldChange{Label: "地点", From: sv.PlaceName, To: bn})
} }
bl := "" // 活动按**身份**判,不按名称:站内与真机对同一个活动改名属于活动层的差异,
// 挂在它名下的每条到访都跟着报一遍就会把真差异淹掉(实测 16 个活动改名 → 355 条假冲突),
// 而且逐条选「用实机」也改不掉活动名,冲突永远收敛不了。
// 两边都有 UUID 才比 UUID;缺 UUID 的老数据退回按名称比。
bl, bu := "", ""
if v.ActivitySrcPK != nil { if v.ActivitySrcPK != nil {
bl = actName[*v.ActivitySrcPK] bl, bu = actName[*v.ActivitySrcPK], actUUID[*v.ActivitySrcPK]
}
if sv.ActivityUUID != "" && bu != "" {
if sv.ActivityUUID != bu {
add("活动", sv.ActivityName, bl)
}
} else {
add("活动", sv.ActivityName, bl)
} }
add("活动", sv.ActivityName, bl)
add("备注", sv.Remark, v.Remark) add("备注", sv.Remark, v.Remark)
add("表情", sv.Emoji, v.Emoji) add("表情", sv.Emoji, v.Emoji)
add("收藏", fmtBool(sv.Bookmarked), fmtBool(v.Bookmarked)) add("收藏", fmtBool(sv.Bookmarked), fmtBool(v.Bookmarked))
@@ -258,21 +269,35 @@ func placeLabel(name string, lat, lon float64) string {
// DiffPlaces 对齐地点。坐标优先(改名不影响配对),配不上再按名称找一遍 // DiffPlaces 对齐地点。坐标优先(改名不影响配对),配不上再按名称找一遍
// (覆盖「站点把坐标纠偏过」这种),两样都对不上才算新增。 // (覆盖「站点把坐标纠偏过」这种),两样都对不上才算新增。
//
// 两侧都用**多值**索引:真机一个坐标下会有好几条同名同坐标的地点(实测「柳州站」3 条,
// 备份里这类分组共 10 组),单槽位 map 会让同一坐标最多只配上一条,剩下的一律判成
// 「备份新增」——于是每次合并都再插一条重复地点、差异永远归不了零。
func DiffPlaces(stored []StoredPlace, b *Backup) PlaceDiff { func DiffPlaces(stored []StoredPlace, b *Backup) PlaceDiff {
byCoord := make(map[string]StoredPlace, len(stored)) byCoord := make(map[string][]StoredPlace, len(stored))
byName := map[string]StoredPlace{} byName := make(map[string][]StoredPlace, len(stored))
for _, sp := range stored { for _, sp := range stored {
if sp.Lat != 0 || sp.Lon != 0 { if sp.Lat != 0 || sp.Lon != 0 {
byCoord[PlaceCoordKey(sp.Lat, sp.Lon)] = sp k := PlaceCoordKey(sp.Lat, sp.Lon)
byCoord[k] = append(byCoord[k], sp)
} }
if _, dup := byName[sp.Name]; !dup && sp.Name != "" { if sp.Name != "" {
byName[sp.Name] = sp byName[sp.Name] = append(byName[sp.Name], sp)
} }
} }
used := map[int]bool{} used := map[int]bool{}
// take 认领第一条还没被用掉的候选,认领即置 used——同一个站内地点不能被两个备份地点配走。
take := func(list []StoredPlace) (StoredPlace, bool) {
for _, sp := range list {
if !used[sp.SrcPK] {
used[sp.SrcPK] = true
return sp, true
}
}
return StoredPlace{}, false
}
var d PlaceDiff var d PlaceDiff
classify := func(sp StoredPlace, l Location) { classify := func(sp StoredPlace, l Location) {
used[sp.SrcPK] = true
if fields := placeFieldChanges(sp, l); len(fields) > 0 { if fields := placeFieldChanges(sp, l); len(fields) > 0 {
d.Changed = append(d.Changed, PlaceConflict{Backup: l, DB: sp, Fields: fields}) d.Changed = append(d.Changed, PlaceConflict{Backup: l, DB: sp, Fields: fields})
} else { } else {
@@ -280,11 +305,11 @@ func DiffPlaces(stored []StoredPlace, b *Backup) PlaceDiff {
} }
} }
for _, l := range b.Locations { for _, l := range b.Locations {
if sp, ok := byCoord[PlaceCoordKey(l.Lat, l.Lon)]; ok && !used[sp.SrcPK] { if sp, ok := take(byCoord[PlaceCoordKey(l.Lat, l.Lon)]); ok {
classify(sp, l) classify(sp, l)
continue continue
} }
if sp, ok := byName[l.Name]; ok && !used[sp.SrcPK] { if sp, ok := take(byName[l.Name]); ok {
classify(sp, l) classify(sp, l)
continue continue
} }
+98
View File
@@ -291,3 +291,101 @@ func TestDiffPlacesMatchesByNameWhenCoordMoved(t *testing.T) {
t.Fatalf("应报出坐标差异: %+v", d.Changed) t.Fatalf("应报出坐标差异: %+v", d.Changed)
} }
} }
// 真机一个坐标下会有好几条同名同坐标的地点(实测「柳州站」同一个坐标 3 条,备份里这类分组共 10 组)。
// 用单槽位 map 时,同一坐标最多只能配上一条,第 3 条永远判「备份新增」→ 每次合并都再插一条
// 重复地点、差异永远归不了零。
func TestDiffPlacesAlignsDuplicateNameCoordGroups(t *testing.T) {
loc := func(pk int) Location {
return Location{SrcPK: pk, Name: "柳州站", Lat: 24.307787, Lon: 109.388309}
}
sp := func(pk int) StoredPlace {
return StoredPlace{SrcPK: pk, Key: PlaceCoordKey(24.307787, 109.388309),
Name: "柳州站", Lat: 24.307787, Lon: 109.388309}
}
b := &Backup{Locations: []Location{loc(41), loc(42), loc(43)}}
d := DiffPlaces([]StoredPlace{sp(11), sp(12), sp(13)}, b)
if len(d.Added) != 0 || len(d.Removed) != 0 || len(d.Changed) != 0 {
t.Fatalf("两边各 3 条同名同坐标应一一配上: +%d -%d ~%d", len(d.Added), len(d.Removed), len(d.Changed))
}
if d.Same != 3 {
t.Fatalf("应配上 3 条,实际 %d", d.Same)
}
// 反过来:站内真的少两条时,仍要老老实实报出「备份新增 2」——
// 多值配对不能变成「只要有同名同坐标就一律不算新增」。
d = DiffPlaces([]StoredPlace{sp(11)}, b)
if len(d.Added) != 2 || len(d.Removed) != 0 || d.Same != 1 {
t.Fatalf("站内只有 1 条时应报新增 2: +%d -%d same=%d", len(d.Added), len(d.Removed), d.Same)
}
}
// 活动改了名(UUID 还在):那是活动层的差异,不该在挂着它的每条到访上重复报一遍。
// 实测真机 16 个活动改名会把 355 条到访全变成假冲突,而且逐条选「用实机」也改不掉活动名。
func TestDiffVisitsIgnoresActivityRename(t *testing.T) {
b := &Backup{
Visits: []Visit{{SrcPK: 5, Arrival: tp("2026-09-01 10:00"), ActivitySrcPK: ip(7)}},
Activities: []Activity{{SrcPK: 7, Name: "吃饭"}},
VisitUUID: map[int]string{5: uuidA},
ActivityUUID: map[int]string{7: uuidC},
}
stored := []StoredVisit{{
SrcPK: 1, UUID: uuidA, Arrival: tp("2026-09-01 10:00"),
ActivityName: "餐厅", ActivityUUID: uuidC,
}}
d := DiffVisits(stored, b)
if len(d.Changed) != 0 {
t.Fatalf("同一个活动改名不该算到访冲突: %+v", d.Changed[0].Fields)
}
if d.Same != 1 {
t.Fatalf("应算作一致,实际 same=%d", d.Same)
}
}
// 换成了另一个活动才是真差异。
func TestDiffVisitsReportsActivityChange(t *testing.T) {
b := &Backup{
Visits: []Visit{{SrcPK: 5, Arrival: tp("2026-09-01 10:00"), ActivitySrcPK: ip(7)}},
Activities: []Activity{{SrcPK: 7, Name: "吃饭"}, {SrcPK: 8, Name: "散步"}},
VisitUUID: map[int]string{5: uuidA},
ActivityUUID: map[int]string{7: uuidC, 8: uuidB},
}
stored := []StoredVisit{{
SrcPK: 1, UUID: uuidA, Arrival: tp("2026-09-01 10:00"),
ActivityName: "散步", ActivityUUID: uuidB,
}}
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 != "活动" || f[0].From != "散步" || f[0].To != "吃饭" {
t.Fatalf("活动差异不对: %+v", f)
}
}
// 站点自建的活动没有 UUID,退不回身份就按名称比(老数据要保持原样)。
func TestDiffVisitsActivityWithoutUUIDFallsBackToName(t *testing.T) {
b := &Backup{
Visits: []Visit{{SrcPK: 5, Arrival: tp("2026-09-01 10:00"), ActivitySrcPK: ip(7)}},
Activities: []Activity{{SrcPK: 7, Name: "吃饭"}},
VisitUUID: map[int]string{5: uuidA},
ActivityUUID: map[int]string{7: uuidC},
}
// 名称一致 → 不报
same := []StoredVisit{{
SrcPK: 1, UUID: uuidA, Arrival: tp("2026-09-01 10:00"), ActivityName: "吃饭",
}}
if d := DiffVisits(same, b); len(d.Changed) != 0 {
t.Fatalf("名称相同时不该报冲突: %+v", d.Changed[0].Fields)
}
// 名称不同 → 报
diff := []StoredVisit{{
SrcPK: 1, UUID: uuidA, Arrival: tp("2026-09-01 10:00"), ActivityName: "散步",
}}
d := DiffVisits(diff, b)
if len(d.Changed) != 1 || d.Changed[0].Fields[0].Label != "活动" {
t.Fatalf("没有 UUID 时应退回按名称比: %+v", d.Changed)
}
}
+8 -3
View File
@@ -239,17 +239,21 @@ func (s *Server) countOther(ctx context.Context, datasetID int64) (movements, we
// loadStoredVisits 读出该数据集的全部到访,并把原始行留档里的 UUID 解出来当对齐键。 // loadStoredVisits 读出该数据集的全部到访,并把原始行留档里的 UUID 解出来当对齐键。
// 没有留档的行就是站点新建的(真机不会给它 UUID),对齐键留空。 // 没有留档的行就是站点新建的(真机不会给它 UUID),对齐键留空。
// 所挂活动的 UUID 也一样:到访层面按活动身份比对,才不会因活动改名而全线报冲突。
func (s *Server) loadStoredVisits(ctx context.Context, datasetID int64) ([]rond.StoredVisit, error) { func (s *Server) loadStoredVisits(ctx context.Context, datasetID int64) ([]rond.StoredVisit, error) {
rows, err := s.db.QueryContext(ctx, ` rows, err := s.db.QueryContext(ctx, `
SELECT v.src_pk, v.arrival, v.departure, COALESCE(v.remark,''), COALESCE(v.emoji,''), SELECT v.src_pk, v.arrival, v.departure, COALESCE(v.remark,''), COALESCE(v.emoji,''),
v.bookmarked, v.is_user_added, COALESCE(p.src_pk,0), v.bookmarked, v.is_user_added, COALESCE(p.src_pk,0),
COALESCE(p.name,''), COALESCE(p.lat,0), COALESCE(p.lon,0), COALESCE(a.name,''), COALESCE(p.name,''), COALESCE(p.lat,0), COALESCE(p.lon,0), COALESCE(a.name,''),
COALESCE(er.raw #>> '{ZIDENTIFIER_,$blob}','') COALESCE(er.raw #>> '{ZIDENTIFIER_,$blob}',''),
COALESCE(aer.raw #>> '{ZUID_,$blob}','')
FROM visits v FROM visits v
LEFT JOIN places p ON p.id = v.place_id LEFT JOIN places p ON p.id = v.place_id
LEFT JOIN activities a ON a.id = v.activity_id LEFT JOIN activities a ON a.id = v.activity_id
LEFT JOIN entity_raw er ON er.dataset_id = v.dataset_id LEFT JOIN entity_raw er ON er.dataset_id = v.dataset_id
AND er.entity = 'ZVISIT' AND er.src_pk = v.src_pk AND er.entity = 'ZVISIT' AND er.src_pk = v.src_pk
LEFT JOIN entity_raw aer ON aer.dataset_id = v.dataset_id
AND aer.entity = 'ZACTIVITY' AND aer.src_pk = a.src_pk
WHERE v.dataset_id = $1`, datasetID) WHERE v.dataset_id = $1`, datasetID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -263,9 +267,9 @@ func (s *Server) loadStoredVisits(ctx context.Context, datasetID int64) ([]rond.
var placePK int var placePK int
var placeName string var placeName string
var lat, lon float64 var lat, lon float64
var b64 string var b64, actB64 string
if err := rows.Scan(&sv.SrcPK, &arr, &dep, &sv.Remark, &sv.Emoji, &sv.Bookmarked, if err := rows.Scan(&sv.SrcPK, &arr, &dep, &sv.Remark, &sv.Emoji, &sv.Bookmarked,
&sv.UserAdded, &placePK, &placeName, &lat, &lon, &sv.ActivityName, &b64); err != nil { &sv.UserAdded, &placePK, &placeName, &lat, &lon, &sv.ActivityName, &b64, &actB64); err != nil {
return nil, err return nil, err
} }
sv.Arrival = &arr sv.Arrival = &arr
@@ -278,6 +282,7 @@ func (s *Server) loadStoredVisits(ctx context.Context, datasetID int64) ([]rond.
sv.PlaceKey = rond.PlaceCoordKey(lat, lon) sv.PlaceKey = rond.PlaceCoordKey(lat, lon)
} }
sv.UUID = uuidFromBase64(b64) sv.UUID = uuidFromBase64(b64)
sv.ActivityUUID = uuidFromBase64(actB64)
out = append(out, sv) out = append(out, sv)
} }
return out, rows.Err() return out, rows.Err()
+18 -4
View File
@@ -62,16 +62,22 @@ func previewBackupToken(t *testing.T, f *movementFixture, path string) (string,
} }
// visitCounts 从概览表里取出「到访」那一行的 (备份新增, 仅站内有, 冲突)。 // visitCounts 从概览表里取出「到访」那一行的 (备份新增, 仅站内有, 冲突)。
// 不能全页 Contains 数字——地点行也有同样的 data-label,会张冠李戴。
func visitCounts(t *testing.T, page string) (added, gone, clash int) { func visitCounts(t *testing.T, page string) (added, gone, clash int) {
t.Helper() t.Helper()
row := regexp.MustCompile(`(?s)<td>到访</td>(.*?)</tr>`).FindStringSubmatch(page) return overviewCounts(t, page, "到访")
}
// overviewCounts 取概览表里任意一行(到访 / 地点 / 活动类型)的三个计数。
// 不能全页 Contains 数字——三行有同样的 data-label,会张冠李戴。
func overviewCounts(t *testing.T, page, label string) (added, gone, clash int) {
t.Helper()
row := regexp.MustCompile(`(?s)<td>` + label + `</td>(.*?)</tr>`).FindStringSubmatch(page)
if row == nil { if row == nil {
t.Fatal("概览表里找不到「到访」行") t.Fatalf("概览表里找不到「%s」行", label)
} }
nums := regexp.MustCompile(`data-label="(备份新增|仅站内有|冲突)">(\d+)<`).FindAllStringSubmatch(row[1], -1) nums := regexp.MustCompile(`data-label="(备份新增|仅站内有|冲突)">(\d+)<`).FindAllStringSubmatch(row[1], -1)
if len(nums) != 3 { if len(nums) != 3 {
t.Fatalf("到访行应有三个计数,实际 %d", len(nums)) t.Fatalf("「%s」行应有三个计数,实际 %d", label, len(nums))
} }
for _, n := range nums { for _, n := range nums {
v, _ := strconv.Atoi(n[2]) v, _ := strconv.Atoi(n[2])
@@ -121,12 +127,20 @@ func (f *movementFixture) shiftSrcPKs(t *testing.T, delta int) {
// //
// 只断言到访,不断言「完全一致」整句:导入后站点会反查补全地点(改名称/地址), // 只断言到访,不断言「完全一致」整句:导入后站点会反查补全地点(改名称/地址),
// 地点层面因此真的存在差异,那是预期行为,不该由这个用例来管。 // 地点层面因此真的存在差异,那是预期行为,不该由这个用例来管。
//
// 但**「备份新增」必须是 0**:导入就是把备份的地点一条一条写进去的(含同名同坐标的重复项),
// 比对再认不出它们就会每次都判「多出来一条」⇒ 合并时反复插重复地点。真机备份里
// 「柳州站」在同一个坐标上有 3 条,正是这个形状。
func TestBackupDiffIdentical(t *testing.T) { func TestBackupDiffIdentical(t *testing.T) {
f := newMovementFixture(t) f := newMovementFixture(t)
page := previewBackup(t, f, f.src) page := previewBackup(t, f, f.src)
if a, g, c := visitCounts(t, page); a != 0 || g != 0 || c != 0 { if a, g, c := visitCounts(t, page); a != 0 || g != 0 || c != 0 {
t.Fatalf("同一份备份的到访不该有差异: 新增 %d 仅站内有 %d 冲突 %d", a, g, c) t.Fatalf("同一份备份的到访不该有差异: 新增 %d 仅站内有 %d 冲突 %d", a, g, c)
} }
if a, g, _ := overviewCounts(t, page, "地点"); a != 0 || g != 0 {
t.Fatalf("刚导入就到比对,地点不该有新增或仅站内有: 新增 %d 仅站内有 %d"+
"(同名同坐标的重复地点没被一一配上)", a, g)
}
} }
// 编号全被平移后(= 数据来自另一份重排过 Z_PK 的备份),同一份内容再比一次仍须零差异。 // 编号全被平移后(= 数据来自另一份重排过 Z_PK 的备份),同一份内容再比一次仍须零差异。
+9 -1
View File
@@ -124,11 +124,16 @@ func TestBackupMergeRestoresMissingVisits(t *testing.T) {
t.Errorf("孤儿位移变多了:%d → %d", orphans0, orphans) t.Errorf("孤儿位移变多了:%d → %d", orphans0, orphans)
} }
// 幂等:再比一次应当完全一致 // 幂等:再比一次应当完全一致(地点也一样——同名同坐标的重复地点必须一一配上,
// 否则每次合并都会再插一条重复地点)
page = previewBackup(t, f, f.src) page = previewBackup(t, f, f.src)
if a, g, c := visitCounts(t, page); a != 0 || g != 0 || c != 0 { if a, g, c := visitCounts(t, page); a != 0 || g != 0 || c != 0 {
t.Fatalf("合并后再比对应当没有差异,实际 新增 %d / 仅站内有 %d / 冲突 %d", a, g, c) t.Fatalf("合并后再比对应当没有差异,实际 新增 %d / 仅站内有 %d / 冲突 %d", a, g, c)
} }
if a, g, _ := overviewCounts(t, page, "地点"); a != 0 || g != 0 {
t.Fatalf("合并后再比对,地点应当归零,实际 新增 %d / 仅站内有 %d"+
"(重复地点会被反复当成「备份新增」→ 每合并一次多一条)", a, g)
}
} }
// 「本地 + **后来新增的备份**」合并的真实形态:备份里的编号被 rond 全部重排过, // 「本地 + **后来新增的备份**」合并的真实形态:备份里的编号被 rond 全部重排过,
@@ -209,6 +214,9 @@ func TestBackupMergeRenumberedBackupRestoresOnlyMissing(t *testing.T) {
if a, g, c := visitCounts(t, page); a != 0 || g != 0 || c != 0 { if a, g, c := visitCounts(t, page); a != 0 || g != 0 || c != 0 {
t.Fatalf("合并后再比对应当没有差异,实际 新增 %d 仅站内有 %d 冲突 %d", a, g, c) t.Fatalf("合并后再比对应当没有差异,实际 新增 %d 仅站内有 %d 冲突 %d", a, g, c)
} }
if a, g, _ := overviewCounts(t, page, "地点"); a != 0 || g != 0 {
t.Fatalf("合并后地点差异也该归零,实际 新增 %d 仅站内有 %d", a, g)
}
} }
// 冲突逐条选「用实机」就把备份的值写进去,选「用站内」就原样不动。 // 冲突逐条选「用实机」就把备份的值写进去,选「用站内」就原样不动。