# IM 全局/区域播报群架构 本文定义腾讯云 IM 全局/区域播报群的架构。它服务红包、区域运营通知、贵重礼物播报等实时入口消息,不替代房间群、公屏、系统 inbox、钱包账务或 Room Cell 状态。 相关文档: ```text docs/语音房基础闭环实现.md docs/语音房客户端接口流程.md docs/App消息Tab架构.md docs/房间Outbox补偿开发.md ``` ## Current State 当前仓库只有房间 IM 群: | Capability | Current Owner | Current Rule | | --- | --- | --- | | IM UserSig | `gateway-service` | `/api/v1/im/usersig` 只签发 `sdk_app_id/user_id/user_sig/expire_at_ms` | | 房间群建群 | `room-service` | `CreateRoom` 返回前同步调用腾讯 IM REST `create_group`,`GroupID = room_id`;`RoomCreated` outbox/IM bridge 后续只做幂等补偿 | | 房间系统消息 | `room-service` | Room Cell 命令成功后写 `room_outbox`,worker direct 投递或 IM bridge consumer 从 MQ 消费后向 `GroupID = room_id` 发 `room_*` 自定义消息 | | IM 入群/发言回调 | `gateway-service` | 当前把所有 `GroupId` 都当作 `room_id`,再回查 `room-service` guard | | 系统/活动 inbox | `activity-service` | backend inbox/fanout,非实时 IM 群 | 因此,直接新建 `global` 或 `region` 群会被现有 IM callback 误判为房间群并拒绝。必须先增加 GroupID 类型识别和 callback 分流。 ## Scope 本阶段新增: | Capability | Owner | Required Result | | --- | --- | --- | | 全局/区域 GroupID 规则 | shared package | 任何服务都用同一套解析和生成规则 | | 播报群建群 reconciler | `activity-service` | 启动和定时补齐全局群、active 区域群 | | `/im/usersig` join 列表 | `gateway-service` | 返回当前用户应加入的全局/区域播报群 | | IM callback 分流 | `gateway-service` | 房间群走 room guard;播报群走用户状态/区域校验 | | 播报发送能力 | `activity-service` | 服务端向全局/区域播报群发送自定义消息 | | 旧区域群成员移除 | `activity-service` | 用户区域变化后,对旧区域群执行 `delete_group_member(user_id)` | | 贵重礼物播报触发 | `activity-service` consuming room outbox | 基于 `RoomGiftSent` 事实判断阈值并发区域播报 | | 宝箱倒计时播报触发 | `activity-service` consuming room outbox | 基于 `RoomTreasureCountdownStarted` 事实按后台配置发区域或全局播报 | 本阶段不做: | Excluded | Reason | | --- | --- | | 把全局/区域群状态放进 `room-service` | `room-service` 只拥有房间状态和房间群桥接 | | 用 IM 群承载红包资金事实 | 红包金额、领取、退款和并发扣减必须由 wallet/red-packet 领域持久化 | | 为每个红包建群 | 红包是业务消息,不是会话边界 | | 客户端自报区域入群 | 用户区域归属只来自 `user-service` | | 普通用户向播报群发消息 | 播报群是服务端只写通道 | ## GroupID Rules GroupID 必须稳定、短、可解析,并满足腾讯 IM 自定义 GroupID 长度约束。推荐规则: | Group Type | GroupID | Meaning | | --- | --- | --- | | room | `` | 现有房间群,必须满足 `pkg/roomid` 规则 | | global_broadcast | `hy__bc_g` | App 内全局播报群 | | region_broadcast | `hy__bc_r_` | App 内指定区域播报群 | 规则: - `app_code` 必须使用 `appcode.Normalize` 后的值。 - `region_id <= 0` 不能生成区域播报群。 - GroupID 解析必须返回 `{type, app_code, region_id}`,不能靠调用方手写字符串判断。 - 全局/区域播报 GroupID 不得被 `roomid.ValidStringID` 误判后进入 room guard;callback 必须先识别 broadcast 前缀。 - 如果未来 `app_code` 可能很长,需要在 App 注册表中约束可用于 GroupID 的短码,不能截断后拼接。 建议新增共享包: ```text pkg/imgroup ``` 核心 API: ```go type Kind string const ( KindRoom Kind = "room" KindGlobalBroadcast Kind = "global_broadcast" KindRegionBroadcast Kind = "region_broadcast" ) type ParsedGroup struct { Kind Kind AppCode string RegionID int64 RoomID string } func GlobalBroadcastGroupID(appCode string) (string, error) func RegionBroadcastGroupID(appCode string, regionID int64) (string, error) func Parse(groupID string) ParsedGroup ``` ## Ownership Boundaries | Area | Owner | Rule | | --- | --- | --- | | 用户身份、资料完成、区域归属 | `user-service` | gateway callback 和 `/im/usersig` 只信 user-service | | 房间群、公屏、房间系统消息 | `room-service` | `GroupID = room_id`,仍由 Room Cell 和 room outbox 驱动 | | 全局/区域播报群 | `activity-service` | 作为消息/播报能力,不写 Room Cell 核心状态 | | 红包资金事实 | `wallet-service` 或独立 red-packet domain | IM 只携带入口和展示字段 | | 客户端 IM 长连接 | Tencent IM SDK | 本仓库不自建 WebSocket | `room-service` 只需要继续产出 `RoomGiftSent`、`RoomTreasureCountdownStarted` 等 outbox 事实。贵重礼物和宝箱倒计时是否需要区域/全局播报,由 `activity-service` 通过 direct gRPC 或 RocketMQ `hyapp_room_outbox` consumer group 消费后判断,这样不会把区域播报策略塞进 Room Cell 主链路。 ## Client Flow ```mermaid sequenceDiagram participant C as Client participant G as gateway-service participant U as user-service participant IM as Tencent IM SDK C->>G: GET /api/v1/im/usersig G->>U: GetUser(user_id) U-->>G: user.region_id, profile_completed G-->>C: UserSig + join_groups C->>IM: login(user_id, user_sig) C->>IM: joinGroup(hy__bc_g) C->>IM: joinGroup(hy__bc_r_) ``` `/api/v1/im/usersig` response 增加 `join_groups`,客户端必须按返回列表加入播报群: ```json { "sdk_app_id": 20036101, "user_id": "42", "user_sig": "xxx", "expire_at_ms": 1770000000000, "join_groups": [ { "group_id": "hy_lalu_bc_g", "type": "global_broadcast" }, { "group_id": "hy_lalu_bc_r_1001", "type": "region_broadcast", "region_id": 1001 } ] } ``` 客户端规则: - 用户完成资料后才允许请求 IM UserSig。 - 登录 IM 后先 join `join_groups`,进入房间时再 join 房间群。 - 区域变化后,下一次 `/im/usersig` 或 profile refresh 应返回新区域群;客户端应退出旧区域群并加入新区域群。 - 全局群可配置为默认返回或按开关返回;区域群是主要实时播报通道。 ## User Region Change Flow 用户国家变化可能改变 `users.region_id`。区域变化时要更新的是“这个用户在旧区域群里的成员关系”,不是区域群生命周期: ```text 不能做: destroy_group(hy__bc_r_) 或清空旧区域群成员 应该做: delete_group_member(group_id=hy__bc_r_, user_id=) ``` 职责拆分: | Step | Owner | Rule | | --- | --- | --- | | App 用户改国家 | `gateway-service -> user-service` | `user-service` 更新 `country/region_id` 并返回 old/new region | | 后台改用户国家 | `hyapp-admin-server` | 后台写 `users.country/region_id` 后对比 old/new region | | 移除旧区域成员关系 | `activity-service` | 调腾讯云 IM `delete_group_member`,只移除该 user,不删除群 | | 加入新区群 | App | 重新拉 profile 或 `/im/usersig`,按新的 `join_groups` join 新区群 | | 准入兜底 | `gateway-service` callback | 用户再次尝试 join 旧区域群时按当前 `user.region_id` 拒绝 | 实现规则: - `activity-service` 暴露 `BroadcastService.RemoveRegionBroadcastMember`,入参是 `user_id + old_region_id`。 - `old_region_id <= 0` 是 no-op,因为 GLOBAL/未知区域没有区域播报群。 - `gateway-service` 和 `hyapp-admin-server` 在国家修改成功后 best-effort 调用该 RPC;IM 外部副作用失败不回滚已提交的用户主数据。 - 失败必须写结构化日志,后续可由后台补偿或用户下次刷新 join 列表修正;callback 仍然保证用户不能重新加入错误区域群。 - 区域群由 reconciler 长期维护,不因为单个用户离开而解散。 ## Group Lifecycle 播报群建群不应该在 `/im/usersig` 请求路径同步调用腾讯 REST,避免登录入口被外部建群耗时拖垮。 推荐由 `activity-service` 增加 broadcast group reconciler: | Trigger | Action | | --- | --- | | service startup | ensure `hy__bc_g` and all active region groups | | periodic reconcile | 扫 active regions,补齐缺失群 | | region created/enabled | ensure corresponding region group | | region disabled | 停止返回该区域群;是否解散群按运营策略单独处理 | Tencent REST 建群必须幂等: - 已存在视为成功。 - 建群失败写结构化日志和指标,等待下一轮 reconcile。 - 不把 SecretKey、UserSig、完整外部响应写入业务错误。 播报群建议使用腾讯 IM 普通群能力,并开启 callback: - `ApplyJoinOption` 允许用户申请/加入,但最终由 callback 判定。 - 普通用户发言由 `CallbackBeforeSendMsg` 拒绝。 - 服务端通过管理员 REST `send_group_msg` 发布自定义消息。 ## Callback Routing gateway 的腾讯 IM callback 必须先解析 `GroupId`: ```text GroupID = room_id Join: VerifyRoomPresence(room_id, user_id) Send: CheckSpeakPermission(room_id, user_id) GroupID = hy__bc_g Join: user exists + profile_completed + app_code matched Send: reject ordinary users; allow Tencent admin/server sender only GroupID = hy__bc_r_ Join: user exists + profile_completed + user.region_id == region_id + app_code matched Send: reject ordinary users; allow Tencent admin/server sender only ``` 失败策略: - callback 鉴权失败必须 fail-closed。 - SDKAppID 不匹配必须 fail-closed。 - 无法解析用户 ID 必须拒绝。 - user-service 不可用时,播报群 join 必须拒绝;不能用客户端缓存区域兜底。 - 未知 GroupID 类型可按 room 群处理,但不得绕过现有 room guard。 ## Broadcast Message Contract 所有播报都使用 `TIMCustomElem`,`CloudCustomData` 带完整 JSON。客户端只根据 `broadcast_type` 和 `action.type` 分发 UI 行为。 公共字段: ```json { "event_id": "stable-id", "broadcast_type": "super_gift", "scope": "region", "app_code": "lalu", "region_id": 1001, "room_id": "lalu_room_xxx", "sender_user_id": 42, "sent_at_ms": 1770000000000, "action": { "type": "enter_room", "room_id": "lalu_room_xxx" } } ``` 贵重礼物播报: ```json { "event_id": "gift_broadcast:lalu_room_xxx:cmd_send_gift_1", "broadcast_type": "super_gift", "scope": "region", "app_code": "lalu", "region_id": 1001, "room_id": "lalu_room_xxx", "sender_user_id": 42, "target_user_id": 43, "gift_id": "gift_rocket", "gift_count": 1, "gift_value": 999999, "room_title": "Room title", "sent_at_ms": 1770000000000, "action": { "type": "enter_room", "room_id": "lalu_room_xxx" } } ``` 红包播报: ```json { "event_id": "red_packet:rp_1000001", "broadcast_type": "red_packet", "scope": "region", "app_code": "lalu", "region_id": 1001, "room_id": "lalu_room_xxx", "red_packet_id": "rp_1000001", "sender_user_id": 42, "sent_at_ms": 1770000000000, "action": { "type": "open_red_packet", "red_packet_id": "rp_1000001", "room_id": "lalu_room_xxx" } } ``` 客户端点击播报进入房间时,仍必须走正常房间入口: ```text JoinRoom(room_id) -> join room IM group -> RTC token if needed ``` 播报消息不能直接授予进房、抢红包或发言权限。 ## Server Broadcast Sending 建议 `activity-service` 增加 `BroadcastService` 内部能力: ```text PublishRegionBroadcast(app_code, region_id, payload) PublishGlobalBroadcast(app_code, payload) EnsureBroadcastGroups(app_code) ``` 实现规则: - 发送前通过 `pkg/imgroup` 生成 GroupID。 - payload 必须包含稳定 `event_id`。 - REST 发送失败不能影响原业务事实提交。 - 重试必须依赖本地 outbox,不能只依赖 Tencent REST 返回。 建议新增表: ```sql CREATE TABLE im_broadcast_outbox ( app_code VARCHAR(32) NOT NULL, event_id VARCHAR(128) NOT NULL, scope VARCHAR(32) NOT NULL, group_id VARCHAR(64) NOT NULL, payload_json JSON NOT NULL, status VARCHAR(32) NOT NULL, attempt_count INT NOT NULL DEFAULT 0, next_retry_at_ms BIGINT NOT NULL DEFAULT 0, last_error VARCHAR(512) NOT NULL DEFAULT '', created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, event_id), KEY idx_im_broadcast_outbox_pending (app_code, status, next_retry_at_ms) ); ``` 如果第一阶段不落表,也必须明确只支持 best-effort 实时播报;红包入口和重要运营消息不能依赖 best-effort。 ## High Value Gift Broadcast 推荐链路: ```mermaid sequenceDiagram participant C as Client participant G as gateway-service participant R as room-service participant W as wallet-service participant MQ as RocketMQ participant A as activity-service participant IM as Tencent IM C->>G: POST /api/v1/rooms/gift/send G->>R: SendGift R->>W: DebitGift W-->>R: receipt + settlement R->>R: Room Cell heat/rank + RoomGiftSent outbox R-->>G: SendGiftResponse G-->>C: room result R->>MQ: publish hyapp_room_outbox when MQ mode is enabled MQ->>A: consume RoomGiftSent by hyapp-activity-room-outbox A->>A: threshold decision A->>IM: send_group_msg(region broadcast group) ``` 本地未启用 MQ 时,`room-service` outbox worker 可以 direct gRPC 投递 activity-service;线上推荐使用 RocketMQ,activity-service 不需要回查 room-service 主链路。 阈值策略属于播报配置,不属于 Room Cell: | Config | Meaning | | --- | --- | | `super_gift_min_value` | 单次礼物总价值达到该值才播报 | | `broadcast_scope` | `region` by default, `global` only for special campaign | | `cooldown_ms` | 同一房间/同一用户短时间内去重,避免刷屏 | `RoomGiftSent` 必须携带足够字段让 activity-service 判断: - `room_id` - `visible_region_id` - `sender_user_id` - `target_user_id` - `gift_id` - `gift_count` - `gift_value` or `gift_point_added` - `command_id` - `room_version` 如果现有事件字段不足,应优先扩展 protobuf event,而不是让 activity-service 反查多个服务拼凑事实。 ## Room Treasure Broadcast 宝箱满能量进入倒计时时,Room Cell 写 `RoomTreasureCountdownStarted` outbox。activity-service 消费该事实后按后台配置决定发区域播报还是全局播报;开箱到点、奖励结算和宝箱状态推进仍由 room-service 负责。 ```mermaid sequenceDiagram participant C as Client participant G as gateway-service participant R as room-service participant W as wallet-service participant MQ as RocketMQ participant A as activity-service participant IM as Tencent IM C->>G: POST /api/v1/rooms/gift/send G->>R: SendGift R->>W: DebitGift W-->>R: receipt + heat/gift point R->>R: add effective treasure energy R->>R: status=countdown when threshold reached R-->>G: SendGiftResponse R->>MQ: RoomTreasureCountdownStarted MQ->>A: consume by hyapp-activity-room-outbox A->>IM: send_group_msg(region/global broadcast group) ``` 播报 payload 的核心字段: ```json { "event_id": "room_treasure:lalu_room_xxx:box_1", "broadcast_type": "room_treasure", "scope": "region", "app_code": "lalu", "region_id": 1001, "room_id": "lalu_room_xxx", "box_id": "box_1", "level": 3, "open_at_ms": 1770000000000, "action": { "type": "enter_room", "room_id": "lalu_room_xxx" } } ``` 倒计时期间继续送礼不会累加到下一宝箱,溢出能量也不会跨级;这些规则由 room-service 在 `RoomTreasureProgressChanged` 和 `RoomTreasureCountdownStarted` 事实中表达,activity-service 不能自行推导宝箱进度。 ## Red Packet Broadcast 红包资金事实必须由 wallet/red-packet 领域持久化: | Fact | Owner | | --- | --- | | 红包创建、总金额、份数 | wallet/red-packet domain | | 抢红包并发扣减 | wallet/red-packet domain | | 领取记录 | wallet/red-packet domain | | 过期退款 | wallet/red-packet domain + cron | | IM 播报 | activity-service broadcast | 红包创建成功后,红包领域写 `RedPacketCreated` 事件,activity-service 转成区域/全局播报。客户端点击播报只打开红包入口,领取时必须调用后端接口,不能信任 IM payload 中的金额。 ## App Code And Region Safety - `app_code` 必须从 request context 或服务配置取得,不能从客户端 body 读取。 - 区域群 join 校验必须使用 user-service 当前 `region_id`。 - 用户改国家/区域后,应在下一次 token refresh 或 profile refresh 中返回新区域群。 - 旧区域群成员可能短期残留,因此敏感消息不能只靠 IM 群成员做授权;客户端点击后的后端接口仍要校验当前用户区域/资格。 ## Observability 必须打点: | Metric / Log | Labels | | --- | --- | | `im_broadcast_group_ensure_total` | `app_code, group_type, result` | | `im_broadcast_send_total` | `app_code, scope, result, broadcast_type` | | `im_broadcast_outbox_pending` | `app_code, scope` | | `im_callback_route_total` | `group_type, command, result` | | `im_callback_region_denied_total` | `app_code, region_id, reason` | 日志必须包含 `request_id/event_id/group_id/app_code/region_id`,不能包含 Tencent SecretKey、UserSig 或完整私密响应。 ## Implementation Sequence 1. 新增 `pkg/imgroup`,覆盖 global/region/room GroupID 生成和解析单测。 2. 扩展 `pkg/tencentim.RESTClient`,增加通用 `EnsureGroup` 和 `PublishGroupCustomMessage`,保留现有 `EnsureRoomGroup/PublishRoomEvent` wrapper。 3. `gateway-service` 扩展 `/api/v1/im/usersig`,查询 user-service 并返回 `join_groups`。 4. `gateway-service` 改造 Tencent IM callback 分流,房间群和播报群走不同 guard。 5. `activity-service` 增加 broadcast group reconciler,启动时确保全局和 active 区域群。 6. `activity-service` 增加 `PublishRegionBroadcast/PublishGlobalBroadcast` 和 broadcast outbox。 7. 扩展 `RoomGiftSent` 事件字段,activity-service 消费后按阈值发 `super_gift` 区域播报。 8. 消费 `RoomTreasureCountdownStarted`,按后台配置发 `room_treasure` 区域或全局播报。 9. 红包领域落账务事实后,复用同一播报能力发送 `red_packet` 消息。 ## Test Expectations 必须覆盖: | Test | Expected | | --- | --- | | GroupID parse/generate | global、region、room 可稳定识别;非法 region 拒绝 | | `/im/usersig` | 已完成资料用户返回 global + current region group | | IM callback room group | 仍走 `VerifyRoomPresence` / `CheckSpeakPermission` | | IM callback region join | region 匹配允许,不匹配拒绝 | | IM callback broadcast send | 普通用户拒绝,服务端 sender 允许 | | group reconciler | 已存在群视为成功,失败可重试 | | broadcast outbox | 同一 `event_id` 幂等,失败后 retry | | super gift | 未达阈值不播报,达阈值发送到用户房间所在区域群 | | room treasure | 宝箱满能量后只按 `RoomTreasureCountdownStarted` 播报一次;区域/全局 scope 来自后台配置 | | red packet | IM payload 不影响领取接口的后端金额校验 |