diff --git a/docs/superpowers/plans/2026-09-18-collection-phase-b.md b/docs/superpowers/plans/2026-09-18-collection-phase-b.md
new file mode 100644
index 0000000..47c0b87
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-18-collection-phase-b.md
@@ -0,0 +1,321 @@
+# 采集阶段B:业务事件体系 — 实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 零网站改动的业务事件体系——SDK 自动监听咨询点击/CF7 表单全过程/产品页,服务端契约+6 事件,D06 归因 paid_social,admin 时间线+询盘漏斗卡。
+
+**Architecture:** SDK 新模块 business.ts(与 page.ts/stay.ts 同模式,事件携带阶段A 归属);server 白名单+校验扩展+归因规则 v2(fb+paid 优先);admin 时间线徽章+漏斗 API/卡片。CF7 事实已按线上 6.1.6 源码核验写入 spec §1.2。
+
+**Tech Stack:** sdk/(TS+vitest+esbuild,基线 100 测试);server/(Django+DRF,基线 161 测试);admin/(Vue3+Element Plus)。
+
+**Spec:** docs/superpowers/specs/2026-09-18-collection-phase-b-design.md(§1.2 已含 CF7 实测事实——wpcf7statuschanged 状态机/原生 submit capture/FormData hidden 采集)
+
+**执行顺序:** T1(server 契约) → T2(归因 D06) → T3(漏斗 API) → T4(SDK business.ts) → T5(track 修复+接线) → T6(admin) → T7(部署,排 9/20 观察窗后) → T8(线上验证+测试询盘)
+
+**测试命令:** server `cd server && python -m pytest tracker/tests/ -q`;sdk `cd sdk && npx vitest run`。
+
+**CF7 已核验事实(实现者必读,spec §1.2):**
+- CF7 拦截原生 submit 于 form 元素冒泡阶段(`addEventListener("submit", e => { wpcf7.submit(...); e.preventDefault() })`)→ SDK 的 **document capture 委托必然先执行**
+- 状态事件 `wpcf7statuschanged`(bubbles:true,`detail.status` ∈ init/validating/invalid/unaccepted/spam/mail_sent/mail_failed/resetting)
+- FormData:`:where(input,textarea,select):enabled` 逐字段 append,hidden 走 default 分支(`_ctk_sid` 会被采集)
+- `wpcf7submit` 在 AJAX 响应后才触发——**弃用**
+
+---
+
+### Task 1: Server 契约扩展(+6 事件与校验)
+
+**Files:**
+- Modify: `server/tracker/constants.py`(EVENT_NAMES/EVENT_PROP_WHITELIST)
+- Modify: `server/tracker/services/validation.py`(validate_event 新键校验)
+- Test: `server/tracker/tests/test_validation.py`
+
+- [ ] **Step 1: 失败测试**(追加):
+
+```python
+import pytest
+
+def _biz(name, **props):
+ return {"event_id": f"biz-{name}", "event_name": name, "occurred_at": timezone.now(),
+ "path": "/p", "title": "t", "site_id": SITE_ID, "visitor_id": "v1",
+ "session_id": "s1", "page_id": "p1", "seq": 1, "properties": props}
+
+@pytest.mark.parametrize("ev,expect_reason", [
+ (_biz("contact_click", channel="whatsapp"), None),
+ (_biz("contact_click", channel="im"), "property_not_allowed"), # 枚举外
+ (_biz("form_start", form_id="cf7-156"), None),
+ (_biz("form_start", form_id="wpcf7-f156-p20"), "property_not_allowed"), # 格式不符
+ (_biz("form_submit", form_id="cf7-156", submission_id="9c8b7a6d-1111-4222-8333-444455556666"), None),
+ (_biz("form_submit", form_id="cf7-156", submission_id=""), None), # 空串合法(极端时序)
+ (_biz("form_submit", form_id="cf7-156", submission_id="not-uuid"), "property_not_allowed"),
+ (_biz("form_error", form_id="cf7-156", submission_id="", error_code="invalid", field_count=2), None),
+ (_biz("form_error", form_id="cf7-156", submission_id="", error_code="bogus"), "property_not_allowed"),
+ (_biz("form_error", form_id="cf7-156", submission_id="", error_code="invalid", field_count=-1), "property_not_allowed"),
+ (_biz("form_submit_result", form_id="cf7-156", submission_id="", result_code="mail_sent"), None),
+ (_biz("form_submit_result", form_id="cf7-156", submission_id="", result_code="ok"), "property_not_allowed"),
+ (_biz("product_view", product_id="byd-equation-leopard-5", inferred=True, rule_version="v1"), None),
+ (_biz("product_view", product_id="x"*101, inferred=True, rule_version="v1"), "property_not_allowed"),
+])
+def test_biz_event_validation(ev, expect_reason):
+ assert validate_event(ev, v2=True, batch_site_id=SITE_ID, batch_visitor_id="v1") == expect_reason
+```
+
+(SITE_ID/imports 参照该文件既有测试;timezone 从 django.utils 导入。)
+
+- [ ] **Step 2: 跑失败**(property_not_allowed——白名单无新事件)
+
+- [ ] **Step 3: 实现——constants.py**:
+
+```python
+EVENT_NAMES = {..., "contact_click", "form_start", "form_error", "form_submit", "form_submit_result", "product_view"}
+EVENT_PROP_WHITELIST = {...,
+ "contact_click": {"channel"},
+ "form_start": {"form_id"},
+ "form_submit": {"form_id", "submission_id"},
+ "form_error": {"form_id", "submission_id", "error_code", "field_count"},
+ "form_submit_result": {"form_id", "submission_id", "result_code"},
+ "product_view": {"product_id", "inferred", "rule_version"},
+}
+```
+
+- [ ] **Step 4: 实现——validation.py**(validate_event 尾部新增,白名单通过后):
+
+```python
+_CHANNEL = {"whatsapp", "phone", "email"}
+_RESULT = {"mail_sent", "mail_failed"}
+_ERROR = {"invalid", "spam", "other"}
+_SUBMIT_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$|^$")
+_FORM_RE = re.compile(r"^cf7-\d{1,10}$")
+_PROD_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$")
+
+def _biz_props_ok(name, props) -> bool:
+ ch = lambda k: isinstance(props.get(k), str)
+ if name == "contact_click": return ch("channel") and props["channel"] in _CHANNEL
+ if name == "form_start": return ch("form_id") and _FORM_RE.match(props["form_id"])
+ if name in ("form_submit", "form_submit_result"):
+ if not (ch("form_id") and _FORM_RE.match(props["form_id"])): return False
+ sid = props.get("submission_id", "")
+ return isinstance(sid, str) and bool(_SUBMIT_RE.match(sid))
+ if name == "form_error":
+ if not (ch("form_id") and _FORM_RE.match(props["form_id"])): return False
+ sid = props.get("submission_id", "")
+ fc = props.get("field_count")
+ return (isinstance(sid, str) and bool(_SUBMIT_RE.match(sid))
+ and ch("error_code") and props["error_code"] in _ERROR
+ and isinstance(fc, int) and 0 <= fc <= 99 and not isinstance(fc, bool))
+ if name == "product_view":
+ return (ch("product_id") and bool(_PROD_RE.match(props["product_id"]))
+ and isinstance(props.get("inferred"), bool)
+ and ch("rule_version") and len(props["rule_version"]) <= 16)
+ return True
+```
+
+(调用点:validate_event 白名单检查后 `if ev["event_name"] in C.BIZ_EVENT_NAMES and not _biz_props_ok(...): return "property_not_allowed"`——枚举常量放 constants:`BIZ_EVENT_NAMES = {...6 个}`,EVENT_NAMES = 旧4 ∪ BIZ。)
+
+- [ ] **Step 5: 全量回归 161+新增全过 → Commit** `feat(server): 业务事件契约——白名单+6事件与枚举校验`
+
+---
+
+### Task 2: D06 归因扩展(paid_social + fb 优先规则)
+
+**Files:**
+- Modify: `server/tracker/constants.py`(CHANNELS/ATTRIBUTION_RULE_VERSION)
+- Modify: `server/tracker/services/attribution.py`(classify 分支)
+- Test: `server/tracker/tests/test_attribution.py`
+
+- [ ] **Step 1: 失败测试**(追加;helper 沿文件既有 classify 调用形态):
+
+```python
+def test_fb_paid_medium_priority():
+ # 实测主导模式:utm_medium=paid&utm_source=fb → paid_social(优先于强化集)
+ r = classify(referrer_url="", utm={"utm_source": "fb", "utm_medium": "paid"},
+ landing_path="/", ua="", internal_domains=[])
+ assert r["channel"] == "paid_social"
+
+def test_fb_family_medium_union():
+ for m in ("cpc", "social", "paid_social", "social_ads"):
+ r = classify(referrer_url="", utm={"utm_source": "facebook.com", "utm_medium": m},
+ landing_path="/", ua="", internal_domains=[])
+ assert r["channel"] == "paid_social", m
+
+def test_nonfb_paid_medium_stays_paid_search_label_only():
+ r = classify(referrer_url="", utm={"utm_source": "google", "utm_medium": "cpc"},
+ landing_path="/", ua="", internal_domains=[])
+ assert r["channel"] == "paid_search" and r["resolution"] == "label_only"
+
+def test_fbclid_now_paid_social():
+ r = classify(referrer_url="", utm={}, landing_path="/?fbclid=IwARxyz", ua="", internal_domains=[])
+ assert r["channel"] == "paid_social"
+
+def test_gclid_still_paid_search():
+ r = classify(referrer_url="", utm={}, landing_path="/?gclid=EAIa", ua="", internal_domains=[])
+ assert r["channel"] == "paid_search"
+```
+
+- [ ] **Step 2: 跑失败**(paid_social 不存在)
+
+- [ ] **Step 3: 实现**:
+- constants:`CHANNELS = (..., "paid_social", ...)`(paid_search 之后);`ATTRIBUTION_RULE_VERSION = "v2"`;付费 medium 全集常量 `PAID_MEDIUMS = {"cpc","ppc","paidsearch","paid","social","paid_social","social_ads","paidsocial"}`、`SOCIAL_MEDIUMS = {"social","paid_social","social_ads","paidsocial"}`
+- attribution.py classify:现有 `_paid_param` 分支改:fbclid → paid_social / gclid/msclkid/yclid → paid_search;**新增分支(在 _paid_param 之后、referrer 判定之前)**:无点击参数时若 utm_source 命中 fb 家族(复用 `_AD_FAMILIES`/家族表)且 utm_medium∈PAID_MEDIUMS → paid_social(evidence_kind="utm_label", resolution="label_only");utm_source 非 fb 家族但 medium∈PAID_MEDIUMS-\SOCIAL_MEDIUMS → paid_search label_only(既有强化行为保持)
+- [ ] **Step 4: 全量回归 + 既有归因测试按新口径更新**(fbclid 断言从 paid_search 改 paid_social 等预期内变更)
+- [ ] **Step 5: Commit** `feat(server): 归因v2——paid_social渠道与fb+付费medium优先规则`
+
+---
+
+### Task 3: 询盘漏斗 API
+
+**Files:**
+- Create/Modify: `server/tracker/api/dashboard.py`(加 funnel_summary 视图或并入既有 dashboard 视图集)+ `server/config/urls.py`(路由)
+- Test: `server/tracker/tests/test_dashboard.py`
+
+- [ ] **Step 1: 失败测试**:
+
+```python
+def test_funnel_summary_independent_session_counting(db, ...):
+ # 会话A:2次page_view+1次contact_click;会话B:1次page_view+1次form_submit;bot事件若干
+ # 断言五级 = [2, 1, 0, 1, 0](distinct session,非嵌套非事件数)
+def test_funnel_summary_excludes_bots(db, ...):
+ # is_bot=True 的事件不计入任何级
+```
+
+- [ ] **Step 2: 实现**:`GET /api/admin/funnel-summary?days=N`(默认 7;权限/site 过滤沿 dashboard 既有模式)。五级 event_name 序列 `["page_view","contact_click","form_start","form_submit","form_submit_result"]`,每级 `Event.objects.filter(site=, occurred_at__gte=窗口, is_bot=False, event_name=级).values("session").distinct().count()`。
+
+- [ ] **Step 3: 回归 + Commit** `feat(server): 询盘漏斗API——五级独立distinct会话计数/排除机器人`
+
+---
+
+### Task 4: SDK business.ts(核心模块)
+
+**Files:**
+- Create: `sdk/src/business.ts`
+- Modify: `sdk/src/page.ts`(onSegmentChange 钩子)
+- Test: `sdk/test/business.test.ts`(新建)
+
+- [ ] **Step 1: page.ts 加 onSegmentChange**(先失败测试再实现——与 onClose 同模式):
+
+```ts
+// 模块级 segmentListeners: Set<(ps: PageSession) => void>
+export function onSegmentChange(fn: (ps: PageSession) => void): void
+// onRouteChange 内新 PageSession 创建并 emitPageView 之后:for fn of segmentListeners → try fn(currentPs) catch 静默
+```
+
+(**不在 close() 内触发**——close 覆盖 pagehide/重复 init,会导致卸载重复 product_view,spec §1.3。)
+
+- [ ] **Step 2: business.ts 失败测试清单**(fake timers + happy-dom,沿 page.test.ts 模式):
+
+```
+contact_click:wa.me/tel:/mailto: 链接点击 → 事件 channel 正确 + flush(true) 被调
+ (非链接点击/其他 http 链接不触发;wa.me 与 api.whatsapp.com 与 whatsapp:// 三形态)
+form_start:.wpcf7-form 内 isTrusted focusin → 事件 + _ctk_sid 空占位注入
+ (同表单第二次 focusin 不重发;isTrusted=false 的合成 focus 不触发)
+form_submit:表单原生 submit(capture 委托)→ submission_id 生成 + _ctk_sid 值更新 + 事件引用同 ID
+ (两次 submit → 两个不同 submission_id)
+form_error:dispatch wpcf7statuschanged(status="invalid") → error_code=invalid;
+ status="spam" → spam;field_count 从 DOM .wpcf7-not-valid-tip 计数
+form_submit_result:status="mail_sent" → result_code=mail_sent;"mail_failed" → mail_failed
+form_id:action="#wpcf7-f156-p20-o1" → "cf7-156"
+product_view:/product/slug/ 与 /de/product/slug/(非锚定段匹配)→ 事件 inferred=true;
+ /product-category/suv/ 不触发;onSegmentChange 切段到产品页 → 事件带新 page_id
+红线:监听器内 throw 不冒泡
+```
+
+- [ ] **Step 3: 实现 business.ts**(结构):
+
+```ts
+export function startBusinessTracking(t: Transport): void {
+ // 1) contact_click:document click capture 委托——closest("a[href]") 命中协议前缀
+ // (wa.me|api.whatsapp.com|whatsapp:// → whatsapp;tel: → phone;mailto: → email)
+ // emit 后 t.flush(true)
+ // 2) form_*:document focusin+input capture(.wpcf7-form 委托,isTrusted,每 form WeakSet 一次)
+ // → form_start + 注入
+ // document submit capture:target.closest(".wpcf7-form") → submission_id=uuid()
+ // → 更新 _ctk_sid 值 → form_submit 事件引用
+ // document wpcf7statuschanged:detail.status 映射 form_error/form_submit_result
+ // (引用最近 submission_id——WeakMap;field_count =
+ // form.querySelectorAll(".wpcf7-not-valid-tip").length)
+ // 3) form_id:从 form action/id 正则 /wpcf7-f(\d+)/ → `cf7-${ID}`;解析不出 → "cf7-0"
+ // 4) product_view:matchProduct(path)(段匹配:路径段数组含 "product" 且其后一段为 slug)
+ // init 时对当前 path 判定 + onSegmentChange(ps => matchProduct(location.pathname))
+ // 所有 emit:事件结构 = events 形态 + ps.attribution() 顶层展开(同 stay.ts 模式)
+ // 所有回调 try/catch 静默(红线)
+}
+function matchProduct(path: string): string | null {
+ const segs = path.split("/").filter(Boolean);
+ const i = segs.indexOf("product");
+ if (i >= 0 && i + 1 < segs.length && segs[i + 1]) return segs[i + 1].slice(0, 100);
+ return null;
+}
+```
+
+- [ ] **Step 4: 全量 `npx vitest run`(100+新增)+ tsc 干净 → Commit** `feat(sdk): business.ts——咨询点击/CF7状态机/产品页自动监听`
+
+---
+
+### Task 5: track 通道修复 + 0.2.1 接线
+
+**Files:**
+- Modify: `sdk/src/index.ts`(track 补归属 + business 接线 + 版本 0.2.1)
+- Modify: `sdk/package.json`(version 0.2.1)
+- Modify: `sdk/src/types.ts`(SDK_VERSION)
+- Test: `sdk/test/index.test.ts`
+
+- [ ] **Step 1: 失败测试**:
+ - track 事件带归属顶层字段(site/visitor/session/page/seq 来自当前 PageSession)
+ - init 后队列含 page_view/sdk_loaded/product_view(产品页路径下)/business 监听已装(可从 contact_click 事件冒烟验证)
+ - SDK_VERSION 断言 "0.2.1"
+- [ ] **Step 2: 实现**:index.ts init 分支加 `startBusinessTracking(transport)`(startStayTracking 之后);track 分支 `transport.push({ ...arg, ...getPageSession()?.attribution() })`(PageSession 未启动时保持原样——兜底组路径)
+- [ ] **Step 3: 全量 + build(记录 gzip)→ Commit** `feat(sdk): 0.2.1——track补归属+business接线`
+
+---
+
+### Task 6: Admin(时间线新事件 + 漏斗卡)
+
+**Files:**
+- Modify: `admin/src/views/tracker/visitor/components/EventTimeline.vue`(EVENT_META 扩展)
+- Modify: `admin/src/views/tracker/dashboard/index.vue`(漏斗卡)
+- Modify: `admin/src/api/`(漏斗 API 函数,沿 dashboard 既有 API 模块)
+- Test: Playwright 视觉验证(T6 内含)
+
+- [ ] **Step 1: EventTimeline EVENT_META 扩展**(先读现有 EVENT_META 结构):
+
+```
+contact_click → 图标 ChatDotRound,label 咨询点击,副行显示 channel 中文(whatsapp→WhatsApp 等)
+form_start → EditPen 表单开始
+form_submit → Promotion 表单提交(submission_id 前 8 位)
+form_error → WarningFilled 表单校验未过(error_code+field_count)
+form_submit_result → 成功/失败按 result_code 区分(mail_sent→SuccessFilled 邮件已发(未验证)/mail_failed→CircleCloseFilled 发送失败)
+product_view → Van 产品查看(product_id,inferred 标记)
+```
+
+- [ ] **Step 2: 漏斗卡**(dashboard):
+- API:`getFunnelSummary(days)` → `{levels: [{key, label, count}]}`;当日/近7天切换(days=1/7)
+- 卡片 UI:五级横向计数条(各级 label+count,**无百分比**);末级 label"邮件已发(未验证)";样式沿客迹 V1 卡片(参考现有渠道分布卡的写法);位置:渠道分布卡下方或旁(看排版)
+- 渠道徽章:grep admin 中 paid_search/付费搜索 的标签映射处,加 paid_social→付费社交
+
+- [ ] **Step 3: pnpm build 通过 → Commit** `feat(admin): 时间线业务事件徽章+询盘漏斗卡`
+
+---
+
+### Task 7: 部署(排 9/20 观察窗 D02 确认后执行)
+
+- [ ] **Step 1: 前置确认**:观察窗指标(孤立 page_stay 归零)已核验;server/sdk/admin 全量测试绿
+- [ ] **Step 2: server 先行**:tar → scp 经跳板 → /opt/cms-cj 原地覆盖 → docker compose up -d --build web → 容器/探活 → v1/v2 回归批
+- [ ] **Step 3: 归因重算**:`docker exec tracker-web python manage.py recompute_attribution --site site_T3Fkse332jjbejPF` → 汇报渠道口径变化(付费搜索部分移至付费社交——预期内)
+- [ ] **Step 4: admin**:build → tar → scp → 解压 → **cp -rf dist/* admin/dist/**(真实挂载点)→ curl 哈希验证
+- [ ] **Step 5: SDK 0.2.1**:build → gzip 记录 → tar → scp → **cp 到 sdk/dist**(真实挂载点)→ curl 验证 build_id
+
+### Task 8: 线上验证 + 测试询盘闭环
+
+- [ ] **Step 1: contact_click/product_view 验证**:Playwright 访问 hesucar 产品页(等 15s flush 或触发交互)→ 生产库查 product_view 落库(inferred=true);点 WhatsApp 链接 → contact_click 落库
+- [ ] **Step 2: form_ 全链路(测试询盘,用户已接受)**:hesucar 联系页表单填 Name="TEST-KEJI"、Tel/Message 合法值 → 提交 → 生产库断言 form_start/form_submit(submission_id 非空)/form_submit_result(mail_sent) 落库;漏斗 API 五级数字 +1 验证;_ctk_sid 已随 POST(可通过 RawBatch 批次体佐证——form_submit 事件的 submission_id 非空即间接证明 capture 时序成立)
+- [ ] **Step 3: admin 视觉**:登录后台 → 访客详情时间线显示新事件徽章 → 数据总览漏斗卡渲染正确
+- [ ] **Step 4: 清理与汇报**:测试询盘数据保留(TEST-KEJI 标记可辨识,商家可自行删除邮件);汇报部署版本/验证结果/归因口径变化
+
+---
+
+## 风险提示(执行者必读)
+
+1. **CF7 事实已核验**(spec §1.2)——不要按旧文档/记忆使用 wpcf7invalid/wpcf7mailsent;一切以 wpcf7statuschanged 状态机为准。
+2. SDK 红线:所有回调 try/catch 静默(§4.5);business.ts 的 DOM 监听全部 capture 委托(时序确定性依赖)。
+3. Task 2 既有归因测试的口径变更是预期内(fbclid→paid_social 等),逐个核对更新而非批量跳过。
+4. 部署顺序硬约束:server → admin → SDK;T7 整体排在观察窗确认后。
+5. `_ctk_sid` 注入使用 DOM API(createElement+appendChild),勿用 innerHTML(宿主页安全)。