Co-Authored-By: Claude Code <noreply@anthropic.com>
21 KiB
采集阶段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: 失败测试(追加):
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 在本文件不存在——从 test_ingest.py import 或本地定义(SITE_ID = "site_test"),timezone 从 django.utils 导入;注意文件头已有 imports 复用。)
-
Step 2: 跑失败(property_not_allowed——白名单无新事件)
-
Step 3: 实现——constants.py:
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 尾部新增,白名单通过后):
_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 调用形态):
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;fb+paid 优先检查集成在 UTM 叠加分支内部(attribution.py:202-204 的 else 路径)而非其前——字面"放在前面"会被后续_utm_channel(utm_source)覆写(fb→referral)并破坏测试:utm_source 命中 fb 家族(经_canon归一后查_AD_FAMILIES)且 medium∈PAID_MEDIUMS → paid_social(evidence_kind="utm_label", resolution="label_only");非 fb 家族 + medium∈PAID_MEDIUMS-SOCIAL_MEDIUMS → paid_search label_only(注意这是新行为:现行为 google+cpc 走 _utm_channel=natural_search,spec §3 将其改判付费);非 fb + medium∈SOCIAL_MEDIUMS 不判付费(twitter&utm_medium=social 仍走社交/引荐——与 spec 字面"其余付费 medium→paid_search"的有意分歧,防误伤自然社交,计划钉死此决策) -
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: 失败测试:
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 既有模式——site 条件按 dashboard 的条件过滤写法(site 参数缺省/None 时不加 filter),勿字面filter(site=None);时间窗复用 dashboard 既有上海自然日窗口计算(make_aware(combine(...)),[T-N+1, T+1)),保证"当日"与总览今日卡口径一致)。五级 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 同模式):
// 模块级 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="unaccepted" → error_code=invalid(spec §1.2 映射,勿漏);
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(结构):
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 + 注入 <input type=hidden name=_ctk_sid value="">
// 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<FormElement, string>;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 干净 → Commitfeat(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:
server/tracker/api/visitors.py(Step 0 时间线安全字段白名单) -
Modify:
server/tracker/tests/test_visitors.py(Step 0 测试) -
Modify:
admin/src/views/tracker/visitor/components/EventTimeline.vue(EVENT_META 扩展) -
Modify:
admin/src/views/tracker/dashboard/index.vue(漏斗卡) -
Modify:
admin/src/api/tracker/dashboard.ts(漏斗 API 函数)+admin/src/api/tracker/visitors.ts(TimelineEvent 接口) -
Test: Playwright 视觉验证(T6 内含)
-
Step 0: server 时间线 payload 白名单扩展(前置——timeline API 现只出 time/event_name/path/title/active_ms,不透出 properties,业务属性到不了前端):
server/tracker/api/visitors.py的_merge_page_stay(约 178-202 行)事件序列化处,按事件名增加安全字段映射:contact_click→channel;form_start→form_id;form_submit→form_id+submission_id 前 8 位;form_error→error_code+field_count;form_submit_result→result_code;product_view→product_id+inferred(全部非 PII,OT10 白名单内字段)。test_visitors.py 加断言(新事件行带安全字段、无其他 properties 泄漏);adminTimelineEvent接口(api/tracker/visitors.ts:119-127)同步字段。先 server 测试红绿再动 admin,随本任务 commit。 -
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 标记可辨识,商家可自行删除邮件);汇报部署版本/验证结果/归因口径变化
风险提示(执行者必读)
- CF7 事实已核验(spec §1.2)——不要按旧文档/记忆使用 wpcf7invalid/wpcf7mailsent;一切以 wpcf7statuschanged 状态机为准。
- SDK 红线:所有回调 try/catch 静默(§4.5);business.ts 的 DOM 监听全部 capture 委托(时序确定性依赖)。
- Task 2 既有归因测试的口径变更是预期内(fbclid→paid_social 等),逐个核对更新而非批量跳过。
- 部署顺序硬约束:server → admin → SDK;T7 整体排在观察窗确认后。
_ctk_sid注入使用 DOM API(createElement+appendChild),勿用 innerHTML(宿主页安全)。