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