19 KiB
IM 全局/区域播报群架构
本文定义腾讯云 IM 全局/区域播报群的架构。它服务红包、区域运营通知、贵重礼物播报等实时入口消息,不替代房间群、公屏、系统 inbox、钱包账务或 Room Cell 状态。
相关文档:
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 |
基于 RoomRocketIgnited 事实按后台配置发区域或全局播报 |
本阶段不做:
| Excluded | Reason |
|---|---|
把全局/区域群状态放进 room-service |
room-service 只拥有房间状态和房间群桥接 |
| 用 IM 群承载红包资金事实 | 红包金额、领取、退款和并发扣减必须由 wallet/red-packet 领域持久化 |
| 为每个红包建群 | 红包是业务消息,不是会话边界 |
| 客户端自报区域入群 | 用户区域归属只来自 user-service |
| 普通用户向播报群发消息 | 播报群是服务端只写通道 |
GroupID Rules
GroupID 必须稳定、短、可解析,并满足腾讯 IM 自定义 GroupID 长度约束。推荐规则:
| Group Type | GroupID | Meaning |
|---|---|---|
| room | <room_id> |
现有房间群,必须满足 pkg/roomid 规则 |
| global_broadcast | hy_<app_code>_bc_g |
App 内全局播报群 |
| region_broadcast | hy_<app_code>_bc_r_<region_id> |
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 的短码,不能截断后拼接。
建议新增共享包:
pkg/imgroup
核心 API:
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、RoomRocketIgnited 等 outbox 事实。贵重礼物和火箭倒计时是否需要区域/全局播报,由 activity-service 通过 direct gRPC 或 RocketMQ hyapp_room_outbox consumer group 消费后判断,这样不会把区域播报策略塞进 Room Cell 主链路。
Client Flow
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_<app>_bc_g)
C->>IM: joinGroup(hy_<app>_bc_r_<region_id>)
/api/v1/im/usersig response 增加 join_groups,客户端必须按返回列表加入播报群:
{
"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。区域变化时要更新的是“这个用户在旧区域群里的成员关系”,不是区域群生命周期:
不能做:
destroy_group(hy_<app>_bc_r_<old_region_id>)
或清空旧区域群成员
应该做:
delete_group_member(group_id=hy_<app>_bc_r_<old_region_id>, user_id=<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_<app>_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:
GroupID = room_id
Join: VerifyRoomPresence(room_id, user_id)
Send: CheckSpeakPermission(room_id, user_id)
GroupID = hy_<app>_bc_g
Join: user exists + profile_completed + app_code matched
Send: reject ordinary users; allow Tencent admin/server sender only
GroupID = hy_<app>_bc_r_<region_id>
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 行为。
公共字段:
{
"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"
}
}
贵重礼物播报:
{
"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"
}
}
红包播报:
{
"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"
}
}
客户端点击播报进入房间时,仍必须走正常房间入口:
JoinRoom(room_id) -> join room IM group -> RTC token if needed
播报消息不能直接授予进房、抢红包或发言权限。
Server Broadcast Sending
建议 activity-service 增加 BroadcastService 内部能力:
PublishRegionBroadcast(app_code, region_id, payload)
PublishGlobalBroadcast(app_code, payload)
EnsureBroadcastGroups(app_code)
实现规则:
- 发送前通过
pkg/imgroup生成 GroupID。 - payload 必须包含稳定
event_id。 - REST 发送失败不能影响原业务事实提交。
- 重试必须依赖本地 outbox,不能只依赖 Tencent REST 返回。
建议新增表:
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
推荐链路:
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_idvisible_region_idsender_user_idtarget_user_idgift_idgift_countgift_valueorgift_point_addedcommand_idroom_version
如果现有事件字段不足,应优先扩展 protobuf event,而不是让 activity-service 反查多个服务拼凑事实。
Room Rocket Broadcast
火箭燃料满进入倒计时时,Room Cell 写 RoomRocketIgnited outbox。activity-service 消费该事实后按后台配置决定发区域播报还是全局播报;发射到点、奖励结算和火箭状态推进仍由 room-service 负责。
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_value
R->>R: add effective rocket fuel
R->>R: append pending_launch when threshold reached
R->>R: start next level immediately
R-->>G: SendGiftResponse
R->>MQ: RoomRocketIgnited
MQ->>A: consume by hyapp-activity-room-outbox
A->>IM: send_group_msg(region/global broadcast group)
播报 payload 的核心字段:
{
"event_id": "room_rocket:lalu_room_xxx:rocket_1",
"broadcast_type": "room_rocket",
"scope": "region",
"app_code": "lalu",
"region_id": 1001,
"room_id": "lalu_room_xxx",
"rocket_id": "rocket_1",
"rocket_level": 3,
"rocket_cover_url": "https://cdn.example/rocket-l3.png",
"launch_at_ms": 1770000000000,
"igniter": {
"user_id": "10001",
"short_id": "80001",
"nickname": "Alice",
"avatar": "https://cdn.example/avatar.png"
},
"action": {
"type": "enter_room",
"room_id": "lalu_room_xxx"
}
}
当前等级满阈值后,room-service 立刻把该火箭加入 pending_launches,并把下一等级作为当前进度继续累计;单次送礼溢出的燃料作废,不顺延。activity-service 只按 RoomRocketIgnited 做飘屏,不自行推导火箭进度。
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
- 新增
pkg/imgroup,覆盖 global/region/room GroupID 生成和解析单测。 - 扩展
pkg/tencentim.RESTClient,增加通用EnsureGroup和PublishGroupCustomMessage,保留现有EnsureRoomGroup/PublishRoomEventwrapper。 gateway-service扩展/api/v1/im/usersig,查询 user-service 并返回join_groups。gateway-service改造 Tencent IM callback 分流,房间群和播报群走不同 guard。activity-service增加 broadcast group reconciler,启动时确保全局和 active 区域群。activity-service增加PublishRegionBroadcast/PublishGlobalBroadcast和 broadcast outbox。- 扩展
RoomGiftSent事件字段,activity-service 消费后按阈值发super_gift区域播报。 - 消费
RoomRocketIgnited,按后台配置发room_rocket区域或全局播报。 - 红包领域落账务事实后,复用同一播报能力发送
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 rocket | 火箭燃料满后只按 RoomRocketIgnited 播报一次;区域/全局 scope 来自后台配置 |
| red packet | IM payload 不影响领取接口的后端金额校验 |