修「补录行被合并回来」的重复:对账时认它的派生 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))
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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{},
|
||||
|
||||
+26
-1
@@ -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 时用的固定命名空间,取值本身无意义,只要不变即可。
|
||||
|
||||
Reference in New Issue
Block a user