UTM 归因处理逻辑
本文档整理系统内
utm_xxx字段的采集、存储、多触点覆盖与下单写入全链路。
核心实现:common/services/UtmService.php;入口中间件:common/middlewares/UtmSource.php。
与实现冲突时以代码为准。
一、总览
flowchart TD A[HTTP 请求] --> B[UtmSource 中间件] B --> C[UtmService::utmHandler] C --> D[getCurrentUtm 解析当前触点] C --> E[getHistoryUtm 读取历史链] D --> F[addUtmToUtmHistory 追加/过滤] F --> G[utmSave 写 Cookie 当前 UTM] F --> H[historyUtmSave 写历史链] B --> I[写入 Context utmSource/Medium/...] J[下单] --> K[getUtmFromHistoryByWeights 算主归因] K --> L[写 o_order / o_cod_order 主表 utm_xxx] J --> M[遍历 history 写 o_order_utm / o_cod_order_utm]
| 层级 | 作用 | 存储 |
|---|---|---|
| 当前 UTM | 本次访问解析出的触点 | Cookie:utm_source ~ utm_content |
| 历史链(history) | 用户访问路径上的多触点序列(最多 20 条) | 游客 Cookie order_utm_history;会员 Redis |
| 订单主归因 | 下单时写入订单主表的单条 UTM | o_order.utm_* / o_cod_order.utm_* |
| 订单归因明细 | 完整触点链,带 first/last/normal | o_order_utm_* / o_cod_order_utm_* |
二、入口与触发时机
2.1 中间件注册
| 模块 | 文件 | 说明 |
|---|---|---|
home | app/home/middleware.php | 前台页面请求 |
homeapi | app/homeapi/middleware.php | 前台 API |
中间件类:common/middlewares/UtmSource.php
每次请求执行:
handlerUtm()→UtmService::utmHandler(),并将结果写入app('Context'):utmSource、utmMedium、utmTerm、utmCampaign、utmContent
- 附带处理:
landing_page、keep_alive、first_http_referer、first_visit_time、fbcode等(与归因辅助相关,见 § 九)。
Collect 埋点控制器会单独调用 (new UtmSource())->handlerUtm()(app/home/controller/Collect.php)。
2.2 核心编排:utmHandler
// UtmService::utmHandler 简化流程
$utm = getCurrentUtm($request); // 解析本次触点
$historyUtm = getHistoryUtm(); // 读历史
$historyUtm = addUtmToUtmHistory($utm, $historyUtm); // 追加/过滤
utmSave($utm); // 写当前 Cookie
historyUtmSave($historyUtm); // 写历史链
return $utm;三、UTM 数据结构
由 UtmService::generateUtm() 统一生成:
| 字段 | 说明 |
|---|---|
utm_source | 来源(平台名、域名、direct 等) |
utm_medium | 媒介(network / referral / default / 自定义) |
utm_campaign | 活动 |
utm_content | 内容 |
utm_term | 关键词 |
source_device | 当前 Context->sourceDevice |
create_time | 创建时间戳 |
expire_time | create_time + attribution_mode_cookie 天数 |
utm_source 为空时返回 [],表示无有效 UTM。
3.1 常量
| 常量 | 值 | 含义 |
|---|---|---|
UTM_SAVE_DAY | 30 | 默认归因 Cookie 天数 |
UTM_PLATFORM_SOCIAL | social | 社交平台 medium |
UTM_PLATFORM_SEARCH | cpc | 搜索平台 medium |
UTM_PLATFORM_REFERRAL | referral | 外链引荐 |
UTM_PLATFORM_NETWORK | network | 广告/网络投放(URL 无 medium 时的默认) |
UTM_MEDIUM_DEFAULT | default | 直接访问等 |
UTM_SOURCE_EMAIL | event_newsletter | 邮件营销来源 |
四、当前 UTM 解析:getCurrentUtm
从 5 个渠道 分别解析,再按优先级合并:
| 渠道 | 方法 | 数据来源 |
|---|---|---|
| Cookie | getUtmFromCookie() | utm_source ~ utm_content Cookie |
| URL 参数 | getUtmFromUrlParams() | ?utm_source=... |
| 广告平台 | getUtmFromPlatform() | Referer host、click_id 参数 |
| 外链引荐 | getUtmFromOther() | 非本站、非已知平台的 Referer host |
| App 内嵌 | getUtmFromApp() | UA 为 inapp_* 的内嵌浏览器 |
4.1 合并优先级(多源同时存在)
仅单一渠道有值:直接返回该渠道。
全部为空:生成 direct / default(仍带上 URL 里可能存在的 campaign/content/term)。
多源同时有值时的覆盖顺序(后者优先于前者,越靠后越「赢」):
URL 参数 > Platform(含例外) > Other > Cookie > App
具体代码顺序:
- 若
$urlParamsUtm非空 → 直接返回 URL 参数(最高优先级) - 若
$platformUtm非空:- 例外:当 platform 的
utm_medium == network且utm_source与 Cookie 中相同 → 保留 Cookie(避免支付回跳等同源 network 覆盖已有归因) - 否则 → 返回 platform
- 例外:当 platform 的
- 若
$otherUtm非空 → 返回 other - 若
$cookieUtm非空 → 返回 cookie - 若
$appUtm非空 → 返回 app
4.2 URL 参数:getUtmFromUrlParams
- 必须有
utm_source才生效 - 未传
utm_medium时默认network - 经
utmDefineUtmSkipTagFilter过滤支付域名等 - 特殊:
utm_source=app_detailcoupon且utm_content为空时,从utm_campaign解析 coupon_id 查券码填入utm_content
4.3 广告平台:getUtmFromPlatform
按 Referer host 或 URL query 中的 click_id 识别平台,分三组映射表:
社交平台 getSocialPlatform()(节选):
| 标记参数 | utm_source |
|---|---|
| fbclid | |
| ttclid | tiktok |
| epik | |
| ScCid | snapchat |
| click_id + adSETID(须同时存在) | kwai |
| bbg + pixel_id(须同时存在) | Bigo |
| … | … |
搜索引擎 getSearchEnginePlatform()(节选):
| 标记参数 | utm_source |
|---|---|
| gclid / wbraid / gbraid | |
| msclkid | bing |
| bd_vid | baidu |
| nb_cid | newsbreak |
| … | … |
AI 平台 getAiPlatform():Referer host 匹配 gemini、claude、copilot、perplexity 等。
识别到平台后,utm_medium 优先取 URL/Referer 上的值,否则默认 network;campaign/content/term 同样优先 URL 再 Referer。
4.4 外链引荐:getUtmFromOther
- Referer host 存在,且 不属于 已知搜索/社交平台、不属于 当前请求 host
- 排除 店铺域名:
shop_name、shop_domain、main_domain - 排除 路径含
checkouts且无 utm_source 的场景(支付回跳) - 生成:
utm_source = referer host,utm_medium = referral
4.5 App 内嵌:getUtmFromApp
- UA 经
getBrowser()判断,须以inapp_开头(如inapp_facebook) utm_source = inapp_后的平台名,utm_medium默认network
五、历史链记录逻辑
5.1 读取:getHistoryUtm
| 用户状态 | 存储位置 | 说明 |
|---|---|---|
| 游客 | Cookie order_utm_history | JSON 数组 |
| 已登录 | Redis {storeId}:utmHistory:{customerId} | 见 CacheKeyHelper::orderUtmHistoryKey |
登录合并:若 Redis 有历史且 Cookie 也有:
- 用
recursionDel去掉 Cookie 头部与 Redis 末条重复项(或 medium 为 default 的项) array_merge(redis, cookie)后按create_time排序- 删除 Cookie
order_utm_history
过期清理:unsetBeOverdueUtm 移除 expire_time < now 的条目。
5.2 追加:addUtmToUtmHistory
在写入历史前依次处理:
utmSkipTagFilter:店铺配置utm_skip_tag(JSON 数组,格式source/medium)命中 → 改写为direct/default(仍保留 campaign 等)isdDisturb()为 true → 不追加,原样返回历史- 邮件归因
utm_source == event_newsletter且历史非空 → 返回[],historyUtmSave因空数组直接 return,不覆盖已有历史 - default medium 保护:新触点
utm_medium == default且历史非空时:- 若历史中不存在 default,或存在多种 medium → 不追加(返回
[]) - 仅当历史全是 default 时才允许继续追加 default
- 若历史中不存在 default,或存在多种 medium → 不追加(返回
- 末条去重:比较当前与历史最后一条的前 5 个字段 MD5,相同则不追加
- 追加到数组末尾
5.3 保存:historyUtmSave
- 相邻完全相同条目去重
- 最多保留 20 条(
array_slice(..., 0, 20)) - 游客 → Cookie,TTL =
attribution_mode_cookie天 - 会员 → Redis,TTL 固定 30 天(
UTM_SAVE_DAY * ONE_DAY_SECONDS,与 Cookie 配置天数独立)
5.4 当前 UTM Cookie:utmSave
分别写入 utm_source、utm_medium、utm_term、utm_campaign、utm_content,TTL = attribution_mode_cookie 天。
六、多归因覆盖与下单归因算法
6.1 下单主归因:getUtmFromHistoryByWeights
下单时 订单主表 只写 一条 UTM,由此方法决定:
history = getHistoryUtm()
attribution_mode_type | 行为 |
|---|---|
空 或 非 last_click | 首次触点:current($history)(数组第一条) |
last_click | 走权重算法,见下 |
last_click 权重流程(utmWeights):
- 将 history 按
utm_source去重,同 source 保留最后一条(array_columns) - 依次尝试店铺配置的三档权重列表(JSON 数组,元素为
utm_source字符串):utm_level1→ 命中则getUtmWeightsSort取 history 中属于该档的条目(保持时间序),取最后一条- 未命中再试
utm_level2、utm_level3
- 三档均未命中 →
end($history)(纯末次点击)
无 history 时:生成 direct/default(注意:fallback 分支代码中引用了未定义变量 $utmHistory,实际 campaign 等为空)。
6.2 标准订单写入
入口:OrderService::saveUtm($order_id)
调用链:
OrderService::createOrder()→saveUtmCheckoutSinglePageService/CheckoutOnePageService下单成功后
步骤:
getUtmFromHistoryByWeights()→ 写入o_order主表utm_source~utm_contentgetHistoryUtm();若空则补一条direct/default- 先删 该订单已有
o_order_utm行,再逐条 insert 完整 history - 每条明细字段:
- 复制 history 中各 utm 字段 +
source_device visit_id= 当前Context->visit_idcreated_at/updated_at= history 项的create_timetype:- 下标
0→first - 最后一条 →
last - 中间 →
normal
- 下标
- 复制 history 中各 utm 字段 +
6.3 COD 订单写入
主表(下单创建时):
CodOrderService::orderInitUtm()→ 同样调用getUtmFromHistoryByWeights()- 合并进
CodOrderModelinsert 数据
明细表(事务内):
CodOrderHandlerService::saveOrder()→CodOrderUtmService::saveOrderUtm()- 逻辑与标准订单类似,写入
o_cod_order_utm,type 规则相同
七、过滤与「不记录」规则
7.1 isdDisturb() — 干扰请求跳过采集
以下请求 不解析 URL/Platform/Other,不追加 history,不 utmSave Cookie:
| 条件 | 说明 |
|---|---|
homeapi 且 path 不含 order | 大部分 API 不采集 |
home 且 isAjax() | AJAX 不采集 |
isOptions() | 预检请求 |
homeapi 中 path 含 order 的请求仍会采集(如 COD 下单 API)。
7.2 店铺级 skip:utm_skip_tag
配置在 o_store_config,key = utm_skip_tag,JSON 数组,如 ["spam/source"]。
命中 utm_source/utm_medium → 改写为 direct/default(campaign 等保留),仍会进入 history 逻辑。
7.3 系统级 skip:utmDefineUtmSkipTagFilter
支付跳转域名列表(PaymentHostService::getPaymentHostList(),含 paypal、airwallex 等):
- 命中
utm_source/utm_medium含这些子串 → 整条 UTM 置空(不记录)
用于支付回跳 Referer 污染。
7.4 邮件 event_newsletter 与 utm_mode=skip_if_exists
营销邮件链接常带:
utm_source=event_newsletter&utm_medium={活动码}&utm_mode=skip_if_exists
业务规则(addUtmToUtmHistory):
- 历史为空:记录邮件归因(首次触点)
- 历史非空:不追加、不覆盖(等价 skip_if_exists 语义)
Middleware 还对 event_newsletter、app_webpush、app_sms 做 老客户模拟:首次访问时间 Cookie 回拨,用于营销受众判定(UtmSource::handlerFirstVisitTime)。
八、店铺配置项
存储于 o_store_config(StoreConfigService 默认值见下):
| key | 默认值 | 作用 |
|---|---|---|
attribution_mode_type | last_click | 下单主归因:末次 vs 首次 |
attribution_mode_cookie | 30 | Cookie 当前 UTM / 游客 history / 部分辅助 Cookie 的天数 |
utm_level1 | (空 JSON) | 末次归因权重第一档 utm_source 列表 |
utm_level2 | (空 JSON) | 第二档 |
utm_level3 | (空 JSON) | 第三档 |
utm_skip_tag | (空 JSON) | 需改写为 direct 的 source/medium 列表 |
九、关联模块(读取 UTM 但非主写入链)
| 模块 | 用法 |
|---|---|
| CartService / CartHandlerService | getCurrentUtm 填入 BeginCheckout 分析事件 |
| CustomerService::getUtm | 注册/登录时快照当前 UTM 到顾客模型字段 |
| AbPlanService::checkParams | AB 落地页 / 装修视角受众:按 utm_type 2/3 匹配 source 或 source/medium |
| ThemeViewService | 装修视角条件显隐,复用 AbPlanService UTM 校验 |
| TagPixelUvFixed | 独立读 platform/url UTM,做 UV 固定像素补报(不经 utmHandler) |
| Request::getVisitIdFromSocial | 从社交平台 click_id 派生 visit_id |
| UrlService | 拼接链接时引用 social/search 平台映射 |
| FirewallService | 各平台默认防火墙规则命名 defaultUtmSource* |
十、数据表
10.1 订单主表(单条主归因)
| 表 | 字段 |
|---|---|
o_order{tbl_hash} | utm_source, utm_medium, utm_term, utm_campaign, utm_content |
o_cod_order{tbl_hash} | 同上 |
10.2 订单归因明细(完整链)
| 表 | 关键字段 |
|---|---|
o_order_utm{tbl_hash} | 同上 + source_device, visit_id, type(first/last/normal), created_at |
o_cod_order_utm{tbl_hash} | 同上 |
OrderUtmModel 常量:FIRST_KEY=0,FIRST='first',LAST='last',NORMAL='normal'。
10.3 运行时缓存
| 键 | 说明 | TTL |
|---|---|---|
Cookie utm_* | 当前 UTM 五字段 | attribution_mode_cookie 天 |
Cookie order_utm_history | 游客 history JSON | 同上 |
Redis {storeId}:utmHistory:{customerId} | 会员 history JSON | 30 天 |
十一、时序示例
11.1 典型广告 → 下单
1. 用户点击 Facebook 广告 → URL 带 fbclid
2. 首次 full page GET → utmHandler
- getCurrentUtm: platform → facebook/network
- history: [facebook/...]
- Cookie + Context 更新
3. 用户浏览若干 AJAX 请求 → isdDisturb,不追加 history
4. 用户从 Google 广告再进入 → URL utm_source 优先
- history: [facebook/..., google/...]
5. 下单 → getUtmFromHistoryByWeights
- last_click + 无 level 配置 → 主归因 google(末条)
- o_order_utm 两条:first=facebook, last=google
11.2 支付回跳
1. 用户已有 facebook Cookie 归因
2. 从 PayPal 跳回,Referer 为 paypal.com
- getDefineUtmSkipTagFilter 清空 platform/other 解析
- network 同 source 例外可能保留 Cookie
3. history 不被 PayPal 污染
11.3 游客转会员
1. 游客 Cookie 中已有 history A、B
2. 登录 → getHistoryUtm 合并 Redis 旧 history X、Y
- recursionDel 去重 Cookie 头与 Redis 尾
- merge + sort by create_time
3. 后续 historyUtmSave 只写 Redis
十二、运维与排查
| 现象 | 排查点 |
|---|---|
| 下单主归因不符合预期 | attribution_mode_type;history 条数与顺序;utm_level1~3 |
| history 缺触点 | 是否 AJAX/homeapi 非 order 请求(isdDisturb);是否被 skip 过滤 |
| 邮件归因覆盖广告 | 设计如此:有 history 时 event_newsletter 不追加 |
| 会员 history 丢失 | Redis key {storeId}:utmHistory:{customerId} TTL 30 天 |
| 主表与明细不一致 | 主表走权重算法;明细是完整 history,first/last 仅标记位置 |
| COD 与标准订单 | 表不同但 UtmService 逻辑共用 |
Console:php think order_utm(app/command/OrderUtm.php)可批量修正历史订单 o_order_utm.type 字段(first/last/normal)。
十三、关键代码索引
| 文件 | 职责 |
|---|---|
common/services/UtmService.php | 采集、合并、history、权重、存储 |
common/middlewares/UtmSource.php | 请求入口、Context、辅助 Cookie |
common/services/OrderService.php | saveUtm 标准订单 |
common/services/CodOrderService.php | COD 主表 utm |
common/services/CodOrderUtmService.php | COD 明细 utm |
common/services/OrderUtmService.php | 明细 CRUD |
common/models/OrderUtmModel.php | 明细表 Model + type 常量 |
extend/helper/CacheKeyHelper.php | orderUtmHistoryKey |
维护:UTM 映射表(social/search/AI)、权重算法或下单写入变更时,请同步更新本文档。