diff --git a/.workbuddy/memory/2026-09-30.md b/.workbuddy/memory/2026-09-30.md index c5961b1..f32548e 100644 --- a/.workbuddy/memory/2026-09-30.md +++ b/.workbuddy/memory/2026-09-30.md @@ -192,3 +192,22 @@ V2(+3 个站内新建地点)、V3(手机里也有的行按手机原值重 验收:`./scripts/verify.sh` 全量 **PASS=318 FAIL=0 SKIP=0**(10m15s)。 新增用例 7 条(`internal/web/baseline_swap_test.go`)。 + +### 补记:导出条数 ≠ 站点条数(19:00) + +收尾时想到一个真实的认知陷阱:站长会以为「换一份更新的手机包当底座 ⇒ 导出的包里就有手机上的新记录」。 +写文案时顺手去核这条,结果**先把自己写错的断言给纠正了**。 + +`tableWriter.finish()` 是**两段**:先删「基线里有、PG 已没有」的行(`known` − `seen`), +再把「导入时进不了 PG 但留档还在」的行(`w.skipped` × `raws`)按原样补回——否则「导入→导出」一趟 +会凭空少数据。所以实测那份 823 条到访的数据集导出包里有 **824** 条,差值正常。 +(我第一次写成功路径用例时断言「导出 == 站点」,红在 824 != 823,就是没意识到这一段。) + +同一段代码也定下了换基线的语义:**新底座里站点没有的行会被清掉**(进 `known` 没进 `seen`)。 +所以「换更新的手机包」不会把手机新记录偷偷带进导出包;要带得先走「分析差异」合并。 +这条既写进了页面提示与 flash 文案,也用 `TestSwapBaselineDoesNotCarryRowsTheSiteDoesNotHave` +钉住了(站内补一条到访 → 导出成包 → 从站内删掉 → 换底座 → 断言导出里既没有那条的派生 UUID、 +UUID 集合也与换之前逐条一致)。 + +第二轮全量验收:**PASS=319 FAIL=0 SKIP=0**。技能 `rond-backup-parse` 补了「导出条数 ≠ 站点条数」 +与「换基线已做成后台动作」两处。 diff --git a/.workbuddy/memory/MEMORY-details.md b/.workbuddy/memory/MEMORY-details.md index 5e38c00..89dde34 100644 --- a/.workbuddy/memory/MEMORY-details.md +++ b/.workbuddy/memory/MEMORY-details.md @@ -570,6 +570,12 @@ Core Data 元数据、索引、持久化历史、`ZRAWVISIT` 采样点、`ZTRIP* 「我确认,强制替换」按钮(不必重传几十 MB);`POST /admin/baseline/confirm` 走同一条路。 - 导出内容在换底座前后**逐条不变**(`ZVISIT` 的 UUID 集合):编号会换、身份不该换, 这条是回归用例最锋利的断言(谁在底座里没对上,就会多出一条派生 UUID 的行)。 +- ⚠️ **导出条数 ≠ 站点条数**,别把差值当 bug:`tableWriter.finish()` 有**两段**——先删 + 「基线里有、PG 已没有」的行(`known` − `seen`),再把「导入时进不了 PG 但留档还在」的行 + (`w.skipped` × `raws`)按原样补回。实测「站内 823 / 导出 824」就是这个第二段。 + 同一段代码也决定了换基线的语义:**新底座里站点没有的行会被清掉**(进 `known` 没进 `seen`), + 所以换到「更新」的手机包**不会**把手机上的新记录带进导出包——想带得先用「分析差异」合并。 + 页面文案与提示都写明了这点,用例 `TestSwapBaselineDoesNotCarryRowsTheSiteDoesNotHave` 守着它。 - 路由:`GET /admin/baseline`、`POST /admin/baseline`(上传并换)、`/confirm`、`/rollback`、`/rebuild`。 ### 用例 `internal/web/baseline_swap_test.go`(7 条) diff --git a/internal/web/baseline.go b/internal/web/baseline.go index eed839d..0961659 100644 --- a/internal/web/baseline.go +++ b/internal/web/baseline.go @@ -227,15 +227,16 @@ func (e *errBaselineMismatch) Error() string { // baselineReport 是一次换底座的结果,用来给页面/提示提供具体数字。 type baselineReport struct { - Name string - SHA string - Size int64 - Matched int - WithUUID int - Counts []baselineCount - Replaced bool // 是否真的换掉了(false = 原本没有底座,是首次建立) - Backup string - ElapsedMs int64 + Name string + SHA string + Size int64 + Matched int + WithUUID int + Counts []baselineCount + Replaced bool // 是否真的换掉了(false = 原本没有底座,是首次建立) + Backup string + SiteVisits int // 站内到访条数,用来提示「新底座里多出来的那些不会进包」 + ElapsedMs int64 } // swapBaseline 把当前数据集的底座换成 archive 那份包。 @@ -284,6 +285,10 @@ func (s *Server) swapBaseline(ctx context.Context, datasetID int64, archive, nam if counts, err := baselineCounts(ctx, tmp); err == nil { rep.Counts = counts } + // 站内到访条数:新底座里多出来的那些不会进导出包(导出的行集合始终等于站点数据), + // 这点反直觉,要在提示里说出来。 + _ = s.db.QueryRowContext(ctx, `SELECT count(*) FROM visits WHERE dataset_id=$1`, datasetID). + Scan(&rep.SiteVisits) if _, err := os.Stat(dst); err == nil { rep.Replaced = true @@ -676,5 +681,21 @@ func baselineSwapMessage(rep baselineReport, datasetName string) string { } } b.WriteString(" 站点数据一行没动,导出即刻以新底座为准。") + // 反直觉的一条:底座里比站内多出来的到访会被导出清掉(导出的行集合以站点数据为准)。 + // 不说清楚的话,站长会以为「换了更新的手机包 ⇒ 导出的包里就有手机上的新记录」。 + if extra := baselineCountValue(rep.Counts, "ZVISIT") - rep.SiteVisits; extra > 0 { + fmt.Fprintf(&b, " ⚠️ 新底座里有 %d 条到访是站内还没有的,它们不会进导出包"+ + "(导出的行集合以站点数据为准):想让它们进包,先用「分析差异」把这份备份合并进来,再来换底座。", extra) + } return b.String() } + +// baselineCountValue 取画像里某张表的行数(没有这张表就是 0)。 +func baselineCountValue(counts []baselineCount, table string) int { + for _, c := range counts { + if c.Table == table { + return c.N + } + } + return 0 +} diff --git a/internal/web/baseline_swap_test.go b/internal/web/baseline_swap_test.go index 821d3e2..92a20ae 100644 --- a/internal/web/baseline_swap_test.go +++ b/internal/web/baseline_swap_test.go @@ -17,6 +17,7 @@ import ( "time" "rond-with-you/internal/ingest" + "rond-with-you/internal/rond" ) // ---------- 换基线 ---------- @@ -498,3 +499,63 @@ func TestBaselinePageRenders(t *testing.T) { t.Errorf("页面没渲染到底(尾部不是 ),多半是模板中途报错了:…%s", tail) } } + +// TestSwapBaselineDoesNotCarryRowsTheSiteDoesNotHave 守一条反直觉的语义: +// 新底座里那些站点还没有的到访**不会**进导出包(导出的行集合以站点数据为准)。 +// +// 站长很容易以为「换了更新的手机包 ⇒ 导出的包里就有手机上的新记录」,所以提示文案里 +// 必须点出这一点;而这条语义本身也不能只靠文案保证——下面用真数据验一遍。 +func TestSwapBaselineDoesNotCarryRowsTheSiteDoesNotHave(t *testing.T) { + f := newMovementFixture(t) + ctx, dsID, srv := f.ctx, f.dsID, f.srv + if _, err := srv.ensureBaseline(ctx, dsID); err != nil { + t.Fatalf("准备底座失败: %v", err) + } + uuidsBefore := exportVisitUUIDs(t, openExport(t, f.export(t))) + + // 先在站内补一条到访(没有留档 ⇒ 导出时会补一个本站派生的确定性 UUID), + // 导出成一份「比站内多一条」的包,再把这条从站内删掉:新底座里就留下了一条 + // 站点没有的行,正是要验证的情形。 + seed := pickDupBase(t, srv, ctx, dsID) + extra, err := srv.nextSrcPK(ctx, f.pg, dsID, visitSeq) + if err != nil { + t.Fatal(err) + } + if err := clearStaleRaw(ctx, f.pg, dsID, "ZVISIT", extra); err != nil { + t.Fatal(err) + } + if _, err := f.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, seed.srcPK, extra); err != nil { + t.Fatal(err) + } + pkg, sha, _ := baselinePackageFile(t, "newer.rondbackup", f.export(t)) + if _, err := f.pg.ExecContext(ctx, `DELETE FROM visits WHERE dataset_id=$1 AND src_pk=$2`, + dsID, extra); err != nil { + t.Fatal(err) + } + + rep, err := srv.swapBaseline(ctx, dsID, pkg, "newer.rondbackup", sha, false) + if err != nil { + t.Fatalf("换底座失败: %v", err) + } + if baselineCountValue(rep.Counts, "ZVISIT") <= rep.SiteVisits { + t.Fatalf("用例前提不成立:新底座应当比站内多出到访,实际底座 %d / 站内 %d", + baselineCountValue(rep.Counts, "ZVISIT"), rep.SiteVisits) + } + if !strings.Contains(baselineSwapMessage(rep, "数据集"), "不会进导出包") { + t.Error("提示里没有点出「底座里多出来的到访不会进导出包」") + } + + after := exportVisitUUIDs(t, openExport(t, f.export(t))) + if !sameStrings(after, uuidsBefore) { + t.Errorf("换到「更新」的底座后导出内容变了(%d 条 → %d 条)", len(uuidsBefore), len(after)) + } + want := rond.DerivedVisitUUID(extra) + for _, u := range after { + if u == want { + t.Errorf("那条只在新底座里的到访(%s)被带进了导出包", u) + } + } +} diff --git a/internal/web/templates/admin/baseline.html b/internal/web/templates/admin/baseline.html index 83f900e..32dd577 100644 --- a/internal/web/templates/admin/baseline.html +++ b/internal/web/templates/admin/baseline.html @@ -111,6 +111,11 @@ 判据有两条——这份包得跟当前数据集是同一批数据(按到访 UUID 认),而且得真的能导出成功。 换之前旧底座会留档,随时可以回滚。 +
+ 注意:导出的行集合以站点数据为准(另加少量「导入时进不了站点、 + 靠原始留档补回」的行),所以这份包里如果有站点还没导入过的到访(手机上新记录的那些), + 它们不会进导出包。想让它们进包,先用后台的「分析差异」把这份备份合并进来,再来换底座。 +