From 152af81f6a1155bd79c1f5f078a82105fc34beb1 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Tue, 29 Sep 2026 19:17:29 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BC=96=E8=BE=91=E9=A1=B5=EF=BC=9A=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E3=80=8C=E9=87=8D=E5=A4=8D=E5=88=B0=E8=AE=BF=E3=80=8D?= =?UTF-8?q?=E5=8D=A1=E7=89=87=20=E2=80=94=E2=80=94=20=E4=BB=A5=E5=9F=BA?= =?UTF-8?q?=E7=BA=BF=EF=BC=88=E6=89=8B=E6=9C=BA=E5=8C=85=EF=BC=89=E4=B8=BA?= =?UTF-8?q?=E5=8F=82=E7=85=A7=E6=B8=85=E6=8E=89=E7=AB=99=E7=82=B9=E4=BE=A7?= =?UTF-8?q?=E7=9A=84=E5=8E=86=E5=8F=B2=E5=89=AF=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 站点库里有一批「到达时间 + 地点与手机包里某条完全相同、但 UUID 不同」的到访副本, 合并/差异按 UUID 认人会把它们当成备份新增,导出到 rond 就成了成对的两条重复记录。 判定与 tools/dedup_site_visits.py 同一套语义:UUID 是前提(只按时间+地点判会误删手机包 自身合法的成对条目);基线跟本站一条都对不上时只给说明、不给按钮。清理在单事务里删 副本 + 其位移/天气/留档,并把指向副本的 ZMERGEDTO/ZPARENT 改指到保留项——只动本来 就有的那个键,工具那版会凭空补出一个 ZMERGEDTO(真机里是「已并入别处」,会白丢记录)。 顺带修一个「一补录天气就整包导出失败」的洞:rond 导出把 weather.is_daylight 扫进裸 bool, 而站点「新增天气」不写这一列 ⇒ NULL ⇒ sql.Scan 报错。改成 COALESCE(is_daylight,false), 与导入侧读真机包同口径。 验收:301 PASS / 0 FAIL / 0 SKIP(新增 4 个用例,含变异验证:去掉 UUID 前提/改回 create_if_missing=true/不删副本位移,三种改法都被用例抓到)。 --- internal/rond/export.go | 5 +- internal/web/dup_visits_test.go | 633 +++++++++++++++++++++++++ internal/web/edit_duplicates.go | 401 ++++++++++++++++ internal/web/handlers_edit.go | 7 + internal/web/server.go | 2 + internal/web/templates/admin/edit.html | 45 ++ internal/web/view.go | 5 + 7 files changed, 1097 insertions(+), 1 deletion(-) create mode 100644 internal/web/dup_visits_test.go create mode 100644 internal/web/edit_duplicates.go diff --git a/internal/rond/export.go b/internal/rond/export.go index 97811ec..4382961 100644 --- a/internal/rond/export.go +++ b/internal/rond/export.go @@ -1322,7 +1322,10 @@ type pgWeather struct { } func queryWeather(ctx context.Context, db *sql.DB, datasetID int64) ([]pgWeather, error) { - rows, err := db.QueryContext(ctx, `SELECT src_pk, visit_src, at, is_daylight, temperature_c, apparent_c, humidity, + // is_daylight 必须兜底成 false:真机 557/557 都有值,但站点「补录天气」建的行 + // 没填昼夜标记(editWeatherCreate 不写这一列),而扫进 bool 遇到 NULL 会让**整包导出失败**。 + // 取 0 与导入侧同口径(parse.go 读真机包用的就是 COALESCE(ZISDAYLIGHT,0))。 + rows, err := db.QueryContext(ctx, `SELECT src_pk, visit_src, at, COALESCE(is_daylight,false), temperature_c, apparent_c, humidity, precipitation_amount, precipitation_chance, wind_speed, visibility, uv_index, COALESCE(condition,''), COALESCE(symbol,''), COALESCE(uv_category,''), COALESCE(wind_direction,'') FROM weather WHERE dataset_id=$1 ORDER BY id`, datasetID) diff --git a/internal/web/dup_visits_test.go b/internal/web/dup_visits_test.go new file mode 100644 index 0000000..9b315ab --- /dev/null +++ b/internal/web/dup_visits_test.go @@ -0,0 +1,633 @@ +package web + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/base64" + "fmt" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "rond-with-you/internal/rond" +) + +// 重复到访(站点侧副本)的判定与清理。夹具是「刚导入、还没被改过」的真实数据集, +// 基线就是它当初那份手机包——判定逻辑必须在这种真数据形状上跑,不能只喂人造的小库。 + +// dupBlob 是一条到访的 UUID(既要有 hex 用来比,也要有 base64 用来写留档)。 +type dupBlob struct { + hex, b64 string +} + +// newDupBlob 造一个基线里绝不会有的 UUID——副本的特征就是「UUID 在基线里找不到」。 +func newDupBlob(t *testing.T) dupBlob { + t.Helper() + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + t.Fatal(err) + } + return dupBlob{hex: fmt.Sprintf("%x", b), b64: base64.StdEncoding.EncodeToString(b[:])} +} + +// dupSeedBase 是被拿来当「原件」的那条到访。 +type dupSeedBase struct { + srcPK int + arrival time.Time +} + +// pickDupBase 挑一条「UUID 在手机包里也有」的到访当原件。skip 里的编号不选 +// (同一份数据里要造两对时,别把上一对的双方再拿去当原件)。 +func pickDupBase(t *testing.T, srv *Server, ctx context.Context, dsID int64, skip ...int) dupSeedBase { + t.Helper() + base, err := readDupBaseline(ctx, srv.baselinePath(dsID)) + if err != nil { + t.Fatalf("读基线库失败: %v", err) + } + site, err := srv.loadDupSiteVisits(ctx, dsID) + if err != nil { + t.Fatalf("读站点到访失败: %v", err) + } + used := map[int]bool{} + for _, s := range skip { + used[s] = true + } + for _, sv := range site { + if sv.uuid == "" || !base.uuids[sv.uuid] || used[sv.srcPK] { + continue + } + var placeID int64 + if err := srv.db.QueryRowContext(ctx, `SELECT COALESCE(place_id,0) + FROM visits WHERE dataset_id=$1 AND src_pk=$2`, dsID, sv.srcPK).Scan(&placeID); err != nil { + t.Fatal(err) + } + if placeID > 0 { + return dupSeedBase{srcPK: sv.srcPK, arrival: sv.arrival} + } + } + t.Fatal("数据集里挑不出可当原件的到访(UUID 得在基线里)") + return dupSeedBase{} +} + +// seedDupVictim 复制一条到访造出「站点副本」,并给它配上位移 / 天气 / 留档。 +// 返回副本的 src_pk 与 UUID。 +func seedDupVictim(t *testing.T, srv *Server, ctx context.Context, dsID int64, + base dupSeedBase, keeper int) (int, dupBlob) { + t.Helper() + pg := srv.db + src, err := srv.nextSrcPK(ctx, pg, dsID, visitSeq) + if err != nil { + t.Fatalf("分配副本编号失败: %v", err) + } + if err := clearStaleRaw(ctx, pg, dsID, "ZVISIT", src); err != nil { + t.Fatal(err) + } + // 到访行照抄原件:现实里的副本就是合并时多出来的那一份拷贝,字段一模一样 + // (所以才会「时间 + 地点完全相同」,光看时间是分不出来的) + if _, err := pg.ExecContext(ctx, `INSERT INTO visits + (dataset_id, src_pk, place_id, activity_id, arrival, departure, duration_min, is_home, is_work, + bookmarked, is_user_added, remark, emoji, weather_symbol) + SELECT dataset_id, $3, place_id, activity_id, arrival, departure, duration_min, is_home, is_work, + bookmarked, is_user_added, remark, emoji, weather_symbol + FROM visits WHERE dataset_id=$1 AND src_pk=$2`, dsID, base.srcPK, src); err != nil { + t.Fatalf("造副本到访失败: %v", err) + } + blob := newDupBlob(t) + // 留档也照抄原件那一行,只改主键与 UUID,合并标记清成 null(副本不该自称已并入别处)。 + // 这样导出时这一行的列形状和原件一致,往返对比才有意义。 + if _, err := pg.ExecContext(ctx, `INSERT INTO entity_raw (dataset_id, entity, src_pk, raw) + SELECT dataset_id, entity, $3, + jsonb_set(jsonb_set(jsonb_set(jsonb_set(raw, + '{Z_PK}', to_jsonb($3::int), true), + '{ZIDENTIFIER_}', jsonb_build_object('$blob', $4::text), true), + '{ZMERGEDTO}', 'null'::jsonb, true), + '{ZPARENT}', 'null'::jsonb, true) + FROM entity_raw WHERE dataset_id=$1 AND entity=$2 AND src_pk=$5`, + dsID, "ZVISIT", src, blob.b64, base.srcPK); err != nil { + t.Fatalf("造副本留档失败: %v", err) + } + // 附属记录:两端各挂一条位移 + 一条天气。清理时必须连着一起走, + // 否则导出包里会留下指着不存在到访的悬空引用。 + for _, ends := range [][2]int{{src, keeper}, {keeper, src}} { + mv, err := srv.nextSrcPK(ctx, pg, dsID, movementSeq) + if err != nil { + t.Fatal(err) + } + if err := clearStaleRaw(ctx, pg, dsID, "ZMOVEMENT", mv); err != nil { + t.Fatal(err) + } + if _, err := pg.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, pg, dsID, weatherSeq) + if err != nil { + t.Fatal(err) + } + if err := clearStaleRaw(ctx, pg, dsID, "ZHOURLYWEATHER", w); err != nil { + t.Fatal(err) + } + if _, err := pg.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, blob +} + +// pickReferrers 挑两条跟副本无关、两个合并标记都是空的到访留档。 +// 两条必须不同,否则「只带其中一个键」的两种情形就分不出来了。 +func pickReferrers(t *testing.T, srv *Server, ctx context.Context, dsID int64, exclude ...int64) (int, int) { + t.Helper() + rows, err := srv.db.QueryContext(ctx, `SELECT src_pk FROM entity_raw + WHERE dataset_id=$1 AND entity='ZVISIT' AND NOT (src_pk = ANY($2::bigint[])) + AND raw->>'ZMERGEDTO' IS NULL AND raw->>'ZPARENT' IS NULL + ORDER BY src_pk LIMIT 2`, dsID, exclude) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var out []int + for rows.Next() { + var pk int + if err := rows.Scan(&pk); err != nil { + t.Fatal(err) + } + out = append(out, pk) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if len(out) < 2 { + t.Fatalf("挑不出两条指向副本的引用行(找到 %d 条)", len(out)) + } + return out[0], out[1] +} + +// pointRef 把某条到访留档里的一个合并标记指到 target(另一个保持 null), +// 模拟「别的到访的留档指着副本」这种要改指的情形。 +func pointRef(t *testing.T, srv *Server, ctx context.Context, dsID int64, referrer int, col string, target int) { + t.Helper() + other := "ZPARENT" + if col == "ZPARENT" { + other = "ZMERGEDTO" + } + if _, err := srv.db.ExecContext(ctx, `UPDATE entity_raw + SET raw = jsonb_set(jsonb_set(raw, ARRAY[$4::text], to_jsonb($3::int), true), + ARRAY[$5::text], 'null'::jsonb, true) + WHERE dataset_id=$1 AND entity='ZVISIT' AND src_pk=$2`, + dsID, referrer, target, col, other); err != nil { + t.Fatalf("设置合并标记失败: %v", err) + } +} + +// readRef 读某条到访留档里的一个合并标记。 +func readRef(t *testing.T, srv *Server, ctx context.Context, dsID int64, srcPK int, col string) sql.NullString { + t.Helper() + var v sql.NullString + if err := srv.db.QueryRowContext(ctx, `SELECT raw->>$3 FROM entity_raw + WHERE dataset_id=$1 AND entity='ZVISIT' AND src_pk=$2`, dsID, srcPK, col).Scan(&v); err != nil { + t.Fatalf("读合并标记失败: %v", err) + } + return v +} + +// openExport 把导出的字节落到临时文件并打开,返回可直接查询的库。 +// 一份包只开一次:每次打开都要写盘 + 解压,动辄几秒。 +func openExport(t *testing.T, blob []byte) *sql.DB { + t.Helper() + out := filepath.Join(t.TempDir(), "export.rondbackup") + if err := os.WriteFile(out, blob, 0o644); err != nil { + t.Fatal(err) + } + edb, err := rond.Open(out, t.TempDir()) + if err != nil { + t.Fatalf("打开导出包失败: %v", err) + } + t.Cleanup(func() { edb.Close() }) + return edb +} + +// countExportTwin 数导出包里「跟某条到访同地点、同到达时间」的到访有几条—— +// 这正是用户看到的那对重复(清理前 2、清理后 1)。 +// +// 只能按「时间 + 地点」数,不能按 UUID:站点侧的行(副本就是)在导出时会被补上本站 +// 派生的确定性 UUID(coreDataUUID),留档里那个 UUID 本来就不会写进包里。 +func countExportTwin(t *testing.T, edb *sql.DB, keeperHex string, arrival time.Time) int { + t.Helper() + var n int + if err := edb.QueryRow(`SELECT count(*) FROM ZVISIT + WHERE ZLOCATION = (SELECT ZLOCATION FROM ZVISIT WHERE hex(ZIDENTIFIER_)=?) + AND abs(ZARRIVALDATE_ - ?) < 0.5`, strings.ToUpper(keeperHex), appleSec(arrival)).Scan(&n); err != nil { + t.Fatalf("查导出包失败: %v", err) + } + return n +} + +// countExportAll 数导出包里的到访总条数。 +func countExportAll(t *testing.T, edb *sql.DB) int { + t.Helper() + var n int + if err := edb.QueryRow(`SELECT count(*) FROM ZVISIT`).Scan(&n); err != nil { + t.Fatalf("查导出包失败: %v", err) + } + return n +} + +// visitUUIDHex 读某条到访留档里的 UUID(小写 hex)。 +func visitUUIDHex(t *testing.T, srv *Server, ctx context.Context, dsID int64, srcPK int) string { + t.Helper() + var b64 string + if err := srv.db.QueryRowContext(ctx, `SELECT COALESCE(raw #>> '{ZIDENTIFIER_,$blob}','') + FROM entity_raw WHERE dataset_id=$1 AND entity='ZVISIT' AND src_pk=$2`, dsID, srcPK).Scan(&b64); err != nil { + t.Fatal(err) + } + return blobHex(b64) +} + +// countDangling 数站点里所有悬空引用:留档指向不存在的到访 / 位移指向不存在的到访。 +func countDangling(t *testing.T, srv *Server, ctx context.Context, dsID int64) (int, int) { + t.Helper() + var raw, mv int + if err := srv.db.QueryRowContext(ctx, `SELECT + (SELECT count(*) FROM entity_raw r WHERE r.dataset_id=$1 AND r.entity='ZVISIT' AND NOT r.skipped + AND NOT EXISTS (SELECT 1 FROM visits v WHERE v.dataset_id=r.dataset_id AND v.src_pk=r.src_pk)), + (SELECT count(*) FROM movements m WHERE m.dataset_id=$1 + AND ((m.from_visit_src IS NOT NULL AND NOT EXISTS (SELECT 1 FROM visits v WHERE v.dataset_id=m.dataset_id AND v.src_pk=m.from_visit_src)) + OR (m.to_visit_src IS NOT NULL AND NOT EXISTS (SELECT 1 FROM visits v WHERE v.dataset_id=m.dataset_id AND v.src_pk=m.to_visit_src))))`, + dsID).Scan(&raw, &mv); err != nil { + t.Fatal(err) + } + return raw, mv +} + +// TestDuplicateVisitsCleanE2E 端到端验证「检出重复到访 → 勾选清理 → 导出不再重复」这条链路。 +// +// 造两对副本(各带位移 / 天气 / 留档),只提交其中一对,然后逐项核对: +// 勾掉的那条连附属记录一起没了、没勾的纹丝不动、指向副本的合并标记改指到保留项、 +// 没有任何悬空引用,并且导出包里那一处重复真的从 2 条变回 1 条。 +func TestDuplicateVisitsCleanE2E(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) + } + + // 起点:夹具是「刚导入的包」,每条到访的 UUID 都来自这个包,站点侧副本必然是 0 对 + before, unavailable, err := srv.detectDuplicateVisits(ctx, dsID) + if err != nil { + t.Fatalf("检测失败: %v", err) + } + if unavailable != "" { + t.Fatalf("夹具的基线判定不了重复(%s),用例前提不成立", unavailable) + } + if len(before) != 0 { + t.Fatalf("刚导入的包不该检出站点副本,实际 %d 对——判定的前提被改坏了", len(before)) + } + base, err := readDupBaseline(ctx, srv.baselinePath(dsID)) + if err != nil { + t.Fatalf("读基线库失败: %v", err) + } + + var startVisits int + if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM visits WHERE dataset_id=$1`, dsID).Scan(&startVisits); err != nil { + t.Fatal(err) + } + dangRaw0, dangMv0 := countDangling(t, srv, ctx, dsID) + + // 造两对:keeper1/victim1、keeper2/victim2 + keeper1 := pickDupBase(t, srv, ctx, dsID) + victim1, blob1 := seedDupVictim(t, srv, ctx, dsID, keeper1, keeper1.srcPK) + keeper2 := pickDupBase(t, srv, ctx, dsID, keeper1.srcPK, victim1) + victim2, _ := seedDupVictim(t, srv, ctx, dsID, keeper2, keeper2.srcPK) + t.Logf("造副本:原件 #%d → 副本 #%d(UUID %s…);原件 #%d → 副本 #%d", + keeper1.srcPK, victim1, blob1.hex[:8], keeper2.srcPK, victim2) + + // 复制品是「同地点同时间、UUID 不同」,判定必须刚好揪出这两条 + pairs, unavailable, err := srv.detectDuplicateVisits(ctx, dsID) + if err != nil || unavailable != "" { + t.Fatalf("检测失败: err=%v unavailable=%s", err, unavailable) + } + got := map[int]int{} + for _, p := range pairs { + got[p.Victim] = p.Keep + } + if len(pairs) != len(before)+2 { + t.Fatalf("应检出 %d 对,实际 %d 对", len(before)+2, len(pairs)) + } + if got[victim1] != keeper1.srcPK || got[victim2] != keeper2.srcPK { + t.Fatalf("配对不对:%v(应 #%d→#%d、#%d→#%d)", + got, victim1, keeper1.srcPK, victim2, keeper2.srcPK) + } + // 判定的定义性不变量:**副本的 UUID 一定不在基线里,保留项的 UUID 一定在**。 + // 只按「时间 + 地点」判(漏了 UUID 前提)时,同一处会冒出一大堆「自己配自己」的配对, + // 而计数断言是会跟着一起膨胀的——只有这条不变量不随样本量走。 + for _, p := range pairs { + if vh := visitUUIDHex(t, srv, ctx, dsID, p.Victim); vh == "" || base.uuids[vh] { + t.Errorf("把 #%d 判成副本,可它的 UUID(%s)在基线里也有——UUID 是判定的前提", p.Victim, vh) + } + if kh := visitUUIDHex(t, srv, ctx, dsID, p.Keep); kh == "" || !base.uuids[kh] { + t.Errorf("#%d 被当成保留项,可它的 UUID(%s)不在基线里", p.Keep, kh) + } + } + + // 指向副本的合并标记:一条只带 ZMERGEDTO,一条只带 ZPARENT + refMerged, refParent := pickReferrers(t, srv, ctx, dsID, + int64(keeper1.srcPK), int64(victim1), int64(keeper2.srcPK), int64(victim2)) + pointRef(t, srv, ctx, dsID, refMerged, "ZMERGEDTO", victim1) + pointRef(t, srv, ctx, dsID, refParent, "ZPARENT", victim1) + + // 页面要能渲染出卡片:标题里带对数,每行一个默认勾上的勾选框。 + // + // ⚠️ render 是**流式直写**的:模板执行到一半出错也只记日志、状态码照样 200,body 就此截断。 + // 所以不能只看 code,必须断言「卡片之后的内容也在」(「全部保留」在 {{.EditLink}} 之后, + // 「到访记录」在整张卡片之后),末尾还得是闭合的 。 + rec := httptest.NewRecorder() + srv.editPage(rec, httptest.NewRequest("GET", "/admin/edit", nil).WithContext(ctx)) + if rec.Code != 200 { + t.Fatalf("渲染数据编辑页失败: code=%d", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{ + fmt.Sprintf("重复到访 %d 对", len(pairs)), + fmt.Sprintf(`name="victim" value="%d" checked`, victim1), + "全部保留", + "到访记录", + } { + if !strings.Contains(body, want) { + t.Errorf("页面里没有 %q(模板可能执行到一半就断了)", want) + } + } + if !strings.HasSuffix(strings.TrimSpace(body), "") { + t.Error("页面没有渲染完(body 被截断)") + } + + // 清理前导出:那一处就是成对的两条(用户看到的现象) + keeperHex1 := visitUUIDHex(t, srv, ctx, dsID, keeper1.srcPK) + edbBefore := openExport(t, f.export(t)) + if n := countExportTwin(t, edbBefore, keeperHex1, keeper1.arrival); n != 2 { + t.Fatalf("清理前导出包里同时间同地点应有 2 条,实际 %d", n) + } + exportVisitsBefore := countExportAll(t, edbBefore) + + // 只提交 victim1:victim2 必须原地不动 + loc := post(t, srv.editDuplicateVisitsApply, ctx, "/admin/edit/dup/clean", + url.Values{"victim": {fmt.Sprint(victim1)}}) + if !strings.Contains(loc, "ok=") { + t.Errorf("清理后没有 ok= 闪信: %s", loc) + } + + var n int + pg.QueryRowContext(ctx, `SELECT count(*) FROM visits WHERE dataset_id=$1`, dsID).Scan(&n) + if n != startVisits+2-1 { + t.Errorf("到访数应为 %d,实际 %d", startVisits+2-1, n) + } + for _, c := range []struct { + name string + sql string + }{ + {"副本到访", `SELECT count(*) FROM visits WHERE dataset_id=$1 AND src_pk=$2`}, + {"副本留档", `SELECT count(*) FROM entity_raw WHERE dataset_id=$1 AND entity='ZVISIT' AND src_pk=$2`}, + {"副本位移(起点)", `SELECT count(*) FROM movements WHERE dataset_id=$1 AND from_visit_src=$2`}, + {"副本位移(终点)", `SELECT count(*) FROM movements WHERE dataset_id=$1 AND to_visit_src=$2`}, + {"副本天气", `SELECT count(*) FROM weather WHERE dataset_id=$1 AND visit_src=$2`}, + } { + pg.QueryRowContext(ctx, c.sql, dsID, victim1).Scan(&n) + if n != 0 { + t.Errorf("%s 没清掉(还剩 %d 条)", c.name, n) + } + } + // 保留项与未被勾选的那对都在 + for _, pk := range []int{keeper1.srcPK, victim2, keeper2.srcPK} { + pg.QueryRowContext(ctx, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND src_pk=$2`, dsID, pk).Scan(&n) + if n != 1 { + t.Errorf("到访 #%d 不该被删(实际存在 %d 条)", pk, n) + } + } + pg.QueryRowContext(ctx, `SELECT count(*) FROM movements WHERE dataset_id=$1 AND (from_visit_src=$2 OR to_visit_src=$2)`, + dsID, victim2).Scan(&n) + if n != 2 { + t.Errorf("未勾选的副本 #%d 的位移不该被清(还剩 %d 条)", victim2, n) + } + + // 合并标记改指到保留项,且**不凭空补出另一个键**: + // 「只有 ZPARENT 指向副本」的那条不许长出 ZMERGEDTO(真机里那是「已并入别处」) + if got := readRef(t, srv, ctx, dsID, refMerged, "ZMERGEDTO"); got.String != fmt.Sprint(keeper1.srcPK) { + t.Errorf("ZMERGEDTO 应改指到 #%d,实际 %q", keeper1.srcPK, got.String) + } + if got := readRef(t, srv, ctx, dsID, refMerged, "ZPARENT"); got.Valid { + t.Errorf("只带 ZMERGEDTO 的那条不该被补上 ZPARENT,实际 %q", got.String) + } + if got := readRef(t, srv, ctx, dsID, refParent, "ZPARENT"); got.String != fmt.Sprint(keeper1.srcPK) { + t.Errorf("ZPARENT 应改指到 #%d,实际 %q", keeper1.srcPK, got.String) + } + if got := readRef(t, srv, ctx, dsID, refParent, "ZMERGEDTO"); got.Valid { + t.Errorf("只带 ZPARENT 的那条不该被补上 ZMERGEDTO,实际 %q", got.String) + } + + // 不得留下任何悬空引用(清理前是多少,清理后还得是多少) + if dangRaw, dangMv := countDangling(t, srv, ctx, dsID); dangRaw != dangRaw0 || dangMv != dangMv0 { + t.Errorf("清理后出现悬空引用:留档 %d→%d,位移 %d→%d", dangRaw0, dangRaw, dangMv0, dangMv) + } + // 数据集计数(页脚「数据截至」与后台 flash 用的那份)也要跟着减 + var dsCount int + pg.QueryRowContext(ctx, `SELECT visit_count FROM datasets WHERE id=$1`, dsID).Scan(&dsCount) + if dsCount != startVisits+2-1 { + t.Errorf("datasets.visit_count 应为 %d,实际 %d", startVisits+2-1, dsCount) + } + + // 再检出一次:未勾选的那对还在(留着让用户自己决定) + pairs, unavailable, err = srv.detectDuplicateVisits(ctx, dsID) + if err != nil || unavailable != "" { + t.Fatalf("复核检测失败: err=%v unavailable=%s", err, unavailable) + } + still := false + for _, p := range pairs { + if p.Victim == victim2 { + still = true + } + if p.Victim == victim1 { + t.Errorf("已清理的副本 #%d 仍被检出", victim1) + } + } + if !still { + t.Errorf("未勾选的副本 #%d 不该消失", victim2) + } + + // 导出:那一处重复从 2 条变回 1 条,整包到访数只少了被清掉的那一条 + edbAfter := openExport(t, f.export(t)) + if n := countExportTwin(t, edbAfter, keeperHex1, keeper1.arrival); n != 1 { + t.Errorf("清理后导出包里同时间同地点应只剩 1 条,实际 %d", n) + } + if n := countExportAll(t, edbAfter); n != exportVisitsBefore-1 { + t.Errorf("清理后导出包的到访数应为 %d,实际 %d", exportVisitsBefore-1, n) + } + // 导出包里那条引用的 ZMERGEDTO 必须正好指向保留项(编号对齐后也要对上) + var mergedTo sql.NullInt64 + if err := edbAfter.QueryRow(`SELECT ZMERGEDTO FROM ZVISIT WHERE Z_PK=?`, refMerged).Scan(&mergedTo); err != nil { + t.Fatalf("导出包里找不到引用行 #%d: %v", refMerged, err) + } + if !mergedTo.Valid || int(mergedTo.Int64) != keeper1.srcPK { + t.Errorf("导出包里 ZMERGEDTO 应为 %d,实际 %v", keeper1.srcPK, mergedTo) + } +} + +// TestDuplicateVisitsRefusesWrongBaseline 基线不可用 / 跟本站对不上时必须判定不了、且不给按钮。 +// +// 这是这个功能唯一会造成不可逆损失的地方:照着对不上的参照跑,站内数据会被成片清掉 +// (所有 UUID 都比不上 ⇒ 所有到访都算副本)。所以宁可什么都不做。 +func TestDuplicateVisitsRefusesWrongBaseline(t *testing.T) { + f := newMovementFixture(t) + ctx, dsID := f.ctx, f.dsID + srv := f.srv + path, err := srv.ensureBaseline(ctx, dsID) + if err != nil { + t.Fatalf("准备基线库失败: %v", err) + } + + // 缺基线:判定不了,而不是「没有重复」 + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if _, unavail, err := srv.detectDuplicateVisits(ctx, dsID); err != nil || unavail == "" { + t.Errorf("基线缺失时应当判定不了(err=%v unavail=%q)", err, unavail) + } + if _, err := srv.ensureBaseline(ctx, dsID); err != nil { + t.Fatalf("重建基线库失败: %v", err) + } + + // 基线里的 UUID 全换掉 ⇒ 跟本站一条都对不上 + base, err := readDupBaseline(ctx, path) + if err != nil { + t.Fatal(err) + } + if len(base.uuids) == 0 { + t.Fatal("基线里没有 UUID,用例前提不成立") + } + sdb, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)) + if err != nil { + t.Fatal(err) + } + if _, err := sdb.Exec(`UPDATE ZVISIT SET ZIDENTIFIER_ = randomblob(16) WHERE ZIDENTIFIER_ IS NOT NULL`); err != nil { + sdb.Close() + t.Fatalf("改写基线库失败: %v", err) + } + if err := sdb.Close(); err != nil { + t.Fatal(err) + } + + pairs, unavail, err := srv.detectDuplicateVisits(ctx, dsID) + if err != nil { + t.Fatalf("检测失败: %v", err) + } + if unavail == "" { + t.Fatalf("基线对不上时必须判定不了,却检出 %d 对", len(pairs)) + } + if len(pairs) != 0 { + t.Errorf("判定不了时不该给出任何配对,实际 %d 对", len(pairs)) + } + // 页面:只给一行说明,不给「清理」入口 + rec := httptest.NewRecorder() + srv.editPage(rec, httptest.NewRequest("GET", "/admin/edit", nil).WithContext(ctx)) + if rec.Code != 200 { + t.Fatalf("渲染数据编辑页失败: code=%d", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, unavail) { + t.Error("页面里没有显示判定不了的说明") + } + if strings.Contains(body, "/admin/edit/dup/clean") { + t.Error("判定不了时页面不该出现清理入口") + } +} + +// TestDupCleanSkipsStaleSelection 提交「已经不是副本」的编号时必须拒绝,一条都不删。 +// +// 渲染到点击之间库可能已经变过(另一处清理过、或用户拿着旧页面点), +// 照着表单里的编号删就是删错行。 +func TestDupCleanSkipsStaleSelection(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) + } + var startVisits int + pg.QueryRowContext(ctx, `SELECT count(*) FROM visits WHERE dataset_id=$1`, dsID).Scan(&startVisits) + + // 挑一条**不是**副本的到访(UUID 就在基线里)提交上去 + base, err := readDupBaseline(ctx, srv.baselinePath(dsID)) + if err != nil { + t.Fatal(err) + } + site, err := srv.loadDupSiteVisits(ctx, dsID) + if err != nil { + t.Fatal(err) + } + var innocent int + for _, sv := range site { + if sv.uuid != "" && base.uuids[sv.uuid] { + innocent = sv.srcPK + break + } + } + if innocent == 0 { + t.Skip("夹具里挑不出「UUID 在基线里」的到访") + } + + r := httptest.NewRequest("POST", "/admin/edit/dup/clean", + strings.NewReader(url.Values{"victim": {fmt.Sprint(innocent)}}.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r = r.WithContext(ctx) + w := httptest.NewRecorder() + srv.editDuplicateVisitsApply(w, r) + if w.Code >= 500 { + t.Fatalf("处理失败: code=%d", w.Code) + } + if !strings.Contains(w.Header().Get("Location"), "err=") { + t.Error("提交一条不是副本的到访应当被拒绝") + } + var n int + pg.QueryRowContext(ctx, `SELECT count(*) FROM visits WHERE dataset_id=$1`, dsID).Scan(&n) + if n != startVisits { + t.Errorf("被拒绝的提交不该删掉任何到访:%d → %d", startVisits, n) + } + pg.QueryRowContext(ctx, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND src_pk=$2`, dsID, innocent).Scan(&n) + if n != 1 { + t.Error("那条到访被误删了") + } +} + +// TestDupCleanKeepsMergeKeysAsIs 静态守卫:改指合并标记必须「只动本来就有的那个键」。 +// +// tools/dedup_site_visits.py 是把两个键都写成保留项(jsonb_set 的 create_if_missing=true)。 +// 照抄的话,「只有 ZPARENT 指向副本」的行会凭空长出一个 ZMERGEDTO——真机里那是 +// 「这条已并入别处」的意思,会白丢一条记录。实测基线 956 条留档都带着这两个键、 +// 只有 54 / 10 条有值,凭空补键很容易踩到。 +// 另外 raw->> 取出来是 text,必须按字符串比(拿数字比会报「操作符不存在」,整个清理会失败)。 +func TestDupCleanKeepsMergeKeysAsIs(t *testing.T) { + src, err := os.ReadFile(pkgFile(t, "edit_duplicates.go")) + if err != nil { + t.Fatalf("读 edit_duplicates.go 失败: %v", err) + } + body := sliceFunc(t, string(src), "func (s *Server) cleanDuplicateVisits(") + if !strings.Contains(body, "jsonb_set(raw, ARRAY[$4::text], to_jsonb($3::int), false)") { + t.Error("改指合并标记必须用 create_if_missing=false,只动本来就存在的那个键") + } + if strings.Contains(body, "to_jsonb($3::int), true)") { + t.Error("不许用 create_if_missing=true:那会凭空补出副本本来没有的合并标记") + } + if !strings.Contains(body, "raw->>$4 = $2") { + t.Error("raw->> 取出来是 text,必须按字符串比") + } + if !strings.Contains(body, "s.pruneEntityRaw") { + t.Error("删完必须 pruneEntityRaw,否则留档里会留下指向已删到访的行") + } +} diff --git a/internal/web/edit_duplicates.go b/internal/web/edit_duplicates.go new file mode 100644 index 0000000..664e1b8 --- /dev/null +++ b/internal/web/edit_duplicates.go @@ -0,0 +1,401 @@ +package web + +import ( + "context" + "database/sql" + "encoding/base64" + "fmt" + "log" + "math" + "net/http" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +// ---------- 重复到访:站点侧的历史副本 ---------- +// +// 背景(2026-09-29):站点库里有一批**到达时间 + 地点与手机包里某条完全相同、但 UUID 不同** +// 的到访副本。差异页 / 合并都是按 UUID 认人的,于是它们被判成「备份新增」, +// 导出到 rond 后就是成对的两条重复记录。 +// +// 判定与 tools/dedup_site_visits.py 是同一套语义(那里已经演练通过,两边不能各写一份): +// - 参照是这份数据集的**基线**(导入时留下的手机包底稿,见 baselinePath); +// - 站点到访的 UUID 在基线里找不到、而基线里有一条「到达时间 + 地点」完全相同的 → 副本。 +// +// UUID 是前提:只按「时间 + 地点」判,会把手机包自身那些合法的成对条目一起删掉。 + +// appleEpoch 是 Core Data 的时间原点(2001-01-01 UTC)。基线的 ZARRIVALDATE_ 就是它起算的秒数。 +var appleEpoch = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + +// EditDupVisit 是「重复到访」卡片里的一对:站点侧的副本,与它该保留的那条(手机包里也有)。 +type EditDupVisit struct { + Victim int // 站点侧副本的 src_pk —— 勾选后删的就是它 + Keep int // 保留项的 src_pk —— UUID 在基线里能找到的那条 + Arrival time.Time + PlaceName string +} + +// ArrivalStr 是模板里显示的到达时间(本地时区,分钟精度)。 +func (v EditDupVisit) ArrivalStr() string { return v.Arrival.Local().Format("2006-01-02 15:04") } + +// dupKey 把「到达时间 + 地点」折成一个比较键。时间取到毫秒:副本与原件的到达时间本来 +// 就是同一个瞬间,而基线与站点两侧都用这一个函数,取整方式一致就不会错配。 +func dupKey(secs float64, who string) string { + return strconv.FormatInt(int64(math.Round(secs*1000)), 10) + "|" + who +} + +// dupCoordKey 是地点坐标的比较键(6 位小数,与工具一致)。 +func dupCoordKey(lat, lon float64) string { return fmt.Sprintf("%.6f|%.6f", lat, lon) } + +// appleSeconds 把时间换算成 Core Data 的秒数(2001-01-01 UTC 起算),与基线的 ZARRIVALDATE_ 同系。 +func appleSeconds(t time.Time) float64 { return t.UTC().Sub(appleEpoch).Seconds() } + +// dupSet 只在键还没被占时写入。Python 那边用的是 dict.setdefault:同一个「时间 + 地点」 +// 在库里有多条时取先到的那条,顺序照抄,两边结果才一致。 +func dupSet[T any](m map[string]T, k string, v T) { + if _, ok := m[k]; !ok { + m[k] = v + } +} + +// dupBaseline 是从基线库读出的判定依据。 +type dupBaseline struct { + uuids map[string]bool // 基线里出现过的到访 UUID(小写 hex) + byKey map[string]string // 「到达时间 + 地点」→ 基线里那条的 UUID +} + +// dupPlace 是基线里一个地点的名字与坐标。 +type dupPlace struct { + name string + lat, lon float64 +} + +// readDupBaseline 只读打开基线库(Core Data 单文件),取出判定要用的两组信息。 +// +// 驱动只认 _pragma / _time_format 这几个 DSN 参数,没有 mode=ro;基线库是 rollback journal +// 模式(VACUUM INTO 的产物),只做 SELECT 不会落下任何旁写文件,再加 query_only 兜住 +// 「误写基线库」这条路——基线是导出的底稿,写坏了整个数据集就导不出去了。 +func readDupBaseline(ctx context.Context, path string) (*dupBaseline, error) { + sdb, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?_pragma=query_only(1)") + if err != nil { + return nil, err + } + defer sdb.Close() + sdb.SetMaxOpenConns(1) + + places, err := readDupPlaces(ctx, sdb) + if err != nil { + return nil, err + } + + b := &dupBaseline{uuids: map[string]bool{}, byKey: map[string]string{}} + rows, err := sdb.QueryContext(ctx, `SELECT hex(ZIDENTIFIER_), ZARRIVALDATE_, ZLOCATION + FROM ZVISIT WHERE ZIDENTIFIER_ IS NOT NULL`) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var uuid string + var arrival sql.NullFloat64 + var loc sql.NullInt64 + if err := rows.Scan(&uuid, &arrival, &loc); err != nil { + return nil, err + } + uuid = strings.ToLower(uuid) + if uuid == "" { + continue + } + b.uuids[uuid] = true + if !arrival.Valid || !loc.Valid { + continue + } + pl, ok := places[loc.Int64] + if !ok { + continue + } + dupSet(b.byKey, dupKey(arrival.Float64, pl.name), uuid) + if pl.lat != 0 || pl.lon != 0 { + dupSet(b.byKey, dupKey(arrival.Float64, dupCoordKey(pl.lat, pl.lon)), uuid) + } + } + return b, rows.Err() +} + +func readDupPlaces(ctx context.Context, sdb *sql.DB) (map[int64]dupPlace, error) { + rows, err := sdb.QueryContext(ctx, `SELECT Z_PK, COALESCE(ZNAME_,''), + COALESCE(ZLATITUDE,0), COALESCE(ZLONGITUDE,0) FROM ZLOCATION`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int64]dupPlace{} + for rows.Next() { + var pk int64 + var pl dupPlace + if err := rows.Scan(&pk, &pl.name, &pl.lat, &pl.lon); err != nil { + return nil, err + } + out[pk] = pl + } + return out, rows.Err() +} + +// siteDupVisit 是判定用的站点侧面貌。 +type siteDupVisit struct { + srcPK int + uuid string // 留档里的 ZIDENTIFIER_(小写 hex),站点新建的行没有 + arrival time.Time + place string + lat, lon float64 +} + +// loadDupSiteVisits 读出站点侧的到访(含留档里的 UUID 与所属地点的名称/坐标),按 src_pk 排序。 +func (s *Server) loadDupSiteVisits(ctx context.Context, datasetID int64) ([]siteDupVisit, error) { + rows, err := s.db.QueryContext(ctx, `SELECT v.src_pk, + COALESCE(er.raw #>> '{ZIDENTIFIER_,$blob}',''), + v.arrival, COALESCE(p.name,''), COALESCE(p.lat,0), COALESCE(p.lon,0) + FROM visits v + LEFT JOIN places p ON p.id = v.place_id + LEFT JOIN entity_raw er ON er.dataset_id=v.dataset_id AND er.entity='ZVISIT' AND er.src_pk=v.src_pk + WHERE v.dataset_id=$1 + ORDER BY v.src_pk`, datasetID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []siteDupVisit + for rows.Next() { + var sv siteDupVisit + var b64 string + if err := rows.Scan(&sv.srcPK, &b64, &sv.arrival, &sv.place, &sv.lat, &sv.lon); err != nil { + return nil, err + } + sv.uuid = blobHex(b64) + out = append(out, sv) + } + return out, rows.Err() +} + +// blobHex 把留档里的 base64 blob 转成小写 hex。基线侧用的是 hex(ZIDENTIFIER_), +// 两边同一个形式才比得上(大小写也要一致,否则一条都认不出来)。 +func blobHex(s string) string { + if s == "" { + return "" + } + b, err := base64.StdEncoding.DecodeString(s) + if err != nil || len(b) == 0 { + return "" + } + return strings.ToLower(fmt.Sprintf("%x", b)) +} + +// detectDuplicateVisits 检出站点侧的重复到访,并给出每条该保留哪一行。 +// +// 第二个返回值非空表示**判定不了**(基线不可用 / 跟本站到访对不上),此时页面只给一行 +// 说明、不给按钮——宁可不动,也不能拿一份对不上的参照去删用户的数据。 +func (s *Server) detectDuplicateVisits(ctx context.Context, datasetID int64) ([]EditDupVisit, string, error) { + path := s.baselinePath(datasetID) + if _, err := os.Stat(path); err != nil { + return nil, "这份数据集还没有可用的基线库(当初上传的原始 .rondbackup 已不在),没法当参照,判定不了重复到访。", nil + } + base, err := readDupBaseline(ctx, path) + if err != nil { + log.Printf("读取基线库失败(数据集 %d,%s): %v", datasetID, path, err) + return nil, "读取基线库失败,判定不了重复到访。", nil + } + site, err := s.loadDupSiteVisits(ctx, datasetID) + if err != nil { + return nil, "", err + } + + var withUUID, matched int + for _, sv := range site { + if sv.uuid == "" { + continue + } + withUUID++ + if base.uuids[sv.uuid] { + matched++ + } + } + // 两道闸都防着「拿一份不相干的库当参照」:基线的 UUID 一个都比不上时, + // 所有站内到访都会被算成副本,照着删就是把整库搬走。 + if len(base.uuids) == 0 { + return nil, "基线里没有带 UUID 的到访,没法当参照,判定不了重复到访。", nil + } + if withUUID > 0 && matched == 0 { + return nil, "基线跟本站到访一条都对不上,它多半不是这份数据集的手机包,判定不可靠,先不动它。", nil + } + + // 保留项:站点侧 UUID 在基线里能找到的那些(手机包里也有这条),按「时间 + 地点」建索引。 + keepByKey := map[string]int{} + for _, sv := range site { + if sv.uuid == "" || !base.uuids[sv.uuid] { + continue + } + secs := appleSeconds(sv.arrival) + dupSet(keepByKey, dupKey(secs, sv.place), sv.srcPK) + if sv.lat != 0 || sv.lon != 0 { + dupSet(keepByKey, dupKey(secs, dupCoordKey(sv.lat, sv.lon)), sv.srcPK) + } + } + + var out []EditDupVisit + for _, sv := range site { + if sv.uuid == "" || base.uuids[sv.uuid] { + continue + } + secs := appleSeconds(sv.arrival) + byName, byCoord := dupKey(secs, sv.place), "" + if sv.lat != 0 || sv.lon != 0 { + byCoord = dupKey(secs, dupCoordKey(sv.lat, sv.lon)) + } + // 基线里得有「同一时间同一地点」那条,否则这不是副本,是站点自己新增的到访 + if base.byKey[byName] == "" && (byCoord == "" || base.byKey[byCoord] == "") { + continue + } + // 站点里也得有那条保留项:没有就无从改指,删了就是净丢一条记录 + keep, ok := keepByKey[byName] + if !ok && byCoord != "" { + keep, ok = keepByKey[byCoord] + } + if !ok { + continue + } + out = append(out, EditDupVisit{Victim: sv.srcPK, Keep: keep, Arrival: sv.arrival, PlaceName: sv.place}) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Arrival.After(out[j].Arrival) }) + return out, "", nil +} + +// editDuplicateVisitsApply 清理勾选的重复到访副本。 +func (s *Server) editDuplicateVisitsApply(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + ds, err := s.datasetFor(ctx) + if err != nil || ds == nil { + redirectEdit(w, r, "err", "还没有可编辑的数据集") + return + } + if err := r.ParseForm(); err != nil { + s.serverError(w, r, err) + return + } + want := map[int]bool{} + for _, v := range r.PostForm["victim"] { + if n, cerr := strconv.Atoi(strings.TrimSpace(v)); cerr == nil && n > 0 { + want[n] = true + } + } + if len(want) == 0 { + redirectEdit(w, r, "err", "没有勾选要清理的副本") + return + } + // 编号不照着表单删:渲染到点击之间库可能已经变过,这里重算一遍判定, + // 只有当下**仍被判为副本**的编号才会动。 + pairs, unavailable, err := s.detectDuplicateVisits(ctx, ds.ID) + if err != nil { + s.serverError(w, r, err) + return + } + if unavailable != "" { + redirectEdit(w, r, "err", unavailable) + return + } + var picked []EditDupVisit + for _, p := range pairs { + if want[p.Victim] { + picked = append(picked, p) + } + } + if len(picked) == 0 { + redirectEdit(w, r, "err", "选中的记录已经不是副本了(库里可能已经变过),请刷新页面重试") + return + } + if err := s.withTx(ctx, func(ex execer) error { + return s.cleanDuplicateVisits(ctx, ex, ds.ID, picked) + }); err != nil { + s.serverError(w, r, err) + return + } + redirectEdit(w, r, "ok", fmt.Sprintf("已清理 %d 条重复到访", len(picked))) +} + +// cleanDuplicateVisits 删掉一批副本,并把指向它们的合并标记改指到保留项。 +// +// 全程在一个事务里:分开提交会留下「到访没了、留档还指着它」的悬空引用, +// 导出到 rond 里就是坏数据(到访那条 ZMERGEDTO 指向一个不存在的 Z_PK)。 +func (s *Server) cleanDuplicateVisits(ctx context.Context, ex execer, datasetID int64, pairs []EditDupVisit) error { + victims := make([]int64, 0, len(pairs)) + for _, p := range pairs { + victims = append(victims, int64(p.Victim)) + } + // 聚合要按地点重算,先把副本所属地点收下来(删完就在 visits 里查不到了) + placeIDs, err := s.dupVictimPlaces(ctx, ex, datasetID, victims) + if err != nil { + return err + } + + // 改指合并标记时**只动本来就存在的那一个键**(jsonb_set 的 create_if_missing=false), + // 与 tools/dedup_site_visits.py 有一处刻意的不同:工具把两个键都写成保留项,于是 + // 「只有 ZPARENT 指向副本」的行会凭空长出一个 ZMERGEDTO——那在真机里是「这条已并入 + // 别处」的意思,会白丢一条记录。实测基线里 956 条留档都带着这两个键,但只有 54 / 10 + // 条有值,凭空补键是很容易踩到的。 + // + // raw->> 取出来是 text,必须按字符串比(拿数字去比会报「text = integer 操作符不存在」)。 + for _, p := range pairs { + for _, col := range []string{"ZMERGEDTO", "ZPARENT"} { + if _, err := ex.ExecContext(ctx, `UPDATE entity_raw + SET raw = jsonb_set(raw, ARRAY[$4::text], to_jsonb($3::int), false) + WHERE dataset_id=$1 AND entity='ZVISIT' AND raw->>$4 = $2`, + datasetID, strconv.Itoa(p.Victim), p.Keep, col); err != nil { + return err + } + } + } + // 副本的附属记录:位移(两端任一)、天气。到访本身删掉后,它自己的留档与标签关联 + // 分别由 pruneEntityRaw 与 visit_tags 的外键级联清掉。 + if _, err := ex.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1 + AND (from_visit_src = ANY($2::bigint[]) OR to_visit_src = ANY($2::bigint[]))`, + datasetID, victims); err != nil { + return err + } + if _, err := ex.ExecContext(ctx, `DELETE FROM weather WHERE dataset_id=$1 AND visit_src = ANY($2::bigint[])`, + datasetID, victims); err != nil { + return err + } + if _, err := ex.ExecContext(ctx, `DELETE FROM visits WHERE dataset_id=$1 AND src_pk = ANY($2::bigint[])`, + datasetID, victims); err != nil { + return err + } + s.pruneEntityRaw(ctx, ex, datasetID) + for _, id := range placeIDs { + if err := s.refreshPlaceAgg(ctx, ex, id); err != nil { + return err + } + } + return s.refreshDatasetAgg(ctx, ex, datasetID) +} + +// dupVictimPlaces 是这批副本所属的地点 id(不含 place_id 为空的)。 +func (s *Server) dupVictimPlaces(ctx context.Context, ex execer, datasetID int64, victims []int64) ([]int64, error) { + rows, err := ex.QueryContext(ctx, `SELECT DISTINCT place_id FROM visits + WHERE dataset_id=$1 AND src_pk = ANY($2::bigint[]) AND place_id IS NOT NULL`, datasetID, victims) + if err != nil { + return nil, err + } + defer rows.Close() + var out []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} diff --git a/internal/web/handlers_edit.go b/internal/web/handlers_edit.go index e06f1f6..d7a8f33 100644 --- a/internal/web/handlers_edit.go +++ b/internal/web/handlers_edit.go @@ -68,6 +68,13 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) { } else { d.CanExport = true } + // 重复到访(站点侧的历史副本)以基线为参照,见 edit_duplicates.go。 + // 这里只读基线库与站点数据,不改任何东西;判定不了时 d.DupUnavailable 非空。 + if pairs, unavailable, err := s.detectDuplicateVisits(ctx, ds.ID); err != nil { + log.Printf("检测重复到访失败(数据集 %d): %v", ds.ID, err) + } else { + d.DupVisits, d.DupUnavailable = pairs, unavailable + } // 活动类型与标签 if d.Activities, err = s.editOptions(ctx, `SELECT a.id, COALESCE(a.name,'') diff --git a/internal/web/server.go b/internal/web/server.go index 575ae28..ad945e1 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -272,6 +272,8 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /admin/photos/import", s.requireAdmin(s.adminPhotosImport)) s.mux.HandleFunc("POST /admin/edit/visit/delete", s.requireAdmin(s.editVisitDelete)) s.mux.HandleFunc("POST /admin/edit/visit/bookmark", s.requireAdmin(s.adminVisitBookmark)) + // 重复到访:清掉站点侧跟手机包重复的历史副本(判定见 edit_duplicates.go) + s.mux.HandleFunc("POST /admin/edit/dup/clean", s.requireAdmin(s.editDuplicateVisitsApply)) s.mux.HandleFunc("POST /admin/edit/movement", s.requireAdmin(s.editMovementCreate)) s.mux.HandleFunc("POST /admin/edit/movement/update", s.requireAdmin(s.editMovementUpdate)) s.mux.HandleFunc("POST /admin/edit/movement/delete", s.requireAdmin(s.editMovementDelete)) diff --git a/internal/web/templates/admin/edit.html b/internal/web/templates/admin/edit.html index d8fa326..8fb6920 100644 --- a/internal/web/templates/admin/edit.html +++ b/internal/web/templates/admin/edit.html @@ -346,6 +346,51 @@ {{else}}
没有匹配的地点
{{end}} + {{/* 重复到访:站点侧跟手机包「同一到达时间 + 同一地点」的历史副本。判定见 + edit_duplicates.go(与 tools/dedup_site_visits.py 同一套语义)。 + 只在检出 >0 对时出现;判定不了时只给一行说明、不给按钮。 */}} + {{if .DupUnavailable}} +
+

重复到访

+
{{.DupUnavailable}}
+
+ {{else if .DupVisits}} +
+

重复到访 {{num (len .DupVisits)}} 对

+
+ 这些到访跟手机包里的某条到达时间 + 地点完全相同、但 UUID 不同,是站点侧的历史副本, + 导出到 rond 后会变成成对的两条。默认全选:删掉勾选的副本会连它的位移 / 天气 / 留档一起清, + 并把指向它的合并标记改指到保留项(不动就会在导出时留下悬空引用)。 +
+
+
+ + + + {{range .DupVisits}} + + + + + + + {{end}} + +
到达时间地点保留(手机来源)删除(站点副本)
{{.ArrivalStr}}{{if .PlaceName}}{{.PlaceName}}{{else}}(未命名地点){{end}}#{{.Keep}} + +
+
+
+ + 全部保留 + 「全部保留」什么都不改,只是取消勾选。 +
+
+
+ {{end}} +

到访记录

diff --git a/internal/web/view.go b/internal/web/view.go index 463c4da..7f31aab 100644 --- a/internal/web/view.go +++ b/internal/web/view.go @@ -808,6 +808,11 @@ type EditData struct { // OpenSrcPK 是「从地点详情页跳进来要定位的地点」(?open=):非 0 时自动翻到 // 该行所在页、展开它的编辑框并高亮,省得在几百行地点里手动搜 OpenSrcPK int + // DupVisits 是「重复到访」卡片检出的站点副本(与保留项的配对),空的就没有卡片 + DupVisits []EditDupVisit + // DupUnavailable 非空表示判定不了(基线不可用 / 跟本站到访对不上), + // 此时只显示一行说明、不给按钮——拿一份对不上的参照去删到访比不判更糟 + DupUnavailable string } // PlaceSources 供模板渲染筛选下拉,顺序固定(与 placeSourceLabels 一致)。