增加腾讯云回调

This commit is contained in:
z 2026-05-05 22:10:26 +08:00
parent d9fdb70016
commit cf9fc2c9aa
30 changed files with 2562 additions and 683 deletions

File diff suppressed because it is too large Load Diff

View File

@ -120,6 +120,19 @@ message JoinRoomResponse {
RoomSnapshot room = 3;
}
// RoomHeartbeatRequest presence
// JoinRoom
message RoomHeartbeatRequest {
RequestMeta meta = 1;
}
// RoomHeartbeatResponse presence
message RoomHeartbeatResponse {
CommandResult result = 1;
RoomUser user = 2;
RoomSnapshot room = 3;
}
// LeaveRoomRequest presence
message LeaveRoomRequest {
RequestMeta meta = 1;
@ -193,6 +206,25 @@ message ConfirmMicPublishingResponse {
RoomSnapshot room = 3;
}
// ApplyRTCEventRequest RTC Room Cell
// event_type audio_startedaudio_stoppedroom_exited
message ApplyRTCEventRequest {
RequestMeta meta = 1;
int64 target_user_id = 2;
string event_type = 3;
int64 event_time_ms = 4;
string reason = 5;
string source = 6;
string external_event_id = 7;
}
// ApplyRTCEventResponse RTC
message ApplyRTCEventResponse {
CommandResult result = 1;
int32 seat_no = 2;
RoomSnapshot room = 3;
}
// SetMicSeatLockRequest
message SetMicSeatLockRequest {
RequestMeta meta = 1;
@ -367,11 +399,13 @@ message ListRoomsResponse {
service RoomCommandService {
rpc CreateRoom(CreateRoomRequest) returns (CreateRoomResponse);
rpc JoinRoom(JoinRoomRequest) returns (JoinRoomResponse);
rpc RoomHeartbeat(RoomHeartbeatRequest) returns (RoomHeartbeatResponse);
rpc LeaveRoom(LeaveRoomRequest) returns (LeaveRoomResponse);
rpc MicUp(MicUpRequest) returns (MicUpResponse);
rpc MicDown(MicDownRequest) returns (MicDownResponse);
rpc ChangeMicSeat(ChangeMicSeatRequest) returns (ChangeMicSeatResponse);
rpc ConfirmMicPublishing(ConfirmMicPublishingRequest) returns (ConfirmMicPublishingResponse);
rpc ApplyRTCEvent(ApplyRTCEventRequest) returns (ApplyRTCEventResponse);
rpc SetMicSeatLock(SetMicSeatLockRequest) returns (SetMicSeatLockResponse);
rpc SetChatEnabled(SetChatEnabledRequest) returns (SetChatEnabledResponse);
rpc SetRoomAdmin(SetRoomAdminRequest) returns (SetRoomAdminResponse);

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.29.2
// - protoc v7.34.1
// source: proto/room/v1/room.proto
package roomv1
@ -21,11 +21,13 @@ const _ = grpc.SupportPackageIsVersion9
const (
RoomCommandService_CreateRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/CreateRoom"
RoomCommandService_JoinRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/JoinRoom"
RoomCommandService_RoomHeartbeat_FullMethodName = "/hyapp.room.v1.RoomCommandService/RoomHeartbeat"
RoomCommandService_LeaveRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/LeaveRoom"
RoomCommandService_MicUp_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicUp"
RoomCommandService_MicDown_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicDown"
RoomCommandService_ChangeMicSeat_FullMethodName = "/hyapp.room.v1.RoomCommandService/ChangeMicSeat"
RoomCommandService_ConfirmMicPublishing_FullMethodName = "/hyapp.room.v1.RoomCommandService/ConfirmMicPublishing"
RoomCommandService_ApplyRTCEvent_FullMethodName = "/hyapp.room.v1.RoomCommandService/ApplyRTCEvent"
RoomCommandService_SetMicSeatLock_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetMicSeatLock"
RoomCommandService_SetChatEnabled_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetChatEnabled"
RoomCommandService_SetRoomAdmin_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetRoomAdmin"
@ -44,11 +46,13 @@ const (
type RoomCommandServiceClient interface {
CreateRoom(ctx context.Context, in *CreateRoomRequest, opts ...grpc.CallOption) (*CreateRoomResponse, error)
JoinRoom(ctx context.Context, in *JoinRoomRequest, opts ...grpc.CallOption) (*JoinRoomResponse, error)
RoomHeartbeat(ctx context.Context, in *RoomHeartbeatRequest, opts ...grpc.CallOption) (*RoomHeartbeatResponse, error)
LeaveRoom(ctx context.Context, in *LeaveRoomRequest, opts ...grpc.CallOption) (*LeaveRoomResponse, error)
MicUp(ctx context.Context, in *MicUpRequest, opts ...grpc.CallOption) (*MicUpResponse, error)
MicDown(ctx context.Context, in *MicDownRequest, opts ...grpc.CallOption) (*MicDownResponse, error)
ChangeMicSeat(ctx context.Context, in *ChangeMicSeatRequest, opts ...grpc.CallOption) (*ChangeMicSeatResponse, error)
ConfirmMicPublishing(ctx context.Context, in *ConfirmMicPublishingRequest, opts ...grpc.CallOption) (*ConfirmMicPublishingResponse, error)
ApplyRTCEvent(ctx context.Context, in *ApplyRTCEventRequest, opts ...grpc.CallOption) (*ApplyRTCEventResponse, error)
SetMicSeatLock(ctx context.Context, in *SetMicSeatLockRequest, opts ...grpc.CallOption) (*SetMicSeatLockResponse, error)
SetChatEnabled(ctx context.Context, in *SetChatEnabledRequest, opts ...grpc.CallOption) (*SetChatEnabledResponse, error)
SetRoomAdmin(ctx context.Context, in *SetRoomAdminRequest, opts ...grpc.CallOption) (*SetRoomAdminResponse, error)
@ -87,6 +91,16 @@ func (c *roomCommandServiceClient) JoinRoom(ctx context.Context, in *JoinRoomReq
return out, nil
}
func (c *roomCommandServiceClient) RoomHeartbeat(ctx context.Context, in *RoomHeartbeatRequest, opts ...grpc.CallOption) (*RoomHeartbeatResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RoomHeartbeatResponse)
err := c.cc.Invoke(ctx, RoomCommandService_RoomHeartbeat_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomCommandServiceClient) LeaveRoom(ctx context.Context, in *LeaveRoomRequest, opts ...grpc.CallOption) (*LeaveRoomResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(LeaveRoomResponse)
@ -137,6 +151,16 @@ func (c *roomCommandServiceClient) ConfirmMicPublishing(ctx context.Context, in
return out, nil
}
func (c *roomCommandServiceClient) ApplyRTCEvent(ctx context.Context, in *ApplyRTCEventRequest, opts ...grpc.CallOption) (*ApplyRTCEventResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ApplyRTCEventResponse)
err := c.cc.Invoke(ctx, RoomCommandService_ApplyRTCEvent_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomCommandServiceClient) SetMicSeatLock(ctx context.Context, in *SetMicSeatLockRequest, opts ...grpc.CallOption) (*SetMicSeatLockResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SetMicSeatLockResponse)
@ -225,11 +249,13 @@ func (c *roomCommandServiceClient) SendGift(ctx context.Context, in *SendGiftReq
type RoomCommandServiceServer interface {
CreateRoom(context.Context, *CreateRoomRequest) (*CreateRoomResponse, error)
JoinRoom(context.Context, *JoinRoomRequest) (*JoinRoomResponse, error)
RoomHeartbeat(context.Context, *RoomHeartbeatRequest) (*RoomHeartbeatResponse, error)
LeaveRoom(context.Context, *LeaveRoomRequest) (*LeaveRoomResponse, error)
MicUp(context.Context, *MicUpRequest) (*MicUpResponse, error)
MicDown(context.Context, *MicDownRequest) (*MicDownResponse, error)
ChangeMicSeat(context.Context, *ChangeMicSeatRequest) (*ChangeMicSeatResponse, error)
ConfirmMicPublishing(context.Context, *ConfirmMicPublishingRequest) (*ConfirmMicPublishingResponse, error)
ApplyRTCEvent(context.Context, *ApplyRTCEventRequest) (*ApplyRTCEventResponse, error)
SetMicSeatLock(context.Context, *SetMicSeatLockRequest) (*SetMicSeatLockResponse, error)
SetChatEnabled(context.Context, *SetChatEnabledRequest) (*SetChatEnabledResponse, error)
SetRoomAdmin(context.Context, *SetRoomAdminRequest) (*SetRoomAdminResponse, error)
@ -254,6 +280,9 @@ func (UnimplementedRoomCommandServiceServer) CreateRoom(context.Context, *Create
func (UnimplementedRoomCommandServiceServer) JoinRoom(context.Context, *JoinRoomRequest) (*JoinRoomResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method JoinRoom not implemented")
}
func (UnimplementedRoomCommandServiceServer) RoomHeartbeat(context.Context, *RoomHeartbeatRequest) (*RoomHeartbeatResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RoomHeartbeat not implemented")
}
func (UnimplementedRoomCommandServiceServer) LeaveRoom(context.Context, *LeaveRoomRequest) (*LeaveRoomResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method LeaveRoom not implemented")
}
@ -269,6 +298,9 @@ func (UnimplementedRoomCommandServiceServer) ChangeMicSeat(context.Context, *Cha
func (UnimplementedRoomCommandServiceServer) ConfirmMicPublishing(context.Context, *ConfirmMicPublishingRequest) (*ConfirmMicPublishingResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ConfirmMicPublishing not implemented")
}
func (UnimplementedRoomCommandServiceServer) ApplyRTCEvent(context.Context, *ApplyRTCEventRequest) (*ApplyRTCEventResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ApplyRTCEvent not implemented")
}
func (UnimplementedRoomCommandServiceServer) SetMicSeatLock(context.Context, *SetMicSeatLockRequest) (*SetMicSeatLockResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetMicSeatLock not implemented")
}
@ -350,6 +382,24 @@ func _RoomCommandService_JoinRoom_Handler(srv interface{}, ctx context.Context,
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_RoomHeartbeat_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RoomHeartbeatRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomCommandServiceServer).RoomHeartbeat(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomCommandService_RoomHeartbeat_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomCommandServiceServer).RoomHeartbeat(ctx, req.(*RoomHeartbeatRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_LeaveRoom_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(LeaveRoomRequest)
if err := dec(in); err != nil {
@ -440,6 +490,24 @@ func _RoomCommandService_ConfirmMicPublishing_Handler(srv interface{}, ctx conte
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_ApplyRTCEvent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ApplyRTCEventRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomCommandServiceServer).ApplyRTCEvent(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomCommandService_ApplyRTCEvent_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomCommandServiceServer).ApplyRTCEvent(ctx, req.(*ApplyRTCEventRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_SetMicSeatLock_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetMicSeatLockRequest)
if err := dec(in); err != nil {
@ -599,6 +667,10 @@ var RoomCommandService_ServiceDesc = grpc.ServiceDesc{
MethodName: "JoinRoom",
Handler: _RoomCommandService_JoinRoom_Handler,
},
{
MethodName: "RoomHeartbeat",
Handler: _RoomCommandService_RoomHeartbeat_Handler,
},
{
MethodName: "LeaveRoom",
Handler: _RoomCommandService_LeaveRoom_Handler,
@ -619,6 +691,10 @@ var RoomCommandService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ConfirmMicPublishing",
Handler: _RoomCommandService_ConfirmMicPublishing_Handler,
},
{
MethodName: "ApplyRTCEvent",
Handler: _RoomCommandService_ApplyRTCEvent_Handler,
},
{
MethodName: "SetMicSeatLock",
Handler: _RoomCommandService_SetMicSeatLock_Handler,

View File

@ -399,19 +399,21 @@ gateway 是 profile gate 的外部入口执行点。为了避免每个业务请
### Password Login
```text
POST /api/v1/auth/password/login
POST /api/v1/auth/account/login
```
Request:
```json
{
"display_user_id": "163000",
"account": "163000",
"password": "plain_password",
"device_id": "device_xxx"
}
```
`account` 就是用户当前有效短号。gateway 会统一转成 user-service 的 `LoginPasswordRequest.display_user_id`
Response `data`:
```json
@ -433,6 +435,8 @@ Response `data`:
密码登录只接受当前有效 `display_user_id`。如果用户申请了临时靓号,默认短号在靓号有效期内不能用于密码登录;靓号过期后,默认短号恢复登录能力。
本地联调 initdb 预置测试账号:账号 `123456`,密码 `1234567`;账号 `12345678`,密码 `12345678`
### Refresh Token
```text
@ -846,7 +850,7 @@ sequenceDiagram
participant G as gateway-service
participant U as user-service
C->>G: POST /api/v1/auth/password/login
C->>G: POST /api/v1/auth/account/login
G->>U: LoginPassword(display_user_id, password)
U->>U: expire pretty display_user_id if needed
U->>U: resolve current display_user_id
@ -930,7 +934,7 @@ gateway HTTP envelope 的 `code` 不使用数字。
| `INVALID_JSON` | 400 | request body is invalid JSON |
| `INVALID_ARGUMENT` | 400 | field format or required field invalid |
| `RATE_LIMITED` | 429 | public auth rate limit exceeded |
| `AUTH_FAILED` | 401 | display_user_id missing, password wrong, no password set, or provider rejected |
| `AUTH_FAILED` | 401 | account missing, password wrong, no password set, or provider rejected |
| `UNAUTHORIZED` | 401 | missing or invalid access token |
| `PROFILE_REQUIRED` | 403 | authenticated user has not completed required registration profile |
| `PASSWORD_ALREADY_SET` | 409 | user already has password identity |
@ -952,11 +956,11 @@ gateway 对三方注册登录、密码登录、refresh 和 logout 做 Redis 固
| `ip` | third-party, password, refresh, logout | 限制单入口 IP 对 public auth 的总请求量 |
| `device_id` | third-party, password, refresh | 限制单设备安装批量注册或刷新 |
| `provider + ip` | third-party | 限制单 IP 对同一三方 provider 批量建号 |
| `display_user_id + ip` | password | 限制单 IP 对同一短号喷射尝试 |
| `display_user_id` | password | 限制同一短号被跨 IP 喷射尝试 |
| `account + ip` | password | 限制单 IP 对同一短号喷射尝试 |
| `account` | password | 限制同一短号被跨 IP 喷射尝试 |
| `session_id + ip` | logout | 限制 logout 重放 |
Redis backend 使用 Lua 脚本一次性检查并递增同一请求命中的所有维度,避免部分 key 已递增但后续维度超限的非原子状态。公网可控的 provider、device_id、display_user_id、session_id 和入口 IP 在 Redis key 中只保留固定长度摘要,避免超长输入污染 key 空间。`auth_rate_limit.enabled=true` 时 gateway 启动必须连通 Redis运行期 Redis 调用失败会 fail-closed 返回 HTTP 502 和 `UPSTREAM_ERROR`,避免入口在依赖故障时无保护放量。内存 backend 只用于单元测试或显式关闭 Redis 频控后的本地构造。
Redis backend 使用 Lua 脚本一次性检查并递增同一请求命中的所有维度,避免部分 key 已递增但后续维度超限的非原子状态。公网可控的 provider、device_id、account、session_id 和入口 IP 在 Redis key 中只保留固定长度摘要,避免超长输入污染 key 空间。`auth_rate_limit.enabled=true` 时 gateway 启动必须连通 Redis运行期 Redis 调用失败会 fail-closed 返回 HTTP 502 和 `UPSTREAM_ERROR`,避免入口在依赖故障时无保护放量。内存 backend 只用于单元测试或显式关闭 Redis 频控后的本地构造。
## Security Rules
@ -1009,7 +1013,7 @@ Redis backend 使用 Lua 脚本一次性检查并递增同一请求命中的所
- 三方新用户未设置密码前,短号密码登录返回 `AUTH_FAILED`
- 已登录用户设置密码成功。
- 重复设置密码返回 `PASSWORD_ALREADY_SET`
- 设置密码后可使用当前 `display_user_id + password` 登录。
- 设置密码后可使用当前 `account + password` 登录。
- 短号不存在和密码错误都返回 `AUTH_FAILED`
- 用户有 active 靓号时,密码登录必须使用靓号,默认短号返回 `AUTH_FAILED`
- 靓号过期后,密码登录必须恢复使用默认短号,旧靓号返回 `AUTH_FAILED`

View File

@ -98,6 +98,19 @@ parameters:
required: false
type: string
description: 腾讯云 IM 回调鉴权 token header。
TencentRTCSDKAppIDHeader:
name: SdkAppId
in: header
required: true
type: integer
format: int64
description: 腾讯 RTC 事件回调 header 中的 SDKAppID。
TencentRTCSignHeader:
name: Sign
in: header
required: true
type: string
description: 腾讯 RTC 事件回调签名,计算方式为 `base64(hmacsha256(callback_sign_key, raw_body))`。
responses:
BadRequest:
description: 请求体不是合法 JSON或下游返回非法参数常见 `code` 为 `INVALID_JSON`、`INVALID_ARGUMENT`、`DISPLAY_USER_ID_INVALID`。
@ -158,12 +171,13 @@ paths:
description: Gateway 不应接新请求,失败详情在 `checks`。
schema:
$ref: "#/definitions/HealthResponse"
/api/v1/auth/password/login:
/api/v1/auth/account/login:
post:
tags:
- auth
summary: 短号密码登录
operationId: loginPassword
summary: 账号密码登录
description: 账号就是用户当前有效短号。
operationId: loginAccountPassword
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
@ -396,6 +410,46 @@ paths:
$ref: "#/definitions/ErrorEnvelope"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/tencent-rtc/callback:
post:
tags:
- tencent-rtc
summary: 腾讯 RTC 服务端事件回调
operationId: handleTencentRTCCallback
description: |
腾讯 RTC 控制台配置的房间/媒体事件回调入口。gateway 校验 `SdkAppId` 与 `Sign` 后,只消费房间退出 `104`、开始推送音频 `203`、停止推送音频 `204`。
该接口不使用业务 envelope成功或可忽略业务错误返回 `{"code":0}`。
parameters:
- $ref: "#/parameters/RequestIDHeader"
- $ref: "#/parameters/TencentRTCSDKAppIDHeader"
- $ref: "#/parameters/TencentRTCSignHeader"
- name: app_code
in: query
required: false
type: string
description: 多 App 部署时建议把腾讯控制台回调 URL 配置为携带 `app_code`,用于 room-service 租户路由。
- name: body
in: body
required: true
schema:
$ref: "#/definitions/TencentRTCCallbackRequest"
responses:
"200":
description: 回调已接受,未知事件或不可恢复业务错误也按成功 ACK避免无意义重试。
schema:
$ref: "#/definitions/TencentRTCCallbackResponse"
"400":
description: 回调 body 非法。
schema:
$ref: "#/definitions/TencentRTCCallbackResponse"
"403":
description: SDKAppID 不匹配或签名校验失败。
schema:
$ref: "#/definitions/TencentRTCCallbackResponse"
"500":
description: room-service 暂时不可用,返回非 200 让腾讯 RTC 按平台策略重试。
schema:
$ref: "#/definitions/TencentRTCCallbackResponse"
/api/v1/files/upload:
post:
tags:
@ -781,6 +835,41 @@ paths:
$ref: "#/responses/Internal"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/rooms/heartbeat:
post:
tags:
- rooms
summary: 刷新房间业务 presence
operationId: roomHeartbeat
description: 心跳只刷新已经存在的房间 presence用户不在房间时返回 404不会隐式 JoinRoom。
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
schema:
$ref: "#/definitions/RoomHeartbeatRequest"
responses:
"200":
description: 心跳成功,`data` 返回刷新后的用户房间态和房间快照。
schema:
$ref: "#/definitions/RoomHeartbeatEnvelope"
"400":
$ref: "#/responses/BadRequest"
"401":
$ref: "#/responses/Unauthorized"
"403":
$ref: "#/responses/Forbidden"
"404":
$ref: "#/responses/NotFound"
"409":
$ref: "#/responses/Conflict"
"500":
$ref: "#/responses/Internal"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/rooms/leave:
post:
tags:
@ -1520,11 +1609,18 @@ definitions:
type: string
LoginPasswordRequest:
type: object
required:
- account
- password
- device_id
description: 账号就是用户当前有效短号。
properties:
display_user_id:
account:
type: string
example: "123456"
password:
type: string
example: "1234567"
device_id:
type: string
LoginThirdPartyRequest:
@ -1786,6 +1882,57 @@ definitions:
- 0
- 1
description: 腾讯云 IM 原生回调响应;`ErrorCode=0` 允许,`ErrorCode=1` 拒绝本次入群或发言。
TencentRTCCallbackRequest:
type: object
properties:
EventGroupId:
type: integer
format: int32
enum:
- 1
- 2
description: 1 为房间事件组2 为媒体事件组。
EventType:
type: integer
format: int32
enum:
- 104
- 203
- 204
description: 当前只消费退出房间、开始推送音频、停止推送音频。
CallbackTs:
type: integer
format: int64
EventInfo:
type: object
properties:
RoomId:
description: 腾讯 RTC 房间 ID本系统要求等于内部字符串 `room_id`。
UserId:
type: string
description: 腾讯 RTC UserID必须是内部长 `user_id` 的十进制字符串。
EventTs:
type: integer
format: int64
EventMsTs:
type: integer
format: int64
Reason:
type: integer
format: int32
description: 腾讯 RTC 原生回调请求;签名必须基于原始 body不要重新序列化后计算。
TencentRTCCallbackResponse:
type: object
properties:
code:
type: integer
format: int32
enum:
- 0
- 1
message:
type: string
description: 腾讯 RTC 回调响应;`code=0` 表示 gateway 已接受或安全忽略该事件。
UserIdentityData:
type: object
properties:
@ -2258,7 +2405,7 @@ definitions:
- command_id
- amount
- reason
description: `target_user_id` 和 `target_display_user_id` 至少传一个;两者都是玩家短 ID/展示号,不是 user-service 内部长 ID。
description: "`target_user_id` 和 `target_display_user_id` 至少传一个;两者都是玩家短 ID/展示号,不是 user-service 内部长 ID。"
properties:
command_id:
type: string
@ -2359,6 +2506,16 @@ definitions:
description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等。
role:
type: string
RoomHeartbeatRequest:
type: object
required:
- room_id
properties:
room_id:
type: string
command_id:
type: string
description: 心跳命令幂等键建议客户端每次心跳生成新值HTTP 重试同一次心跳复用该值。
LeaveRoomRequest:
type: object
properties:
@ -2676,6 +2833,15 @@ definitions:
$ref: "#/definitions/RoomUser"
room:
$ref: "#/definitions/RoomSnapshot"
RoomHeartbeatResponse:
type: object
properties:
result:
$ref: "#/definitions/CommandResult"
user:
$ref: "#/definitions/RoomUser"
room:
$ref: "#/definitions/RoomSnapshot"
LeaveRoomResponse:
type: object
properties:
@ -2923,6 +3089,13 @@ definitions:
properties:
data:
$ref: "#/definitions/JoinRoomResponse"
RoomHeartbeatEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/RoomHeartbeatResponse"
LeaveRoomEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"

View File

@ -228,19 +228,21 @@ room_presence_sweep_interval: "30s"
| Case | Expected Behavior |
| --- | --- |
| IM 短断 | room-service presence 保留,客户端重新登录腾讯云 IM 后重新进群 |
| App 退后台后恢复 | 客户端先调用 `JoinRoom` 幂等刷新,再重新登录腾讯云 IM 并进群 |
| App 退后台后恢复 | 客户端先调用 `RoomHeartbeat` 刷新已有 presence如果返回 `NOT_FOUND` 再重新 `JoinRoom` |
| 超过 stale timeout | room-service 自动清理 presence客户端需要重新进房 |
客户端重连标准流程:
```text
1. 如果 access token 过期,先 RefreshToken
2. 调 POST /api/v1/rooms/join,刷新房间业务 presence
2. 调 POST /api/v1/rooms/heartbeat,刷新房间业务 presence
3. 调 POST /api/v1/im/usersig拿腾讯云 IM UserSig
4. 使用腾讯云 IM SDK 登录
5. 加入 room_id 对应的腾讯云 IM 群
6. 拉取腾讯云 IM 群历史或等待房间系统消息补齐
7. 用 JoinRoom 返回的 RoomSnapshot 覆盖本地房间状态
7. 用 RoomHeartbeat 返回的 RoomSnapshot 覆盖本地房间状态
如果第 2 步返回 `NOT_FOUND`,说明业务 presence 已不存在,客户端必须走完整 `JoinRoom -> IM/RTC 重进` 流程。
```
服务端原则:
@ -259,7 +261,7 @@ room_presence_sweep_interval: "30s"
| `user_id` | 房间内业务用户 |
| `role` | owner, host, admin, audience |
| `joined_at_ms` | 首次进入当前房间时间 |
| `last_seen_at_ms` | 最近一次 JoinRoom、订阅或心跳时间 |
| `last_seen_at_ms` | 最近一次 JoinRoom 或 RoomHeartbeat 时间 |
建议后续追加:
@ -299,7 +301,7 @@ room_presence_sweep_interval: "30s"
| JoinRoom 后进群 | `VerifyRoomPresence` 通过,腾讯云 IM 群收到房间消息 |
| 未 JoinRoom 直接进群或发言 | `VerifyRoomPresence``CheckSpeakPermission` 返回 `not_in_room` |
| Kick 后进群或发言 | 返回 `user_banned` |
| IM 断开后 60 秒内重连 | JoinRoom 幂等刷新,腾讯云 IM 重新登录和进群成功,历史可拉 |
| IM 断开后 60 秒内重连 | RoomHeartbeat 刷新,腾讯云 IM 重新登录和进群成功,历史可拉 |
| 超过 stale timeout | presence 自动清理,需要重新 JoinRoom |
| room-service 重启恢复 | snapshot + command log 恢复后 JoinRoom/subscribe 继续可用 |
@ -309,8 +311,9 @@ room_presence_sweep_interval: "30s"
1. 修正创建房间身份来源gateway 不接收 body 的 owner/hostroom-service 从 `RequestMeta.actor_user_id` 收敛 owner/default host。
2. 补齐 JoinRoom 幂等刷新 `last_seen_at_ms`
3. 补齐 LeaveRoom 后对订阅守卫的拒绝行为测试。
4. 接入腾讯云 IM UserSig 签发、CreateRoom 建群和房间系统消息发布。
5. 增加 stale presence 清理 worker。
6. 增加重连集成测试JoinRoom -> UserSig -> IM login/join group -> disconnect -> reconnect -> history。
7. 增加 room-service 重启恢复测试。
3. 增加显式 RoomHeartbeat只刷新已有 presence不隐式进房。
4. 补齐 LeaveRoom 后对订阅守卫的拒绝行为测试。
5. 接入腾讯云 IM UserSig 签发、CreateRoom 建群和房间系统消息发布。
6. 增加 stale presence 清理 worker。
7. 增加重连集成测试JoinRoom -> UserSig -> IM login/join group -> disconnect -> heartbeat/reconnect -> history。
8. 增加 room-service 重启恢复测试。

View File

@ -117,7 +117,7 @@ RTC numeric roomId -> always 0 or omitted
## External HTTP API
新增 gateway 入口:
RTC token gateway 入口:
```text
POST /api/v1/rtc/token
@ -171,6 +171,53 @@ Response `data`:
| RTC 配置缺失 | 500 | `INTERNAL_ERROR` | fail-closed不返回假 token |
| room-service 不可用 | 502 | `UPSTREAM_ERROR` | gateway 不能自判 presence |
显式房间心跳入口:
```text
POST /api/v1/rooms/heartbeat
Authorization: Bearer <access_token>
```
Request:
```json
{
"room_id": "room_1001",
"command_id": "cmd-heartbeat-1700000000"
}
```
规则:
- 心跳只刷新已经存在的 room-service presence 的 `last_seen_at_ms`
- 用户不在房间时返回 `NOT_FOUND`,不能通过心跳重新进入房间。
- 客户端应按小于 `room_presence_stale_after` 的间隔发送心跳HTTP 重试同一次心跳必须复用同一个 `command_id`
- 主动离房仍然必须调用 `/api/v1/rooms/leave`;心跳停止只代表后台 stale worker 最终清理。
腾讯 RTC 服务端事件回调入口:
```text
POST /api/v1/tencent-rtc/callback?app_code=default
Header: SdkAppId: <sdk_app_id>
Header: Sign: base64(hmacsha256(callback_sign_key, raw_body))
```
消费事件:
| Tencent Event | Internal Event | Room Cell Effect |
| --- | --- | --- |
| `EventGroupId=2, EventType=203` | `audio_started` | 当前麦位为 `pending_publish` 时确认成 `publishing` |
| `EventGroupId=2, EventType=204` | `audio_stopped` | 释放当前用户麦位 |
| `EventGroupId=1, EventType=104` | `room_exited` | 移除业务 presence并释放麦位 |
回调边界:
- gateway 只负责验签、校验 `SdkAppId`、解析 Tencent 字段和派生稳定 `command_id`
- 房间状态变更只调用 room-service `ApplyRTCEvent`,仍进入 Room Cell、command log 和 outbox。
- 未知事件在验签成功后返回 `{"code":0}` 并忽略,避免腾讯新增事件导致无意义重试。
- room-service 暂时不可用时返回非 200让腾讯 RTC 按平台策略重试。
- 如果一个旧 `room_exited` 回调晚于新的业务心跳到达room-service 会保留较新的业务 presence避免旧回调清掉重连用户。
## Internal Flow
```mermaid
@ -196,6 +243,18 @@ sequenceDiagram
C->>T: enterRoom(SDKAppID, UserID, UserSig, strRoomId)
T-->>C: RTC room entered
loop heartbeat before stale timeout
C->>G: POST /api/v1/rooms/heartbeat(room_id)
G->>R: RoomHeartbeat(actor_user_id, room_id)
R-->>G: refreshed last_seen_at_ms
end
T->>G: RTC callback 203/204/104 with Sign
G->>G: verify HMAC-SHA256 over raw body
G->>R: ApplyRTCEvent(audio_started/audio_stopped/room_exited)
R-->>G: RoomSnapshot
G-->>T: {"code":0}
```
签发顺序:
@ -224,11 +283,14 @@ tencent_rtc:
user_sig_ttl: "2h"
room_id_type: "string"
app_scene: "voice_chat_room"
callback_url: "https://api.example.com/api/v1/tencent-rtc/callback?app_code=default"
callback_sign_key: "TENCENT_RTC_CALLBACK_SIGN_KEY"
```
配置规则:
- `secret_key` 只能存在服务端配置或密钥系统,不能下发客户端。
- `callback_sign_key` 只用于腾讯 RTC 回调验签,不要复用 UserSig `secret_key`
- `tencent_rtc``tencent_im` 分开配置,避免误把 IM 应用和 RTC 应用混用。
- 如果腾讯控制台实际使用同一个应用承载 Chat/RTC可以让两块配置填同一组 `sdk_app_id/secret_key`,但仍必须显式配置。
- `user_sig_ttl` 不宜过长;建议 2h 到 24h具体按客户端刷新能力和泄露窗口决定。

View File

@ -54,8 +54,9 @@
| --- | --- | --- | --- |
| 创建房间 | `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 | 客户端心跳或定期 Join 刷新策略需定稿 |
| 重复 JoinRoom 刷新 | `DONE` | 重复进房刷新 `last_seen_at_ms`,不重复发进房事件 | 客户端断线重连自动化测试 |
| 进房 presence | `DONE` | `JoinRoom``OnlineUsers`,返回 user 和 snapshot | 客户端断线重连自动化测试 |
| 显式心跳刷新 | `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 需联调 |
| stale presence 清理 | `DONE` | `RunPresenceStaleWorker``SweepStalePresence` 存在 | 多实例下只扫描本节点已装载 Cell需压测确认 |
| 房间恢复 | `DONE` | snapshot + command log + Redis lease 接管恢复测试存在 | 大房间 command log 回放性能和快照策略压测 |
@ -99,9 +100,10 @@
| RTC room_id 映射 | `DONE` | 统一使用 TRTC `strRoomId`,校验 room_id 子集 | 当前入口 fail-closed非法 room_id 不创建房间 |
| 进频道权限 | `DONE` | 签 RTC token 前调用 `VerifyRoomPresence` | 客户端必须按 JoinRoom 后再取 token |
| RTC 角色 | `PARTIAL` | token 返回默认 `audience` | 上麦后切 `anchor` 或 SDK role 切换未闭环 |
| 离频道 | `PARTIAL` | 服务端 LeaveRoom 会移除业务 presence | 客户端退出 TRTC 房间、异常重进策略未联调 |
| RTC 事件回调 | `DONE` | `/api/v1/tencent-rtc/callback` 验签后消费 203/204/104 并调用 `ApplyRTCEvent` | 腾讯控制台公网回调配置和真实 TRTC 联调 |
| 离频道 | `PARTIAL` | 服务端 LeaveRoom 和 RTC 104 回调都会移除业务 presence | 客户端退出 TRTC 房间、异常重进策略未联调 |
| RTC 断线恢复 | `PARTIAL` | 文档定义 RTC 断线不等于业务离房 | 客户端 SDK 回调、重新取 token、重新 enterRoom 未验收 |
| 音频权限/上麦联动 | `TODO` | MicUp 只改 Room Cell 麦位 | MicUp 后允许推流、MicDown 后停止推流的端侧策略未做 |
| 音频权限/上麦联动 | `PARTIAL` | MicUp 创建 pending_publish客户端确认或 RTC 203 回调推进 publishingRTC 204 回调释放麦位 | MicUp 后切 anchor、MicDown 后停止推流的端侧策略未联调 |
| 音频审核/录制 | `TODO` | 当前无设计和代码 | 后续接腾讯云审核/录制能力 |
## Mic And Room Management

View File

@ -0,0 +1,31 @@
package tencentrtc
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"strings"
)
const (
// CallbackHeaderSign 是腾讯 RTC 事件回调携带的签名 header。
CallbackHeaderSign = "Sign"
// CallbackHeaderSDKAppID 是腾讯 RTC 事件回调携带的应用 ID header。
CallbackHeaderSDKAppID = "SdkAppId"
)
// VerifyCallbackSignature 校验腾讯 RTC 事件回调签名。
func VerifyCallbackSignature(key string, body []byte, sign string) bool {
key = strings.TrimSpace(key)
sign = strings.TrimSpace(sign)
if key == "" || sign == "" {
// 回调入口必须 fail-closed没有 key 或签名都不能进入房间命令链路。
return false
}
mac := hmac.New(sha256.New, []byte(key))
_, _ = mac.Write(body)
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sign))
}

View File

@ -0,0 +1,18 @@
package tencentrtc
import "testing"
func TestVerifyCallbackSignatureUsesRawBody(t *testing.T) {
body := []byte("{\n\t\"EventGroupId\":\t2,\n\t\"EventType\":\t204,\n\t\"CallbackTs\":\t1664209748188,\n\t\"EventInfo\":\t{\n\t\t\"RoomId\":\t8489,\n\t\t\"EventTs\":\t1664209748,\n\t\t\"EventMsTs\":\t1664209748180,\n\t\t\"UserId\":\t\"user_85034614\",\n\t\t\"Reason\":\t0\n\t}\n}")
sign := "kkoFeO3Oh2ZHnjtg8tEAQhtXK16/KI05W3BQff8IvGA="
if !VerifyCallbackSignature("123654", body, sign) {
t.Fatal("expected official Tencent RTC callback signature sample to verify")
}
if VerifyCallbackSignature("123654", []byte(`{"EventType":204}`), sign) {
t.Fatal("signature verification must use the exact raw callback body")
}
if VerifyCallbackSignature("", body, sign) || VerifyCallbackSignature("123654", body, "") {
t.Fatal("missing key or sign must fail closed")
}
}

View File

@ -36,6 +36,8 @@ tencent_rtc:
user_sig_ttl: "2h"
room_id_type: "string"
app_scene: "voice_chat_room"
callback_url: ""
callback_sign_key: ""
tencent-cos:
# Docker 本地联调 App 文件/头像上传secret 只保存在 gateway 服务端配置。

View File

@ -45,6 +45,10 @@ tencent_rtc:
room_id_type: "string"
# 语音房场景固定使用 VoiceChatRoom所有客户端必须保持一致。
app_scene: "voice_chat_room"
# 腾讯 RTC 控制台配置的事件回调地址,用于房间退出和音频发布状态同步。
callback_url: "https://api.example.com/api/v1/tencent-rtc/callback?app_code=default"
# 腾讯 RTC 回调签名 key必须和控制台配置保持一致不要复用 UserSig SecretKey。
callback_sign_key: "TENCENT_RTC_CALLBACK_SIGN_KEY"
tencent-cos:
# App 文件/头像上传使用腾讯云 COSSecretID/SecretKey 只允许保存在服务端配置。

View File

@ -41,6 +41,10 @@ tencent_rtc:
# 当前 room_id 是字符串,客户端只能使用 TRTC strRoomId不能混用数字 roomId。
room_id_type: "string"
app_scene: "voice_chat_room"
# 腾讯 RTC 控制台回调 URL。需要公网可访问本地为空表示暂不启用回调。
callback_url: ""
# 腾讯 RTC 回调签名 key。启用回调后必须填写gateway 会按原始 body 做 HMAC-SHA256 校验。
callback_sign_key: ""
tencent-cos:
# App 文件/头像上传使用腾讯云 COSsecret 只保存在 gateway 服务端配置。

View File

@ -91,12 +91,13 @@ func New(cfg config.Config) (*App, error) {
handler.SetAppConfigReader(appConfigReader)
}
handler.SetTencentRTC(httptransport.TencentRTCConfig{
Enabled: cfg.TencentRTC.Enabled,
SDKAppID: cfg.TencentRTC.SDKAppID,
SecretKey: cfg.TencentRTC.SecretKey,
UserSigTTL: cfg.TencentRTC.UserSigTTL,
RoomIDType: cfg.TencentRTC.RoomIDType,
AppScene: cfg.TencentRTC.AppScene,
Enabled: cfg.TencentRTC.Enabled,
SDKAppID: cfg.TencentRTC.SDKAppID,
SecretKey: cfg.TencentRTC.SecretKey,
UserSigTTL: cfg.TencentRTC.UserSigTTL,
RoomIDType: cfg.TencentRTC.RoomIDType,
AppScene: cfg.TencentRTC.AppScene,
CallbackSignKey: cfg.TencentRTC.CallbackSignKey,
})
if cfg.TencentCOS.Enabled {
uploader, err := tencentcos.NewUploader(tencentcos.Config{

View File

@ -11,11 +11,13 @@ import (
type RoomClient interface {
CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest) (*roomv1.CreateRoomResponse, error)
JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*roomv1.JoinRoomResponse, error)
RoomHeartbeat(ctx context.Context, req *roomv1.RoomHeartbeatRequest) (*roomv1.RoomHeartbeatResponse, error)
LeaveRoom(ctx context.Context, req *roomv1.LeaveRoomRequest) (*roomv1.LeaveRoomResponse, error)
MicUp(ctx context.Context, req *roomv1.MicUpRequest) (*roomv1.MicUpResponse, error)
MicDown(ctx context.Context, req *roomv1.MicDownRequest) (*roomv1.MicDownResponse, error)
ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatRequest) (*roomv1.ChangeMicSeatResponse, error)
ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmMicPublishingRequest) (*roomv1.ConfirmMicPublishingResponse, error)
ApplyRTCEvent(ctx context.Context, req *roomv1.ApplyRTCEventRequest) (*roomv1.ApplyRTCEventResponse, error)
SetMicSeatLock(ctx context.Context, req *roomv1.SetMicSeatLockRequest) (*roomv1.SetMicSeatLockResponse, error)
SetChatEnabled(ctx context.Context, req *roomv1.SetChatEnabledRequest) (*roomv1.SetChatEnabledResponse, error)
SetRoomAdmin(ctx context.Context, req *roomv1.SetRoomAdminRequest) (*roomv1.SetRoomAdminResponse, error)
@ -79,6 +81,10 @@ func (c *grpcRoomClient) JoinRoom(ctx context.Context, req *roomv1.JoinRoomReque
return c.client.JoinRoom(ctx, req)
}
func (c *grpcRoomClient) RoomHeartbeat(ctx context.Context, req *roomv1.RoomHeartbeatRequest) (*roomv1.RoomHeartbeatResponse, error) {
return c.client.RoomHeartbeat(ctx, req)
}
func (c *grpcRoomClient) LeaveRoom(ctx context.Context, req *roomv1.LeaveRoomRequest) (*roomv1.LeaveRoomResponse, error) {
return c.client.LeaveRoom(ctx, req)
}
@ -99,6 +105,10 @@ func (c *grpcRoomClient) ConfirmMicPublishing(ctx context.Context, req *roomv1.C
return c.client.ConfirmMicPublishing(ctx, req)
}
func (c *grpcRoomClient) ApplyRTCEvent(ctx context.Context, req *roomv1.ApplyRTCEventRequest) (*roomv1.ApplyRTCEventResponse, error) {
return c.client.ApplyRTCEvent(ctx, req)
}
func (c *grpcRoomClient) SetMicSeatLock(ctx context.Context, req *roomv1.SetMicSeatLockRequest) (*roomv1.SetMicSeatLockResponse, error) {
return c.client.SetMicSeatLock(ctx, req)
}

View File

@ -85,6 +85,10 @@ type TencentRTCConfig struct {
RoomIDType string `yaml:"room_id_type"`
// AppScene 首版固定 voice_chat_room所有端必须一致。
AppScene string `yaml:"app_scene"`
// CallbackURL 是在腾讯 RTC 控制台配置的事件回调地址。
CallbackURL string `yaml:"callback_url"`
// CallbackSignKey 是腾讯 RTC 事件回调签名 key只用于校验回调来源。
CallbackSignKey string `yaml:"callback_sign_key"`
}
// TencentCOSConfig 描述 gateway 代 App 上传文件到腾讯云 COS 所需的服务端配置。
@ -198,6 +202,8 @@ func (cfg *Config) Normalize() error {
// v1 has no gateway policy surface for multiple RTC scenes, so reject drift early.
return fmt.Errorf("tencent_rtc.app_scene must be %q", tencentrtc.AppSceneVoiceChatRoom)
}
cfg.TencentRTC.CallbackURL = strings.TrimSpace(cfg.TencentRTC.CallbackURL)
cfg.TencentRTC.CallbackSignKey = strings.TrimSpace(cfg.TencentRTC.CallbackSignKey)
return nil
}

View File

@ -29,20 +29,21 @@ type authTokenData struct {
OnboardingStatus string `json:"onboarding_status"`
}
// loginPassword 处理 display_user_id + 密码登录
// loginPassword 处理账号密码登录;账号就是当前有效短号
func (h *Handler) loginPassword(writer http.ResponseWriter, request *http.Request) {
var body struct {
DisplayUserID string `json:"display_user_id"`
Password string `json:"password"`
DeviceID string `json:"device_id"`
Account string `json:"account"`
Password string `json:"password"`
DeviceID string `json:"device_id"`
}
if !decode(writer, request, &body) {
return
}
account := strings.TrimSpace(body.Account)
if !h.allowPublicAuthRequest(writer, request, authRateInput{
operation: authOperationPassword,
deviceID: body.DeviceID,
displayUserID: body.DisplayUserID,
displayUserID: account,
}) {
return
}
@ -53,7 +54,7 @@ func (h *Handler) loginPassword(writer http.ResponseWriter, request *http.Reques
resp, err := h.userClient.LoginPassword(request.Context(), &userv1.LoginPasswordRequest{
Meta: authRequestMeta(request, body.DeviceID),
DisplayUserId: body.DisplayUserID,
DisplayUserId: account,
Password: body.Password,
})
if err != nil {

View File

@ -81,7 +81,7 @@ func TestAuthRateKeysHashUntrustedComponents(t *testing.T) {
handler := NewHandler(&fakeRoomClient{}, &fakeUserAuthClient{})
handler.SetAuthRateLimit(AuthRateLimitConfig{KeyPrefix: "test:"})
untrusted := strings.Repeat("x", 4096)
request := httptest.NewRequest("POST", "/api/v1/auth/password/login", nil)
request := httptest.NewRequest("POST", "/api/v1/auth/account/login", nil)
request.Header.Set("X-Forwarded-For", untrusted)
keys := handler.authRateKeys(request, authRateInput{
@ -108,7 +108,7 @@ func TestAuthRateKeysHashUntrustedComponents(t *testing.T) {
func TestPublicAuthRateLimitDisabledSkipsBackend(t *testing.T) {
handler := NewHandler(&fakeRoomClient{}, &fakeUserAuthClient{})
handler.SetAuthRateLimitBackend(AuthRateLimitConfig{Enabled: false}, &fakeDenyingAuthRateLimiter{})
request := httptest.NewRequest("POST", "/api/v1/auth/password/login", nil)
request := httptest.NewRequest("POST", "/api/v1/auth/account/login", nil)
if !handler.allowPublicAuthRequest(httptest.NewRecorder(), request, authRateInput{operation: authOperationPassword}) {
t.Fatal("disabled auth rate limiter must skip backend")

View File

@ -46,12 +46,13 @@ type TencentIMConfig struct {
// TencentRTCConfig 是 gateway transport 层签发客户端腾讯 RTC 进房票据所需的配置。
type TencentRTCConfig struct {
Enabled bool
SDKAppID int64
SecretKey string
UserSigTTL time.Duration
RoomIDType string
AppScene string
Enabled bool
SDKAppID int64
SecretKey string
UserSigTTL time.Duration
RoomIDType string
AppScene string
CallbackSignKey string
}
// ObjectUploader 是 gateway 上传接口依赖的对象存储最小能力。

View File

@ -3,6 +3,9 @@ package http
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"io"
@ -29,11 +32,13 @@ import (
type fakeRoomClient struct {
lastCreate *roomv1.CreateRoomRequest
lastJoin *roomv1.JoinRoomRequest
lastHeartbeat *roomv1.RoomHeartbeatRequest
lastLeave *roomv1.LeaveRoomRequest
lastMicUp *roomv1.MicUpRequest
lastMicDown *roomv1.MicDownRequest
lastChangeMicSeat *roomv1.ChangeMicSeatRequest
lastConfirmMic *roomv1.ConfirmMicPublishingRequest
lastRTCEvent *roomv1.ApplyRTCEventRequest
lastMicSeatLock *roomv1.SetMicSeatLockRequest
lastChatEnabled *roomv1.SetChatEnabledRequest
lastSetAdmin *roomv1.SetRoomAdminRequest
@ -65,6 +70,11 @@ func (f *fakeRoomClient) JoinRoom(_ context.Context, req *roomv1.JoinRoomRequest
return &roomv1.JoinRoomResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 2}}, nil
}
func (f *fakeRoomClient) RoomHeartbeat(_ context.Context, req *roomv1.RoomHeartbeatRequest) (*roomv1.RoomHeartbeatResponse, error) {
f.lastHeartbeat = req
return &roomv1.RoomHeartbeatResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 2}}, nil
}
func (f *fakeRoomClient) LeaveRoom(_ context.Context, req *roomv1.LeaveRoomRequest) (*roomv1.LeaveRoomResponse, error) {
f.lastLeave = req
return &roomv1.LeaveRoomResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 3}}, nil
@ -90,6 +100,11 @@ func (f *fakeRoomClient) ConfirmMicPublishing(_ context.Context, req *roomv1.Con
return &roomv1.ConfirmMicPublishingResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 7}}, nil
}
func (f *fakeRoomClient) ApplyRTCEvent(_ context.Context, req *roomv1.ApplyRTCEventRequest) (*roomv1.ApplyRTCEventResponse, error) {
f.lastRTCEvent = req
return &roomv1.ApplyRTCEventResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 8}}, nil
}
func (f *fakeRoomClient) SetMicSeatLock(_ context.Context, req *roomv1.SetMicSeatLockRequest) (*roomv1.SetMicSeatLockResponse, error) {
f.lastMicSeatLock = req
return &roomv1.SetMicSeatLockResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 8}}, nil
@ -622,6 +637,15 @@ func TestRoomCommandsPropagateClientCommandID(t *testing.T) {
return client.lastLeave.GetMeta()
},
},
{
name: "heartbeat",
path: "/api/v1/rooms/heartbeat",
body: `{"room_id":"room-1","command_id":"cmd-heartbeat-1"}`,
commandID: "cmd-heartbeat-1",
extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
return client.lastHeartbeat.GetMeta()
},
},
{
name: "mic_up",
path: "/api/v1/rooms/mic/up",
@ -1156,17 +1180,17 @@ func TestThirdPartyLoginRateLimitByProviderAndIP(t *testing.T) {
}
}
func TestPasswordLoginRateLimitByDisplayUserID(t *testing.T) {
func TestAccountLoginRateLimitByAccount(t *testing.T) {
userClient := &fakeUserAuthClient{}
handler := NewHandler(&fakeRoomClient{}, userClient)
handler.SetAuthRateLimit(authRateLimitForTest(func(config *AuthRateLimitConfig) {
config.DisplayUserIDIPLimit = 1
}))
router := handler.Routes(auth.NewVerifier("secret"))
body := []byte(`{"display_user_id":"100001","password":"bad","device_id":"dev-1"}`)
body := []byte(`{"account":"100001","password":"bad","device_id":"dev-1"}`)
for i := 0; i < 2; i++ {
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/login", bytes.NewReader(body))
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/account/login", bytes.NewReader(body))
request.Header.Set("X-Request-ID", "req-password-rate")
request.Header.Set("X-Forwarded-For", "203.0.113.20")
recorder := httptest.NewRecorder()
@ -1185,6 +1209,25 @@ func TestPasswordLoginRateLimitByDisplayUserID(t *testing.T) {
}
}
func TestAccountLoginAcceptsAccountField(t *testing.T) {
userClient := &fakeUserAuthClient{}
router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret"))
body := []byte(`{"account":"123456","password":"1234567","device_id":"ios"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/account/login", bytes.NewReader(body))
request.Header.Set("X-Request-ID", "req-account-login")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusOK, codeOK, "req-account-login")
if userClient.lastLoginPassword == nil || userClient.lastLoginPassword.GetDisplayUserId() != "123456" {
t.Fatalf("account was not propagated as display_user_id: %+v", userClient.lastLoginPassword)
}
if userClient.lastLoginPassword.GetPassword() != "1234567" || userClient.lastLoginPassword.GetMeta().GetDeviceId() != "ios" {
t.Fatalf("password login payload mismatch: %+v", userClient.lastLoginPassword)
}
}
func TestRefreshRateLimitByDeviceID(t *testing.T) {
userClient := &fakeUserAuthClient{}
handler := NewHandler(&fakeRoomClient{}, userClient)
@ -1553,6 +1596,7 @@ func TestProfileGateRejectsIncompleteUsersForRoomIMRTCPaidCapabilities(t *testin
{name: "room_list", method: http.MethodGet, path: "/api/v1/rooms"},
{name: "create_room", method: http.MethodPost, path: "/api/v1/rooms/create", body: `{"room_id":"room-1"}`},
{name: "join_room", method: http.MethodPost, path: "/api/v1/rooms/join", body: `{"room_id":"room-1"}`},
{name: "room_heartbeat", method: http.MethodPost, path: "/api/v1/rooms/heartbeat", body: `{"room_id":"room-1"}`},
{name: "im_usersig", method: http.MethodGet, path: "/api/v1/im/usersig"},
{name: "rtc_token", method: http.MethodPost, path: "/api/v1/rtc/token", body: `{"room_id":"room-1"}`},
{name: "send_gift", method: http.MethodPost, path: "/api/v1/rooms/gift/send", body: `{"room_id":"room-1","gift_id":"rose"}`},
@ -1576,7 +1620,7 @@ func TestProfileGateRejectsIncompleteUsersForRoomIMRTCPaidCapabilities(t *testin
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusForbidden, codeProfileRequired, "req-profile-required")
if roomClient.lastCreate != nil || roomClient.lastJoin != nil || roomClient.lastGift != nil {
if roomClient.lastCreate != nil || roomClient.lastJoin != nil || roomClient.lastHeartbeat != nil || roomClient.lastGift != nil {
t.Fatalf("profile gate must stop protected request before room-service: %+v", roomClient)
}
})
@ -1657,8 +1701,8 @@ func TestUploadFileRequiresObjectUploader(t *testing.T) {
func TestAuthLoginMapsGRPCReason(t *testing.T) {
userClient := &fakeUserAuthClient{loginErr: xerr.ToGRPCError(xerr.New(xerr.AuthFailed, "authentication failed"))}
router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret"))
body := []byte(`{"display_user_id":"100001","password":"bad","device_id":"ios"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/login", bytes.NewReader(body))
body := []byte(`{"account":"100001","password":"bad","device_id":"ios"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/account/login", bytes.NewReader(body))
request.Header.Set("X-Request-ID", "req-auth-failed")
recorder := httptest.NewRecorder()
@ -1986,6 +2030,73 @@ func TestTencentRTCTokenFailsClosedForConfigAndUpstreamErrors(t *testing.T) {
assertEnvelope(t, upstreamRecorder, http.StatusBadGateway, codeUpstreamError, "req-rtc-upstream")
}
func TestTencentRTCCallbackVerifiesSignatureAndAppliesEvent(t *testing.T) {
roomClient := &fakeRoomClient{}
handler := NewHandlerWithConfig(roomClient, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{})
handler.SetTencentRTC(TencentRTCConfig{
SDKAppID: 1400000000,
CallbackSignKey: "callback-key",
})
router := handler.Routes(auth.NewVerifier("secret"))
body := []byte(`{"EventGroupId":2,"EventType":203,"CallbackTs":1700000001001,"EventInfo":{"RoomId":"room_1001","EventMsTs":1700000001000,"UserId":"42"}}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-rtc/callback?app_code=lalu", bytes.NewReader(body))
request.Header.Set("X-Request-ID", "req-rtc-callback")
request.Header.Set("SdkAppId", "1400000000")
request.Header.Set("Sign", signTencentRTCCallback("callback-key", body))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertTencentRTCCallback(t, recorder, http.StatusOK, 0)
if roomClient.lastRTCEvent == nil {
t.Fatal("RTC callback must forward known events to room-service")
}
if roomClient.lastRTCEvent.GetEventType() != "audio_started" || roomClient.lastRTCEvent.GetTargetUserId() != 42 || roomClient.lastRTCEvent.GetEventTimeMs() != 1700000001000 {
t.Fatalf("RTC event fields mismatch: %+v", roomClient.lastRTCEvent)
}
meta := roomClient.lastRTCEvent.GetMeta()
if meta.GetRoomId() != "room_1001" || meta.GetActorUserId() != 42 || meta.GetRequestId() != "req-rtc-callback" || meta.GetAppCode() != "lalu" {
t.Fatalf("RTC event meta mismatch: %+v", meta)
}
if !strings.HasPrefix(meta.GetCommandId(), "cmd_") || !strings.HasPrefix(roomClient.lastRTCEvent.GetExternalEventId(), "rtc_evt_") {
t.Fatalf("RTC callback must derive stable ids: meta=%+v event=%+v", meta, roomClient.lastRTCEvent)
}
}
func TestTencentRTCCallbackRejectsInvalidSignatureAndIgnoresUnknownEvents(t *testing.T) {
roomClient := &fakeRoomClient{}
handler := NewHandlerWithConfig(roomClient, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{})
handler.SetTencentRTC(TencentRTCConfig{
SDKAppID: 1400000000,
CallbackSignKey: "callback-key",
})
router := handler.Routes(auth.NewVerifier("secret"))
body := []byte(`{"EventGroupId":2,"EventType":204,"CallbackTs":1700000001001,"EventInfo":{"RoomId":"room_1001","EventMsTs":1700000001000,"UserId":"42","Reason":0}}`)
badRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-rtc/callback", bytes.NewReader(body))
badRequest.Header.Set("SdkAppId", "1400000000")
badRequest.Header.Set("Sign", "bad-sign")
badRecorder := httptest.NewRecorder()
router.ServeHTTP(badRecorder, badRequest)
assertTencentRTCCallback(t, badRecorder, http.StatusForbidden, 1)
if roomClient.lastRTCEvent != nil {
t.Fatalf("invalid signature must not reach room-service: %+v", roomClient.lastRTCEvent)
}
unknownBody := []byte(`{"EventGroupId":2,"EventType":201,"CallbackTs":1700000001001,"EventInfo":{"RoomId":"room_1001","EventMsTs":1700000001000,"UserId":"42"}}`)
unknownRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-rtc/callback", bytes.NewReader(unknownBody))
unknownRequest.Header.Set("SdkAppId", "1400000000")
unknownRequest.Header.Set("Sign", signTencentRTCCallback("callback-key", unknownBody))
unknownRecorder := httptest.NewRecorder()
router.ServeHTTP(unknownRecorder, unknownRequest)
assertTencentRTCCallback(t, unknownRecorder, http.StatusOK, 0)
if roomClient.lastRTCEvent != nil {
t.Fatalf("unknown RTC event must be acknowledged without room-service call: %+v", roomClient.lastRTCEvent)
}
}
func TestTencentIMJoinCallbackUsesPresenceGuard(t *testing.T) {
guard := &fakeRoomGuardClient{}
router := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{
@ -2100,6 +2211,28 @@ func assertTencentCallback(t *testing.T, recorder *httptest.ResponseRecorder, st
}
}
func assertTencentRTCCallback(t *testing.T, recorder *httptest.ResponseRecorder, statusCode int, code int) {
t.Helper()
if recorder.Code != statusCode {
t.Fatalf("status mismatch: got %d want %d body=%s", recorder.Code, statusCode, recorder.Body.String())
}
var response tencentRTCCallbackResponse
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode tencent rtc callback response failed: %v", err)
}
if response.Code != code {
t.Fatalf("unexpected tencent rtc callback response: %+v", response)
}
}
func signTencentRTCCallback(key string, body []byte) string {
mac := hmac.New(sha256.New, []byte(key))
_, _ = mac.Write(body)
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
func authRateLimitForTest(mutator func(*AuthRateLimitConfig)) AuthRateLimitConfig {
config := AuthRateLimitConfig{
Enabled: true,

View File

@ -143,6 +143,23 @@ func (h *Handler) joinRoom(writer http.ResponseWriter, request *http.Request) {
write(writer, request, resp, err)
}
// roomHeartbeat 把客户端显式心跳转换为 room-service presence refresh 命令。
func (h *Handler) roomHeartbeat(writer http.ResponseWriter, request *http.Request) {
var body struct {
RoomID string `json:"room_id"`
CommandID string `json:"command_id"`
}
if !decode(writer, request, &body) {
return
}
resp, err := h.roomClient.RoomHeartbeat(request.Context(), &roomv1.RoomHeartbeatRequest{
Meta: meta(request, body.RoomID, body.CommandID),
})
write(writer, request, resp, err)
}
// leaveRoom 把退房 HTTP JSON 请求转换为 room-service LeaveRoom 命令。
func (h *Handler) leaveRoom(writer http.ResponseWriter, request *http.Request) {
var body struct {

View File

@ -13,7 +13,7 @@ const apiV1Prefix = "/api/v1"
func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
mux := http.NewServeMux()
mux.Handle(apiV1Prefix+"/auth/password/login", h.publicAPIHandler(h.loginPassword))
mux.Handle(apiV1Prefix+"/auth/account/login", h.publicAPIHandler(h.loginPassword))
mux.Handle(apiV1Prefix+"/auth/password/set", h.apiHandler(jwtVerifier, h.setPassword))
mux.Handle(apiV1Prefix+"/auth/third-party/login", h.publicAPIHandler(h.loginThirdParty))
mux.Handle(apiV1Prefix+"/auth/token/refresh", h.publicAPIHandler(h.refreshToken))
@ -46,6 +46,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
mux.Handle(apiV1Prefix+"/rooms", h.profileAPIHandler(jwtVerifier, h.listRooms))
mux.Handle(apiV1Prefix+"/rooms/create", h.profileAPIHandler(jwtVerifier, h.createRoom))
mux.Handle(apiV1Prefix+"/rooms/join", h.profileAPIHandler(jwtVerifier, h.joinRoom))
mux.Handle(apiV1Prefix+"/rooms/heartbeat", h.profileAPIHandler(jwtVerifier, h.roomHeartbeat))
mux.Handle(apiV1Prefix+"/rooms/leave", h.profileAPIHandler(jwtVerifier, h.leaveRoom))
mux.Handle(apiV1Prefix+"/rooms/mic/up", h.profileAPIHandler(jwtVerifier, h.micUp))
mux.Handle(apiV1Prefix+"/rooms/mic/down", h.profileAPIHandler(jwtVerifier, h.micDown))
@ -59,6 +60,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
mux.Handle(apiV1Prefix+"/rooms/user/kick", h.profileAPIHandler(jwtVerifier, h.kickUser))
mux.Handle(apiV1Prefix+"/rooms/user/unban", h.profileAPIHandler(jwtVerifier, h.unbanUser))
mux.Handle(apiV1Prefix+"/rooms/gift/send", h.profileAPIHandler(jwtVerifier, h.sendGift))
mux.Handle(apiV1Prefix+"/tencent-rtc/callback", h.publicAPIHandler(h.handleTencentRTCCallback))
mux.Handle(apiV1Prefix+"/wallet/me/balances", h.profileAPIHandler(jwtVerifier, h.getMyBalances))
mux.Handle(apiV1Prefix+"/wallet/coin-seller/transfer", h.profileAPIHandler(jwtVerifier, h.transferCoinFromSeller))

View File

@ -0,0 +1,234 @@
package http
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/tencentrtc"
"hyapp/pkg/xerr"
)
const (
tencentRTCEventGroupRoom = 1
tencentRTCEventGroupMedia = 2
tencentRTCEventRoomExit = 104
tencentRTCEventStartAudio = 203
tencentRTCEventStopAudio = 204
)
// tencentRTCCallbackBody 覆盖当前语音房需要消费的腾讯 RTC 房间/媒体回调字段。
type tencentRTCCallbackBody struct {
EventGroupID int `json:"EventGroupId"`
EventType int `json:"EventType"`
CallbackTs int64 `json:"CallbackTs"`
EventInfo tencentRTCCallbackEvent `json:"EventInfo"`
}
type tencentRTCCallbackEvent struct {
RoomID json.RawMessage `json:"RoomId"`
UserID string `json:"UserId"`
EventTs int64 `json:"EventTs"`
EventMsTs int64 `json:"EventMsTs"`
Reason int `json:"Reason"`
}
type tencentRTCCallbackResponse struct {
Code int `json:"code"`
Message string `json:"message,omitempty"`
}
// handleTencentRTCCallback 处理腾讯 RTC 服务端事件回调。
func (h *Handler) handleTencentRTCCallback(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost {
writeTencentRTCCallback(writer, http.StatusMethodNotAllowed, 1, "method not allowed")
return
}
payload, err := io.ReadAll(io.LimitReader(request.Body, 1<<20))
if err != nil || len(bytes.TrimSpace(payload)) == 0 {
writeTencentRTCCallback(writer, http.StatusBadRequest, 1, "invalid callback body")
return
}
if !h.tencentRTCSDKAppIDMatched(request) {
writeTencentRTCCallback(writer, http.StatusForbidden, 1, "sdk_app_id mismatch")
return
}
if !tencentrtc.VerifyCallbackSignature(h.tencentRTC.CallbackSignKey, payload, request.Header.Get(tencentrtc.CallbackHeaderSign)) {
// 腾讯 RTC 回调签名使用原始 body配置缺失或签名缺失都必须 fail-closed。
writeTencentRTCCallback(writer, http.StatusForbidden, 1, "callback signature invalid")
return
}
var body tencentRTCCallbackBody
if err := json.Unmarshal(payload, &body); err != nil {
writeTencentRTCCallback(writer, http.StatusBadRequest, 1, "invalid callback body")
return
}
eventType, known := normalizeTencentRTCEventType(body.EventGroupID, body.EventType)
if !known {
// 白名单外事件只做验签和 SDKAppID 校验,避免腾讯新增事件造成重试风暴。
writeTencentRTCCallback(writer, http.StatusOK, 0, "")
return
}
roomID, ok := parseTencentRTCRoomID(body.EventInfo.RoomID)
userID, userOK := parseTencentIMUserID(body.EventInfo.UserID)
eventTimeMS := tencentRTCEventTimeMS(body)
if !ok || !userOK || eventTimeMS <= 0 || h.roomClient == nil {
log.Printf("gateway_rtc_callback result=ignored request_id=%s event_group=%d event_type=%d room_id=%q user_id=%q reason=input_invalid", requestIDFromContext(request.Context()), body.EventGroupID, body.EventType, roomID, body.EventInfo.UserID)
writeTencentRTCCallback(writer, http.StatusOK, 0, "")
return
}
resp, err := h.roomClient.ApplyRTCEvent(request.Context(), &roomv1.ApplyRTCEventRequest{
Meta: &roomv1.RequestMeta{
RequestId: requestIDFromContext(request.Context()),
CommandId: tencentRTCCallbackCommandID(body, roomID, userID, eventTimeMS),
ActorUserId: userID,
RoomId: roomID,
AppCode: tencentRTCCallbackAppCode(request),
GatewayNodeId: "gateway-local",
SessionId: fmt.Sprintf("tencent-rtc-%d", body.EventType),
SentAtMs: time.Now().UnixMilli(),
},
TargetUserId: userID,
EventType: eventType,
EventTimeMs: eventTimeMS,
Reason: tencentRTCCallbackReason(body),
Source: "tencent_rtc_callback",
ExternalEventId: tencentRTCCallbackExternalEventID(body, roomID, userID, eventTimeMS),
})
if err != nil {
if tencentRTCCallbackCanAckBusinessError(err) {
log.Printf("gateway_rtc_callback result=business_ignored request_id=%s room_id=%s user_id=%d event_group=%d event_type=%d reason=%s", requestIDFromContext(request.Context()), roomID, userID, body.EventGroupID, body.EventType, xerr.ReasonFromGRPC(err))
writeTencentRTCCallback(writer, http.StatusOK, 0, "")
return
}
log.Printf("gateway_rtc_callback result=room_service_error request_id=%s room_id=%s user_id=%d event_group=%d event_type=%d error=%q", requestIDFromContext(request.Context()), roomID, userID, body.EventGroupID, body.EventType, err.Error())
writeTencentRTCCallback(writer, http.StatusInternalServerError, 1, "room service error")
return
}
log.Printf("gateway_rtc_callback result=accepted request_id=%s room_id=%s user_id=%d event_group=%d event_type=%d room_version=%d applied=%t", requestIDFromContext(request.Context()), roomID, userID, body.EventGroupID, body.EventType, resp.GetResult().GetRoomVersion(), resp.GetResult().GetApplied())
writeTencentRTCCallback(writer, http.StatusOK, 0, "")
}
func (h *Handler) tencentRTCSDKAppIDMatched(request *http.Request) bool {
if h.tencentRTC.SDKAppID <= 0 {
return false
}
raw := strings.TrimSpace(request.Header.Get(tencentrtc.CallbackHeaderSDKAppID))
value, err := strconv.ParseInt(raw, 10, 64)
return err == nil && value == h.tencentRTC.SDKAppID
}
func normalizeTencentRTCEventType(groupID int, eventType int) (string, bool) {
switch {
case groupID == tencentRTCEventGroupMedia && eventType == tencentRTCEventStartAudio:
return "audio_started", true
case groupID == tencentRTCEventGroupMedia && eventType == tencentRTCEventStopAudio:
return "audio_stopped", true
case groupID == tencentRTCEventGroupRoom && eventType == tencentRTCEventRoomExit:
return "room_exited", true
default:
return "", false
}
}
func parseTencentRTCRoomID(raw json.RawMessage) (string, bool) {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return "", false
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
return "", false
}
switch typed := value.(type) {
case string:
roomID := strings.TrimSpace(typed)
return roomID, roomID != ""
case json.Number:
roomID := strings.TrimSpace(typed.String())
return roomID, roomID != ""
default:
return "", false
}
}
func tencentRTCEventTimeMS(body tencentRTCCallbackBody) int64 {
if body.EventInfo.EventMsTs > 0 {
return body.EventInfo.EventMsTs
}
if body.EventInfo.EventTs > 0 {
return body.EventInfo.EventTs * 1000
}
return body.CallbackTs
}
func tencentRTCCallbackReason(body tencentRTCCallbackBody) string {
switch body.EventType {
case tencentRTCEventRoomExit:
return fmt.Sprintf("rtc_room_exited:%d", body.EventInfo.Reason)
case tencentRTCEventStopAudio:
return fmt.Sprintf("rtc_audio_stopped:%d", body.EventInfo.Reason)
case tencentRTCEventStartAudio:
return "rtc_audio_started"
default:
return fmt.Sprintf("rtc_event:%d:%d", body.EventGroupID, body.EventType)
}
}
func tencentRTCCallbackCommandID(body tencentRTCCallbackBody, roomID string, userID int64, eventTimeMS int64) string {
return "cmd_" + tencentRTCCallbackDigest(body, roomID, userID, eventTimeMS)
}
func tencentRTCCallbackExternalEventID(body tencentRTCCallbackBody, roomID string, userID int64, eventTimeMS int64) string {
return "rtc_evt_" + tencentRTCCallbackDigest(body, roomID, userID, eventTimeMS)
}
func tencentRTCCallbackDigest(body tencentRTCCallbackBody, roomID string, userID int64, eventTimeMS int64) string {
seed := fmt.Sprintf("%d:%d:%s:%d:%d:%d", body.EventGroupID, body.EventType, roomID, userID, eventTimeMS, body.EventInfo.Reason)
sum := sha256.Sum256([]byte(seed))
return hex.EncodeToString(sum[:16])
}
func tencentRTCCallbackAppCode(request *http.Request) string {
if raw := strings.TrimSpace(request.URL.Query().Get("app_code")); raw != "" {
return appcode.Normalize(raw)
}
return appcode.FromContext(request.Context())
}
func tencentRTCCallbackCanAckBusinessError(err error) bool {
reason := xerr.ReasonFromGRPC(err)
return reason == xerr.NotFound || reason == xerr.InvalidArgument || reason == xerr.RoomClosed || reason == xerr.PermissionDenied
}
func writeTencentRTCCallback(writer http.ResponseWriter, statusCode int, code int, message string) {
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(statusCode)
_ = json.NewEncoder(writer).Encode(tencentRTCCallbackResponse{
Code: code,
Message: message,
})
}

View File

@ -89,6 +89,14 @@ type JoinRoom struct {
// Type 返回命令类型。
func (JoinRoom) Type() string { return "join_room" }
// RoomHeartbeat 定义房间业务 presence 显式心跳。
type RoomHeartbeat struct {
Base
}
// Type 返回命令类型。
func (RoomHeartbeat) Type() string { return "room_heartbeat" }
// LeaveRoom 定义业务离房请求。
type LeaveRoom struct {
Base
@ -143,6 +151,26 @@ type ConfirmMicPublishing struct {
// Type 返回命令类型。
func (ConfirmMicPublishing) Type() string { return "confirm_mic_publishing" }
// ApplyRTCEvent 定义由可信腾讯 RTC 服务端回调驱动的房间状态事件。
type ApplyRTCEvent struct {
Base
// TargetUserID 是腾讯 RTC 回调中的 UserId必须映射回内部长 user_id。
TargetUserID int64 `json:"target_user_id"`
// EventType 是 gateway 标准化后的 RTC 事件,当前为 audio_started/audio_stopped/room_exited。
EventType string `json:"event_type"`
// EventTimeMS 是腾讯 RTC 回调中的 EventMsTs用于丢弃旧事件。
EventTimeMS int64 `json:"event_time_ms"`
// Reason 保留腾讯回调 reason 和入口语义,便于 IM 房间消息和审计排障。
Reason string `json:"reason,omitempty"`
// Source 记录确认来源,当前为 tencent_rtc_callback。
Source string `json:"source,omitempty"`
// ExternalEventID 是由 gateway 从腾讯事件字段派生的稳定事件标识。
ExternalEventID string `json:"external_event_id,omitempty"`
}
// Type 返回命令类型。
func (ApplyRTCEvent) Type() string { return "apply_rtc_event" }
// ChangeMicSeat 定义换麦位请求。
type ChangeMicSeat struct {
Base
@ -302,6 +330,8 @@ func Deserialize(commandType string, payload []byte) (Command, error) {
cmd = &CreateRoom{}
case JoinRoom{}.Type():
cmd = &JoinRoom{}
case RoomHeartbeat{}.Type():
cmd = &RoomHeartbeat{}
case LeaveRoom{}.Type():
cmd = &LeaveRoom{}
case MicUp{}.Type():
@ -310,6 +340,8 @@ func Deserialize(commandType string, payload []byte) (Command, error) {
cmd = &MicDown{}
case ConfirmMicPublishing{}.Type():
cmd = &ConfirmMicPublishing{}
case ApplyRTCEvent{}.Type():
cmd = &ApplyRTCEvent{}
case ChangeMicSeat{}.Type():
cmd = &ChangeMicSeat{}
case SetMicSeatLock{}.Type():

View File

@ -94,6 +94,48 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
}, nil
}
// RoomHeartbeat 显式刷新已在房间内用户的业务 presence。
func (s *Service) RoomHeartbeat(ctx context.Context, req *roomv1.RoomHeartbeatRequest) (*roomv1.RoomHeartbeatResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())
cmd := command.RoomHeartbeat{
Base: baseFromMeta(req.GetMeta()),
}
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
if err := requireActiveRoom(current); err != nil {
return mutationResult{}, nil, err
}
if current.BanUsers[cmd.ActorUserID()] {
// ban 用户不能通过心跳续住业务 presenceKickUser 已经清理 presence这里是防御脏状态。
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "user is banned")
}
existing, exists := current.OnlineUsers[cmd.ActorUserID()]
if !exists {
// 心跳只刷新已存在 presence不能把已离房用户重新加入房间。
return mutationResult{}, nil, xerr.New(xerr.NotFound, "user not in room")
}
existing.LastSeenAtMS = now.UnixMilli()
current.Version++
snapshot := current.ToProto()
return mutationResult{
snapshot: snapshot,
user: findProtoUser(snapshot, cmd.ActorUserID()),
}, nil, nil
})
if err != nil {
return nil, err
}
return &roomv1.RoomHeartbeatResponse{
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
User: result.user,
Room: result.snapshot,
}, nil
}
// LeaveRoom 把用户从房间业务态移出。
func (s *Service) LeaveRoom(ctx context.Context, req *roomv1.LeaveRoomRequest) (*roomv1.LeaveRoomResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())

View File

@ -135,6 +135,14 @@ func replay(current *state.RoomState, cmd command.Command) error {
}
}
current.Version++
case *command.RoomHeartbeat:
existing, exists := current.OnlineUsers[typed.ActorUserID()]
if !exists {
return fmt.Errorf("room_heartbeat replay actor is not in room: %d", typed.ActorUserID())
}
// 心跳只刷新 last_seen不产生用户进房事件。
existing.LastSeenAtMS = typed.SentAtMS
current.Version++
case *command.LeaveRoom:
// 离房回放只删除业务 presence不涉及腾讯云 IM 连接态。
delete(current.OnlineUsers, typed.ActorUserID())
@ -174,6 +182,35 @@ func replay(current *state.RoomState, cmd command.Command) error {
current.MicSeats[index].PublishState = state.MicPublishPublishing
current.MicSeats[index].LastPublishEventTimeMS = typed.EventTimeMS
current.Version++
case *command.ApplyRTCEvent:
switch typed.EventType {
case rtcEventAudioStarted:
seat, exists := current.SeatByUser(typed.TargetUserID)
if !exists {
return fmt.Errorf("apply_rtc_event audio_started replay target is not on seat: %d", typed.TargetUserID)
}
index := current.SeatIndex(seat.SeatNo)
current.MicSeats[index].PublishState = state.MicPublishPublishing
current.MicSeats[index].LastPublishEventTimeMS = typed.EventTimeMS
current.Version++
case rtcEventAudioStopped:
seat, exists := current.SeatByUser(typed.TargetUserID)
if !exists {
return fmt.Errorf("apply_rtc_event audio_stopped replay target is not on seat: %d", typed.TargetUserID)
}
index := current.SeatIndex(seat.SeatNo)
current.ClearMicSession(index)
current.Version++
case rtcEventRoomExited:
delete(current.OnlineUsers, typed.TargetUserID)
if seat, exists := current.SeatByUser(typed.TargetUserID); exists {
index := current.SeatIndex(seat.SeatNo)
current.ClearMicSession(index)
}
current.Version++
default:
return fmt.Errorf("apply_rtc_event replay unsupported event type: %s", typed.EventType)
}
case *command.ChangeMicSeat:
from, exists := current.SeatByUser(typed.TargetUserID)
if !exists {

View File

@ -0,0 +1,267 @@
package service
import (
"context"
"strings"
"time"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/tencentim"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/command"
"hyapp/services/room-service/internal/room/outbox"
"hyapp/services/room-service/internal/room/rank"
"hyapp/services/room-service/internal/room/state"
)
const (
rtcEventAudioStarted = "audio_started"
rtcEventAudioStopped = "audio_stopped"
rtcEventRoomExited = "room_exited"
)
// ApplyRTCEvent 把已验签的腾讯 RTC 服务端事件落到 Room Cell。
func (s *Service) ApplyRTCEvent(ctx context.Context, req *roomv1.ApplyRTCEventRequest) (*roomv1.ApplyRTCEventResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())
cmd := command.ApplyRTCEvent{
Base: baseFromMeta(req.GetMeta()),
TargetUserID: req.GetTargetUserId(),
EventType: strings.TrimSpace(req.GetEventType()),
EventTimeMS: req.GetEventTimeMs(),
Reason: strings.TrimSpace(req.GetReason()),
Source: strings.TrimSpace(req.GetSource()),
ExternalEventID: strings.TrimSpace(req.GetExternalEventId()),
}
if cmd.TargetUserID == 0 {
// gateway 正常会显式传 target_user_id兜底使用 actor 保持内部调用更易测试。
cmd.TargetUserID = cmd.ActorUserID()
}
if cmd.Source == "" {
cmd.Source = "rtc_webhook"
}
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
if cmd.TargetUserID <= 0 || cmd.EventType == "" || cmd.EventTimeMS <= 0 {
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "rtc event is incomplete")
}
switch cmd.EventType {
case rtcEventAudioStarted:
return s.applyRTCAudioStarted(now, current, cmd)
case rtcEventAudioStopped:
return s.applyRTCAudioStopped(now, current, cmd)
case rtcEventRoomExited:
return s.applyRTCRoomExited(now, current, cmd)
default:
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "rtc event type is unsupported")
}
})
if err != nil {
return nil, err
}
return &roomv1.ApplyRTCEventResponse{
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
SeatNo: result.seatNo,
Room: result.snapshot,
}, nil
}
func (s *Service) applyRTCAudioStarted(now time.Time, current *state.RoomState, cmd command.ApplyRTCEvent) (mutationResult, []outbox.Record, error) {
if err := requireActiveRoom(current); err != nil {
return mutationResult{}, nil, err
}
if _, exists := current.OnlineUsers[cmd.TargetUserID]; !exists {
// RTC 事件不拥有业务进房事实;用户不在业务 presence 时只忽略旧事件。
return mutationResult{snapshot: current.ToProto()}, nil, nil
}
seat, exists := current.SeatByUser(cmd.TargetUserID)
if !exists {
return mutationResult{snapshot: current.ToProto()}, nil, nil
}
if !rtcEventMatchesCurrentMicSession(seat, cmd.EventTimeMS, s.micPublishTimeout) {
return mutationResult{snapshot: current.ToProto(), seatNo: seat.SeatNo}, nil, nil
}
if seat.PublishState == state.MicPublishPublishing {
return mutationResult{snapshot: current.ToProto(), seatNo: seat.SeatNo}, nil, nil
}
if seat.PublishState != state.MicPublishPending {
return mutationResult{snapshot: current.ToProto(), seatNo: seat.SeatNo}, nil, nil
}
index := current.SeatIndex(seat.SeatNo)
current.MicSeats[index].PublishState = state.MicPublishPublishing
current.MicSeats[index].LastPublishEventTimeMS = cmd.EventTimeMS
current.Version++
micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, &roomeventsv1.RoomMicChanged{
ActorUserId: cmd.ActorUserID(),
TargetUserId: cmd.TargetUserID,
FromSeat: seat.SeatNo,
ToSeat: seat.SeatNo,
Action: "publish_confirmed",
MicSessionId: seat.MicSessionID,
PublishState: state.MicPublishPublishing,
PublishDeadlineMs: seat.PublishDeadlineMS,
PublishEventTimeMs: cmd.EventTimeMS,
})
if err != nil {
return mutationResult{}, nil, err
}
return mutationResult{
snapshot: current.ToProto(),
seatNo: seat.SeatNo,
syncEvent: &tencentim.RoomEvent{
EventID: micEvent.EventID,
RoomID: current.RoomID,
EventType: "room_mic_publish_confirmed",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.TargetUserID,
SeatNo: seat.SeatNo,
RoomVersion: current.Version,
Attributes: map[string]string{
"action": "publish_confirmed",
"publish_state": state.MicPublishPublishing,
"mic_session_id": seat.MicSessionID,
"publish_event_time_ms": int64String(cmd.EventTimeMS),
"source": cmd.Source,
"external_event_id": cmd.ExternalEventID,
},
},
}, []outbox.Record{micEvent}, nil
}
func (s *Service) applyRTCAudioStopped(now time.Time, current *state.RoomState, cmd command.ApplyRTCEvent) (mutationResult, []outbox.Record, error) {
seat, exists := current.SeatByUser(cmd.TargetUserID)
if !exists {
return mutationResult{snapshot: current.ToProto()}, nil, nil
}
if !rtcStopEventMatchesCurrentMicSession(seat, cmd.EventTimeMS, s.micPublishTimeout) {
return mutationResult{snapshot: current.ToProto(), seatNo: seat.SeatNo}, nil, nil
}
index := current.SeatIndex(seat.SeatNo)
micSessionID := current.MicSeats[index].MicSessionID
current.ClearMicSession(index)
current.Version++
micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, &roomeventsv1.RoomMicChanged{
ActorUserId: cmd.ActorUserID(),
TargetUserId: cmd.TargetUserID,
FromSeat: seat.SeatNo,
ToSeat: 0,
Action: "down",
MicSessionId: micSessionID,
PublishState: state.MicPublishIdle,
Reason: cmd.Reason,
PublishEventTimeMs: cmd.EventTimeMS,
})
if err != nil {
return mutationResult{}, nil, err
}
return mutationResult{
snapshot: current.ToProto(),
seatNo: seat.SeatNo,
syncEvent: &tencentim.RoomEvent{
EventID: micEvent.EventID,
RoomID: current.RoomID,
EventType: "room_mic_down",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.TargetUserID,
SeatNo: seat.SeatNo,
RoomVersion: current.Version,
Attributes: map[string]string{
"action": "down",
"publish_state": state.MicPublishIdle,
"mic_session_id": micSessionID,
"reason": cmd.Reason,
"publish_event_time_ms": int64String(cmd.EventTimeMS),
"source": cmd.Source,
"external_event_id": cmd.ExternalEventID,
},
},
}, []outbox.Record{micEvent}, nil
}
func (s *Service) applyRTCRoomExited(now time.Time, current *state.RoomState, cmd command.ApplyRTCEvent) (mutationResult, []outbox.Record, error) {
if user, exists := current.OnlineUsers[cmd.TargetUserID]; exists && user.LastSeenAtMS > cmd.EventTimeMS {
// 腾讯可能重试或延迟投递旧退房事件;如果业务 presence 已被更新到事件之后,保留当前重连状态。
return mutationResult{snapshot: current.ToProto()}, nil, nil
}
_, inRoom := current.OnlineUsers[cmd.TargetUserID]
seat, onSeat := current.SeatByUser(cmd.TargetUserID)
if onSeat && !rtcStopEventMatchesCurrentMicSession(seat, cmd.EventTimeMS, s.micPublishTimeout) {
return mutationResult{snapshot: current.ToProto(), seatNo: seat.SeatNo}, nil, nil
}
if !inRoom && !onSeat {
return mutationResult{snapshot: current.ToProto()}, nil, nil
}
delete(current.OnlineUsers, cmd.TargetUserID)
if onSeat {
index := current.SeatIndex(seat.SeatNo)
current.ClearMicSession(index)
}
current.Version++
leftEvent, err := outbox.Build(current.RoomID, "RoomUserLeft", current.Version, now, &roomeventsv1.RoomUserLeft{
UserId: cmd.TargetUserID,
})
if err != nil {
return mutationResult{}, nil, err
}
return mutationResult{
snapshot: current.ToProto(),
seatNo: seat.SeatNo,
syncEvent: &tencentim.RoomEvent{
EventID: leftEvent.EventID,
RoomID: current.RoomID,
EventType: "room_user_left",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.TargetUserID,
RoomVersion: current.Version,
Attributes: map[string]string{
"reason": cmd.Reason,
"source": cmd.Source,
"external_event_id": cmd.ExternalEventID,
},
},
}, []outbox.Record{leftEvent}, nil
}
func rtcEventMatchesCurrentMicSession(seat state.MicSeat, eventTimeMS int64, timeout time.Duration) bool {
if !rtcStopEventMatchesCurrentMicSession(seat, eventTimeMS, timeout) {
return false
}
if seat.PublishDeadlineMS > 0 && eventTimeMS > seat.PublishDeadlineMS {
return false
}
return true
}
func rtcStopEventMatchesCurrentMicSession(seat state.MicSeat, eventTimeMS int64, timeout time.Duration) bool {
if seat.MicSessionID == "" || eventTimeMS <= seat.LastPublishEventTimeMS {
return false
}
if createdAtMS := rtcMicSessionCreatedAtMS(seat, timeout); createdAtMS > 0 && eventTimeMS < createdAtMS {
return false
}
return true
}
func rtcMicSessionCreatedAtMS(seat state.MicSeat, timeout time.Duration) int64 {
timeoutMS := timeout.Milliseconds()
if seat.PublishDeadlineMS <= 0 || timeoutMS <= 0 {
return 0
}
return seat.PublishDeadlineMS - timeoutMS
}

View File

@ -569,6 +569,85 @@ func TestMicPublishingConfirmAndStaleEvents(t *testing.T) {
}
}
func TestApplyRTCEventConfirmsAudioAndClearsOnStopOrExit(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
syncPublisher := &fakeSyncPublisher{}
svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{}, usedClock, time.Minute)
roomID := "room-rtc-event"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("JoinRoom user2 failed: %v", err)
}
micResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1})
if err != nil {
t.Fatalf("MicUp user2 failed: %v", err)
}
startEventTime := usedClock.now.Add(time.Second).UnixMilli()
startResp, err := svc.ApplyRTCEvent(ctx, &roomv1.ApplyRTCEventRequest{
Meta: roomservice.NewRequestMeta(roomID, 2),
TargetUserId: 2,
EventType: "audio_started",
EventTimeMs: startEventTime,
Source: "tencent_rtc_callback",
ExternalEventId: "rtc_evt_start",
})
if err != nil {
t.Fatalf("ApplyRTCEvent audio_started failed: %v", err)
}
seat := findSeat(startResp.GetRoom(), 1)
if seat == nil || seat.GetPublishState() != "publishing" || seat.GetMicSessionId() != micResp.GetMicSessionId() || seat.GetLastPublishEventTimeMs() != startEventTime {
t.Fatalf("RTC audio_started should confirm current mic session: seat=%+v", seat)
}
stopResp, err := svc.ApplyRTCEvent(ctx, &roomv1.ApplyRTCEventRequest{
Meta: roomservice.NewRequestMeta(roomID, 2),
TargetUserId: 2,
EventType: "audio_stopped",
EventTimeMs: usedClock.now.Add(2 * time.Second).UnixMilli(),
Reason: "rtc_audio_stopped:0",
Source: "tencent_rtc_callback",
})
if err != nil {
t.Fatalf("ApplyRTCEvent audio_stopped failed: %v", err)
}
if seat := findSeat(stopResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 || seat.GetMicSessionId() != "" {
t.Fatalf("RTC audio_stopped should release mic seat: seat=%+v", seat)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); err != nil {
t.Fatalf("JoinRoom user3 failed: %v", err)
}
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 3), SeatNo: 2}); err != nil {
t.Fatalf("MicUp user3 failed: %v", err)
}
exitResp, err := svc.ApplyRTCEvent(ctx, &roomv1.ApplyRTCEventRequest{
Meta: roomservice.NewRequestMeta(roomID, 3),
TargetUserId: 3,
EventType: "room_exited",
EventTimeMs: usedClock.now.Add(3 * time.Second).UnixMilli(),
Reason: "rtc_room_exited:2",
Source: "tencent_rtc_callback",
})
if err != nil {
t.Fatalf("ApplyRTCEvent room_exited failed: %v", err)
}
if user := findRoomUser(exitResp.GetRoom(), 3); user != nil {
t.Fatalf("RTC room_exited should remove business presence: user=%+v", user)
}
if seat := findSeat(exitResp.GetRoom(), 2); seat == nil || seat.GetUserId() != 0 {
t.Fatalf("RTC room_exited should release occupied mic: seat=%+v", seat)
}
if len(syncPublisher.events) == 0 || syncPublisher.events[len(syncPublisher.events)-1].EventType != "room_user_left" {
t.Fatalf("RTC room_exited should publish room_user_left: %+v", syncPublisher.events)
}
}
func TestMicPublishTimeoutMicDownsPendingSession(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
@ -931,6 +1010,44 @@ func TestRepeatedJoinRefreshesPresenceWithoutDuplicateJoinEvent(t *testing.T) {
}
}
func TestRoomHeartbeatRefreshesExistingPresenceOnly(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
roomID := "room-heartbeat"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
Mode: "voice",
RoomName: "Test Room",
}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("JoinRoom failed: %v", err)
}
beforeJoinEvents := countPendingOutboxType(t, repository, "RoomUserJoined")
usedClock.now = usedClock.now.Add(30 * time.Second)
heartbeatResp, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{Meta: roomservice.NewRequestMeta(roomID, 2)})
if err != nil {
t.Fatalf("RoomHeartbeat failed: %v", err)
}
user := findRoomUser(heartbeatResp.GetRoom(), 2)
if user == nil || user.GetLastSeenAtMs() != usedClock.now.UnixMilli() {
t.Fatalf("heartbeat should refresh last_seen only: user=%+v now=%d", user, usedClock.now.UnixMilli())
}
if after := countPendingOutboxType(t, repository, "RoomUserJoined"); after != beforeJoinEvents {
t.Fatalf("heartbeat must not create join outbox: before=%d after=%d", beforeJoinEvents, after)
}
if _, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); !xerr.IsCode(err, xerr.NotFound) {
t.Fatalf("heartbeat must not create missing presence, got %v", err)
}
}
// TestLeaveRoomReleasesSeatAndBlocksGuards 验证离房同时移除 presence 和麦位占用。
func TestLeaveRoomReleasesSeatAndBlocksGuards(t *testing.T) {
ctx := context.Background()

View File

@ -54,6 +54,13 @@ func (s *Server) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*ro
return mapServiceResult(s.svc.JoinRoom(ctx, req))
}
// RoomHeartbeat 代理到领域服务。
func (s *Server) RoomHeartbeat(ctx context.Context, req *roomv1.RoomHeartbeatRequest) (*roomv1.RoomHeartbeatResponse, error) {
// 心跳只刷新已有业务 presence不能隐式创建进房状态。
ctx = contextWithMetaApp(ctx, req.GetMeta())
return mapServiceResult(s.svc.RoomHeartbeat(ctx, req))
}
// LeaveRoom 代理到领域服务。
func (s *Server) LeaveRoom(ctx context.Context, req *roomv1.LeaveRoomRequest) (*roomv1.LeaveRoomResponse, error) {
// LeaveRoom 不直接操作腾讯云 IM 连接,只修改房间业务态。
@ -89,6 +96,13 @@ func (s *Server) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmMi
return mapServiceResult(s.svc.ConfirmMicPublishing(ctx, req))
}
// ApplyRTCEvent 代理到领域服务。
func (s *Server) ApplyRTCEvent(ctx context.Context, req *roomv1.ApplyRTCEventRequest) (*roomv1.ApplyRTCEventResponse, error) {
// 腾讯 RTC 回调已在 gateway 验签;房间状态变化仍必须由 Room Cell 串行提交。
ctx = contextWithMetaApp(ctx, req.GetMeta())
return mapServiceResult(s.svc.ApplyRTCEvent(ctx, req))
}
// SetMicSeatLock 代理到领域服务。
func (s *Server) SetMicSeatLock(ctx context.Context, req *roomv1.SetMicSeatLockRequest) (*roomv1.SetMicSeatLockResponse, error) {
// 锁麦权限和目标麦位状态由 Room Cell 判定。

View File

@ -1272,3 +1272,165 @@ CREATE TABLE IF NOT EXISTS login_audit (
KEY idx_login_audit_request_id (app_code, request_id),
KEY idx_login_audit_created_at (app_code, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 本地联调测试账号。
-- 密码登录仍以 users.current_display_user_id 为账号password_accounts 只保存 user_id 维度密码身份。
INSERT INTO users (
app_code,
user_id,
default_display_user_id,
current_display_user_id,
current_display_user_id_kind,
current_display_user_id_expires_at_ms,
username,
profile_completed,
profile_completed_at_ms,
onboarding_status,
status,
created_at_ms,
updated_at_ms
)
VALUES (
'lalu',
123456,
'123456',
'123456',
'default',
NULL,
'test_123456',
1,
0,
'completed',
'active',
0,
0
)
ON DUPLICATE KEY UPDATE
current_display_user_id = VALUES(current_display_user_id),
current_display_user_id_kind = VALUES(current_display_user_id_kind),
current_display_user_id_expires_at_ms = VALUES(current_display_user_id_expires_at_ms),
username = VALUES(username),
profile_completed = VALUES(profile_completed),
onboarding_status = VALUES(onboarding_status),
status = VALUES(status),
updated_at_ms = VALUES(updated_at_ms);
INSERT INTO user_display_user_ids (
app_code,
display_user_id,
user_id,
display_user_id_kind,
status,
assigned_at_ms,
activated_at_ms,
created_at_ms,
updated_at_ms
)
VALUES ('lalu', '123456', 123456, 'default', 'active', 0, 0, 0, 0)
ON DUPLICATE KEY UPDATE
display_user_id = VALUES(display_user_id),
display_user_id_kind = VALUES(display_user_id_kind),
status = VALUES(status),
activated_at_ms = VALUES(activated_at_ms),
updated_at_ms = VALUES(updated_at_ms);
INSERT INTO password_accounts (
app_code,
user_id,
password_hash,
hash_alg,
created_at_ms,
updated_at_ms
)
VALUES (
'lalu',
123456,
'$2a$10$9eyyJvfGdkb3oU8EAPUHiuLz1C/AmhGTU1nq8AeOIMNFwznk7B69G',
'bcrypt',
0,
0
)
ON DUPLICATE KEY UPDATE
password_hash = VALUES(password_hash),
hash_alg = VALUES(hash_alg),
updated_at_ms = VALUES(updated_at_ms);
INSERT INTO users (
app_code,
user_id,
default_display_user_id,
current_display_user_id,
current_display_user_id_kind,
current_display_user_id_expires_at_ms,
username,
profile_completed,
profile_completed_at_ms,
onboarding_status,
status,
created_at_ms,
updated_at_ms
)
VALUES (
'lalu',
12345678,
'12345678',
'12345678',
'default',
NULL,
'test_12345678',
1,
0,
'completed',
'active',
0,
0
)
ON DUPLICATE KEY UPDATE
current_display_user_id = VALUES(current_display_user_id),
current_display_user_id_kind = VALUES(current_display_user_id_kind),
current_display_user_id_expires_at_ms = VALUES(current_display_user_id_expires_at_ms),
username = VALUES(username),
profile_completed = VALUES(profile_completed),
onboarding_status = VALUES(onboarding_status),
status = VALUES(status),
updated_at_ms = VALUES(updated_at_ms);
INSERT INTO user_display_user_ids (
app_code,
display_user_id,
user_id,
display_user_id_kind,
status,
assigned_at_ms,
activated_at_ms,
created_at_ms,
updated_at_ms
)
VALUES ('lalu', '12345678', 12345678, 'default', 'active', 0, 0, 0, 0)
ON DUPLICATE KEY UPDATE
display_user_id = VALUES(display_user_id),
display_user_id_kind = VALUES(display_user_id_kind),
status = VALUES(status),
activated_at_ms = VALUES(activated_at_ms),
updated_at_ms = VALUES(updated_at_ms);
INSERT INTO password_accounts (
app_code,
user_id,
password_hash,
hash_alg,
created_at_ms,
updated_at_ms
)
VALUES (
'lalu',
12345678,
'$2a$10$2sdtdAzFlPu23vC0vMQT6OIIm5.KCzATuwvn8i09/SKHz.hmkzJce',
'bcrypt',
0,
0
)
ON DUPLICATE KEY UPDATE
password_hash = VALUES(password_hash),
hash_alg = VALUES(hash_alg),
updated_at_ms = VALUES(updated_at_ms);