增加进房间流程
This commit is contained in:
parent
baa6d81529
commit
a19857a4b5
@ -36,10 +36,11 @@
|
||||
| POST | `/api/v1/users/me/country/change` | users | `changeMyCountry` | 修改当前用户国家 |
|
||||
| POST | `/api/v1/users/me/display-id/change` | users | `changeMyDisplayUserID` | 修改当前用户默认短号 |
|
||||
| POST | `/api/v1/users/me/display-id/pretty/apply` | users | `applyMyPrettyDisplayUserID` | 申请临时靓号 |
|
||||
| GET | `/api/v1/rooms` | rooms | `listRooms` | 房间发现列表,卡片返回 `im_group_id` |
|
||||
| GET | `/api/v1/rooms/current` | rooms | `getCurrentRoom` | 查询当前可恢复房间 |
|
||||
| GET | `/api/v1/rooms/snapshot` | rooms | `getRoomSnapshot` | 查询房间完整快照 |
|
||||
| POST | `/api/v1/rooms/create` | rooms | `createRoom` | 创建房间 |
|
||||
| POST | `/api/v1/rooms/join` | rooms | `joinRoom` | 加入房间 |
|
||||
| POST | `/api/v1/rooms/join` | rooms | `joinRoom` | 加入房间并返回首屏数据和 RTC token |
|
||||
| POST | `/api/v1/rooms/heartbeat` | rooms | `roomHeartbeat` | 刷新房间业务 presence |
|
||||
| POST | `/api/v1/rooms/leave` | rooms | `leaveRoom` | 离开房间 |
|
||||
| POST | `/api/v1/rooms/mic/up` | rooms | `micUp` | 上麦 |
|
||||
@ -261,4 +262,3 @@
|
||||
| POST | `/hyapp.activity.v1.MessageInboxService/CreateFanoutJob` | messages | `activityCreateFanoutJob` | 创建后台 fanout 任务 |
|
||||
| POST | `/grpc.health.v1.Health/Check` | health | `activityHealthCheck` | 标准 gRPC health check |
|
||||
| POST | `/grpc.health.v1.Health/Watch` | health | `activityHealthWatch` | 标准 gRPC health watch |
|
||||
|
||||
|
||||
@ -2377,6 +2377,7 @@
|
||||
if (micSessionID) $('micSessionId').value = String(micSessionID);
|
||||
if (ep && ep.path.endsWith('/im/usersig')) runtime.imSig = data;
|
||||
if (ep && ep.path.endsWith('/rtc/token')) runtime.rtcToken = data;
|
||||
if (ep && ep.path.endsWith('/rooms/join') && data.rtc && data.rtc.token) runtime.rtcToken = data.rtc.token;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg()));
|
||||
}
|
||||
|
||||
@ -2458,7 +2459,7 @@
|
||||
if (p.endsWith('/users/me/country/change')) return { country: c.country || 'US' };
|
||||
if (p.endsWith('/users/me/display-id/change')) return { new_display_user_id: c.displayUserId };
|
||||
if (p.endsWith('/users/me/display-id/pretty/apply')) return { pretty_display_user_id: c.displayUserId };
|
||||
if (p.endsWith('/rooms/create')) return { command_id: commandId('cmd_create'), seat_count: 8, mode: 'voice', room_name: 'Smoke Room', room_avatar: '', room_description: 'local smoke room' };
|
||||
if (p.endsWith('/rooms/create')) return { seat_count: 8, mode: 'voice', room_name: 'Smoke Room', room_avatar: '', room_description: 'local smoke room' };
|
||||
if (p.endsWith('/rooms/join')) return { command_id: commandId('cmd_join'), room_id: c.roomId, role: 'audience' };
|
||||
if (p.endsWith('/rooms/heartbeat')) return { command_id: commandId('cmd_heartbeat'), room_id: c.roomId };
|
||||
if (p.endsWith('/rooms/leave')) return { command_id: commandId('cmd_leave'), room_id: c.roomId };
|
||||
@ -2562,9 +2563,8 @@
|
||||
apiStep('加入房间业务 presence', 'gateway', 'POST', '/api/v1/rooms/join'),
|
||||
apiStep('查询当前可恢复房间', 'gateway', 'GET', '/api/v1/rooms/current'),
|
||||
apiStep('获取 IM UserSig', 'gateway', 'GET', '/api/v1/im/usersig'),
|
||||
clientStep('客户端 IM SDK 登录并加入房间群', '使用上一步 data.sdk_app_id / data.user_id / data.user_sig 调 TIM.create + login;房间群 ID 使用 room_id。'),
|
||||
apiStep('获取 RTC token', 'gateway', 'POST', '/api/v1/rtc/token', '必须已 JoinRoom;返回 str_room_id 和 rtc_user_id。'),
|
||||
clientStep('客户端 TRTC 进房', '使用 RTC token 的 sdk_app_id / rtc_user_id / user_sig / str_room_id 进入 TRTC string room。'),
|
||||
clientStep('客户端 IM SDK 登录并加入房间群', '使用 IM UserSig 登录;房间群 ID 使用 JoinRoom 响应 data.im.group_id。'),
|
||||
clientStep('客户端 TRTC 进房', '优先使用 JoinRoom 响应 data.rtc.token 的 sdk_app_id / rtc_user_id / user_sig / str_room_id 进入 TRTC string room;available=false 时再调用 /api/v1/rtc/token 补偿。'),
|
||||
apiStep('MicUp 进入 pending_publish', 'gateway', 'POST', '/api/v1/rooms/mic/up', '成功后回填 mic_session_id / room_version。'),
|
||||
clientStep('客户端切主播并启动本地音频', 'App 调 TRTC switchRole(anchor) + startLocalAudio;发布成功后立刻执行下一步 ConfirmMicPublishing。'),
|
||||
apiStep('确认 RTC 音频发布成功', 'gateway', 'POST', '/api/v1/rooms/mic/publishing/confirm', '必须带 mic_session_id / room_version / event_time_ms,避免旧事件污染新麦位会话。'),
|
||||
|
||||
@ -53,7 +53,7 @@ parameters:
|
||||
in: header
|
||||
required: false
|
||||
type: string
|
||||
description: 可选追踪 ID;未传时 gateway 自动生成,并返回在响应 header 和 JSON `request_id`。该字段不是幂等键,房间命令重试幂等使用请求体 `command_id`。
|
||||
description: 可选追踪 ID;未传时 gateway 自动生成,并返回在响应 header 和 JSON `request_id`。该字段不是幂等键;需要幂等的写命令使用各自命令 ID,其中创建房间由 gateway 生成内部 `command_id`。
|
||||
DisplayUserIDPath:
|
||||
name: display_user_id
|
||||
in: path
|
||||
@ -893,6 +893,56 @@ paths:
|
||||
$ref: "#/responses/Internal"
|
||||
"502":
|
||||
$ref: "#/responses/UpstreamError"
|
||||
/api/v1/rooms:
|
||||
get:
|
||||
tags:
|
||||
- rooms
|
||||
summary: 房间发现列表
|
||||
operationId: listRooms
|
||||
description: 按当前登录用户的服务端 region_id 查询房间发现列表;响应卡片显式返回 `im_group_id`,当前值等于 `room_id`,但客户端不能硬编码该映射。
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- $ref: "#/parameters/RequestIDHeader"
|
||||
- name: tab
|
||||
in: query
|
||||
required: false
|
||||
type: string
|
||||
enum:
|
||||
- hot
|
||||
- new
|
||||
- me
|
||||
- name: cursor
|
||||
in: query
|
||||
required: false
|
||||
type: string
|
||||
- name: limit
|
||||
in: query
|
||||
required: false
|
||||
type: integer
|
||||
format: int32
|
||||
- name: q
|
||||
in: query
|
||||
required: false
|
||||
type: string
|
||||
description: 房间标题或用户 ID 搜索词;`query` 是兼容别名。
|
||||
- name: query
|
||||
in: query
|
||||
required: false
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: 查询成功,`data.rooms[*].im_group_id` 是 JoinRoom 成功后客户端加入的腾讯 IM 房间群 ID。
|
||||
schema:
|
||||
$ref: "#/definitions/RoomListEnvelope"
|
||||
"400":
|
||||
$ref: "#/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/responses/Forbidden"
|
||||
"502":
|
||||
$ref: "#/responses/UpstreamError"
|
||||
/api/v1/rooms/current:
|
||||
get:
|
||||
tags:
|
||||
@ -957,7 +1007,7 @@ paths:
|
||||
operationId: createRoom
|
||||
description: |
|
||||
gateway 从 access token 取当前 `user_id` 写入 room-service `RequestMeta.actor_user_id`,room-service 由该 actor 确定 owner 和默认 host。
|
||||
请求体不接收 `owner_user_id` 或 `host_user_id`;房间命令重试幂等使用 `command_id`,不是 `X-Request-ID`。
|
||||
请求体不接收 `room_id`、`command_id`、`owner_user_id` 或 `host_user_id`;gateway 生成内部 room_id 和 command_id。
|
||||
同一个登录用户只能作为 owner 创建一个房间;已有房间时返回 409 Conflict。
|
||||
security:
|
||||
- BearerAuth: []
|
||||
@ -993,6 +1043,7 @@ paths:
|
||||
- rooms
|
||||
summary: 加入房间
|
||||
operationId: joinRoom
|
||||
description: JoinRoom 是业务 presence、IM 入群和 RTC 进房的权限分界点。成功后响应返回首屏房间数据、`im.group_id` 和内嵌 `rtc.token`;RTC 签发失败时仍返回房间数据,并设置 `rtc.available=false`。
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
@ -1004,7 +1055,7 @@ paths:
|
||||
$ref: "#/definitions/JoinRoomRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 加入成功,`data` 返回用户房间态和房间快照。
|
||||
description: 加入成功,`data` 返回首屏初始化数据和可选 RTC token。
|
||||
schema:
|
||||
$ref: "#/definitions/JoinRoomEnvelope"
|
||||
"400":
|
||||
@ -3252,12 +3303,6 @@ definitions:
|
||||
- mode
|
||||
- room_name
|
||||
properties:
|
||||
room_id:
|
||||
type: string
|
||||
description: 已废弃;创建房间时 gateway 会忽略客户端传入值,并按当前 App 生成 `<app_code>_<uuid>`。
|
||||
command_id:
|
||||
type: string
|
||||
description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等。
|
||||
seat_count:
|
||||
type: integer
|
||||
format: int32
|
||||
@ -3601,6 +3646,182 @@ definitions:
|
||||
version:
|
||||
type: integer
|
||||
format: int64
|
||||
RoomListItemData:
|
||||
type: object
|
||||
required:
|
||||
- room_id
|
||||
- im_group_id
|
||||
properties:
|
||||
room_id:
|
||||
type: string
|
||||
im_group_id:
|
||||
type: string
|
||||
description: 腾讯 IM 房间群 ID。当前等于 `room_id`,只代表目标群,不代表入群授权。
|
||||
owner_user_id:
|
||||
type: string
|
||||
host_user_id:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
cover_url:
|
||||
type: string
|
||||
mode:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
heat:
|
||||
type: integer
|
||||
format: int64
|
||||
online_count:
|
||||
type: integer
|
||||
format: int32
|
||||
seat_count:
|
||||
type: integer
|
||||
format: int32
|
||||
occupied_seat_count:
|
||||
type: integer
|
||||
format: int32
|
||||
visible_region_id:
|
||||
type: integer
|
||||
format: int64
|
||||
app_code:
|
||||
type: string
|
||||
room_short_id:
|
||||
type: string
|
||||
RoomListData:
|
||||
type: object
|
||||
properties:
|
||||
rooms:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/definitions/RoomListItemData"
|
||||
next_cursor:
|
||||
type: string
|
||||
RoomInitialData:
|
||||
type: object
|
||||
required:
|
||||
- room_id
|
||||
- im_group_id
|
||||
- chat_enabled
|
||||
- online_count
|
||||
- seat_count
|
||||
- version
|
||||
properties:
|
||||
room_id:
|
||||
type: string
|
||||
im_group_id:
|
||||
type: string
|
||||
room_short_id:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
cover_url:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
owner_user_id:
|
||||
type: string
|
||||
host_user_id:
|
||||
type: string
|
||||
mode:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
chat_enabled:
|
||||
type: boolean
|
||||
heat:
|
||||
type: integer
|
||||
format: int64
|
||||
online_count:
|
||||
type: integer
|
||||
format: int32
|
||||
seat_count:
|
||||
type: integer
|
||||
format: int32
|
||||
occupied_seat_count:
|
||||
type: integer
|
||||
format: int32
|
||||
version:
|
||||
type: integer
|
||||
format: int64
|
||||
RoomViewerData:
|
||||
type: object
|
||||
properties:
|
||||
user_id:
|
||||
type: string
|
||||
role:
|
||||
type: string
|
||||
joined_at_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
last_seen_at_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
RoomSeatData:
|
||||
type: object
|
||||
properties:
|
||||
seat_no:
|
||||
type: integer
|
||||
format: int32
|
||||
user_id:
|
||||
type: string
|
||||
locked:
|
||||
type: boolean
|
||||
publish_state:
|
||||
type: string
|
||||
mic_session_id:
|
||||
type: string
|
||||
publish_deadline_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
RoomRankItemData:
|
||||
type: object
|
||||
properties:
|
||||
user_id:
|
||||
type: string
|
||||
score:
|
||||
type: integer
|
||||
format: int64
|
||||
gift_value:
|
||||
type: integer
|
||||
format: int64
|
||||
updated_at_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
RoomUserProfileData:
|
||||
type: object
|
||||
properties:
|
||||
user_id:
|
||||
type: string
|
||||
display_user_id:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
avatar:
|
||||
type: string
|
||||
RoomIMData:
|
||||
type: object
|
||||
properties:
|
||||
group_id:
|
||||
type: string
|
||||
need_join_group:
|
||||
type: boolean
|
||||
RoomRTCData:
|
||||
type: object
|
||||
required:
|
||||
- need_token
|
||||
- available
|
||||
properties:
|
||||
need_token:
|
||||
type: boolean
|
||||
available:
|
||||
type: boolean
|
||||
token:
|
||||
$ref: "#/definitions/TencentRTCTokenData"
|
||||
reason:
|
||||
type: string
|
||||
enum:
|
||||
- rtc_token_unavailable
|
||||
CurrentRoomData:
|
||||
type: object
|
||||
required:
|
||||
@ -3663,13 +3884,44 @@ definitions:
|
||||
$ref: "#/definitions/RoomSnapshot"
|
||||
JoinRoomResponse:
|
||||
type: object
|
||||
required:
|
||||
- result
|
||||
- room
|
||||
- viewer
|
||||
- seats
|
||||
- contribution_rank
|
||||
- profiles
|
||||
- im
|
||||
- rtc
|
||||
- server_time_ms
|
||||
properties:
|
||||
result:
|
||||
$ref: "#/definitions/CommandResult"
|
||||
user:
|
||||
$ref: "#/definitions/RoomUser"
|
||||
room:
|
||||
$ref: "#/definitions/RoomSnapshot"
|
||||
$ref: "#/definitions/RoomInitialData"
|
||||
viewer:
|
||||
$ref: "#/definitions/RoomViewerData"
|
||||
seats:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/definitions/RoomSeatData"
|
||||
contribution_rank:
|
||||
type: array
|
||||
description: 首屏贡献榜,当前最多返回前 10 名;完整榜单后续走分页接口。
|
||||
items:
|
||||
$ref: "#/definitions/RoomRankItemData"
|
||||
profiles:
|
||||
type: array
|
||||
description: 只包含房主、主持人、当前用户、麦位用户和首屏贡献榜用户的展示资料。
|
||||
items:
|
||||
$ref: "#/definitions/RoomUserProfileData"
|
||||
im:
|
||||
$ref: "#/definitions/RoomIMData"
|
||||
rtc:
|
||||
$ref: "#/definitions/RoomRTCData"
|
||||
server_time_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
RoomHeartbeatResponse:
|
||||
type: object
|
||||
properties:
|
||||
@ -3989,6 +4241,13 @@ definitions:
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/definitions/CreateRoomResponse"
|
||||
RoomListEnvelope:
|
||||
allOf:
|
||||
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/definitions/RoomListData"
|
||||
CurrentRoomEnvelope:
|
||||
allOf:
|
||||
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||
|
||||
@ -18,12 +18,12 @@ stateDiagram-v2
|
||||
|
||||
状态语义:
|
||||
|
||||
| State | Meaning |
|
||||
| --- | --- |
|
||||
| `creating` | 正在创建房间外部依赖,不对客户端开放 |
|
||||
| `active` | 可进房、可管理、可上麦、可送礼 |
|
||||
| `closing` | 正在关闭,不再接收新业务命令 |
|
||||
| `closed` | 已关闭,`JoinRoom` 和 guard 查询都必须拒绝 |
|
||||
| State | Meaning |
|
||||
| ---------- | ------------------------------------------ |
|
||||
| `creating` | 正在创建房间外部依赖,不对客户端开放 |
|
||||
| `active` | 可进房、可管理、可上麦、可送礼 |
|
||||
| `closing` | 正在关闭,不再接收新业务命令 |
|
||||
| `closed` | 已关闭,`JoinRoom` 和 guard 查询都必须拒绝 |
|
||||
|
||||
`CloseRoom` 必须作为 Room Cell 命令提交,不能只从后台直接更新 `rooms.status`。关闭房间时必须清空业务 presence、释放麦位、写 command log、写 `RoomClosed` outbox、更新 `rooms` 和 `room_list_entries` 状态。
|
||||
|
||||
|
||||
@ -6,37 +6,38 @@
|
||||
|
||||
本阶段只解决房间生命周期的最小业务闭环。
|
||||
|
||||
| Capability | Required Result |
|
||||
| --- | --- |
|
||||
| 创建房间 | 用户可以创建一个 active 语音房,并获得房间快照 |
|
||||
| 进房 | 用户可以进入房间业务 presence,并获得当前房间状态 |
|
||||
| 订阅房间消息 | 用户进房成功后可以登录腾讯云 IM 并加入房间群 |
|
||||
| 离房 | 用户主动退出后,房间 presence 被清理,后续进群和发言必须被业务守卫拒绝 |
|
||||
| 断线重连 | 用户短暂断线后,可以重新鉴权、恢复房间状态、重新登录腾讯云 IM 并进群 |
|
||||
| 防越权 | 未进房、被踢、被封禁用户不能继续进群或发公屏 |
|
||||
| Capability | Required Result |
|
||||
| ------------ | ---------------------------------------------------------------------- |
|
||||
| 创建房间 | 用户可以创建一个 active 语音房,并获得房间快照 |
|
||||
| 进房 | 用户可以进入房间业务 presence,并获得当前房间状态 |
|
||||
| 订阅房间消息 | 用户进房成功后可以登录腾讯云 IM 并加入房间群 |
|
||||
| 进入音频频道 | `JoinRoom` 成功响应内嵌腾讯 RTC token,客户端可以立即进入 TRTC audience |
|
||||
| 离房 | 用户主动退出后,房间 presence 被清理,后续进群和发言必须被业务守卫拒绝 |
|
||||
| 断线重连 | 用户短暂断线后,可以重新鉴权、恢复房间状态、重新登录腾讯云 IM 并进群 |
|
||||
| 防越权 | 未进房、被踢、被封禁用户不能继续进群或发公屏 |
|
||||
|
||||
## Non Goals
|
||||
|
||||
本阶段不做这些:
|
||||
|
||||
| Not Included | Reason |
|
||||
| --- | --- |
|
||||
| 麦位完整玩法 | 放到下一阶段,避免和进房链路混在一起 |
|
||||
| 腾讯云 RTC token | 下一阶段接 RTC 时补齐 |
|
||||
| 金币和礼物 | 需要 wallet-service 先落地 |
|
||||
| 推荐流和房间列表 | 先保证单房间可用 |
|
||||
| 多端复杂同步 | 首版只保证同一用户重连恢复 |
|
||||
| 热门房主备热同步 | 当前仍是单房间单活 + lease 接管 |
|
||||
| Not Included | Reason |
|
||||
| ---------------- | ------------------------------------ |
|
||||
| 麦位完整玩法 | 放到下一阶段,避免和进房链路混在一起 |
|
||||
| RTC 录制/审核 | 首版只处理进房听音频所需 token |
|
||||
| 金币和礼物 | 需要 wallet-service 先落地 |
|
||||
| 推荐流和房间列表 | 先保证单房间可用 |
|
||||
| 多端复杂同步 | 首版只保证同一用户重连恢复 |
|
||||
| 热门房主备热同步 | 当前仍是单房间单活 + lease 接管 |
|
||||
|
||||
## Ownership
|
||||
|
||||
| Domain | Owner | Rule |
|
||||
| --- | --- | --- |
|
||||
| HTTP 房间命令 | `gateway-service` | 只做鉴权、入参校验、协议转换 |
|
||||
| 房间业务 presence | `room-service` | 创建房间、进房、离房、封禁、恢复 |
|
||||
| 实时连接和房间群 | 腾讯云 IM | 持有客户端长连接、群成员关系、房间消息历史 |
|
||||
| 身份和 token | `user-service` | 登录、token、用户状态 |
|
||||
| 真实语音链路 | 腾讯云 RTC | 后续阶段接入,不由本阶段实现 |
|
||||
| Domain | Owner | Rule |
|
||||
| ----------------- | ----------------- | ------------------------------------------ |
|
||||
| HTTP 房间命令 | `gateway-service` | 只做鉴权、入参校验、协议转换 |
|
||||
| 房间业务 presence | `room-service` | 创建房间、进房、离房、封禁、恢复 |
|
||||
| 实时连接和房间群 | 腾讯云 IM | 持有客户端长连接、群成员关系、房间消息历史 |
|
||||
| 身份和 token | `user-service` | 登录、token、用户状态 |
|
||||
| 真实语音链路 | 腾讯云 RTC | 客户端持 JoinRoom 内嵌 token 进入音频频道 |
|
||||
|
||||
关键边界:
|
||||
|
||||
@ -50,13 +51,13 @@
|
||||
|
||||
需要明确区分三种状态:
|
||||
|
||||
| State | Owner | Meaning |
|
||||
| --- | --- | --- |
|
||||
| 房间业务 presence | `room-service` | 用户业务上属于这个房间 |
|
||||
| 房间群连接态 | 腾讯云 IM | 用户当前客户端是否登录 IM、是否在房间群内 |
|
||||
| RTC 在线态 | 腾讯云 RTC | 用户是否在真实音频频道中 |
|
||||
| State | Owner | Meaning |
|
||||
| ----------------- | -------------- | ----------------------------------------- |
|
||||
| 房间业务 presence | `room-service` | 用户业务上属于这个房间 |
|
||||
| 房间群连接态 | 腾讯云 IM | 用户当前客户端是否登录 IM、是否在房间群内 |
|
||||
| RTC 在线态 | 腾讯云 RTC | 用户是否在真实音频频道中 |
|
||||
|
||||
本阶段只实现 room-service presence,并通过腾讯云 IM 群承载消息。RTC 在线态只预留字段和流程位置。
|
||||
本阶段由 room-service 维护业务 presence,通过腾讯云 IM 群承载消息,并由 gateway 在 JoinRoom 成功后签发腾讯 RTC 进房票据。RTC 在线态仍以腾讯云 RTC 为准,不写入 room-service 业务 presence。
|
||||
|
||||
## External API
|
||||
|
||||
@ -71,8 +72,6 @@ Authorization: Bearer <access_token>
|
||||
|
||||
```json
|
||||
{
|
||||
"room_id": "room_1001",
|
||||
"command_id": "cmd_create_room_1001_10001_1",
|
||||
"seat_count": 8,
|
||||
"mode": "voice",
|
||||
"room_name": "周末语音房",
|
||||
@ -81,7 +80,7 @@ Authorization: Bearer <access_token>
|
||||
}
|
||||
```
|
||||
|
||||
正式客户端不应该传 `owner_user_id` 和 `host_user_id`。gateway 必须从 access token 取当前 `user_id` 写入 `RequestMeta.actor_user_id`;room-service 使用该 actor 确定 owner 和默认 host。`room_name` 必填,`room_avatar` 为空时写入默认系统头像,房间展示资料统一落在 `RoomExt`。
|
||||
正式客户端不应该传 `room_id`、`command_id`、`owner_user_id` 和 `host_user_id`。gateway 必须从 access token 取当前 `user_id` 写入 `RequestMeta.actor_user_id`,并生成内部 `room_id` 和 `command_id`;room-service 使用该 actor 确定 owner 和默认 host。`room_name` 必填,`room_avatar` 为空时写入默认系统头像,房间展示资料统一落在 `RoomExt`。
|
||||
|
||||
响应必须包含:
|
||||
|
||||
@ -113,18 +112,35 @@ Authorization: Bearer <access_token>
|
||||
}
|
||||
```
|
||||
|
||||
响应必须包含用户业务 presence 和房间快照:
|
||||
响应必须包含首屏房间数据、用户业务 presence、IM 房间群和 RTC token:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {},
|
||||
"user": {
|
||||
"user_id": 10001,
|
||||
"role": "audience",
|
||||
"joined_at_ms": 1777000000000,
|
||||
"last_seen_at_ms": 1777000000000
|
||||
"room": {
|
||||
"room_id": "room_1001",
|
||||
"im_group_id": "room_1001",
|
||||
"online_count": 26,
|
||||
"seat_count": 8,
|
||||
"version": 2
|
||||
},
|
||||
"room": {}
|
||||
"viewer": {
|
||||
"user_id": "10001",
|
||||
"role": "audience"
|
||||
},
|
||||
"seats": [],
|
||||
"contribution_rank": [],
|
||||
"profiles": [],
|
||||
"im": {
|
||||
"group_id": "room_1001",
|
||||
"need_join_group": true
|
||||
},
|
||||
"rtc": {
|
||||
"need_token": true,
|
||||
"available": true,
|
||||
"token": {}
|
||||
},
|
||||
"server_time_ms": 1777000000000
|
||||
}
|
||||
```
|
||||
|
||||
@ -150,10 +166,10 @@ Authorization: Bearer <access_token>
|
||||
|
||||
### Tencent IM Login And Join Group
|
||||
|
||||
客户端在 `JoinRoom` 成功后向 gateway 获取腾讯云 IM UserSig:
|
||||
客户端可以在 App 启动后向 gateway 获取腾讯云 IM UserSig;但房间群必须等 `JoinRoom` 成功后再加入:
|
||||
|
||||
```text
|
||||
POST /api/v1/im/usersig
|
||||
GET /api/v1/im/usersig
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
@ -166,7 +182,8 @@ Authorization: Bearer <access_token>
|
||||
"sdk_app_id": 1400000000,
|
||||
"user_id": "10001",
|
||||
"user_sig": "usersig_xxx",
|
||||
"room_id": "room_1001"
|
||||
"room_id": "room_1001",
|
||||
"im_group_id": "room_1001"
|
||||
}
|
||||
```
|
||||
|
||||
@ -184,24 +201,24 @@ room-service.VerifyRoomPresence(room_id, user_id, session_id)
|
||||
|
||||
### gateway-service
|
||||
|
||||
| Requirement | Detail |
|
||||
| --- | --- |
|
||||
| 创建房间身份收敛 | `CreateRoom` 的 owner/host 必须由 room-service 从 `RequestMeta.actor_user_id` 收敛,不接受客户端 body 自报 |
|
||||
| command_id | 所有房间命令请求体都支持稳定 `command_id`,用于 room-service command log 幂等。客户端重试同一业务动作必须复用该值;未传时 gateway 只为本次请求生成新值,不保证 HTTP 重试幂等 |
|
||||
| 统一错误映射 | 房间不存在、被踢、无权限、重复命令要映射成稳定 HTTP envelope |
|
||||
| 请求日志 | 记录 `request_id`、`user_id`、`room_id`、command type |
|
||||
| Requirement | Detail |
|
||||
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 创建房间身份收敛 | `CreateRoom` 的 owner/host 必须由 room-service 从 `RequestMeta.actor_user_id` 收敛,不接受客户端 body 自报 |
|
||||
| command_id | 创建房间请求体不接收 `command_id`,gateway 生成内部命令幂等键;其他房间命令仍支持稳定 `command_id`,用于 room-service command log 幂等 |
|
||||
| 统一错误映射 | 房间不存在、被踢、无权限、重复命令要映射成稳定 HTTP envelope |
|
||||
| 请求日志 | 记录 `request_id`、`user_id`、`room_id`、command type |
|
||||
|
||||
### room-service
|
||||
|
||||
| Requirement | Detail |
|
||||
| --- | --- |
|
||||
| CreateRoom | 创建 meta、command log、snapshot、outbox,并安装 Room Cell |
|
||||
| 单 owner 单房间 | 同一个 `RequestMeta.actor_user_id` 只能作为 owner 创建一个房间;已有 owner 房间时返回 Conflict |
|
||||
| JoinRoom 幂等 | 已在房间时刷新 `last_seen_at_ms`,不重复广播 |
|
||||
| LeaveRoom 幂等 | 不在房间时返回当前快照 |
|
||||
| VerifyRoomPresence | 腾讯云 IM 回调或 gateway 代理进群前检查房间存在、用户在房间、未被 ban |
|
||||
| Presence 过期 | 支持断线宽限期,超过 TTL 后自动清理 |
|
||||
| Recovery | 节点重启后能通过 snapshot + command log 恢复房间 |
|
||||
| Requirement | Detail |
|
||||
| ------------------ | ---------------------------------------------------------------------------------------------- |
|
||||
| CreateRoom | 创建 meta、command log、snapshot、outbox,并安装 Room Cell |
|
||||
| 单 owner 单房间 | 同一个 `RequestMeta.actor_user_id` 只能作为 owner 创建一个房间;已有 owner 房间时返回 Conflict |
|
||||
| JoinRoom 幂等 | 已在房间时刷新 `last_seen_at_ms`,不重复广播 |
|
||||
| LeaveRoom 幂等 | 不在房间时返回当前快照 |
|
||||
| VerifyRoomPresence | 腾讯云 IM 回调或 gateway 代理进群前检查房间存在、用户在房间、未被 ban |
|
||||
| Presence 过期 | 支持断线宽限期,超过 TTL 后自动清理 |
|
||||
| Recovery | 节点重启后能通过 snapshot + command log 恢复房间 |
|
||||
|
||||
建议新增配置:
|
||||
|
||||
@ -213,23 +230,23 @@ room_presence_sweep_interval: "30s"
|
||||
|
||||
### Tencent Cloud IM Integration
|
||||
|
||||
| Requirement | Detail |
|
||||
| --- | --- |
|
||||
| UserSig | `gateway-service` 只给已鉴权用户签发腾讯云 IM UserSig |
|
||||
| EnsureRoomGroup | `room-service.CreateRoom` 必须确保腾讯云 IM 群组存在 |
|
||||
| PublishRoomEvent | 房间状态变更后同步推腾讯云 IM 群系统消息,失败不回滚房间状态 |
|
||||
| Guard Callback | 线上需要腾讯云 IM 进群/发言回调或等价 gateway 代理调用 `VerifyRoomPresence`、`CheckSpeakPermission` |
|
||||
| History | 房间最近消息优先使用腾讯云 IM 群历史;本仓库不再保存自研 IM 时间线 |
|
||||
| Requirement | Detail |
|
||||
| ---------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| UserSig | `gateway-service` 只给已鉴权用户签发腾讯云 IM UserSig |
|
||||
| EnsureRoomGroup | `room-service.CreateRoom` 必须确保腾讯云 IM 群组存在 |
|
||||
| PublishRoomEvent | 房间状态变更后同步推腾讯云 IM 群系统消息,失败不回滚房间状态 |
|
||||
| Guard Callback | 线上需要腾讯云 IM 进群/发言回调或等价 gateway 代理调用 `VerifyRoomPresence`、`CheckSpeakPermission` |
|
||||
| History | 房间最近消息优先使用腾讯云 IM 群历史;本仓库不再保存自研 IM 时间线 |
|
||||
|
||||
## Reconnect Design
|
||||
|
||||
断线分三种。
|
||||
|
||||
| Case | Expected Behavior |
|
||||
| --- | --- |
|
||||
| IM 短断 | room-service presence 保留,客户端重新登录腾讯云 IM 后重新进群 |
|
||||
| App 退后台后恢复 | 客户端先调用 `RoomHeartbeat` 刷新已有 presence;如果返回 `NOT_FOUND` 再重新 `JoinRoom` |
|
||||
| 超过 stale timeout | room-service 自动清理 presence,客户端需要重新进房 |
|
||||
| Case | Expected Behavior |
|
||||
| ------------------ | -------------------------------------------------------------------------------------- |
|
||||
| IM 短断 | room-service presence 保留,客户端重新登录腾讯云 IM 后重新进群 |
|
||||
| App 退后台后恢复 | 客户端先调用 `RoomHeartbeat` 刷新已有 presence;如果返回 `NOT_FOUND` 再重新 `JoinRoom` |
|
||||
| 超过 stale timeout | room-service 自动清理 presence,客户端需要重新进房 |
|
||||
|
||||
客户端重连标准流程:
|
||||
|
||||
@ -238,9 +255,10 @@ room_presence_sweep_interval: "30s"
|
||||
2. 调 POST /api/v1/rooms/heartbeat,刷新房间业务 presence
|
||||
3. 调 POST /api/v1/im/usersig,拿腾讯云 IM UserSig
|
||||
4. 使用腾讯云 IM SDK 登录
|
||||
5. 加入 room_id 对应的腾讯云 IM 群
|
||||
5. 加入 JoinRoom 返回的 `im.group_id` 对应腾讯云 IM 群
|
||||
6. 拉取腾讯云 IM 群历史或等待房间系统消息补齐
|
||||
7. 用 RoomHeartbeat 返回的 RoomSnapshot 覆盖本地房间状态
|
||||
7. 如果需要音频且旧 RTC token 已过期,调用 `/api/v1/rtc/token` 补签
|
||||
8. 用 RoomHeartbeat 返回的 RoomSnapshot 覆盖本地房间状态
|
||||
|
||||
如果第 2 步返回 `NOT_FOUND`,说明业务 presence 已不存在,客户端必须走完整 `JoinRoom -> IM/RTC 重进` 流程。
|
||||
```
|
||||
@ -256,19 +274,19 @@ room_presence_sweep_interval: "30s"
|
||||
|
||||
`RoomUserState` 至少需要支持:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `user_id` | 房间内业务用户 |
|
||||
| `role` | owner, host, admin, audience |
|
||||
| `joined_at_ms` | 首次进入当前房间时间 |
|
||||
| Field | Meaning |
|
||||
| ----------------- | --------------------------------------- |
|
||||
| `user_id` | 房间内业务用户 |
|
||||
| `role` | owner, host, admin, audience |
|
||||
| `joined_at_ms` | 首次进入当前房间时间 |
|
||||
| `last_seen_at_ms` | 最近一次 JoinRoom 或 RoomHeartbeat 时间 |
|
||||
|
||||
建议后续追加:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `connection_state` | online, reconnecting, stale |
|
||||
| `reconnect_deadline_ms` | 断线宽限截止时间 |
|
||||
| Field | Meaning |
|
||||
| ----------------------- | --------------------------- |
|
||||
| `connection_state` | online, reconnecting, stale |
|
||||
| `reconnect_deadline_ms` | 断线宽限截止时间 |
|
||||
|
||||
首版可以先不把 `connection_state` 暴露给客户端,但内部清理逻辑要能基于 `last_seen_at_ms` 判断 stale。
|
||||
|
||||
@ -276,13 +294,13 @@ room_presence_sweep_interval: "30s"
|
||||
|
||||
房间状态成功变更后,`room-service` 必须写 outbox。
|
||||
|
||||
| Action | Outbox Event | IM System Event |
|
||||
| --- | --- | --- |
|
||||
| 创建房间 | `RoomCreated` | 可不广播 |
|
||||
| 进房成功 | `RoomUserJoined` | `room_user_joined` |
|
||||
| 离房成功 | `RoomUserLeft` | `room_user_left` |
|
||||
| stale 自动离房 | `RoomUserLeft` | `room_user_left`,reason=`stale_timeout` |
|
||||
| 被踢 | `RoomUserKicked` | `room_user_kicked` |
|
||||
| Action | Outbox Event | IM System Event |
|
||||
| -------------- | ---------------- | ---------------------------------------- |
|
||||
| 创建房间 | `RoomCreated` | 可不广播 |
|
||||
| 进房成功 | `RoomUserJoined` | `room_user_joined` |
|
||||
| 离房成功 | `RoomUserLeft` | `room_user_left` |
|
||||
| stale 自动离房 | `RoomUserLeft` | `room_user_left`,reason=`stale_timeout` |
|
||||
| 被踢 | `RoomUserKicked` | `room_user_kicked` |
|
||||
|
||||
同步 `PublishRoomEvent` 失败不能回滚房间状态,仍由 room outbox 补投到腾讯云 IM 和后续活动系统。
|
||||
|
||||
@ -290,20 +308,20 @@ room_presence_sweep_interval: "30s"
|
||||
|
||||
必须覆盖这些测试。
|
||||
|
||||
| Test | Expected Result |
|
||||
| --- | --- |
|
||||
| 创建房间成功 | 返回 version=1,owner 在 online_users 内 |
|
||||
| 重复 CreateRoom command_id | 不重复创建,返回同一房间快照 |
|
||||
| 普通用户 JoinRoom | presence 增加,广播进房事件 |
|
||||
| 重复 JoinRoom | 不重复广播,不重复增加人数,刷新 last_seen |
|
||||
| LeaveRoom | presence 移除,进群/发言守卫拒绝后续实时消息权限 |
|
||||
| 重复 LeaveRoom | 幂等返回,不报错 |
|
||||
| JoinRoom 后进群 | `VerifyRoomPresence` 通过,腾讯云 IM 群收到房间消息 |
|
||||
| Test | Expected Result |
|
||||
| -------------------------- | ----------------------------------------------------------------- |
|
||||
| 创建房间成功 | 返回 version=1,owner 在 online_users 内 |
|
||||
| 创建房间请求带客户端 ID | gateway 忽略 `room_id` 和 `command_id`,仍生成内部 ID |
|
||||
| 普通用户 JoinRoom | presence 增加,广播进房事件 |
|
||||
| 重复 JoinRoom | 不重复广播,不重复增加人数,刷新 last_seen |
|
||||
| LeaveRoom | presence 移除,进群/发言守卫拒绝后续实时消息权限 |
|
||||
| 重复 LeaveRoom | 幂等返回,不报错 |
|
||||
| JoinRoom 后进群 | `VerifyRoomPresence` 通过,腾讯云 IM 群收到房间消息 |
|
||||
| 未 JoinRoom 直接进群或发言 | `VerifyRoomPresence` 或 `CheckSpeakPermission` 返回 `not_in_room` |
|
||||
| Kick 后进群或发言 | 返回 `user_banned` |
|
||||
| IM 断开后 60 秒内重连 | RoomHeartbeat 刷新,腾讯云 IM 重新登录和进群成功,历史可拉 |
|
||||
| 超过 stale timeout | presence 自动清理,需要重新 JoinRoom |
|
||||
| room-service 重启恢复 | snapshot + command log 恢复后 JoinRoom/subscribe 继续可用 |
|
||||
| Kick 后进群或发言 | 返回 `user_banned` |
|
||||
| IM 断开后 60 秒内重连 | RoomHeartbeat 刷新,腾讯云 IM 重新登录和进群成功,历史可拉 |
|
||||
| 超过 stale timeout | presence 自动清理,需要重新 JoinRoom |
|
||||
| room-service 重启恢复 | snapshot + command log 恢复后 JoinRoom/subscribe 继续可用 |
|
||||
|
||||
## Implementation Order
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@ docs/room-cell-state-machines.md
|
||||
| 腾讯云 IM 群组 | `room-service` | `CreateRoom` 时确保 `room_id` 对应群组存在 |
|
||||
| 房间系统消息 | `room-service` | 房间事件同步推腾讯云 IM,失败后由 outbox 补投 |
|
||||
| 实时权限守卫 | `gateway-service` + `room-service` | 腾讯云 IM 入群前和群内发言前必须回查房间业务态 |
|
||||
| 腾讯云 RTC 进房票据 | `gateway-service` | `JoinRoom` 成功后在响应中内嵌 RTC token;`/api/v1/rtc/token` 只做补偿和刷新 |
|
||||
|
||||
本阶段不做:
|
||||
|
||||
@ -28,7 +29,7 @@ docs/room-cell-state-machines.md
|
||||
| --- | --- |
|
||||
| 自研 WebSocket | 实时连接和消息通道由腾讯云 IM 承担 |
|
||||
| 全量 IM 历史库 | 群历史优先使用腾讯云 IM 能力 |
|
||||
| RTC token | 下一阶段接入腾讯云 RTC |
|
||||
| RTC 录制/审核 | 只先接入进房听音频所需的 token 和回调,不处理录制与审核 |
|
||||
| 复杂单聊会话 | 先保证房间基础闭环 |
|
||||
| 热门房多活 | 当前仍是单房间单活 + Redis lease 接管 |
|
||||
|
||||
@ -75,6 +76,7 @@ sequenceDiagram
|
||||
participant U as user-service
|
||||
participant R as room-service
|
||||
participant T as Tencent Cloud IM
|
||||
participant RTC as Tencent Cloud RTC
|
||||
|
||||
C->>G: Login / RefreshToken
|
||||
G->>U: gRPC auth
|
||||
@ -88,22 +90,25 @@ sequenceDiagram
|
||||
R-->>G: RoomSnapshot
|
||||
G-->>C: room envelope
|
||||
|
||||
C->>G: GET /api/v1/im/usersig
|
||||
G-->>C: SDKAppID + user_id + UserSig
|
||||
C->>T: SDK login(user_id, user_sig)
|
||||
|
||||
C->>G: POST /api/v1/rooms/join
|
||||
G->>R: JoinRoom(actor_user_id)
|
||||
R->>R: update Room Cell presence
|
||||
R->>T: REST send_group_msg(room_user_joined)
|
||||
R-->>G: RoomSnapshot
|
||||
G-->>C: room envelope
|
||||
G-->>C: initial room envelope + im_group_id + RTC token
|
||||
|
||||
C->>G: GET /api/v1/im/usersig
|
||||
G-->>C: SDKAppID + user_id + UserSig
|
||||
C->>T: SDK login(user_id, user_sig)
|
||||
C->>T: joinGroup(room_id)
|
||||
T->>G: CallbackBeforeApplyJoinGroup
|
||||
G->>R: VerifyRoomPresence(room_id, user_id)
|
||||
R-->>G: present=true
|
||||
G-->>T: allow
|
||||
|
||||
C->>RTC: enterRoom(strRoomId from JoinRoom rtc.token)
|
||||
|
||||
C->>T: send group message
|
||||
T->>G: CallbackBeforeSendMsg
|
||||
G->>R: CheckSpeakPermission(room_id, user_id)
|
||||
|
||||
@ -30,20 +30,28 @@ sequenceDiagram
|
||||
participant IM as Tencent IM SDK
|
||||
participant RTC as Tencent RTC SDK
|
||||
|
||||
C->>G: POST /api/v1/rooms/join
|
||||
G->>R: JoinRoom(actor_user_id from token)
|
||||
R-->>G: room snapshot + user presence
|
||||
G-->>C: room snapshot
|
||||
|
||||
C->>G: GET /api/v1/im/usersig
|
||||
G-->>C: IM user_sig
|
||||
C->>IM: login(user_id, user_sig)
|
||||
C->>IM: joinGroup(room_id)
|
||||
|
||||
C->>G: POST /api/v1/rtc/token
|
||||
G->>R: VerifyRoomPresence(room_id, user_id)
|
||||
G-->>C: RTC user_sig, str_room_id, role=audience
|
||||
C->>RTC: enterRoom(strRoomId, audience)
|
||||
C->>G: GET /api/v1/rooms
|
||||
G->>R: ListRooms(viewer region from user-service)
|
||||
R-->>G: room cards
|
||||
G-->>C: room cards + im_group_id
|
||||
|
||||
C->>G: POST /api/v1/rooms/join
|
||||
G->>R: JoinRoom(actor_user_id from token)
|
||||
R-->>G: room snapshot + user presence
|
||||
G-->>C: room initial data + rtc token
|
||||
|
||||
par realtime bootstrap
|
||||
C->>IM: joinGroup(im_group_id)
|
||||
and audio bootstrap
|
||||
C->>RTC: enterRoom(strRoomId from join.rtc.token, audience)
|
||||
end
|
||||
|
||||
C->>G: GET /api/v1/rooms/snapshot
|
||||
G-->>C: optional active-room resync
|
||||
|
||||
loop while room page is active
|
||||
C->>G: POST /api/v1/rooms/heartbeat
|
||||
@ -54,14 +62,15 @@ sequenceDiagram
|
||||
|
||||
| Step | Interface | Required | Reason |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `POST /api/v1/rooms/join` | yes | 建立业务 presence,后续 IM/RTC 守卫依赖它 |
|
||||
| 2 | `GET /api/v1/im/usersig` | yes | 登录腾讯云 IM,接收房间群消息和系统事件 |
|
||||
| 3 | Tencent IM SDK `login` + `joinGroup` | yes | 本仓库不承载房间长连接 |
|
||||
| 4 | `POST /api/v1/rtc/token` | yes for voice room audio | 签发 TRTC 票据;当前返回 `role=audience` |
|
||||
| 5 | Tencent RTC SDK `enterRoom` | yes for voice room audio | 进入真实音频频道旁听 |
|
||||
| 6 | `POST /api/v1/rooms/heartbeat` | yes | 刷新业务 presence,避免 stale timeout |
|
||||
| 1 | `GET /api/v1/im/usersig` | yes | 用户级 IM 登录票据,可在 App 启动后预取并按 TTL 复用 |
|
||||
| 2 | Tencent IM SDK `login` | yes | 客户端先建立 IM 身份,房间群仍等 JoinRoom 成功后再进 |
|
||||
| 3 | `GET /api/v1/rooms` | yes for discovery | 房间卡片返回 `im_group_id`,只用于展示和准备,不授予入群权限 |
|
||||
| 4 | `POST /api/v1/rooms/join` | yes | 建立业务 presence,并返回首屏房间数据和内嵌 RTC token |
|
||||
| 5 | Tencent IM SDK `joinGroup(im_group_id)` | yes | 必须在 JoinRoom 成功后执行,IM 回调守卫会校验 room-service presence |
|
||||
| 6 | Tencent RTC SDK `enterRoom` | yes for voice room audio | 使用 JoinRoom 响应里的 `rtc.token` 进入真实音频频道旁听 |
|
||||
| 7 | `POST /api/v1/rooms/heartbeat` | yes | 刷新业务 presence,避免 stale timeout |
|
||||
|
||||
如果产品允许“只看公屏不听语音”,第 4、5 步可以延迟到用户打开音频时。但语音房默认应进入 RTC audience,以保证打开房间即可听到声音。
|
||||
如果 JoinRoom 响应里的 `rtc.available=false`,客户端可以先展示房间页并重试 `POST /api/v1/rtc/token`;RTC 失败不能回滚已经成功的业务 presence。
|
||||
|
||||
## Core Interfaces
|
||||
|
||||
@ -92,13 +101,76 @@ Response `data`:
|
||||
"room_version": 2,
|
||||
"server_time_ms": 1778000000000
|
||||
},
|
||||
"user": {
|
||||
"user_id": 10001,
|
||||
"room": {
|
||||
"room_id": "lalu_xxx",
|
||||
"im_group_id": "lalu_xxx",
|
||||
"room_short_id": "100001",
|
||||
"title": "Live Room",
|
||||
"cover_url": "https://cdn.example/room.png",
|
||||
"description": "welcome",
|
||||
"owner_user_id": "10001",
|
||||
"host_user_id": "10001",
|
||||
"mode": "voice",
|
||||
"status": "active",
|
||||
"chat_enabled": true,
|
||||
"heat": 1000,
|
||||
"online_count": 26,
|
||||
"seat_count": 8,
|
||||
"occupied_seat_count": 2,
|
||||
"version": 2
|
||||
},
|
||||
"viewer": {
|
||||
"user_id": "10001",
|
||||
"role": "audience",
|
||||
"joined_at_ms": 1778000000000,
|
||||
"last_seen_at_ms": 1778000000000
|
||||
},
|
||||
"room": {}
|
||||
"seats": [
|
||||
{
|
||||
"seat_no": 1,
|
||||
"user_id": "10002",
|
||||
"locked": false,
|
||||
"publish_state": "publishing",
|
||||
"mic_session_id": "mic_xxx"
|
||||
}
|
||||
],
|
||||
"contribution_rank": [
|
||||
{
|
||||
"user_id": "10002",
|
||||
"score": 100,
|
||||
"gift_value": 100
|
||||
}
|
||||
],
|
||||
"profiles": [
|
||||
{
|
||||
"user_id": "10002",
|
||||
"display_user_id": "200002",
|
||||
"username": "host",
|
||||
"avatar": "https://cdn.example/avatar.png"
|
||||
}
|
||||
],
|
||||
"im": {
|
||||
"group_id": "lalu_xxx",
|
||||
"need_join_group": true
|
||||
},
|
||||
"rtc": {
|
||||
"need_token": true,
|
||||
"available": true,
|
||||
"token": {
|
||||
"sdk_app_id": 20036101,
|
||||
"user_id": "10001",
|
||||
"rtc_user_id": "10001",
|
||||
"user_sig": "xxx",
|
||||
"expire_at_ms": 1778007200000,
|
||||
"room_id": "lalu_xxx",
|
||||
"rtc_room_id": "lalu_xxx",
|
||||
"rtc_room_id_type": "string",
|
||||
"str_room_id": "lalu_xxx",
|
||||
"app_scene": "voice_chat_room",
|
||||
"role": "audience"
|
||||
}
|
||||
},
|
||||
"server_time_ms": 1778000000000
|
||||
}
|
||||
```
|
||||
|
||||
@ -107,7 +179,9 @@ Rules:
|
||||
- `room_id` 必须来自房间列表、分享或恢复接口,不能由客户端拼业务含义。
|
||||
- `command_id` 用于房间命令幂等;客户端重试同一次进房必须复用同一个值。
|
||||
- `role` 只作为展示默认值,不能通过自报 `admin/host/owner` 获得权限。
|
||||
- JoinRoom 成功后才能登录 IM 进群和获取 RTC token。
|
||||
- `im.group_id` 当前等于 `room_id`,但客户端必须使用服务端返回值,不能硬编码映射规则。
|
||||
- JoinRoom 成功后才能调用腾讯 IM SDK `joinGroup`;列表里的 `im_group_id` 只用于准备,不代表入群授权。
|
||||
- JoinRoom 会尝试内嵌签发 RTC token;签发失败时返回 `rtc.available=false`,客户端可以继续展示房间并单独重试 `/api/v1/rtc/token`。
|
||||
|
||||
### IM UserSig
|
||||
|
||||
@ -123,7 +197,18 @@ Response `data`:
|
||||
"sdk_app_id": 20036101,
|
||||
"user_id": "10001",
|
||||
"user_sig": "xxx",
|
||||
"expire_at_ms": 1778086400000
|
||||
"expire_at_ms": 1778086400000,
|
||||
"join_groups": [
|
||||
{
|
||||
"group_id": "hy_lalu_bc_g",
|
||||
"type": "global_broadcast"
|
||||
},
|
||||
{
|
||||
"group_id": "hy_lalu_bc_r_1001",
|
||||
"type": "region_broadcast",
|
||||
"region_id": 1001
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@ -138,11 +223,13 @@ Client SDK action:
|
||||
|
||||
```text
|
||||
Tencent IM SDK login(user_id, user_sig)
|
||||
Tencent IM SDK joinGroup(room_id)
|
||||
Tencent IM SDK joinGroup(join_response.im.group_id)
|
||||
```
|
||||
|
||||
### RTC Token
|
||||
|
||||
`POST /api/v1/rtc/token` 是 JoinRoom 内嵌签发失败、token 过期或 RTC 重连时的补偿入口;首次进房应优先使用 JoinRoom 响应里的 `rtc.token`。
|
||||
|
||||
```http
|
||||
POST /api/v1/rtc/token
|
||||
Authorization: Bearer <access_token>
|
||||
|
||||
@ -41,10 +41,10 @@
|
||||
| --- | --- | --- | --- |
|
||||
| `/api/v1` envelope | `DONE` | gateway 业务响应使用 `{code,message,request_id,data}` | healthcheck 不套 envelope,保持现状 |
|
||||
| 房间命令 HTTP 路由 | `DONE` | create/join/leave/mic/mute/kick/sendGift 路由存在 | OpenAPI 需持续同步最新字段 |
|
||||
| 房间命令 `command_id` | `DONE` | HTTP body 支持 `command_id`,gateway 未传时生成但不保证重试幂等 | 客户端必须稳定复用 command_id |
|
||||
| 房间命令 `command_id` | `DONE` | 创建房间由 gateway 生成内部 `command_id`;其他写命令支持 body `command_id`,未传时 gateway 生成一次性值 | 客户端只在非创建类重试动作复用 command_id |
|
||||
| `request_id` 追踪 | `DONE` | request_id 透传 RequestMeta,不作为幂等键 | 日志和链路追踪系统可继续完善 |
|
||||
| IM UserSig 签发 | `DONE` | `/api/v1/im/usersig` 使用 access token `user_id` 签发 | 真实腾讯云应用联调和告警 |
|
||||
| RTC token 签发 | `DONE` | `/api/v1/rtc/token` 存在,先查 `VerifyRoomPresence` 再签 UserSig | 客户端 TRTC SDK 实际进房联调 |
|
||||
| RTC token 签发 | `DONE` | `JoinRoom` 响应内嵌首屏 RTC token;`/api/v1/rtc/token` 保留为过期刷新和补偿入口 | 客户端 TRTC SDK 实际进房联调 |
|
||||
| 腾讯 IM 回调入口 | `DONE` | `/api/v1/tencent-im/callback` 支持入群前和发言前守卫 | 腾讯云控制台回调 URL 和鉴权参数真实配置 |
|
||||
| 管理端 HTTP API | `TODO` | 当前没有完整 admin auth/API | 房间管理、区域管理、礼物配置、活动配置、封禁等后台接口 |
|
||||
|
||||
@ -54,7 +54,7 @@
|
||||
| --- | --- | --- | --- |
|
||||
| 创建房间 | `DONE` | `CreateRoom` 校验 room_id、actor、seat_count、mode;写 meta/command/outbox/snapshot | 真实 IM 建群失败注入和线上重试策略联调 |
|
||||
| owner/host 收敛 | `DONE` | room-service 从 `actor_user_id` 默认 owner/host,拒绝外部冒用 | 后续转主持需独立命令 |
|
||||
| 进房 presence | `DONE` | `JoinRoom` 写 `OnlineUsers`,返回 user 和 snapshot | 客户端断线重连自动化测试 |
|
||||
| 进房 presence | `DONE` | `JoinRoom` 写 `OnlineUsers`,gateway 返回首屏房间 DTO、`im_group_id` 和 RTC token | 客户端断线重连自动化测试 |
|
||||
| 显式心跳刷新 | `DONE` | `/api/v1/rooms/heartbeat` 和 `RoomHeartbeat` 只刷新已有 `last_seen_at_ms` | 客户端按 stale timeout 配置周期调用 |
|
||||
| 重复 JoinRoom 刷新 | `DONE` | 重复进房刷新 `last_seen_at_ms`,不重复发进房事件 | 新客户端优先使用 RoomHeartbeat |
|
||||
| 离房 | `DONE` | `LeaveRoom` 幂等,移除 presence,释放麦位,写 outbox | 客户端收到离房/被踢后退出 IM/RTC 需联调 |
|
||||
@ -95,10 +95,10 @@
|
||||
|
||||
| Feature | Status | Current Evidence | Remaining Work |
|
||||
| --- | --- | --- | --- |
|
||||
| RTC UserSig 签发 | `DONE` | `pkg/tencentrtc` 和 `/api/v1/rtc/token` 已存在 | 真实 TRTC SDK enterRoom 联调 |
|
||||
| RTC UserSig 签发 | `DONE` | `pkg/tencentrtc`、JoinRoom 内嵌签发和 `/api/v1/rtc/token` 补偿入口已存在 | 真实 TRTC SDK enterRoom 联调 |
|
||||
| RTC user_id 映射 | `DONE` | 内部 `user_id` -> RTC `UserID` 十进制字符串 | 多端同房策略未做 |
|
||||
| RTC room_id 映射 | `DONE` | 统一使用 TRTC `strRoomId`,校验 room_id 子集 | 当前入口 fail-closed,非法 room_id 不创建房间 |
|
||||
| 进频道权限 | `DONE` | 签 RTC token 前调用 `VerifyRoomPresence` | 客户端必须按 JoinRoom 后再取 token |
|
||||
| 进频道权限 | `DONE` | JoinRoom 成功即证明业务 presence;独立 `/rtc/token` 仍调用 `VerifyRoomPresence` | 客户端必须按 JoinRoom 成功后再进 RTC |
|
||||
| RTC 角色 | `PARTIAL` | token 返回默认 `audience` | 上麦后切 `anchor` 或 SDK role 切换未闭环 |
|
||||
| RTC 事件回调 | `DONE` | `/api/v1/tencent-rtc/callback` 验签后消费 203/204/104 并调用 `ApplyRTCEvent` | 腾讯控制台公网回调配置和真实 TRTC 联调 |
|
||||
| 离频道 | `PARTIAL` | 服务端 LeaveRoom 和 RTC 104 回调都会移除业务 presence | 客户端退出 TRTC 房间、异常重进策略未联调 |
|
||||
@ -187,7 +187,7 @@
|
||||
| Feature | Status | Server Dependency | Remaining Work |
|
||||
| --- | --- | --- | --- |
|
||||
| 登录/注册 | `PARTIAL` | gateway/user-service 已有接口 | 客户端提交 Firebase ID token;服务端接 Firebase verifier 后形成生产登录闭环 |
|
||||
| CreateRoom/JoinRoom/LeaveRoom | `PARTIAL` | gateway/room-service 已有接口 | 客户端 command_id、重试、状态覆盖 |
|
||||
| CreateRoom/JoinRoom/LeaveRoom | `PARTIAL` | gateway/room-service 已有接口 | 非创建类 command_id、重试、状态覆盖 |
|
||||
| 腾讯 IM 登录和进群 | `PARTIAL` | `/api/v1/im/usersig` 和回调守卫已存在 | SDK login、joinGroup、回调配置、历史补拉 |
|
||||
| 腾讯 RTC 进房 | `PARTIAL` | `/api/v1/rtc/token` 已存在 | TRTC enterRoom、角色切换、断线重连 |
|
||||
| 麦位 UI | `PARTIAL` | mic commands 已存在 | 上麦后推流、下麦停止推流、锁麦状态展示 |
|
||||
@ -250,7 +250,7 @@
|
||||
当前服务端已经完成或基本完成:
|
||||
|
||||
- 用户注册登录、短号、资料和国家修改底座。
|
||||
- gateway `/api/v1` envelope、房间命令 HTTP 路由、`command_id` 透传。
|
||||
- gateway `/api/v1` envelope、房间命令 HTTP 路由、创建房间内部 `command_id` 生成和非创建命令 `command_id` 透传。
|
||||
- room-service Room Cell、Create/Join/Leave、麦位上下/换位、禁言、踢人、SendGift。
|
||||
- MySQL command log/snapshot/outbox、Redis lease、恢复和 stale presence 清理。
|
||||
- 腾讯 IM UserSig、建群、系统消息、入群/发言回调守卫。
|
||||
|
||||
@ -287,7 +287,20 @@ GET /api/v1/rooms?tab=new&cursor=&limit=20
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {
|
||||
"rooms": [],
|
||||
"rooms": [
|
||||
{
|
||||
"room_id": "lalu_room_xxx",
|
||||
"im_group_id": "lalu_room_xxx",
|
||||
"title": "Live Room",
|
||||
"cover_url": "https://cdn.example/room.png",
|
||||
"mode": "voice",
|
||||
"status": "active",
|
||||
"heat": 1000,
|
||||
"online_count": 26,
|
||||
"seat_count": 8,
|
||||
"occupied_seat_count": 2
|
||||
}
|
||||
],
|
||||
"next_cursor": ""
|
||||
}
|
||||
}
|
||||
@ -300,6 +313,7 @@ GET /api/v1/rooms?tab=new&cursor=&limit=20
|
||||
- `cursor` 使用不透明字符串,客户端不能解析或拼装。
|
||||
- gateway 从 access token 得到 `user_id`,再查 user-service 获取 `region_id`。
|
||||
- 如果 user-service 查询失败,gateway 应返回错误,不应该用 IP 国家临时兜底。
|
||||
- `im_group_id` 当前等于 `room_id`,只用于客户端准备腾讯 IM 房间群;真正入群必须等 `JoinRoom` 成功后由 IM 回调守卫校验 presence。
|
||||
|
||||
### gRPC
|
||||
|
||||
|
||||
@ -50,6 +50,7 @@ type fakeRoomClient struct {
|
||||
lastGift *roomv1.SendGiftRequest
|
||||
createErr error
|
||||
joinErr error
|
||||
joinResp *roomv1.JoinRoomResponse
|
||||
}
|
||||
|
||||
func (f *fakeRoomClient) CreateRoom(_ context.Context, req *roomv1.CreateRoomRequest) (*roomv1.CreateRoomResponse, error) {
|
||||
@ -68,7 +69,33 @@ func (f *fakeRoomClient) JoinRoom(_ context.Context, req *roomv1.JoinRoomRequest
|
||||
if f.joinErr != nil {
|
||||
return nil, f.joinErr
|
||||
}
|
||||
return &roomv1.JoinRoomResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 2}}, nil
|
||||
if f.joinResp != nil {
|
||||
return f.joinResp, nil
|
||||
}
|
||||
userID := req.GetMeta().GetActorUserId()
|
||||
role := req.GetRole()
|
||||
if role == "" {
|
||||
role = "audience"
|
||||
}
|
||||
return &roomv1.JoinRoomResponse{
|
||||
Result: &roomv1.CommandResult{Applied: true, RoomVersion: 2, ServerTimeMs: 1_700_000_000_001},
|
||||
User: &roomv1.RoomUser{UserId: userID, Role: role, JoinedAtMs: 1_700_000_000_000, LastSeenAtMs: 1_700_000_000_000},
|
||||
Room: &roomv1.RoomSnapshot{
|
||||
RoomId: req.GetMeta().GetRoomId(),
|
||||
OwnerUserId: 1001,
|
||||
HostUserId: 1002,
|
||||
Mode: "voice",
|
||||
Status: "active",
|
||||
ChatEnabled: true,
|
||||
MicSeats: []*roomv1.SeatState{{SeatNo: 1}, {SeatNo: 2, UserId: 1002, PublishState: "publishing", MicSessionId: "mic-1002"}},
|
||||
OnlineUsers: []*roomv1.RoomUser{{UserId: userID, Role: role}, {UserId: 1002, Role: "host"}},
|
||||
GiftRank: []*roomv1.RankItem{{UserId: 1002, Score: 200, GiftValue: 200}},
|
||||
RoomExt: map[string]string{"title": "Room", "cover_url": "https://cdn.example/room.png", "description": "welcome", "room_short_id": "100001"},
|
||||
Heat: 99,
|
||||
Version: 2,
|
||||
RoomShortId: "100001",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *fakeRoomClient) RoomHeartbeat(_ context.Context, req *roomv1.RoomHeartbeatRequest) (*roomv1.RoomHeartbeatResponse, error) {
|
||||
@ -761,7 +788,7 @@ func TestRoutesWriteUnifiedEnvelopeAndReuseRequestID(t *testing.T) {
|
||||
client := &fakeRoomClient{}
|
||||
profileClient := &fakeUserProfileClient{regionID: 1001}
|
||||
router := NewHandlerWithClients(client, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
|
||||
body := []byte(`{"room_id":"client-room-ignored","seat_count":8,"mode":"voice","room_name":" Lobby ","room_avatar":" https://cdn.example.com/room.png ","room_description":" welcome "}`)
|
||||
body := []byte(`{"room_id":"client-room-ignored","command_id":"client-command-ignored","seat_count":8,"mode":"voice","room_name":" Lobby ","room_avatar":" https://cdn.example.com/room.png ","room_description":" welcome "}`)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader(body))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-test")
|
||||
@ -795,7 +822,7 @@ func TestRoutesWriteUnifiedEnvelopeAndReuseRequestID(t *testing.T) {
|
||||
if client.lastCreate.GetRoomShortId() != "100001" {
|
||||
t.Fatalf("CreateRoom must use owner display_user_id as room_short_id: %+v", client.lastCreate)
|
||||
}
|
||||
if client.lastCreate.GetMeta().GetCommandId() == "" || client.lastCreate.GetMeta().GetCommandId() == "req-test" {
|
||||
if client.lastCreate.GetMeta().GetCommandId() == "" || client.lastCreate.GetMeta().GetCommandId() == "req-test" || client.lastCreate.GetMeta().GetCommandId() == "client-command-ignored" {
|
||||
t.Fatalf("gateway-generated command_id must be non-empty and separate from request_id: %+v", client.lastCreate.GetMeta())
|
||||
}
|
||||
if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 || client.lastCreate.GetVisibleRegionId() != 1001 {
|
||||
@ -814,15 +841,6 @@ func TestRoomCommandsPropagateClientCommandID(t *testing.T) {
|
||||
commandID string
|
||||
extractMeta func(*fakeRoomClient) *roomv1.RequestMeta
|
||||
}{
|
||||
{
|
||||
name: "create",
|
||||
path: "/api/v1/rooms/create",
|
||||
body: `{"room_id":"room-1","command_id":"cmd-create-1","seat_count":8,"mode":"voice","room_name":"Lobby"}`,
|
||||
commandID: "cmd-create-1",
|
||||
extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
|
||||
return client.lastCreate.GetMeta()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "join",
|
||||
path: "/api/v1/rooms/join",
|
||||
@ -992,6 +1010,82 @@ func TestRoomCommandsPropagateClientCommandID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T) {
|
||||
previousNow := timeNow
|
||||
timeNow = func() time.Time { return time.Unix(1_700_000_000, 0) }
|
||||
defer func() { timeNow = previousNow }()
|
||||
|
||||
roomClient := &fakeRoomClient{joinResp: &roomv1.JoinRoomResponse{
|
||||
Result: &roomv1.CommandResult{Applied: true, RoomVersion: 7, ServerTimeMs: 1_700_000_000_123},
|
||||
User: &roomv1.RoomUser{UserId: 42, Role: "audience", JoinedAtMs: 1_700_000_000_000, LastSeenAtMs: 1_700_000_000_100},
|
||||
Room: &roomv1.RoomSnapshot{
|
||||
RoomId: "room_1001",
|
||||
OwnerUserId: 101,
|
||||
HostUserId: 102,
|
||||
Mode: "voice",
|
||||
Status: "active",
|
||||
ChatEnabled: true,
|
||||
MicSeats: []*roomv1.SeatState{{SeatNo: 1, UserId: 102, PublishState: "publishing", MicSessionId: "mic-102"}, {SeatNo: 2, Locked: true}},
|
||||
OnlineUsers: []*roomv1.RoomUser{{UserId: 42, Role: "audience"}, {UserId: 102, Role: "host"}},
|
||||
GiftRank: []*roomv1.RankItem{{UserId: 301, Score: 900, GiftValue: 900}},
|
||||
RoomExt: map[string]string{"title": "Live Room", "cover_url": "https://cdn.example/cover.png", "description": "welcome"},
|
||||
Heat: 88,
|
||||
Version: 7,
|
||||
RoomShortId: "100101",
|
||||
},
|
||||
}}
|
||||
profileClient := &fakeUserProfileClient{}
|
||||
handler := NewHandlerWithConfig(roomClient, nil, nil, nil, TencentIMConfig{}, profileClient)
|
||||
handler.SetTencentRTC(TencentRTCConfig{
|
||||
Enabled: true,
|
||||
SDKAppID: 1400000000,
|
||||
SecretKey: "secret",
|
||||
UserSigTTL: 2 * time.Hour,
|
||||
RoomIDType: "string",
|
||||
AppScene: "voice_chat_room",
|
||||
})
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/join", bytes.NewReader([]byte(`{"room_id":"room_1001","command_id":"cmd-join","role":"audience"}`)))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-join-initial")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var response responseEnvelope
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode join response failed: %v", err)
|
||||
}
|
||||
data := response.Data.(map[string]any)
|
||||
room := data["room"].(map[string]any)
|
||||
if room["room_id"] != "room_1001" || room["im_group_id"] != "room_1001" || room["title"] != "Live Room" || room["online_count"].(float64) != 2 {
|
||||
t.Fatalf("unexpected initial room data: %+v", room)
|
||||
}
|
||||
seats := data["seats"].([]any)
|
||||
if len(seats) != 2 || seats[0].(map[string]any)["user_id"] != "102" {
|
||||
t.Fatalf("join response must include first-screen seats: %+v", seats)
|
||||
}
|
||||
rank := data["contribution_rank"].([]any)
|
||||
if len(rank) != 1 || rank[0].(map[string]any)["user_id"] != "301" {
|
||||
t.Fatalf("join response must include first-screen contribution rank: %+v", rank)
|
||||
}
|
||||
im := data["im"].(map[string]any)
|
||||
if im["group_id"] != "room_1001" || im["need_join_group"] != true {
|
||||
t.Fatalf("unexpected im block: %+v", im)
|
||||
}
|
||||
rtc := data["rtc"].(map[string]any)
|
||||
token := rtc["token"].(map[string]any)
|
||||
if rtc["available"] != true || token["str_room_id"] != "room_1001" || token["rtc_user_id"] != "42" {
|
||||
t.Fatalf("join response must embed usable rtc token: rtc=%+v token=%+v", rtc, token)
|
||||
}
|
||||
if profileClient.lastBatch == nil || strings.Join(int64SliceToStrings(profileClient.lastBatch.GetUserIds()), ",") != "101,102,42,301" {
|
||||
t.Fatalf("join response must batch only first-screen profile users: %+v", profileClient.lastBatch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRoomsUsesUserRegionFromUserService(t *testing.T) {
|
||||
profileClient := &fakeUserProfileClient{regionID: 1001}
|
||||
queryClient := &fakeRoomQueryClient{resp: &roomv1.ListRoomsResponse{
|
||||
@ -1025,6 +1119,16 @@ func TestListRoomsUsesUserRegionFromUserService(t *testing.T) {
|
||||
if queryClient.lastList.GetTab() != "me" || queryClient.lastList.GetLimit() != 2 || queryClient.lastList.GetCursor() != "cursor-1" || queryClient.lastList.GetQuery() != "room" {
|
||||
t.Fatalf("ListRooms query params were not propagated: %+v", queryClient.lastList)
|
||||
}
|
||||
var response responseEnvelope
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode room list response failed: %v", err)
|
||||
}
|
||||
data := response.Data.(map[string]any)
|
||||
rooms := data["rooms"].([]any)
|
||||
first := rooms[0].(map[string]any)
|
||||
if first["im_group_id"] != "room-1" {
|
||||
t.Fatalf("room list must expose room IM group id: %+v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRoomsRejectsInvalidLimitBeforeGRPC(t *testing.T) {
|
||||
@ -3006,6 +3110,14 @@ func signTencentRTCCallback(key string, body []byte) string {
|
||||
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func int64SliceToStrings(values []int64) []string {
|
||||
items := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
items = append(items, strconv.FormatInt(value, 10))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func authRateLimitForTest(mutator func(*AuthRateLimitConfig)) AuthRateLimitConfig {
|
||||
config := AuthRateLimitConfig{
|
||||
Enabled: true,
|
||||
|
||||
@ -46,7 +46,11 @@ func (h *Handler) listRooms(writer http.ResponseWriter, request *http.Request) {
|
||||
Limit: limit,
|
||||
Query: roomListQuery(request),
|
||||
})
|
||||
write(writer, request, resp, err)
|
||||
if err != nil {
|
||||
writeRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
writeOK(writer, request, roomListDataFromProto(resp))
|
||||
}
|
||||
|
||||
func roomListQuery(request *http.Request) string {
|
||||
@ -148,7 +152,6 @@ func (h *Handler) getRoomSnapshot(writer http.ResponseWriter, request *http.Requ
|
||||
// createRoom 把创建房间 HTTP JSON 请求转换为 room-service CreateRoom 命令。
|
||||
func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request) {
|
||||
var body struct {
|
||||
CommandID string `json:"command_id"`
|
||||
SeatCount int32 `json:"seat_count"`
|
||||
Mode string `json:"mode"`
|
||||
RoomName string `json:"room_name"`
|
||||
@ -185,7 +188,7 @@ func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request)
|
||||
roomID := appcode.NewScopedID(appcode.FromContext(request.Context()))
|
||||
roomShortID := userResp.GetUser().GetDisplayUserId()
|
||||
resp, err := h.roomClient.CreateRoom(request.Context(), &roomv1.CreateRoomRequest{
|
||||
Meta: meta(request, roomID, body.CommandID),
|
||||
Meta: meta(request, roomID, ""),
|
||||
SeatCount: body.SeatCount,
|
||||
Mode: body.Mode,
|
||||
VisibleRegionId: userResp.GetUser().GetRegionId(),
|
||||
@ -197,7 +200,7 @@ func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request)
|
||||
write(writer, request, resp, err)
|
||||
}
|
||||
|
||||
// joinRoom 把进房 HTTP JSON 请求转换为 room-service JoinRoom 命令。
|
||||
// joinRoom 把进房 HTTP JSON 请求转换为 room-service JoinRoom 命令,并组装房间首屏初始化包。
|
||||
func (h *Handler) joinRoom(writer http.ResponseWriter, request *http.Request) {
|
||||
var body struct {
|
||||
RoomID string `json:"room_id"`
|
||||
@ -213,7 +216,83 @@ func (h *Handler) joinRoom(writer http.ResponseWriter, request *http.Request) {
|
||||
Meta: meta(request, body.RoomID, body.CommandID),
|
||||
Role: body.Role,
|
||||
})
|
||||
write(writer, request, resp, err)
|
||||
if err != nil {
|
||||
writeRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
writeOK(writer, request, h.joinRoomInitialData(request, body.RoomID, resp))
|
||||
}
|
||||
|
||||
func (h *Handler) joinRoomInitialData(request *http.Request, requestedRoomID string, resp *roomv1.JoinRoomResponse) joinRoomData {
|
||||
if resp == nil {
|
||||
return joinRoomData{}
|
||||
}
|
||||
viewerUserID := auth.UserIDFromContext(request.Context())
|
||||
snapshot := resp.GetRoom()
|
||||
roomID := snapshot.GetRoomId()
|
||||
if roomID == "" {
|
||||
// 老测试桩或异常上游可能只返回 result;用 JoinRoom meta 兜底,避免 HTTP DTO 生成崩溃。
|
||||
roomID = strings.TrimSpace(requestedRoomID)
|
||||
}
|
||||
|
||||
return joinRoomData{
|
||||
Result: commandResultDataFromProto(resp.GetResult()),
|
||||
Room: roomInitialRoomDataFromSnapshot(snapshot, roomID),
|
||||
Viewer: roomViewerDataFromProto(resp.GetUser(), viewerUserID),
|
||||
Seats: roomSeatDataFromSnapshot(snapshot),
|
||||
ContributionRank: roomRankDataFromSnapshot(snapshot, joinRoomContributionRankLimit),
|
||||
Profiles: h.roomInitialProfiles(request, snapshot, viewerUserID),
|
||||
IM: roomIMData{GroupID: roomIMGroupID(roomID), NeedJoinGroup: true},
|
||||
RTC: h.joinRoomRTCData(roomID, viewerUserID),
|
||||
ServerTimeMS: resp.GetResult().GetServerTimeMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) joinRoomRTCData(roomID string, viewerUserID int64) roomRTCData {
|
||||
rtc := roomRTCData{NeedToken: true}
|
||||
token, err := h.generateTencentRTCToken(viewerUserID, roomID)
|
||||
if err != nil {
|
||||
// RTC 票据是进房后的音频能力,不应该回滚已经成功的业务 presence。
|
||||
rtc.Available = false
|
||||
rtc.Reason = "rtc_token_unavailable"
|
||||
return rtc
|
||||
}
|
||||
rtc.Available = true
|
||||
rtc.Token = &token
|
||||
return rtc
|
||||
}
|
||||
|
||||
func (h *Handler) roomInitialProfiles(request *http.Request, snapshot *roomv1.RoomSnapshot, viewerUserID int64) []roomUserProfileData {
|
||||
if h.userProfileClient == nil {
|
||||
return nil
|
||||
}
|
||||
userIDs := roomInitialProfileUserIDs(snapshot, viewerUserID, joinRoomContributionRankLimit)
|
||||
if len(userIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
|
||||
Meta: authRequestMeta(request, ""),
|
||||
UserIds: userIDs,
|
||||
})
|
||||
if err != nil {
|
||||
// 用户资料只服务首屏展示,失败时让客户端用 /users/profiles:batch 补偿,避免阻塞进房。
|
||||
return nil
|
||||
}
|
||||
|
||||
profiles := make([]roomUserProfileData, 0, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
user := resp.GetUsers()[userID]
|
||||
if user == nil {
|
||||
continue
|
||||
}
|
||||
profiles = append(profiles, roomUserProfileData{
|
||||
UserID: strconv.FormatInt(user.GetUserId(), 10),
|
||||
DisplayUserID: user.GetDisplayUserId(),
|
||||
Username: user.GetUsername(),
|
||||
Avatar: user.GetAvatar(),
|
||||
})
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
// roomHeartbeat 把客户端显式心跳转换为 room-service presence refresh 命令。
|
||||
|
||||
281
services/gateway-service/internal/transport/http/room_view.go
Normal file
281
services/gateway-service/internal/transport/http/room_view.go
Normal file
@ -0,0 +1,281 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"hyapp/pkg/tencentrtc"
|
||||
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
)
|
||||
|
||||
const joinRoomContributionRankLimit = 10
|
||||
|
||||
type roomListData struct {
|
||||
Rooms []roomListItemData `json:"rooms"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
}
|
||||
|
||||
type roomListItemData struct {
|
||||
RoomID string `json:"room_id"`
|
||||
IMGroupID string `json:"im_group_id"`
|
||||
OwnerUserID string `json:"owner_user_id,omitempty"`
|
||||
HostUserID string `json:"host_user_id,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Heat int64 `json:"heat"`
|
||||
OnlineCount int32 `json:"online_count"`
|
||||
SeatCount int32 `json:"seat_count"`
|
||||
OccupiedSeatCount int32 `json:"occupied_seat_count"`
|
||||
VisibleRegionID int64 `json:"visible_region_id,omitempty"`
|
||||
AppCode string `json:"app_code,omitempty"`
|
||||
RoomShortID string `json:"room_short_id,omitempty"`
|
||||
}
|
||||
|
||||
type joinRoomData struct {
|
||||
Result roomCommandResultData `json:"result"`
|
||||
Room roomInitialData `json:"room"`
|
||||
Viewer roomViewerData `json:"viewer"`
|
||||
Seats []roomSeatData `json:"seats"`
|
||||
ContributionRank []roomRankItemData `json:"contribution_rank"`
|
||||
Profiles []roomUserProfileData `json:"profiles"`
|
||||
IM roomIMData `json:"im"`
|
||||
RTC roomRTCData `json:"rtc"`
|
||||
ServerTimeMS int64 `json:"server_time_ms"`
|
||||
}
|
||||
|
||||
type roomCommandResultData struct {
|
||||
Applied bool `json:"applied"`
|
||||
RoomVersion int64 `json:"room_version"`
|
||||
ServerTimeMS int64 `json:"server_time_ms"`
|
||||
}
|
||||
|
||||
type roomInitialData struct {
|
||||
RoomID string `json:"room_id"`
|
||||
IMGroupID string `json:"im_group_id"`
|
||||
RoomShortID string `json:"room_short_id,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
OwnerUserID string `json:"owner_user_id,omitempty"`
|
||||
HostUserID string `json:"host_user_id,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ChatEnabled bool `json:"chat_enabled"`
|
||||
Heat int64 `json:"heat"`
|
||||
OnlineCount int32 `json:"online_count"`
|
||||
SeatCount int32 `json:"seat_count"`
|
||||
OccupiedSeatCount int32 `json:"occupied_seat_count"`
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
|
||||
type roomViewerData struct {
|
||||
UserID string `json:"user_id"`
|
||||
Role string `json:"role,omitempty"`
|
||||
JoinedAtMS int64 `json:"joined_at_ms,omitempty"`
|
||||
LastSeenAtMS int64 `json:"last_seen_at_ms,omitempty"`
|
||||
}
|
||||
|
||||
type roomSeatData struct {
|
||||
SeatNo int32 `json:"seat_no"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
Locked bool `json:"locked"`
|
||||
PublishState string `json:"publish_state,omitempty"`
|
||||
MicSessionID string `json:"mic_session_id,omitempty"`
|
||||
PublishDeadlineMS int64 `json:"publish_deadline_ms,omitempty"`
|
||||
}
|
||||
|
||||
type roomRankItemData struct {
|
||||
UserID string `json:"user_id"`
|
||||
Score int64 `json:"score"`
|
||||
GiftValue int64 `json:"gift_value"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms,omitempty"`
|
||||
}
|
||||
|
||||
type roomUserProfileData struct {
|
||||
UserID string `json:"user_id"`
|
||||
DisplayUserID string `json:"display_user_id,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
}
|
||||
|
||||
type roomIMData struct {
|
||||
GroupID string `json:"group_id"`
|
||||
NeedJoinGroup bool `json:"need_join_group"`
|
||||
}
|
||||
|
||||
type roomRTCData struct {
|
||||
NeedToken bool `json:"need_token"`
|
||||
Available bool `json:"available"`
|
||||
Token *tencentrtc.TokenResult `json:"token,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
func roomListDataFromProto(resp *roomv1.ListRoomsResponse) roomListData {
|
||||
if resp == nil {
|
||||
return roomListData{}
|
||||
}
|
||||
items := make([]roomListItemData, 0, len(resp.GetRooms()))
|
||||
for _, room := range resp.GetRooms() {
|
||||
roomID := room.GetRoomId()
|
||||
items = append(items, roomListItemData{
|
||||
RoomID: roomID,
|
||||
IMGroupID: roomIMGroupID(roomID),
|
||||
OwnerUserID: formatOptionalUserID(room.GetOwnerUserId()),
|
||||
HostUserID: formatOptionalUserID(room.GetHostUserId()),
|
||||
Title: room.GetTitle(),
|
||||
CoverURL: room.GetCoverUrl(),
|
||||
Mode: room.GetMode(),
|
||||
Status: room.GetStatus(),
|
||||
Heat: room.GetHeat(),
|
||||
OnlineCount: room.GetOnlineCount(),
|
||||
SeatCount: room.GetSeatCount(),
|
||||
OccupiedSeatCount: room.GetOccupiedSeatCount(),
|
||||
VisibleRegionID: room.GetVisibleRegionId(),
|
||||
AppCode: room.GetAppCode(),
|
||||
RoomShortID: room.GetRoomShortId(),
|
||||
})
|
||||
}
|
||||
return roomListData{Rooms: items, NextCursor: resp.GetNextCursor()}
|
||||
}
|
||||
|
||||
func commandResultDataFromProto(result *roomv1.CommandResult) roomCommandResultData {
|
||||
if result == nil {
|
||||
return roomCommandResultData{}
|
||||
}
|
||||
return roomCommandResultData{
|
||||
Applied: result.GetApplied(),
|
||||
RoomVersion: result.GetRoomVersion(),
|
||||
ServerTimeMS: result.GetServerTimeMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func roomInitialRoomDataFromSnapshot(snapshot *roomv1.RoomSnapshot, roomID string) roomInitialData {
|
||||
if snapshot == nil {
|
||||
return roomInitialData{RoomID: roomID, IMGroupID: roomIMGroupID(roomID)}
|
||||
}
|
||||
ext := snapshot.GetRoomExt()
|
||||
if roomID == "" {
|
||||
roomID = snapshot.GetRoomId()
|
||||
}
|
||||
roomShortID := snapshot.GetRoomShortId()
|
||||
if roomShortID == "" {
|
||||
roomShortID = ext["room_short_id"]
|
||||
}
|
||||
seatCount, occupiedSeatCount := roomSeatCounts(snapshot)
|
||||
return roomInitialData{
|
||||
RoomID: roomID,
|
||||
IMGroupID: roomIMGroupID(roomID),
|
||||
RoomShortID: roomShortID,
|
||||
Title: ext["title"],
|
||||
CoverURL: ext["cover_url"],
|
||||
Description: ext["description"],
|
||||
OwnerUserID: formatOptionalUserID(snapshot.GetOwnerUserId()),
|
||||
HostUserID: formatOptionalUserID(snapshot.GetHostUserId()),
|
||||
Mode: snapshot.GetMode(),
|
||||
Status: snapshot.GetStatus(),
|
||||
ChatEnabled: snapshot.GetChatEnabled(),
|
||||
Heat: snapshot.GetHeat(),
|
||||
OnlineCount: int32(len(snapshot.GetOnlineUsers())),
|
||||
SeatCount: seatCount,
|
||||
OccupiedSeatCount: occupiedSeatCount,
|
||||
Version: snapshot.GetVersion(),
|
||||
}
|
||||
}
|
||||
|
||||
func roomViewerDataFromProto(user *roomv1.RoomUser, fallbackUserID int64) roomViewerData {
|
||||
userID := fallbackUserID
|
||||
if user.GetUserId() > 0 {
|
||||
userID = user.GetUserId()
|
||||
}
|
||||
return roomViewerData{
|
||||
UserID: formatOptionalUserID(userID),
|
||||
Role: user.GetRole(),
|
||||
JoinedAtMS: user.GetJoinedAtMs(),
|
||||
LastSeenAtMS: user.GetLastSeenAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func roomSeatDataFromSnapshot(snapshot *roomv1.RoomSnapshot) []roomSeatData {
|
||||
seats := snapshot.GetMicSeats()
|
||||
items := make([]roomSeatData, 0, len(seats))
|
||||
for _, seat := range seats {
|
||||
items = append(items, roomSeatData{
|
||||
SeatNo: seat.GetSeatNo(),
|
||||
UserID: formatOptionalUserID(seat.GetUserId()),
|
||||
Locked: seat.GetLocked(),
|
||||
PublishState: seat.GetPublishState(),
|
||||
MicSessionID: seat.GetMicSessionId(),
|
||||
PublishDeadlineMS: seat.GetPublishDeadlineMs(),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func roomRankDataFromSnapshot(snapshot *roomv1.RoomSnapshot, limit int) []roomRankItemData {
|
||||
rank := snapshot.GetGiftRank()
|
||||
if limit > 0 && len(rank) > limit {
|
||||
rank = rank[:limit]
|
||||
}
|
||||
items := make([]roomRankItemData, 0, len(rank))
|
||||
for _, item := range rank {
|
||||
items = append(items, roomRankItemData{
|
||||
UserID: formatOptionalUserID(item.GetUserId()),
|
||||
Score: item.GetScore(),
|
||||
GiftValue: item.GetGiftValue(),
|
||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func roomInitialProfileUserIDs(snapshot *roomv1.RoomSnapshot, viewerUserID int64, rankLimit int) []int64 {
|
||||
seen := map[int64]bool{}
|
||||
userIDs := make([]int64, 0, 16)
|
||||
add := func(userID int64) {
|
||||
if userID <= 0 || seen[userID] {
|
||||
return
|
||||
}
|
||||
seen[userID] = true
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
|
||||
add(snapshot.GetOwnerUserId())
|
||||
add(snapshot.GetHostUserId())
|
||||
add(viewerUserID)
|
||||
for _, seat := range snapshot.GetMicSeats() {
|
||||
add(seat.GetUserId())
|
||||
}
|
||||
rank := snapshot.GetGiftRank()
|
||||
if rankLimit > 0 && len(rank) > rankLimit {
|
||||
rank = rank[:rankLimit]
|
||||
}
|
||||
for _, item := range rank {
|
||||
add(item.GetUserId())
|
||||
}
|
||||
return userIDs
|
||||
}
|
||||
|
||||
func roomSeatCounts(snapshot *roomv1.RoomSnapshot) (int32, int32) {
|
||||
seatCount := int32(len(snapshot.GetMicSeats()))
|
||||
occupiedSeatCount := int32(0)
|
||||
for _, seat := range snapshot.GetMicSeats() {
|
||||
if seat.GetUserId() > 0 {
|
||||
occupiedSeatCount++
|
||||
}
|
||||
}
|
||||
return seatCount, occupiedSeatCount
|
||||
}
|
||||
|
||||
func roomIMGroupID(roomID string) string {
|
||||
// 当前房间群规则固定为腾讯云 IM GroupID 等于内部 room_id;显式下发给客户端,避免端侧硬编码规则。
|
||||
return roomID
|
||||
}
|
||||
|
||||
func formatOptionalUserID(userID int64) string {
|
||||
if userID <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatInt(userID, 10)
|
||||
}
|
||||
@ -37,14 +37,7 @@ func (h *Handler) issueTencentRTCToken(writer http.ResponseWriter, request *http
|
||||
}
|
||||
logCtx := logx.With(request.Context(), slog.String("request_id", requestIDFromContext(request.Context())), slog.String("app_code", appcode.FromContext(request.Context())))
|
||||
|
||||
rtcConfig := tencentrtc.TokenConfig{
|
||||
Enabled: h.tencentRTC.Enabled,
|
||||
SDKAppID: h.tencentRTC.SDKAppID,
|
||||
SecretKey: h.tencentRTC.SecretKey,
|
||||
TTL: h.tencentRTC.UserSigTTL,
|
||||
RoomIDType: h.tencentRTC.RoomIDType,
|
||||
AppScene: h.tencentRTC.AppScene,
|
||||
}
|
||||
rtcConfig := h.tencentRTCTokenConfig()
|
||||
if err := tencentrtc.ValidateConfig(rtcConfig); err != nil {
|
||||
logx.Error(logCtx, "gateway_rtc_token_config_error", err, slog.Int64("user_id", userID), slog.String("room_id", body.RoomID))
|
||||
writeError(writer, request, http.StatusInternalServerError, codeInternalError, "internal error")
|
||||
@ -72,7 +65,7 @@ func (h *Handler) issueTencentRTCToken(writer http.ResponseWriter, request *http
|
||||
return
|
||||
}
|
||||
|
||||
token, err := tencentrtc.GenerateToken(rtcConfig, userID, body.RoomID, timeNow())
|
||||
token, err := h.generateTencentRTCToken(userID, body.RoomID)
|
||||
if err != nil {
|
||||
logx.Error(logCtx, "gateway_rtc_token_sign_failed", err, slog.Int64("user_id", userID), slog.String("room_id", body.RoomID))
|
||||
writeError(writer, request, http.StatusInternalServerError, codeInternalError, "internal error")
|
||||
@ -116,3 +109,19 @@ var timeNow = defaultTimeNow
|
||||
func defaultTimeNow() time.Time {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
func (h *Handler) tencentRTCTokenConfig() tencentrtc.TokenConfig {
|
||||
return tencentrtc.TokenConfig{
|
||||
Enabled: h.tencentRTC.Enabled,
|
||||
SDKAppID: h.tencentRTC.SDKAppID,
|
||||
SecretKey: h.tencentRTC.SecretKey,
|
||||
TTL: h.tencentRTC.UserSigTTL,
|
||||
RoomIDType: h.tencentRTC.RoomIDType,
|
||||
AppScene: h.tencentRTC.AppScene,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) generateTencentRTCToken(userID int64, roomID string) (tencentrtc.TokenResult, error) {
|
||||
// JoinRoom 内嵌签发和独立 /rtc/token 使用同一套签名策略,避免两条入口出现 RTC 身份漂移。
|
||||
return tencentrtc.GenerateToken(h.tencentRTCTokenConfig(), userID, roomID, timeNow())
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user