修「补录行被合并回来」的重复:对账时认它的派生 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:
+14
-6
@@ -169,11 +169,12 @@ func DiffVisits(stored []StoredVisit, b *Backup) VisitDiff {
|
|||||||
byUUID := make(map[string]int, len(stored))
|
byUUID := make(map[string]int, len(stored))
|
||||||
byTime := map[string][]int{}
|
byTime := map[string][]int{}
|
||||||
for i, sv := range stored {
|
for i, sv := range stored {
|
||||||
if sv.UUID != "" {
|
// 站点补录的行没有留档 UUID,但它导出过一次之后就带着派生 UUID 在包里
|
||||||
byUUID[sv.UUID] = i
|
// (见 VisitMatchUUID)——不拿它当键,同一行会被判成「备份新增」再插一条。
|
||||||
continue
|
if u := VisitMatchUUID(sv); u != "" {
|
||||||
|
byUUID[u] = i
|
||||||
}
|
}
|
||||||
if sv.Arrival != nil {
|
if sv.UUID == "" && sv.Arrival != nil {
|
||||||
k := timeKey(*sv.Arrival, sv.PlaceKey)
|
k := timeKey(*sv.Arrival, sv.PlaceKey)
|
||||||
byTime[k] = append(byTime[k], i)
|
byTime[k] = append(byTime[k], i)
|
||||||
}
|
}
|
||||||
@@ -206,8 +207,15 @@ func DiffVisits(stored []StoredVisit, b *Backup) VisitDiff {
|
|||||||
if v.LocationSrcPK != nil {
|
if v.LocationSrcPK != nil {
|
||||||
bpk = placeKey[*v.LocationSrcPK]
|
bpk = placeKey[*v.LocationSrcPK]
|
||||||
}
|
}
|
||||||
if list := byTime[timeKey(*v.Arrival, bpk)]; len(list) > 0 {
|
// 照旧按「时间 + 地点」兜底,但要跳过已经被 UUID 那一路认领的行——
|
||||||
byTime[timeKey(*v.Arrival, bpk)] = list[1:]
|
// 同一条站点行可能两个索引里都有,重复 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)
|
classify(list[0], v)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestDiffVisitsFallbackNeedsSameTime(t *testing.T) { b := &Backup{
|
||||||
b := &Backup{
|
|
||||||
Visits: []Visit{{SrcPK: 900, Arrival: tp("2026-09-01 10:00"), LocationSrcPK: ip(3)}},
|
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}},
|
Locations: []Location{{SrcPK: 3, Name: "家", Lat: 24.1, Lon: 109.1}},
|
||||||
VisitUUID: map[int]string{},
|
VisitUUID: map[int]string{},
|
||||||
|
|||||||
+26
-1
@@ -1560,11 +1560,36 @@ func appleSeconds(t time.Time) float64 {
|
|||||||
// coreDataUUID 给「基线里没有、站点又没这个字段」的新行补一个确定性 UUID(见 uuidV5)。
|
// coreDataUUID 给「基线里没有、站点又没这个字段」的新行补一个确定性 UUID(见 uuidV5)。
|
||||||
// 基线里已有的行一律返回 nil(交给 keepNilCols 沿用原值):站点本就没有这个字段,
|
// 基线里已有的行一律返回 nil(交给 keepNilCols 沿用原值):站点本就没有这个字段,
|
||||||
// 不该反过来改写真机的身份标识——真机里也确实有 2 条到访这一列是空的。
|
// 不该反过来改写真机的身份标识——真机里也确实有 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 {
|
func coreDataUUID(isNew bool, table string, pk int) any {
|
||||||
if !isNew {
|
if !isNew {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return uuidV5(fmt.Sprintf("%s/%d", table, pk))
|
return DerivedUUID(table, pk)
|
||||||
}
|
}
|
||||||
|
|
||||||
// uuidNamespace 是本站补 UUID 时用的固定命名空间,取值本身无意义,只要不变即可。
|
// uuidNamespace 是本站补 UUID 时用的固定命名空间,取值本身无意义,只要不变即可。
|
||||||
|
|||||||
@@ -418,8 +418,9 @@ func (s *Server) mergeVisits(ctx context.Context, ex execer, dsID int64, b *rond
|
|||||||
visSrc := map[int]int{}
|
visSrc := map[int]int{}
|
||||||
storedByUUID := map[string]rond.StoredVisit{}
|
storedByUUID := map[string]rond.StoredVisit{}
|
||||||
for _, sv := range storedVisits {
|
for _, sv := range storedVisits {
|
||||||
if sv.UUID != "" {
|
// 站点补录的行没有留档 UUID,用它的派生 UUID 当键(与差异页同一套对齐,见 rond.VisitMatchUUID)
|
||||||
storedByUUID[sv.UUID] = sv
|
if u := rond.VisitMatchUUID(sv); u != "" {
|
||||||
|
storedByUUID[u] = sv
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, v := range b.Visits {
|
for _, v := range b.Visits {
|
||||||
|
|||||||
@@ -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 静态守卫:改指合并标记必须「只动本来就有的那个键」。
|
// TestDupCleanKeepsMergeKeysAsIs 静态守卫:改指合并标记必须「只动本来就有的那个键」。
|
||||||
//
|
//
|
||||||
// tools/dedup_site_visits.py 是把两个键都写成保留项(jsonb_set 的 create_if_missing=true)。
|
// tools/dedup_site_visits.py 是把两个键都写成保留项(jsonb_set 的 create_if_missing=true)。
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"rond-with-you/internal/rond"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------- 重复到访:站点侧的历史副本 ----------
|
// ---------- 重复到访:站点侧的历史副本 ----------
|
||||||
@@ -31,12 +33,26 @@ import (
|
|||||||
// appleEpoch 是 Core Data 的时间原点(2001-01-01 UTC)。基线的 ZARRIVALDATE_ 就是它起算的秒数。
|
// appleEpoch 是 Core Data 的时间原点(2001-01-01 UTC)。基线的 ZARRIVALDATE_ 就是它起算的秒数。
|
||||||
var appleEpoch = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
|
var appleEpoch = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
// EditDupVisit 是「重复到访」卡片里的一对:站点侧的副本,与它该保留的那条(手机包里也有)。
|
// 两类重复的成因(卡片上打个小标,站长才好判断该不该删)。
|
||||||
|
const (
|
||||||
|
// 站点侧副本:手机包里也有「同一时刻同一地点」的一条,但 UUID 不同
|
||||||
|
dupSiteCopy = "站点侧副本"
|
||||||
|
// 补录行被合并回来:站点补录的行没有留档 UUID,导出时会被补一个派生 UUID,
|
||||||
|
// 这份包再合并回站点时对不上,就被当成「备份新增」又插了一条
|
||||||
|
dupHintMergedBack = "你补录的那条被合并回来的一份"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EditDupVisit 是「重复到访」卡片里的一对:站点侧多出来的那条,与它该保留的那条。
|
||||||
type EditDupVisit struct {
|
type EditDupVisit struct {
|
||||||
Victim int // 站点侧副本的 src_pk —— 勾选后删的就是它
|
Victim int // 站点侧多出来的 src_pk —— 勾选后删的就是它
|
||||||
Keep int // 保留项的 src_pk —— UUID 在基线里能找到的那条
|
Keep int // 要留下来的 src_pk
|
||||||
Arrival time.Time
|
Arrival time.Time
|
||||||
PlaceName string
|
PlaceName string
|
||||||
|
// Reason 是这一对的成因(卡片上打标用)
|
||||||
|
Reason string
|
||||||
|
// MoveAssets 表示删副本时把它名下的位移 / 天气**改挂**到保留项(而不是删掉):
|
||||||
|
// 「补录行的副本」属于这种——那段行程是真实发生过的,而补录行自己名下没有位移。
|
||||||
|
MoveAssets bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArrivalStr 是模板里显示的到达时间(本地时区,分钟精度)。
|
// ArrivalStr 是模板里显示的到达时间(本地时区,分钟精度)。
|
||||||
@@ -246,8 +262,30 @@ func (s *Server) detectDuplicateVisits(ctx context.Context, datasetID int64) ([]
|
|||||||
}
|
}
|
||||||
|
|
||||||
var out []EditDupVisit
|
var out []EditDupVisit
|
||||||
|
seen := map[int]bool{}
|
||||||
|
// 第二类:站点补录的行(留档里没有 UUID)导出过一次之后,包里那条带的是站点给它补的
|
||||||
|
// 派生 UUID;这份包合并回站点时对不上(站点这侧本来是空的),于是又插了一条。
|
||||||
|
// 判据:站内某一行的 UUID 恰好等于「另一条无留档行」的派生 UUID。
|
||||||
|
soloByUUID := map[string]siteDupVisit{}
|
||||||
for _, sv := range site {
|
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
|
continue
|
||||||
}
|
}
|
||||||
secs := appleSeconds(sv.arrival)
|
secs := appleSeconds(sv.arrival)
|
||||||
@@ -267,8 +305,11 @@ func (s *Server) detectDuplicateVisits(ctx context.Context, datasetID int64) ([]
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
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) })
|
sort.SliceStable(out, func(i, j int) bool { return out[i].Arrival.After(out[j].Arrival) })
|
||||||
return out, "", nil
|
return out, "", nil
|
||||||
}
|
}
|
||||||
@@ -331,8 +372,12 @@ func (s *Server) editDuplicateVisitsApply(w http.ResponseWriter, r *http.Request
|
|||||||
// 导出到 rond 里就是坏数据(到访那条 ZMERGEDTO 指向一个不存在的 Z_PK)。
|
// 导出到 rond 里就是坏数据(到访那条 ZMERGEDTO 指向一个不存在的 Z_PK)。
|
||||||
func (s *Server) cleanDuplicateVisits(ctx context.Context, ex execer, datasetID int64, pairs []EditDupVisit) error {
|
func (s *Server) cleanDuplicateVisits(ctx context.Context, ex execer, datasetID int64, pairs []EditDupVisit) error {
|
||||||
victims := make([]int64, 0, len(pairs))
|
victims := make([]int64, 0, len(pairs))
|
||||||
|
var repoint []EditDupVisit
|
||||||
for _, p := range pairs {
|
for _, p := range pairs {
|
||||||
victims = append(victims, int64(p.Victim))
|
victims = append(victims, int64(p.Victim))
|
||||||
|
if p.MoveAssets {
|
||||||
|
repoint = append(repoint, p)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 聚合要按地点重算,先把副本所属地点收下来(删完就在 visits 里查不到了)
|
// 聚合要按地点重算,先把副本所属地点收下来(删完就在 visits 里查不到了)
|
||||||
placeIDs, err := s.dupVictimPlaces(ctx, ex, datasetID, victims)
|
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 的外键级联清掉。
|
// 分别由 pruneEntityRaw 与 visit_tags 的外键级联清掉。
|
||||||
if _, err := ex.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1
|
if _, err := ex.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1
|
||||||
|
|||||||
@@ -358,19 +358,22 @@
|
|||||||
<div class="card" data-card="dup-visits">
|
<div class="card" data-card="dup-visits">
|
||||||
<h2>重复到访 {{num (len .DupVisits)}} 对</h2>
|
<h2>重复到访 {{num (len .DupVisits)}} 对</h2>
|
||||||
<div class="card-sub">
|
<div class="card-sub">
|
||||||
这些到访跟手机包里的某条<b>到达时间 + 地点完全相同、但 UUID 不同</b>,是站点侧的历史副本,
|
同一刻同一地点存了两条。成因有两种:<b>站点侧副本</b>(手机包里也有这条,但 UUID 不同——合并 / 差异按 UUID 认人,
|
||||||
导出到 rond 后会变成成对的两条。默认全选:删掉勾选的副本会连它的位移 / 天气 / 留档一起清,
|
把它当成了「备份新增」)与<b>补录行被合并回来</b>(补录的行没有 UUID,导出时会被补一个派生 UUID,
|
||||||
并把指向它的合并标记改指到保留项(不动就会在导出时留下悬空引用)。
|
这份包再合并回站点时对不上,于是又插了一条)。默认全选:删掉勾选的副本会连它的位移 / 天气 / 留档一起清
|
||||||
|
(「补录行被合并回来」那类会把位移 / 天气<b>改挂</b>到保留项,不白丢里程),
|
||||||
|
并把指向它的合并标记改指到保留项。导出到 rond 后会变成成对的两条,清完就没了。
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/admin/edit/dup/clean">
|
<form method="post" action="/admin/edit/dup/clean">
|
||||||
<div class="tw">
|
<div class="tw">
|
||||||
<table class="tb tb-stack">
|
<table class="tb tb-stack">
|
||||||
<thead><tr><th>到达时间</th><th>地点</th><th>保留(手机来源)</th><th>删除(站点副本)</th></tr></thead>
|
<thead><tr><th>到达时间</th><th>地点</th><th>保留</th><th>删除(多出来的那条)</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .DupVisits}}
|
{{range .DupVisits}}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="muted mono" data-label="到达时间">{{.ArrivalStr}}</td>
|
<td class="muted mono" data-label="到达时间">{{.ArrivalStr}}</td>
|
||||||
<td data-label="地点">{{if .PlaceName}}{{.PlaceName}}{{else}}<span class="muted">(未命名地点)</span>{{end}}</td>
|
<td data-label="地点">{{if .PlaceName}}{{.PlaceName}}{{else}}<span class="muted">(未命名地点)</span>{{end}}
|
||||||
|
{{if .Reason}}<span class="tag" title="这一对是怎么重复的">{{.Reason}}</span>{{end}}</td>
|
||||||
<td class="mono" data-label="保留">#{{.Keep}}</td>
|
<td class="mono" data-label="保留">#{{.Keep}}</td>
|
||||||
<td data-label="删除">
|
<td data-label="删除">
|
||||||
<label class="check"><input type="checkbox" name="victim" value="{{.Victim}}" checked>
|
<label class="check"><input type="checkbox" name="victim" value="{{.Victim}}" checked>
|
||||||
|
|||||||
Reference in New Issue
Block a user