# 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 | 基于 `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_v2`（v1: `hy_<app_code>_bc_g`） | App 内全局播报群 |
| region_broadcast | `hy_<app_code>_bc_r_v2_<region_id>`（v1: `hy_<app_code>_bc_r_<region_id>`） | App 内指定区域播报群 |

v1 → v2 迁移（2026-07）：

- v1 播报群是 ChatRoom 类型，受套餐成员上限约束（标准版 2000 人），线上已触发 10038 拒绝加群；全局群与区域群先后都迁移到 AVChatRoom 类型的 v2 群（成员无上限、免审批）。
- `imgroup.GlobalBroadcastGroupID*` / `RegionBroadcastGroupID*` 只生成 v2 ID；v1 ID 仅能由 `Legacy*` 变体生成，用于过渡期双发和旧群成员移除。
- 解析层对 v1/v2 返回同一个 Kind，调用方不感知版本；reconciler 只 ensure v2 群，v1 旧群不再维护。
- 过渡期 outbox 投递成功后会向对应 v1 旧群补发一份 `<event_id>_legacy` 副本，覆盖尚未重新拉取 join 列表的存量会话；老客户端全部换代后可移除双发。

规则：

- `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`、`RoomRocketIgnited` 等 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_v2)
  C->>IM: joinGroup(hy_<app>_bc_r_v2_<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_v2",
      "type": "global_broadcast"
    },
    {
      "group_id": "hy_lalu_bc_r_v2_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 调用该 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`：

```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；线上推荐使用 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 Rocket Broadcast

火箭燃料满进入倒计时时，Room Cell 写 `RoomRocketIgnited` 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_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 的核心字段：

```json
{
  "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

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. 消费 `RoomRocketIgnited`，按后台配置发 `room_rocket` 区域或全局播报。
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 rocket | 火箭燃料满后只按 `RoomRocketIgnited` 播报一次；区域/全局 scope 来自后台配置 |
| red packet | IM payload 不影响领取接口的后端金额校验 |
