修「补录行被合并回来」的重复:对账时认它的派生 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
+66 -5
View File
@@ -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