成因链:站点补录的到访没有留档行(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 一条端到端(造补录行与副本 → 检出 →
清理 → 断言位移与天气改挂、留档被清)。变异验证:去掉改挂语义、关掉该判定,两条都立刻红。
804 lines
33 KiB
Go
804 lines
33 KiB
Go
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}} 之后,
|
||
// 「到访记录」在整张卡片之后),末尾还得是闭合的 </html>。
|
||
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), "</html>") {
|
||
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("那条到访被误删了")
|
||
}
|
||
}
|
||
|
||
// 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 静态守卫:改指合并标记必须「只动本来就有的那个键」。
|
||
//
|
||
// 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,否则留档里会留下指向已删到访的行")
|
||
}
|
||
}
|