2026-09-18 20:05:09 +08:00
# 采集阶段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
```
2026-09-18 20:13:03 +08:00
( **SITE_ID 在本文件不存在**——从 test_ingest.py import 或本地定义(`SITE_ID = "site_test"` ),timezone 从 django.utils 导入;注意文件头已有 imports 复用。)
2026-09-18 20:05:09 +08:00
- [ ] **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"}`
2026-09-18 20:13:03 +08:00
- 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"的有意分歧,防误伤自然社交,计划钉死此决策)
2026-09-18 20:05:09 +08:00
- [ ] **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 的事件不计入任何级
```
2026-09-18 20:13:03 +08:00
- [ ] **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()` 。
2026-09-18 20:05:09 +08:00
- [ ] **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;
2026-09-18 20:13:03 +08:00
status="unaccepted" → error_code=invalid( spec §1.2 映射,勿漏);
2026-09-18 20:05:09 +08:00
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 + 注入 <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 干净 → 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:**
2026-09-18 20:14:53 +08:00
- Modify: `server/tracker/api/visitors.py` (Step 0 时间线安全字段白名单)
- Modify: `server/tracker/tests/test_visitors.py` ( Step 0 测试)
2026-09-18 20:05:09 +08:00
- Modify: `admin/src/views/tracker/visitor/components/EventTimeline.vue` ( EVENT_META 扩展)
- Modify: `admin/src/views/tracker/dashboard/index.vue` (漏斗卡)
2026-09-18 20:14:53 +08:00
- Modify: `admin/src/api/tracker/dashboard.ts` (漏斗 API 函数)+ `admin/src/api/tracker/visitors.ts` ( TimelineEvent 接口)
2026-09-18 20:05:09 +08:00
- Test: Playwright 视觉验证(T6 内含)
2026-09-18 20:13:03 +08:00
- [ ] **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 泄漏);admin `TimelineEvent` 接口(api/tracker/visitors.ts:119-127)同步字段。**先 server 测试红绿再动 admin**,随本任务 commit。
2026-09-18 20:05:09 +08:00
- [ ] **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(宿主页安全)。