hyapp-server/docs/IM全局与区域广播架构.md
2026-05-27 15:57:01 +08:00

518 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 | `<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 guardcallback 必须先识别 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_<app>_bc_g)
C->>IM: joinGroup(hy_<app>_bc_r_<region_id>)
```
`/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_<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 调用该 RPCIM 外部副作用失败不回滚已提交的用户主数据。
- 失败必须写结构化日志,后续可由后台补偿或用户下次刷新 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`
```text
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 行为。
公共字段:
```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线上推荐使用 RocketMQactivity-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 不影响领取接口的后端金额校验 |