# 语音房大额礼物全局飘窗方案 整理时间:2026-05-06 适用范围:语音房普通大额礼物飘窗;只在一级页面 `home / explore / message / me` 展示;点击进入对应房间。 本轮只做方案整理,不改业务代码。 ## 1. 需求目标 在用户送出大额礼物时,新增一个顶部飘窗,展示类似: > 用户 A 在房间 B 送了 C 礼物 要求: - 礼物类型:普通礼物大额送礼,明确排除幸运礼物、魔法礼物等走 `giveLuckyGift` 的礼物。 - 展示范围:全局一级页面,但只限首页主框架内的四个 Tab:`home / explore / message / me`。 - 不在语音房页面、二级页面、设置页、充值页、聊天详情页等页面展示。 - 点击飘窗后进入具体房间。 - 飘窗位置沿用当前幸运礼物/礼物飘屏顶部位置。 ## 2. 当前代码现状 ### 2.1 顶部飘窗能力已存在 现有飘窗由 `OverlayManager` 统一调度: - 文件:`lib/shared/data_sources/sources/local/floating_screen_manager.dart` - 入口:`OverlayManager().addMessage(SCFloatingMessage)` - 队列:`SCPriorityQueue`,按 `priority` 排序。 - 位置:`OverlayEntry` 使用 `Align(topStart)`,再 `Transform.translate(offset: Offset(0, 70.w))`。 - 动画组件: - `type = 0`:`FloatingLuckGiftScreenWidget` - `type = 1`:`FloatingGiftScreenWidget` - `type = 2`:游戏中奖 - `type = 3`:火箭 - `type = 4`:红包 - `type = 5`:VIP 进房 现有 `FloatingLuckGiftScreenWidget` 和 `FloatingGiftScreenWidget` 都已经支持: - 从右往左飘过。 - 左滑快速关闭。 - 点击后调用 `SCRoomUtils.goRoom(roomId, context, fromFloting: true)` 进入房间。 ### 2.2 现有普通礼物飘窗是房间内逻辑 普通礼物发送成功后会进入 `GiftPage.sendGiftMsg(...)`: - 文件:`lib/modules/gift/gift_page.dart` - 当前判断:`gift.giftCandy * quantity > 9999` 时创建 `SCFloatingMessage(type: 1)`。 - 当前内容:用户 A 送给用户 B 礼物,字段偏向 `toUserName / toUserAvatarUrl`。 - 当前触发范围:发送者本地和当前房间 RTM 收到 `SCRoomMsgType.gift` 后触发。 这条逻辑不适合直接作为“全局大额礼物飘窗”: - 它是房间消息,不是全服消息。 - 它按收礼人循环,可能一次多目标送礼触发多条。 - UI 文案是“send to 某用户”,不是“在房间 B 送了 C 礼物”。 - `OverlayManager._shouldDisplayMessage(...)` 当前对 `type = 0 / 1 / 5` 要求必须匹配当前房间 ID,否则不展示。 ### 2.3 全服广播通道已存在 RTM 初始化后会加入全服广播群: - 文件:`lib/services/audio/rtm_manager.dart` - `init(...)` 中调用 `joinBigBroadcastGroup()` - 全服消息在 `_newBroadCastMsgRecv(...)` 处理 - 当前已处理: - `GAME_LUCKY_GIFT` - 游戏中奖 - 火箭 - 红包 因此新增大额普通礼物全局飘窗时,最合适的入口是全服广播群,而不是房间群消息。 ## 3. 推荐总体方案 推荐采用: ```mermaid flowchart LR A["用户在房间送普通礼物"] --> B["后端校验送礼成功"] B --> C{"是否普通大额礼物"} C -- "否" --> D["只走现有房间送礼消息"] C -- "是" --> E["后端推送 BIG_GIFT 到全服广播群"] E --> F["客户端 _newBroadCastMsgRecv 接收"] F --> G{"当前是否首页四个一级 Tab"} G -- "否" --> H["丢弃,不入队"] G -- "是" --> I["OverlayManager 入队展示顶部飘窗"] I --> J["点击 SCRoomUtils.goRoom(roomId)"] ``` 核心点: - 大额判断放后端做,前端只负责展示,避免客户端伪造全服大礼物消息。 - 前端新增独立消息类型,例如 `BIG_GIFT`,不复用幸运礼物 `GAME_LUCKY_GIFT`。 - 前端新增独立飘窗类型,例如 `SCFloatingMessage.type = 6`,避免影响当前房间内 `type = 1` 的普通礼物飘窗。 - 展示作用域由 `OverlayManager` 或主框架路由状态控制,只允许首页四 Tab 展示。 ## 4. 服务端数据建议 建议后端在普通礼物接口送礼成功后判断是否需要推送全服广播: - 接口链路:`/gift/batch`,前端对应 `SCChatRoomRepository.giveGift(...)`。 - 排除链路:`/gift/give/lucky-gift`,前端对应 `giveLuckyGift(...)`。 - 推荐阈值:沿用客户端现有房间内逻辑 `totalCoins >= 10000`,最终以产品/后端配置为准。 - 推荐只推送一次送礼行为,不按收礼人数量重复推送。 推荐全服广播 payload: ```json { "type": "BIG_GIFT", "data": { "eventId": "giftSendRecordId or unique id", "roomId": "room id", "roomName": "room name", "sendUserId": "sender user id", "sendUserName": "sender nickname", "sendUserAvatar": "sender avatar", "giftId": "gift id", "giftName": "gift name", "giftPhoto": "gift cover url", "giftQuantity": 1, "giftCandy": 10000, "totalCoins": 10000, "timestamp": 1710000000000 } } ``` 字段说明: - `eventId`:用于客户端去重。没有则用 `roomId + sendUserId + giftId + giftQuantity + timestamp` 做兜底 key。 - `roomId`:点击飘窗进房必须字段。 - `roomName`:满足“在房间 B”的展示需求。 - `giftName / giftPhoto`:满足“送了 C 礼物”的展示和礼物图标展示。 - `totalCoins`:可用于优先级、样式分级或埋点。 ## 5. 前端改造点 ### 5.1 新增大额礼物广播模型 建议新增模型: - `lib/shared/business_logic/models/res/sc_big_gift_broadcast_push.dart` 字段对应服务端 payload。不要把所有字段硬塞进 `SCBroadCastLuckGiftPush`,幸运礼物模型语义不同。 也可以扩展 `SCFloatingMessage`: - 新增 `roomName` - 新增 `giftName` - 新增 `eventId` - 保留 `roomId / userName / userAvatarUrl / giftUrl / giftId / number / coins / priority` ### 5.2 RTM 全服广播接入 在 `RealTimeMessagingManager._newBroadCastMsgRecv(...)` 增加: ```dart } else if (type == "BIG_GIFT") { final push = SCBigGiftBroadcastPush.fromJson(data); _handleBigGiftGlobalNews(push); } ``` `_handleBigGiftGlobalNews(...)` 推荐职责: - 校验 `roomId`、`sendUserName`、`giftName` 至少有可展示值。 - 排除幸运礼物或服务端标记的非普通礼物。 - 去重。 - 组装 `SCFloatingMessage(type: 6, priority: 900, ...)`。 - 调用 `OverlayManager().addMessage(...)`。 优先级建议: - 幸运礼物:当前 `priority = 1000`。 - 大额普通礼物:建议 `priority = 900`。 - 普通房间礼物本地飘窗:保持默认或现有逻辑。 ### 5.3 新增大额礼物飘窗 Widget 建议新增: - `lib/ui_kit/widgets/room/floating/floating_big_gift_screen_widget.dart` 原因: - 当前 `FloatingGiftScreenWidget` 展示的是“用户 A sendTo 用户 B + 礼物图标 x 数量”。 - 新需求是“用户 A 在房间 B 送了 C 礼物”。 - 新增 Widget 可以复用现有动画、点击、左滑关闭逻辑,但不影响房间内普通礼物飘窗。 UI 建议: - 保持同一位置和相近尺寸:`height 54.w` 左右,`width 320.w - 350.w`。 - 左侧:送礼用户头像和昵称。 - 中间:房间名和文案,例如 `在 {roomName} 送出`。 - 右侧:礼物图标 + 礼物名,可保留 `x{quantity}`。 - 长昵称、长房间名、长礼物名使用 `TextOverflow.ellipsis` 或局部跑马灯,避免遮挡。 - RTL 语言需要检查布局方向,至少保持点击和左滑逻辑与现有组件一致。 `OverlayManager._buildScreenWidget(...)` 增加: ```dart case 6: return FloatingBigGiftScreenWidget( message: message, onAnimationCompleted: onComplete, ); ``` ### 5.4 展示作用域:只允许首页四 Tab 当前 `OverlayManager` 是根 Overlay 展示,天然能覆盖全 App。需要新增“允许展示作用域”,否则可能在二级页、房间页也展示。 推荐方案:由 `SCIndexPage` 作为主框架控制作用域。 实现思路: - `SCIndexPage` 接入 `RouteAware`,订阅全局 `routeObserver`。 - 当 `SCIndexPage` 是栈顶时,设置 `OverlayManager().setMainTabsVisible(true)`。 - 当从首页 push 到任意二级页面时,`didPushNext` 设置为 false。 - 当从二级页返回首页时,`didPopNext` 设置为 true。 - `SCIndexPage.dispose` 设置为 false。 - `OverlayManager.addMessage(...)` 或 `_shouldDisplayMessage(...)` 对 `type = 6` 判断 `mainTabsVisible == true`,否则直接丢弃或不入队。 不推荐只用 `navigator.canPop()`: - 可以作为兜底,但语义不够清晰。 - 登录页、启动页也可能是根页面,不能等同于首页四 Tab。 ### 5.5 调整现有展示过滤 当前 `OverlayManager._shouldDisplayMessage(...)` 对 `type = 0 / 1 / 5` 做当前房间匹配: ```dart if (message.type != 0 && message.type != 1 && message.type != 5) { return true; } ... return currentRoomId == messageRoomId; ``` 新增 `type = 6` 时不要走当前房间匹配,而是走首页四 Tab 匹配: - `type = 6`:只判断首页四 Tab 是否可见、全局飘屏开关是否开启、必要字段是否完整。 - `type = 0 / 1 / 5`:保留现有房间匹配,避免破坏当前房间内特效体验。 - `type = 2 / 3 / 4`:按现有逻辑评估是否也需要作用域限制,本需求不建议顺手改。 ### 5.6 点击进房 新 Widget 点击继续复用: ```dart SCRoomUtils.goRoom( message.roomId!, navigatorKey.currentState!.context, fromFloting: true, ); ``` 现有 `SCRoomUtils.goRoom(...)` 已处理: - 当前没有房间:弹确认后进房。 - 当前房间最小化且房间相同:打开当前房间。 - 当前已在其他房间:弹确认切房。 这里不需要新增路由能力。 ## 6. 客户端兜底方案 如果后端暂时不能推送 `BIG_GIFT`,可以做临时客户端兜底,但不推荐作为长期方案。 临时做法: - 在 `GiftPage._executeGiftRequest(...)` 普通礼物成功后判断: - `!request.isLuckyGiftRequest` - `giftTab` 不是 `LUCK / LUCKY_GIFT / MAGIC` - `gift.giftCandy * request.quantity >= threshold` - 构造 `BigBroadcastGroupMessage("BIG_GIFT", SCFloatingMessage(...))`。 - 调用 `RtmProvider.sendBigBroadcastGroup(...)`。 - 必须只按一次送礼行为发送,不能放在 `sendGiftMsg(...)` 的 `acceptUsers` 循环里。 风险: - 客户端可伪造全服广播,不够可信。 - 多端并发、弱网重试可能重复。 - 阈值和礼物类型判断容易与后端不一致。 因此正式方案仍建议后端推送。 ## 7. 去重与队列策略 建议新增去重缓存: - 位置:`RealTimeMessagingManager` 或 `OverlayManager`。 - 结构:`LinkedHashMap`,保存最近 100 条 event key 和时间。 - TTL:30 秒到 60 秒。 - key 优先使用 `eventId`。 队列策略: - 继续使用 `OverlayManager` 现有优先级队列。 - 大额礼物 `priority = 900`,低于幸运礼物大奖 `1000`。 - 可选:当队列过长时丢弃低优先级大额礼物,避免首页连续刷屏。 ## 8. 开关策略 沿用现有全局飘屏开关: - `SCGlobalConfig.isFloatingAnimationInGlobal` - 本地持久化 key:`FloatingAnimationInGlobal` - 低性能设备会通过 `clampVisualEffectPreference(...)` 关闭。 建议: - 大额礼物全局飘窗受这个开关控制。 - 不受 `isGiftSpecialEffects` 控制,因为它是全屏礼物特效开关。 - 不受 `isLuckGiftSpecialEffects` 控制,因为本需求不是幸运礼物。 ## 9. 验收用例 ### 9.1 基础展示 - 用户 A 在房间 B 送普通礼物 C,总价值达到阈值。 - 当前用户在 `home`:看到顶部飘窗。 - 当前用户在 `explore`:看到顶部飘窗。 - 当前用户在 `message`:看到顶部飘窗。 - 当前用户在 `me`:看到顶部飘窗。 ### 9.2 范围限制 - 当前用户在语音房页面:不展示该全局大额礼物飘窗。 - 当前用户在个人资料页、设置页、聊天详情页、充值页:不展示。 - App 在启动页或登录页:不展示。 ### 9.3 礼物类型 - 普通礼物达到阈值:展示。 - 普通礼物未达到阈值:不展示。 - 幸运礼物或魔法礼物达到金额:不走 `BIG_GIFT`,继续走现有幸运礼物逻辑。 - 多人收礼的一次送礼:只展示一条。 ### 9.4 点击进房 - 当前无房间:点击飘窗,确认后进入房间 B。 - 当前房间已最小化且是房间 B:点击后打开当前房间。 - 当前已在其他房间:点击后弹切房确认。 - `roomId` 为空或房间不存在:不展示或进房失败时走现有错误提示。 ### 9.5 稳定性 - 连续多条大额礼物:按队列顺序展示,不重叠。 - 同一 `eventId` 重复到达:只展示一次。 - 左滑关闭后下一条能继续展示。 - 关闭全局飘屏开关后不展示。 - 低性能设备默认不展示。 ## 10. 建议实施顺序 1. 和后端确认 `BIG_GIFT` 全服广播协议、阈值、是否包含 `roomName / giftName / giftPhoto / eventId`。 2. 新增 `SCBigGiftBroadcastPush` 模型和 `SCFloatingMessage` 扩展字段。 3. 新增 `FloatingBigGiftScreenWidget`,复用现有动画、点击、左滑行为。 4. `OverlayManager` 支持 `type = 6`,并新增首页四 Tab 可见性控制。 5. `SCIndexPage` 用 `RouteAware` 控制 `OverlayManager` 的主 Tab 展示作用域。 6. `RtmProvider._newBroadCastMsgRecv(...)` 接入 `BIG_GIFT`。 7. 加去重和队列上限保护。 8. 做手动验收与必要 Widget/单元测试。 ## 11. 需要确认的问题 - 大额阈值最终是多少:沿用 `10000`,还是后端配置? - 飘窗是否展示收礼人:当前需求文案不需要,只写“用户 A 在房间 B 送了 C 礼物”。 - 多目标送礼是否只展示一次:建议只展示一次。 - 如果发送者自己也在首页四 Tab,是否也展示自己刚送的大额礼物:建议展示,和全服广播一致;如产品不希望自显,需要后端或前端按 `sendUserId == currentUserId` 过滤。 - 房间 B 名称为空时的兜底文案:建议使用 `roomId` 或本地化的“语音房”。