--- todos: - id: "parse-fbc-fbclid" content: "在 insertADTag 回退路径实现 fbc→fbclid 解析与字段映射(ad_fbp/ad_ua/ad_ip)" status: pending - id: "redis-pop-latest" content: "实现 resolveLatestDotPayloadBySource:zPopMax user:dot:set:{source} + unserialize 校验" status: pending - id: "imei-only-guard" content: "回退逻辑仅 IMEI 新注册触发(覆写 ImeiRegisterService::insertADTag 或在 Abstract 中加平台/类型判断)" status: pending - id: "redis-cleanup" content: "可选:SiteController 或 pop 侧增加 ZSet 过期/长度清理" status: pending - id: "verify-fbdot" content: "联调:落地页上报 → IMEI 注册 → 检查 user tag 与 FbDot 注册打点" status: pending isProject: false --- # IMEI 注册 FB 像素归因补写方案评估 ## 结论 **方案可行**,且与现有设计意图一致:[`slot_user/app/api/controller/SiteController.php`](slot_user/app/api/controller/SiteController.php) 负责落地页采集,[`slot_user/app/api/controller/UserController.php`](slot_user/app/api/controller/UserController.php) 里已有被注释的 `zPopMax` 回退逻辑,[`slot_user/app/service/register/ImeiRegisterService.php`](slot_user/app/service/register/ImeiRegisterService.php) 在新用户路径调用 `insertADTag`。 你已确认范围:**仅 IMEI 新用户注册**(现有 `insertADTag` 调用点),不覆盖老用户登录补写。 --- ## 现有数据流 ```mermaid sequenceDiagram participant Landing as LandingPage_FB_IAB participant Site as SiteController_fb participant Redis as Redis_ZSet participant App as iOS_App participant Reg as ImeiRegisterService participant Tag as insertADTag participant Dot as slot_console_FbDot Landing->>Site: POST fbp,fbc,ua,source Site->>Redis: zAdd user:dot:set:{source} App->>Reg: IMEI register (无 fb 参数) Reg->>Tag: insertADTag(uid) Tag->>Redis: 若无 fbclid 则取最新 Tag->>Tag: UserInfoCache 写入 ad_fbc/ad_fbp/ad_ua/ad_ip Dot->>Tag: 读 ad_fbc 作为 fbclid 打 CompleteRegistration ``` | 环节 | 现状 | |------|------| | 落地页采集 | [`SiteController::fb`](slot_user/app/api/controller/SiteController.php) 校验 `FB_IAB` UA,写入 `user:dot:set:{source}`,member 为 `serialize(fbp,fbc,ua,ip,source)`,score 为 `microtime` | | IMEI 注册 | [`ImeiRegisterService::run()`](slot_user/app/service/register/ImeiRegisterService.php) 新用户分支 L115 调用 `insertADTag` | | 写入用户标签 | [`insertADTag`](slot_user/app/service/register/AbstractRegisterService.php) 当前固定用 `$this->fbclid` 写 `ad_fbc`,iOS 客户端通常为空 | | FB 正式打点 | [`FbDot`](slot_console/app/command/dot/FbDot.php) 读 `ad_fbc`,[`FbService::generateFBC()`](slot_console/app/service/dot/FbService.php) 用 fbclid 生成 fbc/fbp | --- ## 方案为何可行 1. **基础设施已就绪**:Redis key [`USER_DOT_SET_KEY`](slot_user/app/service/RedisKeyManagerService.php)、落地页写入、注册侧回退思路(UserController 注释代码)均已存在。 2. **iOS 场景匹配**:iOS 包拿不到 WebView cookie,但 FB 广告落地页在 `FB_IAB` 内可拿到 `fbp`/`fbc`,先上报再装 App 是合理补偿路径。 3. **与 PWA 不冲突**:PWA 仍优先用请求里的 `fbclid`;仅当 `$this->fbclid`(及关联 fb 字段)为空时才回退 Redis。 4. **打点链路可通**:`FbDot` 只要求 `ad_fbc` 非空;存入正确 fbclid 后现有 `FbService` 可继续生成 CAPI 所需 fbc/fbp。 --- ## 必须处理的 4 个技术点 ### 1. 字段映射(最关键) 当前 `insertADTag` 写: ```348:352:slot_user/app/service/register/AbstractRegisterService.php protected function insertADTag(int $uid) { // ... $userInfo->setInfo(['ad_fbc' => $this->fbclid, 'ad_fbp' => $this->fbp, ...], ''); } ``` Redis 里存的是落地页 **`fbc`/`fbp`**,不是 `fbclid`。而 `FbService` 期望 `ad_fbc` 存 **原始 fbclid**(见测试数据与 `generateFBC()` 实现)。 **实现要求**:从 Redis 取出 `fbc` 后,需解析出 fbclid: - `fbc` 格式:`fb.{subdomainIndex}.{creationTime}.{fbclid}` - 取最后一个 `.` 之后的片段作为 fbclid 写入 `ad_fbc` - `ad_fbp`、`ad_ua`、`ad_ip` 直接使用 Redis 中的值(比服务端随机生成 fbp 更准确) - 若 `fbc` 为空(SiteController 允许),则无法补 attribution,应跳过写入并打 warn 日志 ### 2. 取「最新」的方式 - 使用 `ZPOPMAX user:dot:set:{source} 1`(与注释代码一致),**消费**一条,避免同一条数据被下一个用户重复使用。 - 若 `insertADTag` 后续 `setInfo` 失败,可考虑不 pop 或失败写回(当前 `insertADTag` 有 try/catch,失败只记日志;pop 后丢失风险可接受,或改为「先 ZREVRANGE 再 ZREM 成功后再写」)。 ### 3. 同渠道并发碰撞(已知局限) 按 `source` 取全局最新一条,**无法 100% 保证**「这条 fbc 就是当前这台设备」。同渠道短时间多用户注册可能串 attribution。 可接受的 MVP:与当初 UserController 注释方案一致。若要提升准确率,后续可加: - 时间窗口(如 30 分钟内) - IP 辅助匹配(落地页 IP vs 注册 IP,不完全可靠) - 落地页生成 `dot_token` 透传到 App(需客户端配合,改动更大) ### 4. Redis 集合维护 当前 `zAdd` **无 TTL、无上限**,长期会膨胀。建议实现时顺带: - 写入时 `ZREMRANGEBYSCORE` 清理超过 N 天(如 7 天)的旧 member - 或按 source 限制 ZSet 最大长度 --- ## 推荐改动位置(实现阶段) 不在 `UserController::register` 做(你已选 register_only + insertADTag 内聚),建议: **主改文件**:[`slot_user/app/service/register/AbstractRegisterService.php`](slot_user/app/service/register/AbstractRegisterService.php) - 新增私有方法,例如 `resolveLatestDotPayloadBySource(string $source): ?array` - `Redis::zPopMax(RedisKeyManagerService::getDotSetKey($source), 1)` - `unserialize` + 校验必需字段(至少 `fbp` 非空;`fbc` 非空才能解析 fbclid) - 改造 `insertADTag`: - 若 `$this->fbclid` 为空,调用上述方法补全 `fbclid/fbp/ua/ip` - 若补全后仍无 fbclid,`return`(不写空 tag) - 否则按现有逻辑 `setInfo` **可选**:若希望仅 IMEI 走回退、不影响 Name 注册,可把回退逻辑放在 [`ImeiRegisterService`](slot_user/app/service/register/ImeiRegisterService.php) 覆写 `insertADTag`,而非改 Abstract 基类。 --- ## 前置条件(产品/客户端) 方案生效依赖用户路径: 1. 用户从 FB 广告进入落地页(UA 含 `FB_IAB`) 2. 落地页成功调用 `SiteController::fb`(带正确 `source`) 3. 用户在合理时间窗口内完成 iOS 首次 IMEI 注册,且 `source` 与落地页一致 若用户跳过落地页直接装包注册,仍无法归因——这是架构固有限制,不是实现 bug。 --- ## 风险清单 | 风险 | 级别 | 说明 | |------|------|------| | fbc 为空 | 中 | 落地页允许空 fbc,补写无效,FbDot 仍会报 fbclid 为空 | | 同 source 串号 | 中 | 多用户并发时可能拿错上一条 fbc | | pop 后写失败 | 低 | 数据丢失,仅影响单次 attribution | | ad_fbp 未被 FbDot 直接使用 | 低 | 当前 FbDot 用 fbclid 重新 generate fbp;落地页 fbp 写入 user tag 但 CAPI 仍用生成值。MVP 可接受;若要最佳 attribution 需后续改 FbDot 优先用 `ad_fbp`/真实 `fbc` | --- ## 验证建议 1. 落地页 mock:POST `SiteController::fb`,确认 Redis `user:dot:set:{source}` 有 member 2. IMEI 新用户注册(不带 fb 参数),查 `UserInfoCache` / user tag 中 `ad_fbc`、`ad_fbp`、`ad_ua` 3. 触发 `slot_console` 注册打点,确认 `FbDot::registerEvent` 不再报 fbclid 为空 4. 并发两条落地页 + 两次注册,观察是否出现串号(评估是否需加时间窗) --- ## 总结 **可行,且是 iOS 上架场景下合理的补偿方案。** 实现重点不是「能不能取 Redis」,而是: 1. 正确从 `fbc` 解析 fbclid 再写入 `ad_fbc` 2. 仅在客户端无 fb 参数时回退 3. 用 `zPopMax` 消费最新一条 4. 接受同渠道并发下的 attribution 碰撞,或后续迭代匹配策略 按你确认的范围,**只改 IMEI 新注册 `insertADTag` 即可**,无需动登录链路。