From a19857a4b5cc5b5b4b8b751cdb3800f1360511b7 Mon Sep 17 00:00:00 2001 From: zhx Date: Mon, 11 May 2026 15:38:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=BF=9B=E6=88=BF=E9=97=B4?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-endpoints.md | 4 +- docs/api-test-harness.html | 8 +- docs/openapi/gateway.swagger.yaml | 283 +++++++++++++++++- docs/room-cell-state-machines.md | 12 +- ...ntry-reconnect-development-requirements.md | 218 +++++++------- docs/voice-room-basic-loop-implementation.md | 15 +- docs/voice-room-client-api-flow.md | 133 ++++++-- docs/voice-room-feature-inventory.md | 14 +- ...oice-room-region-room-list-architecture.md | 16 +- .../internal/transport/http/response_test.go | 136 ++++++++- .../internal/transport/http/room_handler.go | 89 +++++- .../internal/transport/http/room_view.go | 281 +++++++++++++++++ .../internal/transport/http/rtc_handler.go | 27 +- 13 files changed, 1050 insertions(+), 186 deletions(-) create mode 100644 services/gateway-service/internal/transport/http/room_view.go diff --git a/docs/api-endpoints.md b/docs/api-endpoints.md index 4cff0a76..6ccbc4ac 100644 --- a/docs/api-endpoints.md +++ b/docs/api-endpoints.md @@ -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 | - diff --git a/docs/api-test-harness.html b/docs/api-test-harness.html index 3647cd5b..fe793116 100644 --- a/docs/api-test-harness.html +++ b/docs/api-test-harness.html @@ -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,避免旧事件污染新麦位会话。'), diff --git a/docs/openapi/gateway.swagger.yaml b/docs/openapi/gateway.swagger.yaml index d171ac8c..3c76bdc9 100644 --- a/docs/openapi/gateway.swagger.yaml +++ b/docs/openapi/gateway.swagger.yaml @@ -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 生成 `_`。 - 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" diff --git a/docs/room-cell-state-machines.md b/docs/room-cell-state-machines.md index 55553f6d..847d0bfd 100644 --- a/docs/room-cell-state-machines.md +++ b/docs/room-cell-state-machines.md @@ -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` 状态。 diff --git a/docs/room-entry-reconnect-development-requirements.md b/docs/room-entry-reconnect-development-requirements.md index 80a62599..c3db52a6 100644 --- a/docs/room-entry-reconnect-development-requirements.md +++ b/docs/room-entry-reconnect-development-requirements.md @@ -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 ```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 } ``` -正式客户端不应该传 `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 } ``` -响应必须包含用户业务 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 ### 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 ``` @@ -166,7 +182,8 @@ Authorization: Bearer "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 diff --git a/docs/voice-room-basic-loop-implementation.md b/docs/voice-room-basic-loop-implementation.md index b9447da6..42367e5a 100644 --- a/docs/voice-room-basic-loop-implementation.md +++ b/docs/voice-room-basic-loop-implementation.md @@ -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) diff --git a/docs/voice-room-client-api-flow.md b/docs/voice-room-client-api-flow.md index d600f3b6..5570dc36 100644 --- a/docs/voice-room-client-api-flow.md +++ b/docs/voice-room-client-api-flow.md @@ -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 diff --git a/docs/voice-room-feature-inventory.md b/docs/voice-room-feature-inventory.md index 02d5b424..0fbd5b9d 100644 --- a/docs/voice-room-feature-inventory.md +++ b/docs/voice-room-feature-inventory.md @@ -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、建群、系统消息、入群/发言回调守卫。 diff --git a/docs/voice-room-region-room-list-architecture.md b/docs/voice-room-region-room-list-architecture.md index d9b8994f..ac9a95d6 100644 --- a/docs/voice-room-region-room-list-architecture.md +++ b/docs/voice-room-region-room-list-architecture.md @@ -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 diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index df95fb34..b86ad436 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -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, diff --git a/services/gateway-service/internal/transport/http/room_handler.go b/services/gateway-service/internal/transport/http/room_handler.go index 9b4ee339..2dc3c23e 100644 --- a/services/gateway-service/internal/transport/http/room_handler.go +++ b/services/gateway-service/internal/transport/http/room_handler.go @@ -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 命令。 diff --git a/services/gateway-service/internal/transport/http/room_view.go b/services/gateway-service/internal/transport/http/room_view.go new file mode 100644 index 00000000..eb2c341e --- /dev/null +++ b/services/gateway-service/internal/transport/http/room_view.go @@ -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) +} diff --git a/services/gateway-service/internal/transport/http/rtc_handler.go b/services/gateway-service/internal/transport/http/rtc_handler.go index fadb93c9..611873af 100644 --- a/services/gateway-service/internal/transport/http/rtc_handler.go +++ b/services/gateway-service/internal/transport/http/rtc_handler.go @@ -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()) +}