--- name: Lucky Reward 双弹窗分析 overview: 被邀请人 Support Invite 应从 HomeEvent 迁到 lucky_reward_invite_bind 成功时 WS 即时推送(只弹一次);HomeEvent 仅保留邀请人 Claim Now(pending_spin_count,可重复补弹)。这样同一 uid 在 HomeEvent 不再同时命中两种 Lucky Rewards 弹窗。 todos: - id: diagnose-no-helper content: 按日志/SQL 排查 invite_bind 全链路,定位 helper 未落库的具体 status/reason status: pending - id: move-support-invite-to-bind content: invite_bind success 后:ensureInviteeAutoOpenBox + buildSupportInvitePopup + WsService::notifyClientPOP;HomeEvent 移除被邀请人逻辑 status: pending - id: update-tests-docs-yapi content: 更新集成测、05 方案文档、YApi #109 WS 弹窗示例;Claim Now 仍走 HomeEvent status: pending - id: confirm-product-rule content: 确认产品规则:双角色用户允许两种弹窗,还是同一 flush 只保留一种(及优先级) status: cancelled - id: implement-mutex-if-needed content: 若需互斥:在 LuckyRewardInviteLogic 抽 resolveHomePopups,HomeEvent 只 merge 一种;补互邀集成测 status: cancelled - id: support-invite-once content: (可选)若 Support Invite 应只弹一次:增加 consume/已读状态,与互斥分开评估 status: cancelled isProject: false --- # Lucky Rewards 同一人两种弹窗 — 根因与可选改法 ## 现象对应代码 [`HomeEvent::appendLuckyRewardHomePopups`](slot_console/app/command/event/HomeEvent.php) 对**同一个** `$uid` 顺序做两件事,**互不排斥**: ```437:455:slot_console/app/command/event/HomeEvent.php protected function appendLuckyRewardHomePopups(array &$popList, int $uid, UserInfoEntity $userInfoEntity): void { // ... $inviteLogic->ensureInviteeAutoOpenBox($uid, $source); $supportInvitePop = $inviteLogic->buildSupportInvitePopup($uid, $source); if ($supportInvitePop !== false) { $luckyRewardPopups[] = $supportInvitePop; } $inviterClaimNowPop = $inviteLogic->buildInviterClaimNowPopup($uid, $source); if ($inviterClaimNowPop !== null) { $luckyRewardPopups[] = $inviterClaimNowPop->toHomePopListItem(); } $popList = array_merge($luckyRewardPopups, $popList); } ``` 两种弹窗判定维度不同: | 弹窗 | type | 判定方法 | 核心条件(同一 uid) | | --- | --- | --- | --- | | Support Invite | `lucky_reward_support_invite` | [`buildSupportInvitePopup`](slot_console/app/api/logic/LuckyRewardInviteLogic.php) | `lucky_reward_helper` 存在 **invitee_uid = uid** 且 **已开箱** | | Claim Now | `lucky_reward_invite_success` | [`buildInviterClaimNowPopup`](slot_console/app/api/logic/LuckyRewardInviteLogic.php) | `pending_spin_count > 0` 且存在 **未领的 GRANT_TYPE_INVITER grant** | 需求文档 [`05_slot_console_邀请助力与弹窗方案.md`](docs/requirements/lucky_rewards/05_slot_console_邀请助力与弹窗方案.md) 表格里写的是「邀请人 / 被邀请人」两种**角色**,但代码**没有**写「同一 uid 只能占一种角色」。 ```mermaid flowchart TD homeFlush["HomeEvent flush uid=X"] openBox["ensureInviteeAutoOpenBox(X)"] checkInvitee["buildSupportInvitePopup(X)\nhelper.invitee_uid=X ?"] checkInviter["buildInviterClaimNowPopup(X)\npending_spin_count>0 ?"] popSupport["lucky_reward_support_invite"] popClaim["lucky_reward_invite_success"] merge["array_merge 两种都进 popList"] homeFlush --> openBox --> checkInvitee checkInvitee -->|是| popSupport checkInvitee -->|否| checkInviter popSupport --> checkInviter checkInviter -->|是| popClaim popClaim --> merge checkInviter -->|否| merge ``` ## 为什么「一个人」会同时有两种? **正常单链邀请(A 邀 B)不会出现**:A 只有 Claim Now,B 只有 Support Invite(见 [`LuckyRewardInviteBindIntegrationTest`](slot_console/tests/Integration/LuckyRewardInviteBindIntegrationTest.php))。 **同一 uid 双角色时会出现**,典型场景: 1. **互邀 / 双链**:A 邀 B,B 也邀 A(同一 `cycle_id`,均新注册有效助力) - A:`helper(invitee=A)` → Support Invite;`pending_spin_count>0` → Claim Now - B:同理 2. **先被邀、后又成功邀别人**:用户 C 邀了 A(A 为被邀请人);之后 A 又邀了 D(A 为邀请人且未 claim)→ 回大厅两种都满足 3. **邀请人未 claim 的重复提醒 + 被邀请身份长期有效**:Claim Now 在 `pending_spin_count>0` 期间**每次回大厅都弹**;Support Invite 在 helper 存在且已开箱后**每次回大厅都弹**(无 consume/已读标记)→ 双角色用户会**持续**收到两个 pop `ensurePlayerRow` 会在 invite_bind 时为**邀请人**创建 player 行(不必先开箱),因此「邀请人侧 Claim Now」与「被邀请人侧 Support Invite」在数据上可以并存。 ## 这不是 invite_bind 写错 [`persistInviteHelperInTransaction`](slot_console/app/api/logic/LuckyRewardInviteLogic.php) 只给**邀请人**写 grant + `pending_spin_count`;被邀请人**不再**写 invitee grant(测试也断言 `invite_invitee:` grant 为 null)。双弹窗来自 **HomeEvent 合并逻辑**,不是一次回调写了两种状态。 ## 若产品期望「同一时刻只弹一种」 ~~需产品定优先级,再在 HomeEvent 加互斥~~ **已按产品意见调整方案**:被邀请人弹窗**不再走 HomeEvent**,见下文 §「被邀请人弹窗迁出 HomeEvent」;HomeEvent 只留邀请人 Claim Now,从架构上消除「同一人两种 Lucky Rewards 弹窗」。 --- # 被邀请人 Support Invite 迁出 HomeEvent(产品定案) ## 背景 - **被邀请人**:IMEI 进游戏即注册,`invite_bind` 发生时用户**在线**,Support Invite **只弹一次**,不存在「离线补弹」。 - **邀请人**:可能正在玩游戏/离线,Claim Now 须 **`pending_spin_count` + 每次回大厅重复弹**,继续走 HomeEvent。 当前实现把两者都塞进 [`appendLuckyRewardHomePopups`](slot_console/app/command/event/HomeEvent.php),导致: - 被邀请人**每次回大厅重复弹** Support Invite(无 consume) - 双角色用户同一 flush 可能同时出现两种 type ## 目标架构 ```mermaid sequenceDiagram participant Agent as agent_invite_bind participant ConsoleBus as console_bus participant Logic as LuckyRewardInviteLogic participant WS as WsService_notifyClientPOP participant Home as HomeEvent Agent->>ConsoleBus: lucky_reward_invite_bind ConsoleBus->>Logic: handleInviteBind success Logic->>Logic: ensureInviteeAutoOpenBox(invitee) Logic->>Logic: buildSupportInvitePopup(invitee) Logic->>WS: 即时推送 invitee 仅一次 Note over Home: 邀请人路径不变 Home->>Logic: buildInviterClaimNowPopup(inviter) Home->>WS: home flush popList ``` | 弹窗 | 触发点 | 下发方式 | 重复策略 | | --- | --- | --- | --- | | Support Invite | `lucky_reward_invite_bind` → `status=success` 后 | `WsService::notifyClientPOP(inviteeUid, [...])` | **一次**(bind 幂等保证不重复推) | | Claim Now | HomeEvent flush | 合并进 `popList` | **未 claim 前每次回大厅** | ## 代码改动要点 ### 1. [`EventBus::luckyRewardInviteBindEvent`](slot_console/app/command/EventBus.php) `handleInviteBind` 返回 `success` 后新增(建议封装为 `LuckyRewardInviteLogic::pushInviteeSupportInvitePopup()`): 1. `ensureInviteeAutoOpenBox($inviteeUid, $source)` — 从 HomeEvent **挪到这里**(弹窗需要 `my_amount`) 2. `buildSupportInvitePopup($inviteeUid, $source)` 3. 非 false 则 `WsService::notifyClientPOP($inviteeUid, [$popup])` `already_processed` / 各类 `skipped_*` **不推送**。 ### 2. [`HomeEvent::appendLuckyRewardHomePopups`](slot_console/app/command/event/HomeEvent.php) **删除**: - `ensureInviteeAutoOpenBox` - `buildSupportInvitePopup` 及 merge **保留**: - `buildInviterClaimNowPopup` → Claim Now 方法可重命名为 `appendInviterClaimNowPopup` 或保持原名但注释只服务邀请人。 ### 3. 测试 - [`LuckyRewardInviteBindIntegrationTest`](slot_console/tests/Integration/LuckyRewardInviteBindIntegrationTest.php):`testEnsureInviteeAutoOpenBoxAndSupportInvitePopup` 改为断言 bind 成功路径(mock/spy `WsService` 或抽 `pushInviteeSupportInvitePopup` 返回值) - 新增:bind 幂等第二次不推 WS - HomeEvent 单测:被邀请人 uid flush **不应**再含 `lucky_reward_support_invite` ### 4. 文档 - [`05_slot_console_邀请助力与弹窗方案.md`](docs/requirements/lucky_rewards/05_slot_console_邀请助力与弹窗方案.md) §5 表格拆分触发点 - YApi #109 WS popup 示例补 Support Invite 即时推送说明 ## 边界说明 | 点 | 处理 | | --- | --- | | MQ 消费略晚于注册完成 | 产品认定被邀请人在线;仍走 WS 即时推。若 hub 未连上,**不做 HomeEvent 补弹**(与「只弹一次、无离线」一致) | | 游戏内禁弹 | bind 发生在注册链路,通常不在 `enter_game`;若需硬约束可后续加 hub 侧 scene 判断 | | openBox 失败 | 记录 error,不推 Support Invite(无 my_amount) | | 双角色用户 | HomeEvent 仅可能出 Claim Now;Support Invite 仅在作为被邀请人 bind 当时推一次 | ## 与「没有 helper」排查的关系 Support Invite 迁出 **不解决** helper 未落库;须先保证 `handleInviteBind` → `success` 写 helper,再在 success 分支推 WS。 --- # 「没有助力」(DB 无 lucky_reward_helper)— 排查结论 > 你已确认:**不是弹窗问题**,而是 `lucky_reward_helper` 没有新记录 / `valid_invite_count` 不涨。 ## 有效助力必须满足的链路 ```mermaid sequenceDiagram participant User as slot_user注册 participant AgentMQ as agent_bus participant Agent as slot_agent_EventBus participant Gateway as LuckyRewardInviteCallbackGateway participant ConsoleMQ as console_bus participant Console as slot_console_EventBus participant Logic as handleInviteBind User->>AgentMQ: TYPE_INVITE_BIND\nshare_origin=wheel\nis_new_register=1 Agent->>Agent: ref_invite_relation 新建? Agent->>Gateway: 仅 isNewInviteRelation=true 时调用 Gateway->>Gateway: lucky_reward_callback_enabled\nshare_origin=wheel Gateway->>ConsoleMQ: TYPE_LUCKY_REWARD_INVITE_BIND Console->>Logic: handleInviteBind Logic->>Logic: 无 skip 则写 helper + inviter grant ``` **任意一环失败 → DB 无 helper。** ## 按概率排序的常见原因 ### 1. 用了 Agent 中心短码,不是转盘 wheel 短码(最常见误用) - **agent 网关硬过滤**:[`LuckyRewardInviteCallbackGatewayService`](slot_agent/app/service/LuckyRewardInviteCallbackGatewayService.php) 仅 `share_origin=wheel` 才投递 console_bus;`user_agent` → `reason=skipped_not_wheel_origin`,**console 根本收不到消息**。 - **console 侧也会再拦**:[`resolveInviteBindSkipResult`](slot_console/app/api/logic/LuckyRewardInviteLogic.php) `share_origin !== wheel` → `skipped_not_wheel`。 - **正确链接**:须来自 `GET /api/lucky-reward/invite-link` 或 `Gift/createShareUrl from=wheel`(绑定当前 `lucky_reward_cycle` 的 `share_url.origin=wheel`)。 ### 2. wheel 短码已过期(「以前有,现在没有」高概率) - 轮次轮转后,旧短码 `share_url.biz_id` 指向**已结束 cycle**,与当前 active cycle 不一致 → console 返回 [`STATUS_SKIPPED_EXPIRED_CYCLE`](slot_console/app/entity/luckyReward/InviteBindResultEntity.php),**不写 helper**(见集成测 `testHandleInviteBindSkipsWhenShareCycleMismatchActiveCycle`)。 - **处理**:邀请人重新拉 `invite-link`,用**本轮**短码测。 ### 3. 被邀请人不是「本次新注册」 - console 要求 `is_new_register=1`;老 IMEI 登录 / 手动绑码 → `is_new_register=0` → `skipped_old_user`。 - IMEI 路径:仅 [`ImeiRegisterService::run`](slot_user/app/service/register/ImeiRegisterService.php) **新建账号**时 `notifyAgentInviteBind`;已存在 IMEI 走 login,**不发 invite_bind**。 - 手动绑码 [`UserController`](slot_user/app/innerapi/controller/UserController.php) 固定 `is_new_register=0`,且返回体**未带** `share_origin`(MQ 里为空)→ agent 网关 wheel 过滤失败。 ### 4. 活动未开启或无 active cycle - 种子数据默认 [`lucky_reward_config.status=2`(关闭)](backend/slot_admin/db/lucky_reward.sql)。 - console `resolveInviteBindProcessContext` 活动关闭 → `activity_closed`。 - 弹窗/开宝箱也依赖开启配置,与 helper 同源。 ### 5. agent 仅「首次 ref_invite_relation」才回调 console [`slot_agent EventBus::inviteBindEvent`](slot_agent/app/command/EventBus.php): ```php if ($isNewInviteRelation) { LuckyRewardInviteCallbackGatewayService::...->notifyFromInviteBindEntity($entity); } ``` 若该 `invitee_uid` 在 `ref_invite_relation` **已有行**(历史任何来源绑过),**不会再投递** `lucky_reward_invite_bind`,即使本次 MQ 仍处理 agent 奖励。 ### 6. MQ / 开关 / 进程 | 检查项 | 位置 | | --- | --- | | `lucky_reward_callback_enabled=1` | center → agent | | `php webman event:bus` 消费 agent_bus | slot_agent | | `php webman event:bus` 消费 console_bus | slot_console | | agent 日志 `luckyRewardCallback` | `dispatched` / `reason` | | console 日志 `luckyRewardInviteBindEvent` | `status` 字段 | ## 建议排查步骤(按顺序执行) 1. **复现一次邀请注册**,记录:邀请短码、`share_url.origin`、`share_url.biz_id`、当前 `lucky_reward_cycle.id`。 2. **查 agent 日志** `inviteBindEvent` + `luckyRewardCallback`: - 无 `luckyRewardCallback` → 看 `isNewInviteRelation` 或 MQ 未进 agent。 - `reason=skipped_not_wheel_origin` → 短码/来源不对。 - `reason=callback_disabled` → center 开关。 3. **查 console 日志** `EventBus::luckyRewardInviteBindEvent` 的 `status`: - 无日志 → console_bus 未消费或 agent 未投递。 - `skipped_expired_cycle` / `skipped_old_user` / `activity_closed` / `skipped_not_wheel` → 对号入座。 4. **SQL 验证**(替换 uid/cycle): - `SELECT * FROM lucky_reward_helper WHERE invitee_uid=? ORDER BY id DESC LIMIT 5;` - `SELECT * FROM ref_invite_relation WHERE invitee_uid=?;` - `SELECT id,status,cycle_no,end_at FROM lucky_reward_cycle WHERE source='default' ORDER BY id DESC LIMIT 3;` - `SELECT short_code,origin,biz_type,biz_id FROM share_url WHERE short_code='?';` ## 若日志 status=success 仍无 helper 才考虑事务异常/库连接错误(少见);查 console `LuckyReward inviteBind failed` error 日志。 ## 与弹窗问题的关系 helper **未落库**时:`valid_invite_count` 不涨、Record/helpers 为空、Claim Now / Support Invite **都不会出现**(弹窗是 helper/grant 的下游展示)。应先修 invite_bind 链路,再谈弹窗互斥。