修「补录行被合并回来」的重复:对账时认它的派生 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:
@@ -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)。
|
||||
|
||||
Reference in New Issue
Block a user