diff --git a/internal/rond/diff.go b/internal/rond/diff.go index 347fec9..6de775a 100644 --- a/internal/rond/diff.go +++ b/internal/rond/diff.go @@ -27,6 +27,7 @@ type StoredVisit struct { PlaceKey string // 地点对齐键,空 = 该到访没有地点 PlaceName string // 仅用于展示 ActivityName string + ActivityUUID string // 该到访所挂活动的对齐键,空 = 站点新建的活动 } // StoredPlace 是库里的一个地点。 @@ -170,7 +171,7 @@ func DiffVisits(stored []StoredVisit, b *Backup) VisitDiff { classify := func(i int, v Visit) { used[i] = true 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}) } else { d.Same++ @@ -215,7 +216,7 @@ func timeKey(t time.Time, place string) string { 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 add := func(label, from, to string) { if from != to { @@ -228,7 +229,7 @@ func visitFieldChanges(sv StoredVisit, v Visit, placeKey, placeName, actName map if !sameTime(sv.Departure, v.Departure) { add("离开时间", fmtTime(sv.Departure), fmtTime(v.Departure)) } - // 地点按坐标判、显示名称;活动按名称判。都不能比 src_pk——两边编号空间不同。 + // 地点按坐标、活动按身份比对,都不能比 src_pk——两边编号空间不同。 bp, bn := "", "" if v.LocationSrcPK != nil { 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 { out = append(out, FieldChange{Label: "地点", From: sv.PlaceName, To: bn}) } - bl := "" + // 活动按**身份**判,不按名称:站内与真机对同一个活动改名属于活动层的差异, + // 挂在它名下的每条到访都跟着报一遍就会把真差异淹掉(实测 16 个活动改名 → 355 条假冲突), + // 而且逐条选「用实机」也改不掉活动名,冲突永远收敛不了。 + // 两边都有 UUID 才比 UUID;缺 UUID 的老数据退回按名称比。 + bl, bu := "", "" 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.Emoji, v.Emoji) add("收藏", fmtBool(sv.Bookmarked), fmtBool(v.Bookmarked)) @@ -258,21 +269,35 @@ func placeLabel(name string, lat, lon float64) string { // DiffPlaces 对齐地点。坐标优先(改名不影响配对),配不上再按名称找一遍 // (覆盖「站点把坐标纠偏过」这种),两样都对不上才算新增。 +// +// 两侧都用**多值**索引:真机一个坐标下会有好几条同名同坐标的地点(实测「柳州站」3 条, +// 备份里这类分组共 10 组),单槽位 map 会让同一坐标最多只配上一条,剩下的一律判成 +// 「备份新增」——于是每次合并都再插一条重复地点、差异永远归不了零。 func DiffPlaces(stored []StoredPlace, b *Backup) PlaceDiff { - byCoord := make(map[string]StoredPlace, len(stored)) - byName := map[string]StoredPlace{} + byCoord := make(map[string][]StoredPlace, len(stored)) + byName := make(map[string][]StoredPlace, len(stored)) for _, sp := range stored { 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 != "" { - byName[sp.Name] = sp + if sp.Name != "" { + byName[sp.Name] = append(byName[sp.Name], sp) } } 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 classify := func(sp StoredPlace, l Location) { - used[sp.SrcPK] = true if fields := placeFieldChanges(sp, l); len(fields) > 0 { d.Changed = append(d.Changed, PlaceConflict{Backup: l, DB: sp, Fields: fields}) } else { @@ -280,11 +305,11 @@ func DiffPlaces(stored []StoredPlace, b *Backup) PlaceDiff { } } 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) continue } - if sp, ok := byName[l.Name]; ok && !used[sp.SrcPK] { + if sp, ok := take(byName[l.Name]); ok { classify(sp, l) continue } diff --git a/internal/rond/diff_test.go b/internal/rond/diff_test.go index 20f4242..4c737b3 100644 --- a/internal/rond/diff_test.go +++ b/internal/rond/diff_test.go @@ -291,3 +291,101 @@ func TestDiffPlacesMatchesByNameWhenCoordMoved(t *testing.T) { 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) + } +} diff --git a/internal/web/backup_diff.go b/internal/web/backup_diff.go index a46663a..73c8fc8 100644 --- a/internal/web/backup_diff.go +++ b/internal/web/backup_diff.go @@ -239,17 +239,21 @@ func (s *Server) countOther(ctx context.Context, datasetID int64) (movements, we // loadStoredVisits 读出该数据集的全部到访,并把原始行留档里的 UUID 解出来当对齐键。 // 没有留档的行就是站点新建的(真机不会给它 UUID),对齐键留空。 +// 所挂活动的 UUID 也一样:到访层面按活动身份比对,才不会因活动改名而全线报冲突。 func (s *Server) loadStoredVisits(ctx context.Context, datasetID int64) ([]rond.StoredVisit, error) { rows, err := s.db.QueryContext(ctx, ` 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), 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 LEFT JOIN places p ON p.id = v.place_id LEFT JOIN activities a ON a.id = v.activity_id LEFT JOIN entity_raw er ON er.dataset_id = v.dataset_id 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) if err != nil { return nil, err @@ -263,9 +267,9 @@ func (s *Server) loadStoredVisits(ctx context.Context, datasetID int64) ([]rond. var placePK int var placeName string 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, - &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 } sv.Arrival = &arr @@ -278,6 +282,7 @@ func (s *Server) loadStoredVisits(ctx context.Context, datasetID int64) ([]rond. sv.PlaceKey = rond.PlaceCoordKey(lat, lon) } sv.UUID = uuidFromBase64(b64) + sv.ActivityUUID = uuidFromBase64(actB64) out = append(out, sv) } return out, rows.Err() diff --git a/internal/web/backup_diff_test.go b/internal/web/backup_diff_test.go index 383da25..a643efb 100644 --- a/internal/web/backup_diff_test.go +++ b/internal/web/backup_diff_test.go @@ -62,16 +62,22 @@ func previewBackupToken(t *testing.T, f *movementFixture, path string) (string, } // visitCounts 从概览表里取出「到访」那一行的 (备份新增, 仅站内有, 冲突)。 -// 不能全页 Contains 数字——地点行也有同样的 data-label,会张冠李戴。 func visitCounts(t *testing.T, page string) (added, gone, clash int) { t.Helper() - row := regexp.MustCompile(`(?s)