--- todos: - id: "fix-fbc-format" content: "FbService 增加 resolveFbcForCapi:raw fbclid 格式化为 fbc;creationTime 优先从 ad_fbp 解析" status: pending - id: "fix-purchase-event-id" content: "purchaseSelf 恢复使用 orderId 作为 event_id 保证幂等" status: pending - id: "hash-external-id" content: "(可选)registerSelf/purchaseSelf 对 external_id 做 SHA256 哈希,对齐 TikTokDot" status: pending - id: "align-user-data" content: "registerSelf 使用 array_filter;解析 4xx 响应 body 写日志;可选异常上抛" status: pending - id: "fix-400-debug" content: "记录 Meta error JSON(不含 token);日志脱敏 access_token;建议轮换已泄露 token" status: pending - id: "verify-meta-test-events" content: "用 test_event_code 在 Meta Test Events 验证 fbc 格式与 Purchase 去重" status: pending isProject: false --- # FbService 像素打点问题分析与修复计划 ## 结论:**有问题**,且会影响 FB 广告归因;**400 很可能就是 fbc 格式错误导致** 当前 [`slot_console/app/service/dot/FbService.php`](slot_console/app/service/dot/FbService.php) 的 `registerSelf` / `purchaseSelf` 在重构为 Guzzle 直调 Graph API 后,**把原始 fbclid 当作 `user_data.fbc` 上报**。Meta CAPI 对 `fbc` 有严格格式校验,**不符合 `fb.1.{ms}.{fbclid}` 时会返回 HTTP 400**(Graph API error code 100,Invalid parameter)。 --- ## 关于「URL 上传来的 fbc/fbclid 没有点」——这是正常的 对照 Meta 官方文档([ClickID and the fbp and fbc Parameters](https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc/),**Updated: Jan 9, 2026**): | 阶段 | 字段 | 格式 | 是否带点 | |---|---|---|---| | 广告落地 URL | `fbclid` 查询参数 | 原始 ClickID,如 `IwAR2F4-dbP0l7Mn1IawQQGCINEz7PYXQvwjNwB_qa2ofrHyiLjcbCRxTDMgk` | **否** | | 浏览器 `_fbc` cookie / CAPI `user_data.fbc` | 格式化 ClickID | `fb.{subdomainIndex}.{creationTimeMs}.{fbclid}` | **是**(3 个点分隔 4 段) | | 浏览器 `_fbp` cookie / CAPI `user_data.fbp` | Browser ID | `fb.{subdomainIndex}.{creationTimeMs}.{random}` | **是** | 官方示例: - URL:`https://example.com/?fbclid=IwAR2F4-dbP0l7Mn1IawQQGCINEz7PYXQvwjNwB_qa2ofrHyiLjcbCRxTDMgk` - CAPI payload:`"fbc": "fb.1.1554763741205.IwAR2F4-dbP0l7Mn1IawQQGCINEz7PYXQvwjNwB_qa2ofrHyiLjcbCRxTDMgk"` 官方明确说明: > If the `_fbc` cookie is not available ... it is still possible to send the `fbc` event parameter ... if an `fbclid` query parameter is in the URL ... **The formatted ClickID value must be of the form** `version.subdomainIndex.creationTime.fbclid`. > **creationTime** ... If you don't save the `_fbc` cookie, use the timestamp **when you first observed or received this fbclid value**. 因此: - **落地页 / 注册侧存 raw fbclid(无点)是对的**,与你们 [`ImeiRegisterService`](slot_user/app/service/register/ImeiRegisterService.php) 注释一致。 - **问题不在采集,而在 CAPI 发送前缺少格式化**:`FbService` 应把 raw fbclid 组装成 `fb.1.{ms}.{fbclid}` 再写入 `user_data.fbc`,而不是原样上报。 - `fbp` 来自浏览器 `_fbp` cookie,本身带点(如 `fb.1.1769311259905.2389032359262323`),当前做法正确。 **creationTime 补充**:官方要求用「首次观察到 fbclid 的时间」,不是转化事件发送时间。同一次落地页访问里,`fbp` 已含首访毫秒时间戳,可从中解析作为 `fbc` 的 `creationTime`(见下方真实样例)。 --- ## 真实生产样例(SiteController 日志) 来源:[`SiteController::fb`](slot_user/app/api/controller/SiteController.php) 第 28 行日志。 | 字段 | 实际值 | 说明 | |---|---|---| | `post.fbp` | `fb.1.1782099747655.910276663728351279` | 浏览器 `_fbp` cookie,**已带点、格式正确**,可直接作为 CAPI `fbp` | | `post.fbc` | `IwZXh0bgNhZW0BMABhZGlkAAAvy_DRTLVzcnRjBmFwcF9pZAo2NjI4NTY4Mzc5AAEe0aCSkd3i8J9sLoIGWHNcZF4YJuNXtOMXsFspgYX_76nQTzSUrDDtOzE4qJY_aem_gZtG64LnGIXu3gNiyhejkQ` | URL 上的 **raw fbclid,无点**,命名虽叫 `fbc` 但不是 CAPI 最终格式 | | `post.ua` | `...[FBAN/FBIOS;...]` | Facebook iOS 内置浏览器 | 注册后写入用户:`ad_fbc` = 上面 raw fbclid,`ad_fbp` = 上面 fbp。 **当前 FbService 错误上报**(原样发送 `ad_fbc`): ```json "fbc": "IwZXh0bgNhZW0BMABhZGlkAAAvy_DRTLVzcnRjBmFwcF9pZAo2NjI4NTY4Mzc5AAEe0aCSkd3i8J9sLoIGWHNcZF4YJuNXtOMXsFspgYX_76nQTzSUrDDtOzE4qJY_aem_gZtG64LnGIXu3gNiyhejkQ" ``` **修复后应上报**(从 `fbp` 解析 `creationTime=1782099747655`,拼接 raw fbclid): ```json "fbc": "fb.1.1782099747655.IwZXh0bgNhZW0BMABhZGlkAAAvy_DRTLVzcnRjBmFwcF9pZAo2NjI4NTY4Mzc5AAEe0aCSkd3i8J9sLoIGWHNcZF4YJuNXtOMXsFspgYX_76nQTzSUrDDtOzE4qJY_aem_gZtG64LnGIXu3gNiyhejkQ", "fbp": "fb.1.1782099747655.910276663728351279" ``` **creationTime 策略(推荐)**: 1. 优先从 `entity.fbp` / `ad_fbp` 解析第 3 段毫秒时间戳(同会话首访时间,符合 Meta 要求)。 2. 解析失败时 fallback 到 `generateFBC()` 当前毫秒时间。 3. 禁止对 fbclid 做大小写变换。 **附带发现(可选后续)**:`SiteController` 第 33 行要求 UA 含 `FB_IAB`,但 iOS Facebook 内置浏览器 UA 为 `FBAN/FBIOS`,该条日志在 info 之后可能被 `PARAMS_ERROR` 拒绝、未写入 Redis。若 iOS 用户依赖落地页 Redis 回退取归因,需放宽 UA 校验(如同时接受 `FB_IAB` / `FBAN/`)。客户端直传 fbclid 注册路径不受影响。 --- ## HTTP 400 原因分析(结合当前代码) ### 生产报错确认(2026-06-22) 实际日志(`FbService::registerSelf`,uid 3895162): ``` Client error: POST https://graph.facebook.com/v24.0/211064574998002/events?access_token=... resulted in a `400 Bad Request` response: ``` 可确认: - 失败方法:`registerSelf`(注册打点 CompleteRegistration) - Pixel ID:`211064574998002` - API:`v24.0/events` - **响应 body 被 Guzzle 截断/未记录**,日志里看不到 Meta 的 `error.message`(通常为 `Invalid parameter` / `fbc` 相关) - **安全**:`access_token` 完整出现在 ERROR 日志中(Guzzle 异常 message 含 query string),应在 Meta 后台 **立即轮换 token**,并修复日志脱敏(禁止记录含 token 的 URL) 结合同一用户链路(落地页 raw fbclid + 合法 fbp),**400 与未格式化的 `user_data.fbc` 高度吻合**。 ### 最可能根因:`user_data.fbc` 格式非法(与真实样例直接相关) 当前发送(错误): ```json "fbc": "IwZXh0bgNhZW0BMABhZGlkAAAvy_DRTLVzcnRj..." ``` Meta 要求([Customer Information Parameters](https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/customer-information-parameters/),Jan 9, 2026): > The format is: `fb.${subdomain_index}.${creation_time}.${fbclid}`. 未格式化的 raw fbclid **不以 `fb.` 开头**,会被判定为 invalid parameter → **HTTP 400**。这与「采集端无点是对的、发送端必须带点」完全一致。 修复后应发送: ```json "fbc": "fb.1.1782099747655.IwZXh0bgNhZW0BMABhZGlkAAAvy_DRTLVzcnRj..." ``` ### 为什么日志里可能看不清 Meta 具体报错 Guzzle 默认 `http_errors=true`,收到 400 会抛 `ClientException`,当前代码: ```php } catch (\Throwable $e) { LoggerService::error(__METHOD__, $e->getMessage()); } ``` - **成功分支的 `LoggerService::info($body)` 不会执行**(400 时进 catch)。 - 只记 `$e->getMessage()`,**通常不含 Meta 返回的完整 JSON**(`error.message`、`error_user_msg`、`error_subcode`)。 - 修复时应从 `$e->getResponse()->getBody()` 解析并记录,便于确认是否为 `Invalid parameter: fbc`。 ### 其它可能导致 400 的次要因素 | 因素 | 当前代码 | 风险 | |---|---|---| | `registerSelf` 未 `array_filter` | 可能发送 `"fbc":""` / `"fbp":""` | 空字符串也可能触发参数校验失败 | | `external_id` 类型不一致 | register 传 `[1689380]`(整数),purchase 传 `["1689380"]`(字符串) | 部分 Graph 版本对类型敏感,应统一为字符串数组 | | `client_user_agent` 为空 | `action_source=website` 时 Meta 标注为 required | 若 `ad_ua` 为空仍发送 `""`,可能 400 | | `Test.php` 测试 | `fbRegister` 未设置 `entity.fbc/fbp` | 本地测试必 400(空 fbc) | | access_token / pixel_id 错误 | 配置问题 | 多为 401/403 或 OAuth error,不一定是 400 | **不太可能是 400 的项**:`fbp` 格式(你的样例 `fb.1.1782099747655.910276663728351279` 合法)、`currency` 大小写、`event_time` 秒级时间戳。 --- ## 数据流(当前) ```mermaid sequenceDiagram participant LP as LandingPage participant User as slot_user注册 participant Dot as FbDot participant FB as FbService participant Meta as Meta_CAPI LP->>User: POST fbp + fbc(实为URL fbclid) User->>User: ad_fbc = 原始fbclid Dot->>FB: entity.fbc = ad_fbc FB->>Meta: user_data.fbc = 原始fbclid Note over FB,Meta: 错误:应为 fb.1.{ms}.{fbclid} ``` --- ## 已确认的问题 ### 1. 【严重】`fbc` 格式错误(根因) - 用户侧 [`slot_user/app/service/register/ImeiRegisterService.php`](slot_user/app/service/register/ImeiRegisterService.php) 明确注释:**落地页 `fbc` 实际是 URL 上的 `fbclid`,直接写入 `ad_fbc`**。 - [`UserInfoEntity::$ad_fbc`](slot_lib/src/entity/user/UserInfoEntity.php) 注释也是「FB点击id」,不是 `_fbc` cookie。 - [`FbDot.php`](slot_console/app/command/dot/FbDot.php) 把 `ad_fbc` 赋给 `$entity->fbc` 后,`FbService` 原样上报: ```82:98:slot_console/app/service/dot/FbService.php $fbc = $entity->fbc;// $this->generateFBC(); $fbp = $entity->fbp ;// $this->generateFPB(); // ... "user_data" => [ // ... "fbc" => $fbc, "fbp" => $fbp ] ``` - Meta 要求 `fbc` 格式:`fb.{subdomainIndex}.{creationTimeMs}.{fbclid}`。 - 类内已有正确实现 [`generateFBC()`](slot_console/app/service/dot/FbService.php)(旧 SDK 代码也在用),但新代码里被注释掉未调用: ```235:244:slot_console/app/service/dot/FbService.php public function generateFBC() { if (!$this->fbclid) return null; return "fb.1.$creationTime.$this->fbclid"; } ``` - 构造函数已接收 fbclid(`FbDot` 传的是 `ad_fbc`),但 `registerSelf`/`purchaseSelf` 完全没用 `$this->fbclid` 和 `generateFBC()`。 ### 2. 【中等】Purchase 的 `event_id` 丢失幂等 - 旧 SDK 实现使用 `$entity->orderId` 作为 `event_id`(可去重、可重试)。 - 新 `purchaseSelf` 改为 `uniqid('purchase_', true)`,同一订单重复打点会被 Meta 视为不同事件,**无法幂等**。 ```174:182:slot_console/app/service/dot/FbService.php $eventId = uniqid('purchase_', true); // 旧代码: ->setEventId($entity->orderId) ``` ### 3. 【低】`external_id` 未 SHA256 哈希(官方为 recommended,非 required) - 直调 CAPI 当前发送明文 uid:`"external_id" => [$entity->uid]` / `["{$entity->uid}"]`。 - [Customer Information Parameters](https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/customer-information-parameters/)(Jan 9, 2026)对 `external_id` 写的是 **Hashing recommended**,不是必须。 - 旧 Facebook SDK 的 `UserData::setExternalId()` 会自动哈希;同项目 [`TikTokDot`](slot_console/app/command/dot/TikTokDot.php) 也已哈希。建议对齐,但优先级低于 fbc 格式化。 ### 4. 【低】其它可改进点 | 项 | 说明 | |---|---| | `registerSelf` 未 `array_filter` | `purchaseSelf` 会过滤空字段,`registerSelf` 可能上报空 `fbc`/`fbp` | | 响应未校验 | 仅 `LoggerService::info` 打 body,不检查 `events_received` / `error` | | 异常被吞 | `catch` 只记日志不抛出,上层无法感知失败 | | 死代码 | 大段注释 SDK 代码 + 未使用的 FacebookAds import | | `FbDot` 命名误导 | `$fbclid = $userInfo->ad_fbc` 逻辑对,但 `$entity->fbc = $fbclid` 易让人误以为已是 `_fbc` 格式 | `fbp` 侧目前是正确的:来自浏览器 cookie(`ad_fbp`),不应服务端 `generateFPB()`。 --- ## 修复方案(建议最小改动) ### A. 在 `FbService` 内统一解析 `fbc`(核心) 新增私有方法,例如 `resolveFbcForCapi(string $rawFbclid, string $fbp = ''): ?string`: 1. 若 `$rawFbclid` 已匹配 `^fb\.\d+\.\d+\.` → 直接使用(兼容未来若存完整 `_fbc` cookie)。 2. 否则取 raw fbclid(优先 `$rawFbclid`,fallback `$this->fbclid`)→ 格式化为 `fb.1.{creationTimeMs}.{fbclid}`。 - `creationTimeMs` **优先从 `$fbp` 解析**(正则 `^fb\.\d+\.(\d+)\.`),与真实样例 `1782099747655` 对齐 Meta「首访观察时间」。 - 解析失败时 fallback 到 `round(microtime(true) * 1000)`。 - 服务端生成时 `subdomainIndex` 固定 `1`(官方推荐)。 3. 无 fbclid 时返回 `null`,配合 `array_filter` 不上报空字段。 4. **禁止修改 fbclid 大小写**(官方:ClickID is case sensitive)。 `registerSelf` / `purchaseSelf` 均改为: ```php $fbc = $this->resolveFbcForCapi($entity->fbc, $entity->fbp); ``` 不再直接把 `ad_fbc` 当 `fbc` 上报。 ### B. 恢复 Purchase 幂等 ```php "event_id" => $entity->orderId !== '' ? $entity->orderId : uniqid('purchase_', true), ``` 优先订单号;仅测试/缺单号时 fallback。 ### C. 哈希 `external_id` ```php "external_id" => [hash('sha256', (string) $entity->uid)], ``` register / purchase 保持一致。 ### D. 对齐 `registerSelf` 与 `purchaseSelf` + 改善 400 排障 - `registerSelf` 的 `user_data` 也使用 `array_filter`(空 fbc/fbp/ua 不上报)。 - `external_id` 统一为字符串数组:`[(string) $entity->uid]`。 - Guzzle 请求增加 `'http_errors' => false` **或** catch `ClientException` 时读取 response body;**日志禁止输出含 access_token 的完整 URL**(只记 pixel_id、status、body): ```php LoggerService::error(__METHOD__, [ 'status' => $resp->getStatusCode(), 'body' => (string) $resp->getBody(), 'pixel_id' => $this->pixelId, 'event_name' => 'CompleteRegistration', ]); ``` - 可选:`events_received < 1` 或 HTTP >= 400 时抛业务异常,让 FbDot 感知失败。 ### E. `FbDot` 小清理(可选) - 变量改名:`$fbclid = $userInfo->ad_fbc` 保留语义。 - `$entity->fbc` 仍可传 raw fbclid(由 Service 格式化),或只依赖构造函数 fbclid、entity 不再设 fbc——二选一,避免双份来源;**推荐只传 raw fbclid 给构造函数,entity.fbc 可选**。 --- ## 验证方式 1. 用 [`Test.php`](slot_console/app/command/Test.php) 的 `fbRegister` / `fbPurchase`(带 `test_event_code`)在 Meta Events Manager → Test Events 查看: - `fbc` 应为 `fb.1.{13位毫秒}.{fbclid}` 格式 - `fbp` 保持 `fb.1.{ms}.{random}` - Purchase 重复同一 `orderId` 不应产生 duplicate 事件 2. 对比修复前后 payload 日志(`registerSelf` / `purchaseSelf` 已有 body 日志)。 3. 跑 slot 后端 completion report(改动 PHP 后必跑)。 --- ## 改动范围 | 文件 | 改动 | |---|---| | [`slot_console/app/service/dot/FbService.php`](slot_console/app/service/dot/FbService.php) | 核心:resolve fbc、event_id、external_id 哈希、array_filter、可选响应校验 | | [`slot_console/app/command/dot/FbDot.php`](slot_console/app/command/dot/FbDot.php) | 可选:变量命名/clarity,无业务逻辑必须改 | **不需要改** `slot_user` 注册写入逻辑(`ad_fbc` 存 raw fbclid 是合理设计,格式化应在 CAPI 发送层完成)。