diff --git a/internal/rond/export.go b/internal/rond/export.go index 0fae9a5..674cbd0 100644 --- a/internal/rond/export.go +++ b/internal/rond/export.go @@ -511,6 +511,17 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st return fmt.Errorf("清理失去目标的原始采样点失败: %w", err) } + // ZVISIT.ZRAW 是「这次到访由多少个原始采样点归并而来」的计数,而采样点表 ZRAWVISIT 是 + // **不托管实体**(原样从基线带过来)。合并进来的到访带着源包的计数,可源包里的那些采样行 + // 站点没留下 ⇒ 计数会超过包里实际有的采样行数。2026-09-30 实测:真机会因此**整包拒收** + // (「备份恢复失败,你的当前数据未被修改」)——#1000 声称 2305 个采样点而包里只有 2246 行, + // 把它夹到 2246 以内立刻就能导入。被真机接受过的包里 ZRAW 为 NULL/0 的行本来就有 39~139 条, + // 所以往下夹是安全的(夹取只降不升)。 + if _, err := tx.ExecContext(ctx, `UPDATE ZVISIT SET ZRAW = (SELECT count(*) FROM ZRAWVISIT) + WHERE ZRAW IS NOT NULL AND ZRAW > (SELECT count(*) FROM ZRAWVISIT)`); err != nil { + return fmt.Errorf("夹原始采样点数失败: %w", err) + } + // 8) 访问↔标签关联(列名从基线探测得到;纯两列关系,全量重写即可) if join.Name != "" && join.VisitCol != "" && join.TagCol != "" { if _, err := tx.ExecContext(ctx, "DELETE FROM "+join.Name); err != nil { diff --git a/internal/web/edit_duplicates.go b/internal/web/edit_duplicates.go index 66194ca..49eb582 100644 --- a/internal/web/edit_duplicates.go +++ b/internal/web/edit_duplicates.go @@ -96,6 +96,11 @@ type dupPlace struct { // 模式(VACUUM INTO 的产物),只做 SELECT 不会落下任何旁写文件,再加 query_only 兜住 // 「误写基线库」这条路——基线是导出的底稿,写坏了整个数据集就导不出去了。 func readDupBaseline(ctx context.Context, path string) (*dupBaseline, error) { + // 先确认文件在:sql.Open 默认是 READWRITE|CREATE,路径不存在时会**建一个空库**, + // 于是查表报「no such table」而不是「没有基线」——那种错误很难查。 + if _, err := os.Stat(path); err != nil { + return nil, err + } sdb, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?_pragma=query_only(1)&_pragma=busy_timeout(5000)") if err != nil { return nil, err diff --git a/internal/web/export_zraw_test.go b/internal/web/export_zraw_test.go new file mode 100644 index 0000000..85d534b --- /dev/null +++ b/internal/web/export_zraw_test.go @@ -0,0 +1,68 @@ +package web + +import ( + "database/sql" + "testing" +) + +// TestExportClampsRawSampleCount 守「ZRAW 不能超过包里实际有的采样行数」。 +// +// ZVISIT.ZRAW 是「这次到访由多少个原始采样点归并而来」的缓存计数,而采样点表 ZRAWVISIT 是 +// **不托管实体**(原样从基线带过来)。合并进来的到访带着源包的计数、源包的采样行却不在本站, +// 于是计数会超出包里实际有的采样行数——2026-09-30 实测真机会因此**整包拒收** +// (「备份恢复失败,你的当前数据未被修改」:生产数据里是 #1000 声称 2305 个采样点、包里只有 2246 行), +// 而把这一列夹回采样行数以内、重新导出就能导入。被真机接受过的包里 ZRAW 为 NULL/0 的行本来就有 +// 39~139 条,所以往下夹是安全的。 +func TestExportClampsRawSampleCount(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) + } + + // 造一条「声称有几百万个采样点」的到访:照抄一条正常到访的整行留档,只把 ZRAW 改大 + base := pickDupBase(t, srv, ctx, dsID) + src, err := srv.nextSrcPK(ctx, pg, dsID, visitSeq) + if err != nil { + t.Fatal(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) + SELECT dataset_id, $3, place_id, activity_id, arrival, departure, duration_min + FROM visits WHERE dataset_id=$1 AND src_pk=$2`, dsID, base.srcPK, src); err != nil { + t.Fatal(err) + } + if _, err := pg.ExecContext(ctx, `INSERT INTO entity_raw (dataset_id, entity, src_pk, raw) + SELECT dataset_id, entity, $3, jsonb_set(raw, '{ZRAW}', to_jsonb($4::int), true) + FROM entity_raw WHERE dataset_id=$1 AND entity=$2 AND src_pk=$5`, + dsID, "ZVISIT", src, 999999, base.srcPK); err != nil { + t.Fatal(err) + } + + // 导出并核对:任何一行都不许声称比包里的采样行还多 + edb := openExport(t, f.export(t)) + var nraw, maxRaw int + if err := edb.QueryRow(`SELECT (SELECT count(*) FROM ZRAWVISIT), COALESCE(max(ZRAW),0) FROM ZVISIT`). + Scan(&nraw, &maxRaw); err != nil { + t.Fatalf("读导出包失败: %v", err) + } + t.Logf("导出包:采样行 %d,到访的 ZRAW 最大值 %d", nraw, maxRaw) + if nraw == 0 { + t.Fatal("导出包里没有采样行,用例前提不成立") + } + if maxRaw > nraw { + t.Errorf("有到访声称 %d 个采样点,包里只有 %d 行采样——真机会整包拒收", maxRaw, nraw) + } + // 被夹的那一行本身还在(只改计数、不丢记录),且计数被夹到了采样行数 + var got sql.NullInt64 + if err := edb.QueryRow(`SELECT ZRAW FROM ZVISIT WHERE Z_PK=?`, src).Scan(&got); err != nil { + t.Fatalf("导出包里找不到刚造的到访 #%d: %v", src, err) + } + if !got.Valid || int(got.Int64) != nraw { + t.Errorf("被夹的到访 #%d 的 ZRAW 应为 %d,实际 %v", src, nraw, got) + } +}