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

133 lines
12 KiB
Markdown
Raw Normal View History

# 采集底座修复(阶段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 内存降级(页面刷新视为新访客,已知限制,不在本阶段扩大)。