diff --git a/internal/db/schema.sql b/internal/db/schema.sql index d86e9b7..2d1e75d 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -592,3 +592,25 @@ WHERE p.id = sub.place_id ON er.dataset_id = v.dataset_id AND er.entity = 'ZVISIT' AND er.src_pk = v.src_pk WHERE v.place_id = p.id AND (er.raw->>'ZMERGEDTO' IS NOT NULL OR er.raw->>'ZPARENT' IS NOT NULL)); + +-- 「这次到访不独立成立」(已并入别处 / 只是某个父段的一部分)做成派生列(2026-09-30) +-- +-- 读侧到处要判它(时间线、统计、地点列的 5 个子查询、专题……),原来是每行去 entity_raw 里 +-- 取回**整行 Core Data JSON** 再解析 raw->>'ZMERGEDTO':实体表里的 raw 是 TOAST 过的整行, +-- 一次探针要几毫秒,地点库那种「每个地点 × 5 个子查询」的量级下实测 70 秒才出页面。 +-- 派生成一列之后读侧只看布尔值(实测 /places 5.3s → 0.01s 量级)。 +-- +-- ⚠️ 写入侧要跟着刷:导入(internal/ingest)、合并(backup_merge 的 recountPlaces)都会重算。 +-- 直接改 entity_raw 的合并标记(比如 tools/ 里的脚本)之后也要重跑一次这里的 UPDATE。 +ALTER TABLE visits ADD COLUMN IF NOT EXISTS is_merged BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE visits v SET is_merged = TRUE + FROM entity_raw er +WHERE er.dataset_id = v.dataset_id AND er.entity = 'ZVISIT' AND er.src_pk = v.src_pk + AND (er.raw->>'ZMERGEDTO' IS NOT NULL OR er.raw->>'ZPARENT' IS NOT NULL) + AND NOT v.is_merged; + +-- 给「从 entity_raw 重算 is_merged」用:只索引确实带合并标记的那几十行, +-- 不用取回 raw 就能判定(上面那条回填与导入回填都走它,否则一次导入要多花几秒)。 +CREATE INDEX IF NOT EXISTS idx_entity_raw_visit_flags ON entity_raw (dataset_id, src_pk) + WHERE entity = 'ZVISIT' AND (raw->>'ZMERGEDTO' IS NOT NULL OR raw->>'ZPARENT' IS NOT NULL); diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go index e16563d..38105c4 100644 --- a/internal/ingest/ingest.go +++ b/internal/ingest/ingest.go @@ -376,6 +376,12 @@ func Import(ctx context.Context, db *sql.DB, userID int64, originalName, sha str return 0, nil, err } + // 派生列:从留档里认「已并入 / 子段」的到访(读侧只看这一列,见 stats.SoloVisitSQL)。 + // ⚠️ 同样必须排在 insertEntityRaw 之后——留档还没写盘时一条都认不出来。 + if _, err := tx.ExecContext(ctx, stats.RefreshMergedFlagsSQL, datasetID); err != nil { + return 0, nil, fmt.Errorf("回填合并标记: %w", err) + } + // 回填地点聚合:走与刷新路径(refreshPlaceAgg / recountPlaces)同一条 SQL, // 口径才不会在两处漂移——到访数与停留按访客口径(stats.SoloVisitSQL,不含已并入 / 子段), // 首末到访保持全量。原来这里在 Go 里另算一遍,还漏了停留上限(LEAST(...,10080))。 diff --git a/internal/stats/stats.go b/internal/stats/stats.go index 3e4960f..c4564d4 100644 --- a/internal/stats/stats.go +++ b/internal/stats/stats.go @@ -112,18 +112,26 @@ func (w *where) andSQL() string { // // 真机把它**并入了别的到访**(ZVISIT.ZMERGEDTO),或它**只是某个父段的一部分**(ZVISIT.ZPARENT)时, // app 既不单独显示它、也不把它算进次数与停留——本站以前把这两种行当成普通到访, -// 于是时间线上出现「同一时刻同一地点两条」(实测数据集 9 有 54 + 10 条,26 条的到达时间与原件完全相同), +// 于是时间线上出现「同一时刻同一地点两条」(实测数据集 9 有 64 条,26 条的到达时间与原件完全相同), // 日停留也跟着重复计入。 // +// 判据取派生列 `visits.is_merged`(导入 / 合并时从 entity_raw 算好,见 RefreshMergedFlagsSQL): +// 早先是每行去 entity_raw 取回**整行 Core Data JSON** 再解析 raw->>'ZMERGEDTO', +// 而 raw 是 TOAST 过的整行——地点库「每个地点 × 5 个子查询」的量级下,一个页面实测要 70 秒。 +// // ⚠️ 只在**读**的一侧收口:导出包必须原样带着这些行(rond 靠它们认这次合并发生过), -// 所以 internal/rond 与 backup_diff / backup_merge 一律不加这个条件。 +// 所以 internal/rond 与 backup_diff / backup_merge 的实体对齐一律不加这个条件。 // // 判据只此一份,别在别处手抄——抄一份迟早跟这里漂移(区域限制那套就犯过一次)。 -func SoloVisitSQL(alias string) string { - return `NOT EXISTS (SELECT 1 FROM entity_raw er WHERE er.dataset_id=` + alias + `.dataset_id` + - ` AND er.entity='ZVISIT' AND er.src_pk=` + alias + `.src_pk` + - ` AND (er.raw->>'ZMERGEDTO' IS NOT NULL OR er.raw->>'ZPARENT' IS NOT NULL))` -} +func SoloVisitSQL(alias string) string { return "NOT " + alias + ".is_merged" } + +// RefreshMergedFlagsSQL 从 entity_raw 重算 visits.is_merged。 +// 导入、合并(以及任何直接改过留档里 ZMERGEDTO / ZPARENT 的操作)之后都要跑一次。 +const RefreshMergedFlagsSQL = `UPDATE visits v SET is_merged = TRUE + FROM entity_raw er + WHERE er.dataset_id = v.dataset_id AND er.entity = 'ZVISIT' AND er.src_pk = v.src_pk + AND (er.raw->>'ZMERGEDTO' IS NOT NULL OR er.raw->>'ZPARENT' IS NOT NULL) + AND v.dataset_id = $1 AND NOT v.is_merged` // visitsWhere 构造作用于 visits 表别名 v 的过滤条件(**访客口径**:已并入 / 子段的到访不算数)。 // off 用于嵌套:子查询里的占位符从 off+1 开始编号,避免与外层冲突。 diff --git a/internal/web/backup_merge.go b/internal/web/backup_merge.go index b9e9cec..42ebfc5 100644 --- a/internal/web/backup_merge.go +++ b/internal/web/backup_merge.go @@ -761,6 +761,10 @@ func (s *Server) applyDeletions(ctx context.Context, ex execer, dsID int64, plan // 到访数与停留按**访客口径**(不含已并入 / 子段,见 stats.SoloVisitSQL),与 stats.Filter 一致; // 首末到访与数据集计数保持全量(last_visit_at 会写进导出包,datasets.visit_count 是后台口径)。 func (s *Server) recountPlaces(ctx context.Context, ex execer, dsID int64) error { + // 先把派生列刷新(合并刚插进来的行带着留档),下面的聚合读的就是它 + if _, err := ex.ExecContext(ctx, stats.RefreshMergedFlagsSQL, dsID); err != nil { + return err + } if _, err := ex.ExecContext(ctx, `UPDATE places SET visit_count=0, dwell_minutes=0, first_visit_at=NULL, last_visit_at=NULL WHERE dataset_id=$1`, dsID); err != nil { return err diff --git a/internal/web/city_agg_test.go b/internal/web/city_agg_test.go index 63acc0f..d28eb72 100644 --- a/internal/web/city_agg_test.go +++ b/internal/web/city_agg_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "net/http/httptest" "testing" + + "rond-with-you/internal/stats" ) // 地图上的城市圈气泡(/api/cities)必须「先按地点聚合、再按城市汇总」。 @@ -144,6 +146,10 @@ func TestCityAggCountsOncePerPlace(t *testing.T) { WHERE dataset_id=$1 AND entity='ZVISIT' AND src_pk=$2`, dsID, latestVisit, latestVisit); err != nil { t.Fatal(err) } + // 读侧取的是派生列 visits.is_merged,直接改留档之后要按生产路径刷一次 + if _, err := pg.ExecContext(ctx, stats.RefreshMergedFlagsSQL, dsID); err != nil { + t.Fatal(err) + } lastAfterWant = truthLast() if lastAfterWant == lastBeforeWant { t.Skipf("城市「%s」里最晚那次不是唯一的最晚记录,换一个再测", cityName) diff --git a/internal/web/hidden_visit_test.go b/internal/web/hidden_visit_test.go index 6705b6a..bac2bcc 100644 --- a/internal/web/hidden_visit_test.go +++ b/internal/web/hidden_visit_test.go @@ -93,12 +93,16 @@ func TestHiddenVisitStaysInExportButLeavesReads(t *testing.T) { t.Fatalf("标记前 ZMERGEDTO 就不该有值,实际 %v", merged.Int64) } - // 标记成「已并入 #target」 + // 标记成「已并入 #target」。读侧判据取的是派生列 visits.is_merged, + // 所以直接改留档之后必须按生产路径刷一次(导入 / 合并也是这么刷的)。 if _, err := pg.ExecContext(ctx, `UPDATE entity_raw SET raw = jsonb_set(raw, '{ZMERGEDTO}', to_jsonb($3::int), true) WHERE dataset_id=$1 AND entity='ZVISIT' AND src_pk=$2`, dsID, victim, target); err != nil { t.Fatalf("标记失败: %v", err) } + if _, err := pg.ExecContext(ctx, stats.RefreshMergedFlagsSQL, dsID); err != nil { + t.Fatalf("刷新合并标记失败: %v", err) + } // 1) 访客侧:时间线少了一条,且被标记的那条不再出现 rows, totalAfter, err := srv.q.Visits(ctx, flt, 5, 0, "desc") diff --git a/internal/web/page_render_budget_test.go b/internal/web/page_render_budget_test.go new file mode 100644 index 0000000..398f98e --- /dev/null +++ b/internal/web/page_render_budget_test.go @@ -0,0 +1,45 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// TestKeyPagesRenderFast 是一道**灾难探测器**,不是基准测试。 +// +// 2026-09-30 踩过一次:给「已并入 / 子段」做的收口写成每行去 entity_raw 取回**整行 Core Data JSON** +// 再解析 raw->>'ZMERGEDTO',而 raw 是 TOAST 过的整行——/places 要 70 秒、首页 44 秒才出页面, +// 而那批「渲染若干页面」的布局用例因此从 20 秒涨到 300 秒,整套验收从 6 分钟变 20+ 分钟。 +// 当时没有任何用例会红:功能全对,只是慢。 +// +// 所以这里给每个关键页面卡一个**很松**的上限(修好后实测 0.04~1.6 秒,这里卡 10 秒): +// 只拦「数量级」的退化,不拦正常的机器抖动。真要做性能基准另说。 +func TestKeyPagesRenderFast(t *testing.T) { + f := newMovementFixture(t) + const budget = 10 * time.Second + for _, tg := range []struct { + name string + url string + h http.HandlerFunc + }{ + {"主页", "/", f.srv.home}, + {"统计", "/stats", f.srv.statsPage}, + {"地点库", "/places", f.srv.placesPage}, + {"报告", "/report", f.srv.reportPage}, + {"时间线", "/timeline", f.srv.timelinePage}, + {"后台首页", "/admin", f.srv.adminHome}, + {"数据编辑", "/admin/edit", f.srv.editPage}, + } { + start := time.Now() + rec := httptest.NewRecorder() + tg.h(rec, httptest.NewRequest("GET", tg.url, nil).WithContext(f.ctx)) + el := time.Since(start) + t.Logf("%-8s %8dms %d bytes", tg.name, el.Milliseconds(), rec.Body.Len()) + if el > budget { + t.Errorf("%s 渲染用了 %s(上限 %s)——多半又出现了「每行去读整行留档」这类退化", + tg.name, el.Round(time.Millisecond), budget) + } + } +}