hyapp-server/docs/IM全局与区域广播架构.md

19 KiB
Raw Blame History

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_groupGroupID = room_idRoomCreated outbox/IM bridge 后续只做幂等补偿
房间系统消息 room-service Room Cell 命令成功后写 room_outboxworker direct 投递或 IM bridge consumer 从 MQ 消费后向 GroupID = room_idroom_* 自定义消息
IM 入群/发言回调 gateway-service 当前把所有 GroupId 都当作 room_id,再回查 room-service guard
系统/活动 inbox activity-service backend inbox/fanout非实时 IM 群

因此,直接新建 globalregion 群会被现有 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 guardcallback 必须先识别 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 只需要继续产出 RoomGiftSentRoomRocketIgnited 等 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-servicehyapp-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

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

所有播报都使用 TIMCustomElemCloudCustomData 带完整 JSON。客户端只根据 broadcast_typeaction.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线上推荐使用 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 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

  1. 新增 pkg/imgroup,覆盖 global/region/room GroupID 生成和解析单测。
  2. 扩展 pkg/tencentim.RESTClient,增加通用 EnsureGroupPublishGroupCustomMessage,保留现有 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 不影响领取接口的后端金额校验