修「补录行被合并回来」的重复:对账时认它的派生 UUID

成因链:站点补录的到访没有留档行(entity_raw 里没有它),也就没有 UUID;导出时会被补上
一个确定性 UUID v5("ZVISIT/<src_pk>") 写进包里,手机于是把它当成一条真记录;这份包再
合并回站点时,站点这侧没有 UUID 可对齐,DiffVisits 直接判成「备份新增」又插一条 ——
时间线上出现两条一模一样的到访(实测数据集 9 有 43 对,生产时间线一屏就有 53 对)。

- rond:把派生 UUID 抽成 DerivedUUID / DerivedVisitUUID / VisitMatchUUID(导出侧 coreDataUUID
  复用同一个函数),差异与合并都拿它当对账键(diff.go 的 byUUID、backup_merge 的 storedByUUID)。
- 「时间 + 地点」兜底配对补 used 检查:同一条站点行现在两个索引里都有,不查会被 classify 两次。
- 数据编辑页的「重复到访」卡片认得这类(成因标「你补录的那条被合并回来的一份」),
  清理时把副本名下的位移 / 天气**改挂**到保留项——补录行自己名下没有位移,跟着删就白丢里程;
  派生 UUID 判定排在「站点侧副本」之前(证据更强)。

用例:rond 两条(正例 + 派生 UUID 不相配的反例)、web 一条端到端(造补录行与副本 → 检出 →
清理 → 断言位移与天气改挂、留档被清)。变异验证:去掉改挂语义、关掉该判定,两条都立刻红。
This commit is contained in:
tamakyi
2026-09-30 15:02:45 +08:00
parent 881f2933ff
commit 8dec973db1
7 changed files with 329 additions and 21 deletions
+170
View File
@@ -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)。