导出:把 ZVISIT.ZRAW 夹到包内采样行数以内(越界会让真机整包拒收)
ZVISIT.ZRAW 是「这次到访由多少个原始采样点归并而来」的缓存计数,而采样点表 ZRAWVISIT 是 **不托管实体**(只从基线原样带过来)。合并进来的到访带着源包的计数、源包的那些采样行站点 没留下 ⇒ 计数会超过包里实际有的采样行数。 2026-09-30 逐行二分定位:生产导出被真机拒收(「备份恢复失败,你的当前数据未被修改」), 罪魁是单条 `ZVISIT #1000`——它声称 2305 个采样点,而包里只有 2246 行 ZRAWVISIT; 把这一列改成 1421 后同一份包立刻能导入。旁证:被真机接受过的三份包(09-14 基线、 09-20 A 包、验收纯净往返包)的 ZRAW 最大值都恰好 ≤ 采样行数,而它们 ZRAW 为 NULL/0 的行 本来就有 39~139 条 ⇒ 往下夹是安全的、夹取只降不升。 顺带:`readDupBaseline` 先 os.Stat 再开库——sql.Open 默认 READWRITE|CREATE,路径不存在时 会静默建一个空库,报出来的是「no such table」而不是「没有基线」,很难查。 用例 `TestExportClampsRawSampleCount`:造一条声称 999999 个采样点的到访,导出后断言 「任何一行的 ZRAW 都不超过包内采样行数」且该行仍在(只改计数、不丢记录);变异验证: 关掉夹取立刻红。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user