This commit is contained in:
ray zhou
2026-06-29 14:51:55 +08:00
parent 225fb2bd28
commit 2dd9f17da9
319 changed files with 29461 additions and 9412 deletions

View File

@@ -0,0 +1,285 @@
---
name: Lucky Reward 双弹窗分析
overview: 被邀请人 Support Invite 应从 HomeEvent 迁到 lucky_reward_invite_bind 成功时 WS 即时推送只弹一次HomeEvent 仅保留邀请人 Claim Nowpending_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::notifyClientPOPHomeEvent 移除被邀请人逻辑
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 抽 resolveHomePopupsHomeEvent 只 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 NowB 只有 Support Invite见 [`LuckyRewardInviteBindIntegrationTest`](slot_console/tests/Integration/LuckyRewardInviteBindIntegrationTest.php))。
**同一 uid 双角色时会出现**,典型场景:
1. **互邀 / 双链**A 邀 BB 也邀 A同一 `cycle_id`,均新注册有效助力)
- A`helper(invitee=A)` → Support Invite`pending_spin_count>0` → Claim Now
- B同理
2. **先被邀、后又成功邀别人**:用户 C 邀了 AA 为被邀请人);之后 A 又邀了 DA 为邀请人且未 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 NowSupport 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 链路,再谈弹窗互斥。