增加腾讯云回调
This commit is contained in:
parent
d9fdb70016
commit
cf9fc2c9aa
File diff suppressed because it is too large
Load Diff
@ -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_started、audio_stopped、room_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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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`。
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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/host,room-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 重启恢复测试。
|
||||
|
||||
@ -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,具体按客户端刷新能力和泄露窗口决定。
|
||||
|
||||
@ -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 回调推进 publishing,RTC 204 回调释放麦位 | MicUp 后切 anchor、MicDown 后停止推流的端侧策略未联调 |
|
||||
| 音频审核/录制 | `TODO` | 当前无设计和代码 | 后续接腾讯云审核/录制能力 |
|
||||
|
||||
## Mic And Room Management
|
||||
|
||||
31
pkg/tencentrtc/callback.go
Normal file
31
pkg/tencentrtc/callback.go
Normal 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))
|
||||
}
|
||||
18
pkg/tencentrtc/callback_test.go
Normal file
18
pkg/tencentrtc/callback_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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 服务端配置。
|
||||
|
||||
@ -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 文件/头像上传使用腾讯云 COS;SecretID/SecretKey 只允许保存在服务端配置。
|
||||
|
||||
@ -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 文件/头像上传使用腾讯云 COS;secret 只保存在 gateway 服务端配置。
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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 上传接口依赖的对象存储最小能力。
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
@ -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():
|
||||
|
||||
@ -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 用户不能通过心跳续住业务 presence;KickUser 已经清理 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())
|
||||
|
||||
@ -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 {
|
||||
|
||||
267
services/room-service/internal/room/service/rtc_event.go
Normal file
267
services/room-service/internal/room/service/rtc_event.go
Normal 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
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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 判定。
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user