修「补录行被合并回来」的重复:对账时认它的派生 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
+14 -6
View File
@@ -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
} }
+42 -2
View File
@@ -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
View File
@@ -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 时用的固定命名空间,取值本身无意义,只要不变即可。
+3 -2
View File
@@ -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 {
+170
View File
@@ -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)。
+66 -5
View File
@@ -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
+8 -5
View File
@@ -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>