换基线:说清「新底座里多出来的到访不会进导出包」,并把它钉成用例

写文案时去核这条,先把一条写错的断言纠正了:导出条数本来就不等于站点条数。

tableWriter.finish() 是两段:先删「基线里有、PG 已没有」的行(known − seen),
再把「导入时进不了 PG 但留档还在」的行(w.skipped × raws)按原样补回——否则
「导入→导出」一趟会凭空少数据。实测 823 条到访的数据集导出包里是 824 条,差值正常。
(第一版成功路径用例断言「导出 == 站点」,红在 824 != 823 就是这个原因;已改成更锋利的
「换底座前后导出包里 ZVISIT 的 UUID 集合逐条一致」。)

同一段代码定下了换基线的语义:新底座里站点没有的行会被清掉(进 known 没进 seen)。
所以换一份更新的手机包**不会**把手机新记录带进导出包,要带得先走「分析差异」合并。
页面提示与 flash 文案都写明了这点,并新增用例
TestSwapBaselineDoesNotCarryRowsTheSiteDoesNotHave 钉住它(站内补一条到访 → 导成包 →
从站内删掉 → 换底座 → 断言导出里既没有那条的派生 UUID、UUID 集合也与换之前逐条一致)。

验收:./scripts/verify.sh 全量 PASS=319 FAIL=0 SKIP=0。
This commit is contained in:
tamakyi
2026-09-30 19:15:48 +08:00
parent 6ff6f944a1
commit 52a82631da
5 changed files with 121 additions and 9 deletions
+30 -9
View File
@@ -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
}
+61
View File
@@ -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("页面没渲染到底(尾部不是 </html>),多半是模板中途报错了:…%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)
}
}
}
@@ -111,6 +111,11 @@
判据有两条——这份包得跟当前数据集是同一批数据(按到访 UUID 认),而且得真的能导出成功。
换之前旧底座会留档,随时可以回滚。
</div>
<div class="alert">
<strong>注意</strong>:导出的行集合以<strong>站点数据</strong>为准(另加少量「导入时进不了站点、
靠原始留档补回」的行),所以这份包里如果有站点还没导入过的到访(手机上新记录的那些),
它们<strong>不会</strong>进导出包。想让它们进包,先用后台的「分析差异」把这份备份合并进来,再来换底座。
</div>
<form method="post" action="/admin/baseline" enctype="multipart/form-data">
<div class="row" style="align-items:center;flex-wrap:wrap">
<input type="file" name="backup" accept=".rondbackup,.zip" required>