Files
fangke/docs/superpowers/specs/2026-09-18-collection-foundation-phase-a-design.md
T

133 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 采集底座修复(阶段A)— 设计文档
日期:2026-09-18
分支:m1
状态:设计已获用户批准(含两轮自审修订)
依据:gpt/14-原始上报数据分析与采集优化实施方案.md(V1.3),范围 = 阶段A(O00-O03)
前置探索:2026-09-17 代码现状核验,文档全部"代码事实"确认属实
## 背景与目标
M1 采集底座已在生产运行(hesucar.com,日访客 400+)。2026-09-16 真实样本分析(gpt/14)暴露采集底座的结构性缺陷,其中 P0 两项:
- **D02 事件归属漂移**:SDK 在 flush 时才调 getSessionId(transport.ts:44-45),事件产生与会话判定最长错位 15s;样本中 5 个孤立停留会话(8 条 page_stay 无同会话 page_view)与此吻合。事件无 page_id,归属真相活在批次信封层。
- **D08 URL 敏感值清洗缺口**:sanitize_url 只按参数名黑名单删(email/phone/token 等名),白名单参数名下藏敏感值不会被过滤;sanitize_payload 只按 JSON 键名过滤。
阶段A 目标(文档 §10 阶段A 出口):**归属不漂移、停留区间准确、清洗与用途状态生效、版本可诊断**。旧 SDK(0.1.0 缓存)持续兼容,旧数据不回填。
## 范围
**做**:O00 复现台账 + O01 契约/版本 + O02 归属冻结/停留/时间质量 + O03 清洗强化。SDK 0.1.0 → 0.2.0,服务端 schema v2(兼容 v1)。
**不做**(后续阶段):purpose/epoch/grant 授权状态机(V1.1 治理 → M2/M3,本阶段仅预留字段位);业务事件 O04/O05(阶段B = M2 核心);sdk_diag 低频诊断事件、performance_summary、error_summary(O07 后续);admin 前端展示新字段(阶段B);站点级清洗参数配置(阶段B);历史数据 page_id 回填(文档 4.1.7 禁止);Event 表保留期限清理(现状量级不需要)。
## 核心架构决策
**事件级归属快照**(用户选定方案 A):事件入队时深快照 `{site_id, visitor_id, session_id, page_id, seq}` 写入事件对象;flush 用快照构造信封,不再读当前身份;混合归属队列按四元组分组拆子批。服务端按事件级归属校验落库,批级信封仅作 v1 兼容。purpose/epoch/grant_ref 预留 null 字段位,将来直接挂进同一快照。
---
## 1. SDK(0.1.0 → 0.2.0)
### 1.1 PageContext(新模块 sdk/src/page.ts)
- **创建**:SDK init 时;SPA 路由切换时(见 1.3)。字段:`page_id`(uuid)、`session_id`(**入队时快照、flush 不再重读**;会话恢复时按规则换新,见下)、`visitor_id`、`started_at`、页内 `seq` 计数器。
- **会话恢复**:空闲 >30min(IDLE 沿用现值)后同页再活动 → 生成新 session_id + 发 `session_resume` 事件(属性:`prev_session_id`、`reason="idle_timeout"`),不伪造 page_view。**三个检查点**:用户交互时、`visibilitychange→visible` 时、flush 前;hidden 期间不算活动。
- **bfcache 恢复**(OT02):`pageshow persisted=true` 时内存上下文仍在 → **沿用原 page_id 复活**(seq 继续),不产生新 PV;若超 30min 则补 session_resume。不引入独立 document_id。
- 每页面段关闭(路由切换/pagehide)时结算尾部停留。
### 1.2 事件结构升级(events.ts)
事件对象新增公共字段:`site_id/visitor_id/session_id/page_id/seq` + 预留 `purpose/epoch/grant_ref`(null)。
- `page_view`:现有字段不变 + 公共字段;nav 信号保持 init 冻结。
- `page_stay`:**独立区间语义**——每条代表一个不重叠活动区间:`active_from/active_to`(单调时钟 epoch ms)、`active_ms`(区间时长)、`page_id`、`flush_reason`(periodic/hidden/pagehide/route_change)、`measurement_version:"2"`。详见 1.4。
- `session_resume`(新):`prev_session_id`、`reason`。
- `sdk_loaded`:`sdk_version`(不再硬编码)、`build_id`、`schema_version`、`config_version`(构建时常量,当前固定 "1",随配置结构演进升级)。
### 1.3 SPA 路由切换
hook `history.pushState/replaceState` + `popstate`。**仅当规范化 path(去 query、去 hash)变化时**开新页面段(结算旧段 → 新 page_id → 新 page_view);replaceState、hash 变化、query 变化不开段(防"地址清洗触发重复PV",文档 4.1.5)。hash 路由 SPA 站点本阶段不适配(边界,阶段B)。
### 1.4 停留重做(stay.ts)
- `performance.now()` 单调时钟计算区间;`Date.now()` 仅作展示时间(occurred_at)。
- 有效活动定义沿用冻结策略(可见 + 最近 30s 内有交互)+ `measurement_version` 标记。
- **周期结算**:每 15s(可配置 `stayIntervalMs`)将当前连续活动区间切一刀发出(flush_reason=periodic);60s 连续活动 → 4 条相邻区间,服务端并集拼回。
- `visibilitychange→hidden` 与 `pagehide` **共享结算状态**(hidden 已结算则 pagehide 不重发);hidden 时刻强制结束当前区间;切回 visible 后活动恢复时开新区间(起点=活动恢复时刻)。
- 区间与 event_id 绑定:入队后重试不换 ID(幂等)。
- beacon 成功入队 ≠ 服务端确认:沿用 unknown 语义(见 1.6)。
### 1.5 传输重构(transport.ts)
- flush 从队首事件快照取归属构造信封;队列存在多组 `(visitor, session, page)` 时**拆子批**按序发送,子批独立重试(重试重放原 body 字符串,event_id 不变)。
- 乱序到达是正常现象:服务端不拒,仅标记(见 2.3)。
- 批级信封新增:`schema_version:2`、`sdk_version`、`build_id`(`sent_at` 现有 transport.ts:46 已发送,不重复开发)。
- **beacon 字节预算**:pagehide 发送前检查 payload 字节,超 60KB(Chrome sendBeacon 64KB 限制留余量)→ 降级 fetch keepalive,仍失败则丢最旧事件并计 dropped。
- 响应处理:解析服务端逐项结果(accepted/duplicated/rejected 明细),内存累计计数。
### 1.6 版本注入
esbuild `--define`:`__SDK_VERSION__`(package.json 0.2.0)、`__BUILD_ID__`(git short hash + 构建时间戳)。每次功能变化产生可区分构建标识(D05)。
## 2. 服务端(schema v2,兼容 v1)
### 2.1 契约(constants.py / validation.py)
- 批级 `schema_version`:2 = 新路径;缺省 = v1(旧 SDK),照旧处理。
- 事件白名单 +`session_resume`;属性白名单扩展对应新字段。
- 事件级校验:site_id 匹配批 site、四元组完整、page_id uuid 格式、seq 软校验(乱序标记不拒绝)。
- event_id 跨批幂等:**已确认存在** `uq_site_event (site, event_id)` 唯一约束(models.py:105),重试重放双倍入库风险不成立,无需额外迁移。
### 2.2 模型迁移(models.py + migration)
- Event 新增:`page_id`(char, null)、`active_from_ms/active_to_ms`(bigint, null)、`flush_reason`(char)、`time_quality`(char: ok/late/skew_backwards)、`sdk_version`(char)、`schema_version`(int)。
- VisitSession 新增:`resume_of`(FK self, null)。
- RawBatch 新增:`request_id`(char)。
- 索引:`page_id` 单列;(site,event_id)唯一约束(见 2.1)。
- 加列为 instant DDL,万级表无锁风险。
### 2.3 入库(ingest.py)
- **v2 事件级归属**:visitor/session 按事件级字段 get_or_create;批级信封仅作 v1 兼容。
- `session_resume`:创建新 VisitSession(resume_of 指向 prev);prev_session_id 无效/不存在 → 软处理(resume_of=null + 计数告警),不拒事件。
- v1 事件:沿用现有路径(含 30min sid#N 兜底);v2 的会话切换由 SDK 显式 resume 驱动,服务端兜底不介入(避免双切)。
- **page_stay 聚合语义**:VisitSession.stay_ms 改为**重算式**——受影响会话每次入库后从 Event 表按 `(page_id)` 分组做**区间并集**后求和回写(当前量级重算成本低;uq_site_event 存储层去重保证重算不重复)。不按 SUM(active_ms) 增量累加(15s 增量会重复计)。**v1/v2 混合解释**:v1 事件(无 page_id,含灰度期与回滚期)沿用现有 active_ms 加法,v2 事件走区间并集,同会话内两种来源分别处理后相加——admin 数值只有一种解释。**受影响聚合点核查清单**:VisitSession.stay_ms(ingest.py F 增量处)、趋势/总览实时聚合(dashboard.py avg_stay_ms)、访客详情(visitors.py total_stay_ms/会话停留)——全部适配,保证 admin 现有页面数值正确(阶段A 不改 admin 界面)。
- time_quality:按 received−occurred 差值分类(阈值沿用现有 late 判定 + 新增负值 → skew_backwards)。
- **响应契约**:`{accepted, duplicated, rejected:[{index, event_id, reason}], request_id}`——rejected 明细回传;HTTP 200 不再隐含全量成功。
### 2.4 清洗强化(D08,validation.py + collect.py)
URL 清洗三轨制(替代现行"仅参数名黑名单"):
1. **参数名黑名单**(phone/mobile/tel/email/name/user/token/key/password/verif 等)→ 删;
2. **归因白名单**(utm_*、gclid、gbraid、wbraid、fbclid、gad_source、gad_campaignid、msclkid 等)→ 提取保留(供归因);
3. **其余参数按值判别**:含 @、超 32 位随机串、超长值 → 删;纯数字 ≤10 位、短 slug → 保留(未知业务参数如 ?id=123 自动保住,WordPress query 路由不受影响)。slug 安全线定 **32**(总长 ≤32 且 [A-Za-z0-9._-],32+ 连续字母数字视为 token 删——实现计划已用向量钉死此口径,以计划为准)。
另:URL userinfo 剥离;fragment 全删(hash 路由站点阶段B 前不适配,边界注明);title 中敏感内容按站点模板规则本阶段不做(无规则来源,边界注明)。
清洗时机:SDK 入队前 + 服务端快照前双保险;清洗前内容不写入 RawBatch/日志/导出(文档 8.3)。
**双实现一致性**:TS 与 Python 两份清洗逻辑,共享**单源测试向量** `server/tracker/tests/fixtures/sanitize_vectors.json`(一组含敏感值的 URL 输入/期望输出);SDK vitest 以相对路径读同一文件,两侧断言一致。规则带版本常量(同步升降)。
## 3. 部署与灰度
1. **服务端先上线**(`docker compose up -d --build web` 自动 migrate)→ 线上 v1 上报回归验证(现有 142 测试 + 生产探活)。
2. SDK 0.2.0 构建替换 sdk/dist(nginx 挂载 /opt/cms-cj/sdk/dist)→ 新访客逐步加载,旧缓存自然过期。
3. 观察指标:孤立 page_stay 会话占比(D02 复现指标,应降为 0)、事件量增幅(15s 增量结算预计 +3~4 倍 page_stay,MySQL 万级/日无压力)、拒绝率、RawBatch 状态分布。
4. **回滚安全**:SDK 回滚至 0.1.0 无需回滚服务端(ingest 聚合语义与 SDK 版本解耦,v1 路径完好);Event 新字段留驻无害。
## 4. 测试与验收(O00 复现台账)
- **SDK 单测**(fake timers):PageContext 生命周期;归属快照不漂移(模拟 30min 空闲 + 延迟 flush + 会话切换);停留区间切分/并集(15s 周期、hidden/pagehide 共享结算、bfcache 恢复、路由切换);混合队列拆子批;beacon 字节预算;重试幂等。
- **server 单测**:v2 校验/落库、v1 兼容不回归、session_resume(含 prev 无效软处理)、page_stay 并集聚合(VisitSession.stay_ms、趋势聚合核对)、清洗三轨制(共享向量)、响应明细、event_id 唯一约束。
- **台账**:`docs/验收记录-阶段A.md`——OT01-OT04/OT06/OT17 用例与自动化测试映射表(OT01 30min 同页、OT02 多标签/SPA/bfcache/重试、OT03 时钟、OT06 合成敏感值、OT17 beacon unknown/部分拒绝),记录构建版本/输入/预期实际。
- 生产验证:部署后 48h 观察 D02 指标归零、清洗后 URL 无敏感值残留抽查。
## 5. 风险与边界
- 手机号藏于 `?id=13800138000` 类参数:值判别无法与订单号区分,理论残余风险由规则版本化 + 阶段B 站点级配置兜底(文档口径:覆盖已知模式,不承诺理论完备)。
- 移动端后台冻结定时器:区间制天然正确(恢复时结算真实区间),真机抽检项。
- 多标签页:page_id 内存级互不干扰;共享 ctk_act 下标签间活动互续会话,符合"访客活跃"语义;停留按页面段累计,不称"访客去重参与时长"(文档 4.2)。
- 隐私模式 localStorage 失效:沿用 M1 内存降级(页面刷新视为新访客,已知限制,不在本阶段扩大)。