diff --git a/internal/rond/diff.go b/internal/rond/diff.go index 338f80d..8d683c9 100644 --- a/internal/rond/diff.go +++ b/internal/rond/diff.go @@ -169,11 +169,12 @@ func DiffVisits(stored []StoredVisit, b *Backup) VisitDiff { byUUID := make(map[string]int, len(stored)) byTime := map[string][]int{} for i, sv := range stored { - if sv.UUID != "" { - byUUID[sv.UUID] = i - continue + // 站点补录的行没有留档 UUID,但它导出过一次之后就带着派生 UUID 在包里 + // (见 VisitMatchUUID)——不拿它当键,同一行会被判成「备份新增」再插一条。 + if u := VisitMatchUUID(sv); u != "" { + byUUID[u] = i } - if sv.Arrival != nil { + if sv.UUID == "" && sv.Arrival != nil { k := timeKey(*sv.Arrival, sv.PlaceKey) byTime[k] = append(byTime[k], i) } @@ -206,8 +207,15 @@ func DiffVisits(stored []StoredVisit, b *Backup) VisitDiff { if v.LocationSrcPK != nil { bpk = placeKey[*v.LocationSrcPK] } - if list := byTime[timeKey(*v.Arrival, bpk)]; len(list) > 0 { - byTime[timeKey(*v.Arrival, bpk)] = list[1:] + // 照旧按「时间 + 地点」兜底,但要跳过已经被 UUID 那一路认领的行—— + // 同一条站点行可能两个索引里都有,重复 classify 会把它算成两条。 + k := timeKey(*v.Arrival, bpk) + list := byTime[k] + for len(list) > 0 && used[list[0]] { + list = list[1:] + } + if len(list) > 0 { + byTime[k] = list[1:] classify(list[0], v) continue } diff --git a/internal/rond/diff_test.go b/internal/rond/diff_test.go index fee4695..b53e427 100644 --- a/internal/rond/diff_test.go +++ b/internal/rond/diff_test.go @@ -123,10 +123,50 @@ func TestDiffVisitsFallsBackWhenUUIDMissing(t *testing.T) { } } +// 站点补录的行(没有留档 UUID)导出过一次之后,包里那条带着的是站点给它补的派生 UUID。 +// 这份包再合并回来时,必须认出「这就是我导出去的那条」——不认就会当成「备份新增」又插一条, +// 时间线上于是出现两条一模一样的到访(实测数据集 9 有 43 对)。 +func TestDiffVisitsAlignsSiteCreatedRowByDerivedUUID(t *testing.T) { + const srcPK = 42 + b := &Backup{ + Visits: []Visit{{SrcPK: 900, Arrival: tp("2026-09-01 10:00"), LocationSrcPK: ip(3)}}, + Locations: []Location{{SrcPK: 3, Name: "家", Lat: 24.1, Lon: 109.1}}, + VisitUUID: map[int]string{900: DerivedVisitUUID(srcPK)}, // 导出时补给这条的 + ActivityUUID: map[int]string{}, + } + stored := []StoredVisit{{ + SrcPK: srcPK, UUID: "", // 站点补录:entity_raw 里没有这一行 + Arrival: tp("2026-09-01 10:00"), PlaceKey: PlaceCoordKey(24.1, 109.1), + }} + d := DiffVisits(stored, b) + if len(d.Added) != 0 || len(d.Removed) != 0 { + t.Fatalf("该认出是同一行(派生 UUID 对上了): +%d -%d", len(d.Added), len(d.Removed)) + } + if d.Same != 1 { + t.Fatalf("应配上 1 条,实际 same=%d changed=%d", d.Same, len(d.Changed)) + } +} + +// 反例:派生 UUID 是别的编号的,就不该被认领(否则会把两条不同的到访混成一条)。 +func TestDiffVisitsDoesNotAlignOtherDerivedUUID(t *testing.T) { + const srcPK = 42 + b := &Backup{ + Visits: []Visit{{SrcPK: 900, Arrival: tp("2026-09-01 10:00"), LocationSrcPK: ip(3)}}, + Locations: []Location{{SrcPK: 3, Name: "家", Lat: 24.1, Lon: 109.1}}, + VisitUUID: map[int]string{900: DerivedVisitUUID(srcPK + 1)}, + } + stored := []StoredVisit{{ + SrcPK: srcPK, Arrival: tp("2026-09-01 10:00"), PlaceKey: PlaceCoordKey(24.1, 109.1), + }} + d := DiffVisits(stored, b) + if len(d.Added) != 1 || len(d.Removed) != 1 { + t.Fatalf("派生 UUID 对不上就该分别报出: +%d -%d", len(d.Added), len(d.Removed)) + } +} + // 兜底配对要认得出「同一条但时间被改过」——时间不同就配不上,此时按新增 + 仅站内有报出, // 总比静默当成同一条好。 -func TestDiffVisitsFallbackNeedsSameTime(t *testing.T) { - b := &Backup{ +func TestDiffVisitsFallbackNeedsSameTime(t *testing.T) { b := &Backup{ Visits: []Visit{{SrcPK: 900, Arrival: tp("2026-09-01 10:00"), LocationSrcPK: ip(3)}}, Locations: []Location{{SrcPK: 3, Name: "家", Lat: 24.1, Lon: 109.1}}, VisitUUID: map[int]string{}, diff --git a/internal/rond/export.go b/internal/rond/export.go index 4382961..0fae9a5 100644 --- a/internal/rond/export.go +++ b/internal/rond/export.go @@ -1560,11 +1560,36 @@ func appleSeconds(t time.Time) float64 { // coreDataUUID 给「基线里没有、站点又没这个字段」的新行补一个确定性 UUID(见 uuidV5)。 // 基线里已有的行一律返回 nil(交给 keepNilCols 沿用原值):站点本就没有这个字段, // 不该反过来改写真机的身份标识——真机里也确实有 2 条到访这一列是空的。 +// DerivedUUID 是站点给「新建行」补的确定性 UUID(RFC 4122 v5,名字取「表名/编号」)。 +// +// 导出侧用它填 ZVISIT.ZIDENTIFIER_ / ZACTIVITY.ZUID_;它同时是**对账键**: +// 站点补录的行自己没有留档 UUID(entity_raw 里没有那一行),但导出过一次之后, +// 包里那条就是这个名字——这份包再合并回来时必须认得出来,否则同一行会被判成 +// 「备份新增」又插一条(实测数据集 9 有 43 对这样的重复:一半是补录行,一半是它的副本)。 +func DerivedUUID(table string, pk int) []byte { + return uuidV5(fmt.Sprintf("%s/%d", table, pk)) +} + +// DerivedVisitUUID 是派生 UUID 的小写十六进制形式,与 Backup.VisitUUID / StoredVisit.UUID 同形。 +func DerivedVisitUUID(pk int) string { return fmt.Sprintf("%x", DerivedUUID("ZVISIT", pk)) } + +// VisitMatchUUID 返回一条站点到访用来对账的 UUID:留档里有就用留档里的, +// 没有(站点补录的行)就用导出那一刻会补给它的派生 UUID。 +func VisitMatchUUID(sv StoredVisit) string { + if sv.UUID != "" { + return sv.UUID + } + if sv.SrcPK <= 0 { + return "" + } + return DerivedVisitUUID(sv.SrcPK) +} + func coreDataUUID(isNew bool, table string, pk int) any { if !isNew { return nil } - return uuidV5(fmt.Sprintf("%s/%d", table, pk)) + return DerivedUUID(table, pk) } // uuidNamespace 是本站补 UUID 时用的固定命名空间,取值本身无意义,只要不变即可。 diff --git a/internal/web/backup_merge.go b/internal/web/backup_merge.go index 282d4b7..b9e9cec 100644 --- a/internal/web/backup_merge.go +++ b/internal/web/backup_merge.go @@ -418,8 +418,9 @@ func (s *Server) mergeVisits(ctx context.Context, ex execer, dsID int64, b *rond visSrc := map[int]int{} storedByUUID := map[string]rond.StoredVisit{} for _, sv := range storedVisits { - if sv.UUID != "" { - storedByUUID[sv.UUID] = sv + // 站点补录的行没有留档 UUID,用它的派生 UUID 当键(与差异页同一套对齐,见 rond.VisitMatchUUID) + if u := rond.VisitMatchUUID(sv); u != "" { + storedByUUID[u] = sv } } for _, v := range b.Visits { diff --git a/internal/web/dup_visits_test.go b/internal/web/dup_visits_test.go index 9b315ab..dee3e20 100644 --- a/internal/web/dup_visits_test.go +++ b/internal/web/dup_visits_test.go @@ -605,6 +605,176 @@ func TestDupCleanSkipsStaleSelection(t *testing.T) { } } +// seedSiteVisit 造一条「站点补录」的到访:库里有行、但没有 entity_raw 留档(所以没有 UUID)。 +// +// 时间刻意比模板行晚 7 分钟:真实的补录行只存在于站点,手机包里**没有**同时刻同地点的一条 +// ——否则它会先被「站点侧副本」那条判据认走,测不到「补录行被合并回来」这条路径。 +func seedSiteVisit(t *testing.T, srv *Server, ctx context.Context, dsID int64, base dupSeedBase) int { + t.Helper() + src, err := srv.nextSrcPK(ctx, srv.db, dsID, visitSeq) + if err != nil { + t.Fatal(err) + } + if err := clearStaleRaw(ctx, srv.db, dsID, "ZVISIT", src); err != nil { + t.Fatal(err) + } + if _, err := srv.db.ExecContext(ctx, `INSERT INTO visits + (dataset_id, src_pk, place_id, activity_id, arrival, departure, duration_min, is_user_added) + SELECT dataset_id, $3, place_id, activity_id, arrival + interval '7 minutes', + departure + interval '7 minutes', duration_min, TRUE + FROM visits WHERE dataset_id=$1 AND src_pk=$2`, dsID, base.srcPK, src); err != nil { + t.Fatalf("造补录行失败: %v", err) + } + return src +} + +// seedMergedCopy 造「这条补录行被合并回来」的那一份:一行普通到访 + 留档, +// 留档里的 ZIDENTIFIER_ 正是导出时补给补录行的派生 UUID(真机上就是这么回来的)。 +// 位移与天气都挂在副本名下,清理时应当改挂到保留项而不是删掉。 +func seedMergedCopy(t *testing.T, srv *Server, ctx context.Context, dsID int64, base dupSeedBase, keeper int) int { + t.Helper() + src, err := srv.nextSrcPK(ctx, srv.db, dsID, visitSeq) + if err != nil { + t.Fatal(err) + } + if err := clearStaleRaw(ctx, srv.db, dsID, "ZVISIT", src); err != nil { + t.Fatal(err) + } + // 与补录行同一时刻同一地点(这就是「同一条的两份」),时间照补录行那份来 + if _, err := srv.db.ExecContext(ctx, `INSERT INTO visits + (dataset_id, src_pk, place_id, activity_id, arrival, departure, duration_min, is_user_added) + SELECT dataset_id, $3, place_id, activity_id, arrival + interval '7 minutes', + departure + interval '7 minutes', duration_min, is_user_added + FROM visits WHERE dataset_id=$1 AND src_pk=$2`, dsID, base.srcPK, src); err != nil { + t.Fatalf("造合并副本失败: %v", err) + } + blob := base64.StdEncoding.EncodeToString(rond.DerivedUUID("ZVISIT", keeper)) + if _, err := srv.db.ExecContext(ctx, `INSERT INTO entity_raw (dataset_id, entity, src_pk, raw) + VALUES ($1,'ZVISIT',$2, jsonb_build_object('Z_PK',$2::int, + 'ZIDENTIFIER_', jsonb_build_object('$blob', $3::text)))`, dsID, src, blob); err != nil { + t.Fatalf("造副本留档失败: %v", err) + } + for _, ends := range [][2]int{{src, base.srcPK}, {base.srcPK, src}} { + mv, err := srv.nextSrcPK(ctx, srv.db, dsID, movementSeq) + if err != nil { + t.Fatal(err) + } + if err := clearStaleRaw(ctx, srv.db, dsID, "ZMOVEMENT", mv); err != nil { + t.Fatal(err) + } + if _, err := srv.db.ExecContext(ctx, `INSERT INTO movements + (dataset_id, src_pk, from_visit_src, to_visit_src) VALUES ($1,$2,$3,$4)`, + dsID, mv, ends[0], ends[1]); err != nil { + t.Fatalf("造副本位移失败: %v", err) + } + } + w, err := srv.nextSrcPK(ctx, srv.db, dsID, weatherSeq) + if err != nil { + t.Fatal(err) + } + if err := clearStaleRaw(ctx, srv.db, dsID, "ZHOURLYWEATHER", w); err != nil { + t.Fatal(err) + } + if _, err := srv.db.ExecContext(ctx, `INSERT INTO weather (dataset_id, src_pk, visit_src, at) + VALUES ($1,$2,$3,$4)`, dsID, w, src, base.arrival); err != nil { + t.Fatalf("造副本天气失败: %v", err) + } + return src +} + +// TestDupCleanMergedBackSiteRow 站点补录的行在导出后又被合并回来时,会留下一条一模一样的副本。 +// +// 成因:补录行没有留档 UUID,导出时被补上派生 UUID 写进包里;这份包再合并回站点时, +// 合并按 UUID 对齐认不出它,就判成「备份新增」又插了一条。卡片要能认出这一对, +// 且清理时把副本名下的位移 / 天气**改挂**到保留项(那段行程真实发生过,补录行自己没有位移)。 +func TestDupCleanMergedBackSiteRow(t *testing.T) { + f := newMovementFixture(t) + ctx, pg, dsID := f.ctx, f.pg, f.dsID + srv := f.srv + if _, err := srv.ensureBaseline(ctx, dsID); err != nil { + t.Fatalf("准备基线库失败: %v", err) + } + before, unavailable, err := srv.detectDuplicateVisits(ctx, dsID) + if err != nil || unavailable != "" { + t.Fatalf("检测失败: err=%v unavailable=%s", err, unavailable) + } + + base := pickDupBase(t, srv, ctx, dsID) + keeper := seedSiteVisit(t, srv, ctx, dsID, base) // 补录行:没有留档 + copyPK := seedMergedCopy(t, srv, ctx, dsID, base, keeper) // 被合并回来的那一份 + t.Logf("补录行 #%d + 合并副本 #%d(留档 UUID = v5(ZVISIT/%d))", keeper, copyPK, keeper) + + pairs, unavailable, err := srv.detectDuplicateVisits(ctx, dsID) + if err != nil || unavailable != "" { + t.Fatalf("检测失败: err=%v unavailable=%s", err, unavailable) + } + if len(pairs) != len(before)+1 { + t.Fatalf("应检出 %d 对,实际 %d 对", len(before)+1, len(pairs)) + } + var found *EditDupVisit + for i := range pairs { + if pairs[i].Victim == copyPK { + found = &pairs[i] + } + } + if found == nil { + t.Fatalf("没认出「补录行被合并回来」这一对(副本 #%d)", copyPK) + } + if found.Keep != keeper { + t.Errorf("保留项应为补录行 #%d,实际 #%d", keeper, found.Keep) + } + if !found.MoveAssets { + t.Error("这一对必须把位移 / 天气改挂到保留项,而不是跟着副本删掉") + } + if found.Reason != dupHintMergedBack { + t.Errorf("成因标注应为 %q,实际 %q", dupHintMergedBack, found.Reason) + } + + // 清理这一对 + post(t, srv.editDuplicateVisitsApply, ctx, "/admin/edit/dup/clean", + url.Values{"victim": {fmt.Sprint(copyPK)}}) + + var n int + pg.QueryRowContext(ctx, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND src_pk=$2`, dsID, copyPK).Scan(&n) + if n != 0 { + t.Errorf("副本 #%d 没被删掉", copyPK) + } + pg.QueryRowContext(ctx, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND src_pk=$2`, dsID, keeper).Scan(&n) + if n != 1 { + t.Errorf("保留的补录行 #%d 不该被删", keeper) + } + pg.QueryRowContext(ctx, `SELECT count(*) FROM entity_raw WHERE dataset_id=$1 AND entity='ZVISIT' AND src_pk=$2`, + dsID, copyPK).Scan(&n) + if n != 0 { + t.Errorf("副本的留档没被清掉") + } + // 位移与天气要改挂到保留项(各两条 / 一条),而不是被删 + pg.QueryRowContext(ctx, `SELECT count(*) FROM movements WHERE dataset_id=$1 + AND (from_visit_src=$2 OR to_visit_src=$2)`, dsID, copyPK).Scan(&n) + if n != 0 { + t.Errorf("副本名下还挂着 %d 条位移", n) + } + pg.QueryRowContext(ctx, `SELECT count(*) FROM movements WHERE dataset_id=$1 + AND (from_visit_src=$2 OR to_visit_src=$2)`, dsID, keeper).Scan(&n) + if n != 2 { + t.Errorf("位移应改挂到补录行 #%d(2 条),实际 %d 条", keeper, n) + } + pg.QueryRowContext(ctx, `SELECT count(*) FROM weather WHERE dataset_id=$1 AND visit_src=$2`, dsID, keeper).Scan(&n) + if n != 1 { + t.Errorf("天气应改挂到补录行 #%d(1 条),实际 %d 条", keeper, n) + } + // 清完不该再报这一对 + pairs, unavailable, err = srv.detectDuplicateVisits(ctx, dsID) + if err != nil || unavailable != "" { + t.Fatalf("复核检测失败: err=%v unavailable=%s", err, unavailable) + } + for _, p := range pairs { + if p.Victim == copyPK { + t.Errorf("已清理的副本 #%d 仍被检出", copyPK) + } + } +} + // TestDupCleanKeepsMergeKeysAsIs 静态守卫:改指合并标记必须「只动本来就有的那个键」。 // // tools/dedup_site_visits.py 是把两个键都写成保留项(jsonb_set 的 create_if_missing=true)。 diff --git a/internal/web/edit_duplicates.go b/internal/web/edit_duplicates.go index 0c8469f..08a1d44 100644 --- a/internal/web/edit_duplicates.go +++ b/internal/web/edit_duplicates.go @@ -14,6 +14,8 @@ import ( "strconv" "strings" "time" + + "rond-with-you/internal/rond" ) // ---------- 重复到访:站点侧的历史副本 ---------- @@ -31,12 +33,26 @@ import ( // appleEpoch 是 Core Data 的时间原点(2001-01-01 UTC)。基线的 ZARRIVALDATE_ 就是它起算的秒数。 var appleEpoch = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) -// EditDupVisit 是「重复到访」卡片里的一对:站点侧的副本,与它该保留的那条(手机包里也有)。 +// 两类重复的成因(卡片上打个小标,站长才好判断该不该删)。 +const ( + // 站点侧副本:手机包里也有「同一时刻同一地点」的一条,但 UUID 不同 + dupSiteCopy = "站点侧副本" + // 补录行被合并回来:站点补录的行没有留档 UUID,导出时会被补一个派生 UUID, + // 这份包再合并回站点时对不上,就被当成「备份新增」又插了一条 + dupHintMergedBack = "你补录的那条被合并回来的一份" +) + +// EditDupVisit 是「重复到访」卡片里的一对:站点侧多出来的那条,与它该保留的那条。 type EditDupVisit struct { - Victim int // 站点侧副本的 src_pk —— 勾选后删的就是它 - Keep int // 保留项的 src_pk —— UUID 在基线里能找到的那条 + Victim int // 站点侧多出来的 src_pk —— 勾选后删的就是它 + Keep int // 要留下来的 src_pk Arrival time.Time PlaceName string + // Reason 是这一对的成因(卡片上打标用) + Reason string + // MoveAssets 表示删副本时把它名下的位移 / 天气**改挂**到保留项(而不是删掉): + // 「补录行的副本」属于这种——那段行程是真实发生过的,而补录行自己名下没有位移。 + MoveAssets bool } // ArrivalStr 是模板里显示的到达时间(本地时区,分钟精度)。 @@ -246,8 +262,30 @@ func (s *Server) detectDuplicateVisits(ctx context.Context, datasetID int64) ([] } var out []EditDupVisit + seen := map[int]bool{} + // 第二类:站点补录的行(留档里没有 UUID)导出过一次之后,包里那条带的是站点给它补的 + // 派生 UUID;这份包合并回站点时对不上(站点这侧本来是空的),于是又插了一条。 + // 判据:站内某一行的 UUID 恰好等于「另一条无留档行」的派生 UUID。 + soloByUUID := map[string]siteDupVisit{} for _, sv := range site { - if sv.uuid == "" || base.uuids[sv.uuid] { + if sv.uuid == "" && sv.srcPK > 0 { + soloByUUID[rond.DerivedVisitUUID(sv.srcPK)] = sv + } + } + for _, sv := range site { + if ssv, ok := soloByUUID[sv.uuid]; sv.uuid != "" && ok && !seen[sv.srcPK] { + // 派生 UUID 已经很特异了,这里再对一道时间与地点,免得编号被复用的行配到一起 + if ssv.place != sv.place || dupKey(appleSeconds(ssv.arrival), ssv.place) != dupKey(appleSeconds(sv.arrival), sv.place) { + continue + } + out = append(out, EditDupVisit{Victim: sv.srcPK, Keep: ssv.srcPK, Arrival: sv.arrival, + PlaceName: sv.place, Reason: dupHintMergedBack, MoveAssets: true}) + seen[sv.srcPK] = true + } + } + // 第一类:站点的 UUID 在基线里找不到、而基线里有一条「同时刻同地点」的——站点侧副本 + for _, sv := range site { + if sv.uuid == "" || base.uuids[sv.uuid] || seen[sv.srcPK] { continue } secs := appleSeconds(sv.arrival) @@ -267,8 +305,11 @@ func (s *Server) detectDuplicateVisits(ctx context.Context, datasetID int64) ([] if !ok { continue } - out = append(out, EditDupVisit{Victim: sv.srcPK, Keep: keep, Arrival: sv.arrival, PlaceName: sv.place}) + out = append(out, EditDupVisit{Victim: sv.srcPK, Keep: keep, Arrival: sv.arrival, + PlaceName: sv.place, Reason: dupSiteCopy}) + seen[sv.srcPK] = true } + sort.SliceStable(out, func(i, j int) bool { return out[i].Arrival.After(out[j].Arrival) }) return out, "", nil } @@ -331,8 +372,12 @@ func (s *Server) editDuplicateVisitsApply(w http.ResponseWriter, r *http.Request // 导出到 rond 里就是坏数据(到访那条 ZMERGEDTO 指向一个不存在的 Z_PK)。 func (s *Server) cleanDuplicateVisits(ctx context.Context, ex execer, datasetID int64, pairs []EditDupVisit) error { victims := make([]int64, 0, len(pairs)) + var repoint []EditDupVisit for _, p := range pairs { victims = append(victims, int64(p.Victim)) + if p.MoveAssets { + repoint = append(repoint, p) + } } // 聚合要按地点重算,先把副本所属地点收下来(删完就在 visits 里查不到了) placeIDs, err := s.dupVictimPlaces(ctx, ex, datasetID, victims) @@ -357,6 +402,22 @@ func (s *Server) cleanDuplicateVisits(ctx context.Context, ex execer, datasetID } } } + // 「补录行的副本」先把它名下的位移 / 天气改挂到保留项:那段行程真实发生过, + // 而补录行自己名下没有位移,跟着副本一起删就是把里程白丢了。 + for _, p := range repoint { + if _, err := ex.ExecContext(ctx, `UPDATE movements SET from_visit_src=$3 + WHERE dataset_id=$1 AND from_visit_src=$2`, datasetID, p.Victim, p.Keep); err != nil { + return err + } + if _, err := ex.ExecContext(ctx, `UPDATE movements SET to_visit_src=$3 + WHERE dataset_id=$1 AND to_visit_src=$2`, datasetID, p.Victim, p.Keep); err != nil { + return err + } + if _, err := ex.ExecContext(ctx, `UPDATE weather SET visit_src=$3 + WHERE dataset_id=$1 AND visit_src=$2`, datasetID, p.Victim, p.Keep); err != nil { + return err + } + } // 副本的附属记录:位移(两端任一)、天气。到访本身删掉后,它自己的留档与标签关联 // 分别由 pruneEntityRaw 与 visit_tags 的外键级联清掉。 if _, err := ex.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1 diff --git a/internal/web/templates/admin/edit.html b/internal/web/templates/admin/edit.html index 4911b39..bf7a2db 100644 --- a/internal/web/templates/admin/edit.html +++ b/internal/web/templates/admin/edit.html @@ -358,19 +358,22 @@