diff --git a/internal/rond/export.go b/internal/rond/export.go index 94bcff1..fba923c 100644 --- a/internal/rond/export.go +++ b/internal/rond/export.go @@ -460,6 +460,14 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st if err := visit.finish(); err != nil { return err } + // 站点删掉到访之后,它的原始采样点会失去目标。ZRAWVISIT 是不托管实体(原样保留), + // 而 Core Data 的关系由它自己维护、SQLite 里没有外键约束,所以这种悬空引用不会报错, + // 但它是**真机自己绝不会产生的形状**——真机删到访时采样点跟着级联删。 + // 留着还有副作用:app 会把「没归并的采样点」重新推成一条到访,删掉的记录又回来了。 + if _, err := tx.ExecContext(ctx, `DELETE FROM ZRAWVISIT WHERE ZVISIT IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM ZVISIT v WHERE v.Z_PK = ZRAWVISIT.ZVISIT)`); err != nil { + return fmt.Errorf("清理失去目标的原始采样点失败: %w", err) + } // 8) 访问↔标签关联(列名从基线探测得到;纯两列关系,全量重写即可) if join.Name != "" && join.VisitCol != "" && join.TagCol != "" { diff --git a/internal/web/export_placeholder_test.go b/internal/web/export_placeholder_test.go index de382f7..270aa6d 100644 --- a/internal/web/export_placeholder_test.go +++ b/internal/web/export_placeholder_test.go @@ -115,3 +115,61 @@ func countNull(t *testing.T, db *sql.DB, q string, args ...any) int { } return n } + +// TestExportDropsOrphanedRawVisits 守「导出包的关系图不许有悬空引用」。 +// +// ZRAWVISIT(原始 GPS 采样点)是我们**不托管**的实体,导出时原样保留;而站点删掉一条到访时, +// 指向它的采样点就留成了悬空 —— Core Data 的关系由它自己维护、SQLite 里没有外键约束, +// 所以 `foreign_key_check` 查不出来,但它是真机自己绝不会产生的形状(真机删到访时采样点级联删)。 +// 留着还有副作用:app 会把「没归并的采样点」重新推成一条到访,删掉的记录又回来了。 +func TestExportDropsOrphanedRawVisits(t *testing.T) { + f := newMovementFixture(t) + srcDB := f.openSource(t) + + // 挑一条「有原始采样点」的到访:原始采样点的 ZVISIT 就是到访的 Z_PK(= 站点的 src_pk) + var visitPK int + if err := srcDB.QueryRow(`SELECT ZVISIT FROM ZRAWVISIT + WHERE ZVISIT IS NOT NULL GROUP BY ZVISIT ORDER BY ZVISIT LIMIT 1`).Scan(&visitPK); err != nil { + t.Skipf("基线里没有带采样点的到访,跳过: %v", err) + } + + post(t, f.srv.editVisitDelete, f.ctx, "/admin/edit/visit/delete", map[string][]string{ + "src_pk": {fmt.Sprint(visitPK)}, + }) + + out := filepath.Join(t.TempDir(), "export.rondbackup") + if err := os.WriteFile(out, f.export(t), 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() }) + + if n := countNull(t, edb, `SELECT COUNT(*) FROM ZRAWVISIT WHERE ZVISIT=?`, visitPK); n != 0 { + t.Errorf("删掉到访 #%d 后,它的 %d 条原始采样点还留着(悬空引用)", visitPK, n) + } + // 通用判据:整包不许有任何悬空引用(Core Data 自己维护关系,pragma 查不到) + for _, ref := range unmanagedRefs { + q := fmt.Sprintf(`SELECT COUNT(*) FROM %s x WHERE x.%s IS NOT NULL AND x.%s<>0 + AND NOT EXISTS (SELECT 1 FROM %s y WHERE y.Z_PK = x.%s)`, + ref[0], ref[1], ref[1], ref[2], ref[1]) + if n := countNull(t, edb, q); n > 0 { + t.Errorf("%s.%s 有 %d 条指向不存在的 %s —— 真机从不产生悬空关系", ref[0], ref[1], n, ref[2]) + } + } +} + +// unmanagedRefs 是「不托管的表指向托管实体」的关系列(库、列、目标表)。 +// 加新表时照这个格式补,通用判据会自动覆盖。 +var unmanagedRefs = [][3]string{ + {"ZRAWVISIT", "ZVISIT", "ZVISIT"}, + {"ZRAWVISIT", "ZLOCATION", "ZLOCATION"}, + {"ZRAWVISIT", "ZACTIVITY", "ZACTIVITY"}, + {"ZSTAYSEGMENT", "ZLOCATION", "ZLOCATION"}, + {"ZUSERPHOTO", "ZVISIT_", "ZVISIT"}, + {"ZVISITPHOTOFAVORITE", "ZVISIT_", "ZVISIT"}, + {"ZKEYWORD", "ZACTIVITY_", "ZACTIVITY"}, + {"ZPOICATEGORY", "ZACTIVITY_", "ZACTIVITY"}, +} diff --git a/tools/verify_export_structure.py b/tools/verify_export_structure.py index 03e6292..f8d1e70 100644 --- a/tools/verify_export_structure.py +++ b/tools/verify_export_structure.py @@ -225,6 +225,30 @@ def compare_rondbackup(orig, new, new2=None): stale += 1 say("对照:原版自身「缓存 != MAX(到访到达)」的地点 %d 个(原版缓存陈旧,导出是修正)" % stale) + # 悬空关系引用。**必须手查**:Core Data 的关系由它自己维护,SQLite 里根本没有 + # 外键约束,所以 PRAGMA foreign_key_check 永远是 0 违例,查不出这类问题。 + # 典型场景:站点删掉一条到访,它的 ZRAWVISIT 采样点(不托管实体)就失去了目标。 + head("1.45 关系完整性(有没有指向已删记录的悬空引用)") + REFS = [("ZRAWVISIT", "ZVISIT", "ZVISIT"), ("ZRAWVISIT", "ZLOCATION", "ZLOCATION"), + ("ZRAWVISIT", "ZACTIVITY", "ZACTIVITY"), ("ZSTAYSEGMENT", "ZLOCATION", "ZLOCATION"), + ("ZUSERPHOTO", "ZVISIT_", "ZVISIT"), ("ZVISITPHOTOFAVORITE", "ZVISIT_", "ZVISIT"), + ("ZKEYWORD", "ZACTIVITY_", "ZACTIVITY"), ("ZPOICATEGORY", "ZACTIVITY_", "ZACTIVITY")] + for tag, con in (("原版", co), ("导出", cn)): + bad = 0 + for t, col, target in REFS: + try: + n = con.execute("SELECT COUNT(*) FROM %s x WHERE x.%s IS NOT NULL AND x.%s<>0 " + "AND NOT EXISTS (SELECT 1 FROM %s y WHERE y.Z_PK=x.%s)" + % (t, col, col, target, col)).fetchone()[0] + except Exception: + continue + if n: + say(" !! %s:%s.%s 有 %d 条指向不存在的 %s(真机从不产生悬空关系)" % (tag, t, col, n, target)) + FAILED[0] = True + bad += n + if not bad: + say(" %s:悬空引用 0 ✓" % tag) + head("1.5 确定性(同一份数据导出两次是否一致)") if new2 and os.path.exists(new2): def dig(p):