增加腾讯云回调
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;
|
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 移出。
|
// LeaveRoomRequest 把用户从房间 presence 移出。
|
||||||
message LeaveRoomRequest {
|
message LeaveRoomRequest {
|
||||||
RequestMeta meta = 1;
|
RequestMeta meta = 1;
|
||||||
@ -193,6 +206,25 @@ message ConfirmMicPublishingResponse {
|
|||||||
RoomSnapshot room = 3;
|
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 锁定或解锁指定麦位。
|
// SetMicSeatLockRequest 锁定或解锁指定麦位。
|
||||||
message SetMicSeatLockRequest {
|
message SetMicSeatLockRequest {
|
||||||
RequestMeta meta = 1;
|
RequestMeta meta = 1;
|
||||||
@ -367,11 +399,13 @@ message ListRoomsResponse {
|
|||||||
service RoomCommandService {
|
service RoomCommandService {
|
||||||
rpc CreateRoom(CreateRoomRequest) returns (CreateRoomResponse);
|
rpc CreateRoom(CreateRoomRequest) returns (CreateRoomResponse);
|
||||||
rpc JoinRoom(JoinRoomRequest) returns (JoinRoomResponse);
|
rpc JoinRoom(JoinRoomRequest) returns (JoinRoomResponse);
|
||||||
|
rpc RoomHeartbeat(RoomHeartbeatRequest) returns (RoomHeartbeatResponse);
|
||||||
rpc LeaveRoom(LeaveRoomRequest) returns (LeaveRoomResponse);
|
rpc LeaveRoom(LeaveRoomRequest) returns (LeaveRoomResponse);
|
||||||
rpc MicUp(MicUpRequest) returns (MicUpResponse);
|
rpc MicUp(MicUpRequest) returns (MicUpResponse);
|
||||||
rpc MicDown(MicDownRequest) returns (MicDownResponse);
|
rpc MicDown(MicDownRequest) returns (MicDownResponse);
|
||||||
rpc ChangeMicSeat(ChangeMicSeatRequest) returns (ChangeMicSeatResponse);
|
rpc ChangeMicSeat(ChangeMicSeatRequest) returns (ChangeMicSeatResponse);
|
||||||
rpc ConfirmMicPublishing(ConfirmMicPublishingRequest) returns (ConfirmMicPublishingResponse);
|
rpc ConfirmMicPublishing(ConfirmMicPublishingRequest) returns (ConfirmMicPublishingResponse);
|
||||||
|
rpc ApplyRTCEvent(ApplyRTCEventRequest) returns (ApplyRTCEventResponse);
|
||||||
rpc SetMicSeatLock(SetMicSeatLockRequest) returns (SetMicSeatLockResponse);
|
rpc SetMicSeatLock(SetMicSeatLockRequest) returns (SetMicSeatLockResponse);
|
||||||
rpc SetChatEnabled(SetChatEnabledRequest) returns (SetChatEnabledResponse);
|
rpc SetChatEnabled(SetChatEnabledRequest) returns (SetChatEnabledResponse);
|
||||||
rpc SetRoomAdmin(SetRoomAdminRequest) returns (SetRoomAdminResponse);
|
rpc SetRoomAdmin(SetRoomAdminRequest) returns (SetRoomAdminResponse);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.5.1
|
// - protoc-gen-go-grpc v1.5.1
|
||||||
// - protoc v5.29.2
|
// - protoc v7.34.1
|
||||||
// source: proto/room/v1/room.proto
|
// source: proto/room/v1/room.proto
|
||||||
|
|
||||||
package roomv1
|
package roomv1
|
||||||
@ -21,11 +21,13 @@ const _ = grpc.SupportPackageIsVersion9
|
|||||||
const (
|
const (
|
||||||
RoomCommandService_CreateRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/CreateRoom"
|
RoomCommandService_CreateRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/CreateRoom"
|
||||||
RoomCommandService_JoinRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/JoinRoom"
|
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_LeaveRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/LeaveRoom"
|
||||||
RoomCommandService_MicUp_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicUp"
|
RoomCommandService_MicUp_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicUp"
|
||||||
RoomCommandService_MicDown_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicDown"
|
RoomCommandService_MicDown_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicDown"
|
||||||
RoomCommandService_ChangeMicSeat_FullMethodName = "/hyapp.room.v1.RoomCommandService/ChangeMicSeat"
|
RoomCommandService_ChangeMicSeat_FullMethodName = "/hyapp.room.v1.RoomCommandService/ChangeMicSeat"
|
||||||
RoomCommandService_ConfirmMicPublishing_FullMethodName = "/hyapp.room.v1.RoomCommandService/ConfirmMicPublishing"
|
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_SetMicSeatLock_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetMicSeatLock"
|
||||||
RoomCommandService_SetChatEnabled_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetChatEnabled"
|
RoomCommandService_SetChatEnabled_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetChatEnabled"
|
||||||
RoomCommandService_SetRoomAdmin_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetRoomAdmin"
|
RoomCommandService_SetRoomAdmin_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetRoomAdmin"
|
||||||
@ -44,11 +46,13 @@ const (
|
|||||||
type RoomCommandServiceClient interface {
|
type RoomCommandServiceClient interface {
|
||||||
CreateRoom(ctx context.Context, in *CreateRoomRequest, opts ...grpc.CallOption) (*CreateRoomResponse, error)
|
CreateRoom(ctx context.Context, in *CreateRoomRequest, opts ...grpc.CallOption) (*CreateRoomResponse, error)
|
||||||
JoinRoom(ctx context.Context, in *JoinRoomRequest, opts ...grpc.CallOption) (*JoinRoomResponse, 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)
|
LeaveRoom(ctx context.Context, in *LeaveRoomRequest, opts ...grpc.CallOption) (*LeaveRoomResponse, error)
|
||||||
MicUp(ctx context.Context, in *MicUpRequest, opts ...grpc.CallOption) (*MicUpResponse, error)
|
MicUp(ctx context.Context, in *MicUpRequest, opts ...grpc.CallOption) (*MicUpResponse, error)
|
||||||
MicDown(ctx context.Context, in *MicDownRequest, opts ...grpc.CallOption) (*MicDownResponse, error)
|
MicDown(ctx context.Context, in *MicDownRequest, opts ...grpc.CallOption) (*MicDownResponse, error)
|
||||||
ChangeMicSeat(ctx context.Context, in *ChangeMicSeatRequest, opts ...grpc.CallOption) (*ChangeMicSeatResponse, error)
|
ChangeMicSeat(ctx context.Context, in *ChangeMicSeatRequest, opts ...grpc.CallOption) (*ChangeMicSeatResponse, error)
|
||||||
ConfirmMicPublishing(ctx context.Context, in *ConfirmMicPublishingRequest, opts ...grpc.CallOption) (*ConfirmMicPublishingResponse, 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)
|
SetMicSeatLock(ctx context.Context, in *SetMicSeatLockRequest, opts ...grpc.CallOption) (*SetMicSeatLockResponse, error)
|
||||||
SetChatEnabled(ctx context.Context, in *SetChatEnabledRequest, opts ...grpc.CallOption) (*SetChatEnabledResponse, error)
|
SetChatEnabled(ctx context.Context, in *SetChatEnabledRequest, opts ...grpc.CallOption) (*SetChatEnabledResponse, error)
|
||||||
SetRoomAdmin(ctx context.Context, in *SetRoomAdminRequest, opts ...grpc.CallOption) (*SetRoomAdminResponse, 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
|
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) {
|
func (c *roomCommandServiceClient) LeaveRoom(ctx context.Context, in *LeaveRoomRequest, opts ...grpc.CallOption) (*LeaveRoomResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(LeaveRoomResponse)
|
out := new(LeaveRoomResponse)
|
||||||
@ -137,6 +151,16 @@ func (c *roomCommandServiceClient) ConfirmMicPublishing(ctx context.Context, in
|
|||||||
return out, nil
|
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) {
|
func (c *roomCommandServiceClient) SetMicSeatLock(ctx context.Context, in *SetMicSeatLockRequest, opts ...grpc.CallOption) (*SetMicSeatLockResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(SetMicSeatLockResponse)
|
out := new(SetMicSeatLockResponse)
|
||||||
@ -225,11 +249,13 @@ func (c *roomCommandServiceClient) SendGift(ctx context.Context, in *SendGiftReq
|
|||||||
type RoomCommandServiceServer interface {
|
type RoomCommandServiceServer interface {
|
||||||
CreateRoom(context.Context, *CreateRoomRequest) (*CreateRoomResponse, error)
|
CreateRoom(context.Context, *CreateRoomRequest) (*CreateRoomResponse, error)
|
||||||
JoinRoom(context.Context, *JoinRoomRequest) (*JoinRoomResponse, error)
|
JoinRoom(context.Context, *JoinRoomRequest) (*JoinRoomResponse, error)
|
||||||
|
RoomHeartbeat(context.Context, *RoomHeartbeatRequest) (*RoomHeartbeatResponse, error)
|
||||||
LeaveRoom(context.Context, *LeaveRoomRequest) (*LeaveRoomResponse, error)
|
LeaveRoom(context.Context, *LeaveRoomRequest) (*LeaveRoomResponse, error)
|
||||||
MicUp(context.Context, *MicUpRequest) (*MicUpResponse, error)
|
MicUp(context.Context, *MicUpRequest) (*MicUpResponse, error)
|
||||||
MicDown(context.Context, *MicDownRequest) (*MicDownResponse, error)
|
MicDown(context.Context, *MicDownRequest) (*MicDownResponse, error)
|
||||||
ChangeMicSeat(context.Context, *ChangeMicSeatRequest) (*ChangeMicSeatResponse, error)
|
ChangeMicSeat(context.Context, *ChangeMicSeatRequest) (*ChangeMicSeatResponse, error)
|
||||||
ConfirmMicPublishing(context.Context, *ConfirmMicPublishingRequest) (*ConfirmMicPublishingResponse, error)
|
ConfirmMicPublishing(context.Context, *ConfirmMicPublishingRequest) (*ConfirmMicPublishingResponse, error)
|
||||||
|
ApplyRTCEvent(context.Context, *ApplyRTCEventRequest) (*ApplyRTCEventResponse, error)
|
||||||
SetMicSeatLock(context.Context, *SetMicSeatLockRequest) (*SetMicSeatLockResponse, error)
|
SetMicSeatLock(context.Context, *SetMicSeatLockRequest) (*SetMicSeatLockResponse, error)
|
||||||
SetChatEnabled(context.Context, *SetChatEnabledRequest) (*SetChatEnabledResponse, error)
|
SetChatEnabled(context.Context, *SetChatEnabledRequest) (*SetChatEnabledResponse, error)
|
||||||
SetRoomAdmin(context.Context, *SetRoomAdminRequest) (*SetRoomAdminResponse, 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) {
|
func (UnimplementedRoomCommandServiceServer) JoinRoom(context.Context, *JoinRoomRequest) (*JoinRoomResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method JoinRoom not implemented")
|
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) {
|
func (UnimplementedRoomCommandServiceServer) LeaveRoom(context.Context, *LeaveRoomRequest) (*LeaveRoomResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method LeaveRoom not implemented")
|
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) {
|
func (UnimplementedRoomCommandServiceServer) ConfirmMicPublishing(context.Context, *ConfirmMicPublishingRequest) (*ConfirmMicPublishingResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method ConfirmMicPublishing not implemented")
|
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) {
|
func (UnimplementedRoomCommandServiceServer) SetMicSeatLock(context.Context, *SetMicSeatLockRequest) (*SetMicSeatLockResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method SetMicSeatLock not implemented")
|
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)
|
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) {
|
func _RoomCommandService_LeaveRoom_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(LeaveRoomRequest)
|
in := new(LeaveRoomRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@ -440,6 +490,24 @@ func _RoomCommandService_ConfirmMicPublishing_Handler(srv interface{}, ctx conte
|
|||||||
return interceptor(ctx, in, info, handler)
|
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) {
|
func _RoomCommandService_SetMicSeatLock_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(SetMicSeatLockRequest)
|
in := new(SetMicSeatLockRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@ -599,6 +667,10 @@ var RoomCommandService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "JoinRoom",
|
MethodName: "JoinRoom",
|
||||||
Handler: _RoomCommandService_JoinRoom_Handler,
|
Handler: _RoomCommandService_JoinRoom_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "RoomHeartbeat",
|
||||||
|
Handler: _RoomCommandService_RoomHeartbeat_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "LeaveRoom",
|
MethodName: "LeaveRoom",
|
||||||
Handler: _RoomCommandService_LeaveRoom_Handler,
|
Handler: _RoomCommandService_LeaveRoom_Handler,
|
||||||
@ -619,6 +691,10 @@ var RoomCommandService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "ConfirmMicPublishing",
|
MethodName: "ConfirmMicPublishing",
|
||||||
Handler: _RoomCommandService_ConfirmMicPublishing_Handler,
|
Handler: _RoomCommandService_ConfirmMicPublishing_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ApplyRTCEvent",
|
||||||
|
Handler: _RoomCommandService_ApplyRTCEvent_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "SetMicSeatLock",
|
MethodName: "SetMicSeatLock",
|
||||||
Handler: _RoomCommandService_SetMicSeatLock_Handler,
|
Handler: _RoomCommandService_SetMicSeatLock_Handler,
|
||||||
|
|||||||
@ -399,19 +399,21 @@ gateway 是 profile gate 的外部入口执行点。为了避免每个业务请
|
|||||||
### Password Login
|
### Password Login
|
||||||
|
|
||||||
```text
|
```text
|
||||||
POST /api/v1/auth/password/login
|
POST /api/v1/auth/account/login
|
||||||
```
|
```
|
||||||
|
|
||||||
Request:
|
Request:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"display_user_id": "163000",
|
"account": "163000",
|
||||||
"password": "plain_password",
|
"password": "plain_password",
|
||||||
"device_id": "device_xxx"
|
"device_id": "device_xxx"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`account` 就是用户当前有效短号。gateway 会统一转成 user-service 的 `LoginPasswordRequest.display_user_id`。
|
||||||
|
|
||||||
Response `data`:
|
Response `data`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@ -433,6 +435,8 @@ Response `data`:
|
|||||||
|
|
||||||
密码登录只接受当前有效 `display_user_id`。如果用户申请了临时靓号,默认短号在靓号有效期内不能用于密码登录;靓号过期后,默认短号恢复登录能力。
|
密码登录只接受当前有效 `display_user_id`。如果用户申请了临时靓号,默认短号在靓号有效期内不能用于密码登录;靓号过期后,默认短号恢复登录能力。
|
||||||
|
|
||||||
|
本地联调 initdb 预置测试账号:账号 `123456`,密码 `1234567`;账号 `12345678`,密码 `12345678`。
|
||||||
|
|
||||||
### Refresh Token
|
### Refresh Token
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@ -846,7 +850,7 @@ sequenceDiagram
|
|||||||
participant G as gateway-service
|
participant G as gateway-service
|
||||||
participant U as user-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)
|
G->>U: LoginPassword(display_user_id, password)
|
||||||
U->>U: expire pretty display_user_id if needed
|
U->>U: expire pretty display_user_id if needed
|
||||||
U->>U: resolve current display_user_id
|
U->>U: resolve current display_user_id
|
||||||
@ -930,7 +934,7 @@ gateway HTTP envelope 的 `code` 不使用数字。
|
|||||||
| `INVALID_JSON` | 400 | request body is invalid JSON |
|
| `INVALID_JSON` | 400 | request body is invalid JSON |
|
||||||
| `INVALID_ARGUMENT` | 400 | field format or required field invalid |
|
| `INVALID_ARGUMENT` | 400 | field format or required field invalid |
|
||||||
| `RATE_LIMITED` | 429 | public auth rate limit exceeded |
|
| `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 |
|
| `UNAUTHORIZED` | 401 | missing or invalid access token |
|
||||||
| `PROFILE_REQUIRED` | 403 | authenticated user has not completed required registration profile |
|
| `PROFILE_REQUIRED` | 403 | authenticated user has not completed required registration profile |
|
||||||
| `PASSWORD_ALREADY_SET` | 409 | user already has password identity |
|
| `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 的总请求量 |
|
| `ip` | third-party, password, refresh, logout | 限制单入口 IP 对 public auth 的总请求量 |
|
||||||
| `device_id` | third-party, password, refresh | 限制单设备安装批量注册或刷新 |
|
| `device_id` | third-party, password, refresh | 限制单设备安装批量注册或刷新 |
|
||||||
| `provider + ip` | third-party | 限制单 IP 对同一三方 provider 批量建号 |
|
| `provider + ip` | third-party | 限制单 IP 对同一三方 provider 批量建号 |
|
||||||
| `display_user_id + ip` | password | 限制单 IP 对同一短号喷射尝试 |
|
| `account + ip` | password | 限制单 IP 对同一短号喷射尝试 |
|
||||||
| `display_user_id` | password | 限制同一短号被跨 IP 喷射尝试 |
|
| `account` | password | 限制同一短号被跨 IP 喷射尝试 |
|
||||||
| `session_id + ip` | logout | 限制 logout 重放 |
|
| `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
|
## Security Rules
|
||||||
|
|
||||||
@ -1009,7 +1013,7 @@ Redis backend 使用 Lua 脚本一次性检查并递增同一请求命中的所
|
|||||||
- 三方新用户未设置密码前,短号密码登录返回 `AUTH_FAILED`。
|
- 三方新用户未设置密码前,短号密码登录返回 `AUTH_FAILED`。
|
||||||
- 已登录用户设置密码成功。
|
- 已登录用户设置密码成功。
|
||||||
- 重复设置密码返回 `PASSWORD_ALREADY_SET`。
|
- 重复设置密码返回 `PASSWORD_ALREADY_SET`。
|
||||||
- 设置密码后可使用当前 `display_user_id + password` 登录。
|
- 设置密码后可使用当前 `account + password` 登录。
|
||||||
- 短号不存在和密码错误都返回 `AUTH_FAILED`。
|
- 短号不存在和密码错误都返回 `AUTH_FAILED`。
|
||||||
- 用户有 active 靓号时,密码登录必须使用靓号,默认短号返回 `AUTH_FAILED`。
|
- 用户有 active 靓号时,密码登录必须使用靓号,默认短号返回 `AUTH_FAILED`。
|
||||||
- 靓号过期后,密码登录必须恢复使用默认短号,旧靓号返回 `AUTH_FAILED`。
|
- 靓号过期后,密码登录必须恢复使用默认短号,旧靓号返回 `AUTH_FAILED`。
|
||||||
|
|||||||
@ -98,6 +98,19 @@ parameters:
|
|||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
description: 腾讯云 IM 回调鉴权 token header。
|
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:
|
responses:
|
||||||
BadRequest:
|
BadRequest:
|
||||||
description: 请求体不是合法 JSON,或下游返回非法参数;常见 `code` 为 `INVALID_JSON`、`INVALID_ARGUMENT`、`DISPLAY_USER_ID_INVALID`。
|
description: 请求体不是合法 JSON,或下游返回非法参数;常见 `code` 为 `INVALID_JSON`、`INVALID_ARGUMENT`、`DISPLAY_USER_ID_INVALID`。
|
||||||
@ -158,12 +171,13 @@ paths:
|
|||||||
description: Gateway 不应接新请求,失败详情在 `checks`。
|
description: Gateway 不应接新请求,失败详情在 `checks`。
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/definitions/HealthResponse"
|
$ref: "#/definitions/HealthResponse"
|
||||||
/api/v1/auth/password/login:
|
/api/v1/auth/account/login:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
- auth
|
- auth
|
||||||
summary: 短号密码登录
|
summary: 账号密码登录
|
||||||
operationId: loginPassword
|
description: 账号就是用户当前有效短号。
|
||||||
|
operationId: loginAccountPassword
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: "#/parameters/RequestIDHeader"
|
- $ref: "#/parameters/RequestIDHeader"
|
||||||
- name: body
|
- name: body
|
||||||
@ -396,6 +410,46 @@ paths:
|
|||||||
$ref: "#/definitions/ErrorEnvelope"
|
$ref: "#/definitions/ErrorEnvelope"
|
||||||
"502":
|
"502":
|
||||||
$ref: "#/responses/UpstreamError"
|
$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:
|
/api/v1/files/upload:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
@ -781,6 +835,41 @@ paths:
|
|||||||
$ref: "#/responses/Internal"
|
$ref: "#/responses/Internal"
|
||||||
"502":
|
"502":
|
||||||
$ref: "#/responses/UpstreamError"
|
$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:
|
/api/v1/rooms/leave:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
@ -1520,11 +1609,18 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
LoginPasswordRequest:
|
LoginPasswordRequest:
|
||||||
type: object
|
type: object
|
||||||
|
required:
|
||||||
|
- account
|
||||||
|
- password
|
||||||
|
- device_id
|
||||||
|
description: 账号就是用户当前有效短号。
|
||||||
properties:
|
properties:
|
||||||
display_user_id:
|
account:
|
||||||
type: string
|
type: string
|
||||||
|
example: "123456"
|
||||||
password:
|
password:
|
||||||
type: string
|
type: string
|
||||||
|
example: "1234567"
|
||||||
device_id:
|
device_id:
|
||||||
type: string
|
type: string
|
||||||
LoginThirdPartyRequest:
|
LoginThirdPartyRequest:
|
||||||
@ -1786,6 +1882,57 @@ definitions:
|
|||||||
- 0
|
- 0
|
||||||
- 1
|
- 1
|
||||||
description: 腾讯云 IM 原生回调响应;`ErrorCode=0` 允许,`ErrorCode=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:
|
UserIdentityData:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@ -2258,7 +2405,7 @@ definitions:
|
|||||||
- command_id
|
- command_id
|
||||||
- amount
|
- amount
|
||||||
- reason
|
- 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:
|
properties:
|
||||||
command_id:
|
command_id:
|
||||||
type: string
|
type: string
|
||||||
@ -2359,6 +2506,16 @@ definitions:
|
|||||||
description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等。
|
description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等。
|
||||||
role:
|
role:
|
||||||
type: string
|
type: string
|
||||||
|
RoomHeartbeatRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- room_id
|
||||||
|
properties:
|
||||||
|
room_id:
|
||||||
|
type: string
|
||||||
|
command_id:
|
||||||
|
type: string
|
||||||
|
description: 心跳命令幂等键;建议客户端每次心跳生成新值,HTTP 重试同一次心跳复用该值。
|
||||||
LeaveRoomRequest:
|
LeaveRoomRequest:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@ -2676,6 +2833,15 @@ definitions:
|
|||||||
$ref: "#/definitions/RoomUser"
|
$ref: "#/definitions/RoomUser"
|
||||||
room:
|
room:
|
||||||
$ref: "#/definitions/RoomSnapshot"
|
$ref: "#/definitions/RoomSnapshot"
|
||||||
|
RoomHeartbeatResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
result:
|
||||||
|
$ref: "#/definitions/CommandResult"
|
||||||
|
user:
|
||||||
|
$ref: "#/definitions/RoomUser"
|
||||||
|
room:
|
||||||
|
$ref: "#/definitions/RoomSnapshot"
|
||||||
LeaveRoomResponse:
|
LeaveRoomResponse:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@ -2923,6 +3089,13 @@ definitions:
|
|||||||
properties:
|
properties:
|
||||||
data:
|
data:
|
||||||
$ref: "#/definitions/JoinRoomResponse"
|
$ref: "#/definitions/JoinRoomResponse"
|
||||||
|
RoomHeartbeatEnvelope:
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
$ref: "#/definitions/RoomHeartbeatResponse"
|
||||||
LeaveRoomEnvelope:
|
LeaveRoomEnvelope:
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||||
|
|||||||
@ -228,19 +228,21 @@ room_presence_sweep_interval: "30s"
|
|||||||
| Case | Expected Behavior |
|
| Case | Expected Behavior |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| IM 短断 | room-service presence 保留,客户端重新登录腾讯云 IM 后重新进群 |
|
| IM 短断 | room-service presence 保留,客户端重新登录腾讯云 IM 后重新进群 |
|
||||||
| App 退后台后恢复 | 客户端先调用 `JoinRoom` 幂等刷新,再重新登录腾讯云 IM 并进群 |
|
| App 退后台后恢复 | 客户端先调用 `RoomHeartbeat` 刷新已有 presence;如果返回 `NOT_FOUND` 再重新 `JoinRoom` |
|
||||||
| 超过 stale timeout | room-service 自动清理 presence,客户端需要重新进房 |
|
| 超过 stale timeout | room-service 自动清理 presence,客户端需要重新进房 |
|
||||||
|
|
||||||
客户端重连标准流程:
|
客户端重连标准流程:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. 如果 access token 过期,先 RefreshToken
|
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
|
3. 调 POST /api/v1/im/usersig,拿腾讯云 IM UserSig
|
||||||
4. 使用腾讯云 IM SDK 登录
|
4. 使用腾讯云 IM SDK 登录
|
||||||
5. 加入 room_id 对应的腾讯云 IM 群
|
5. 加入 room_id 对应的腾讯云 IM 群
|
||||||
6. 拉取腾讯云 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` | 房间内业务用户 |
|
| `user_id` | 房间内业务用户 |
|
||||||
| `role` | owner, host, admin, audience |
|
| `role` | owner, host, admin, audience |
|
||||||
| `joined_at_ms` | 首次进入当前房间时间 |
|
| `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` 通过,腾讯云 IM 群收到房间消息 |
|
||||||
| 未 JoinRoom 直接进群或发言 | `VerifyRoomPresence` 或 `CheckSpeakPermission` 返回 `not_in_room` |
|
| 未 JoinRoom 直接进群或发言 | `VerifyRoomPresence` 或 `CheckSpeakPermission` 返回 `not_in_room` |
|
||||||
| Kick 后进群或发言 | 返回 `user_banned` |
|
| Kick 后进群或发言 | 返回 `user_banned` |
|
||||||
| IM 断开后 60 秒内重连 | JoinRoom 幂等刷新,腾讯云 IM 重新登录和进群成功,历史可拉 |
|
| IM 断开后 60 秒内重连 | RoomHeartbeat 刷新,腾讯云 IM 重新登录和进群成功,历史可拉 |
|
||||||
| 超过 stale timeout | presence 自动清理,需要重新 JoinRoom |
|
| 超过 stale timeout | presence 自动清理,需要重新 JoinRoom |
|
||||||
| room-service 重启恢复 | snapshot + command log 恢复后 JoinRoom/subscribe 继续可用 |
|
| 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。
|
1. 修正创建房间身份来源:gateway 不接收 body 的 owner/host,room-service 从 `RequestMeta.actor_user_id` 收敛 owner/default host。
|
||||||
2. 补齐 JoinRoom 幂等刷新 `last_seen_at_ms`。
|
2. 补齐 JoinRoom 幂等刷新 `last_seen_at_ms`。
|
||||||
3. 补齐 LeaveRoom 后对订阅守卫的拒绝行为测试。
|
3. 增加显式 RoomHeartbeat,只刷新已有 presence,不隐式进房。
|
||||||
4. 接入腾讯云 IM UserSig 签发、CreateRoom 建群和房间系统消息发布。
|
4. 补齐 LeaveRoom 后对订阅守卫的拒绝行为测试。
|
||||||
5. 增加 stale presence 清理 worker。
|
5. 接入腾讯云 IM UserSig 签发、CreateRoom 建群和房间系统消息发布。
|
||||||
6. 增加重连集成测试:JoinRoom -> UserSig -> IM login/join group -> disconnect -> reconnect -> history。
|
6. 增加 stale presence 清理 worker。
|
||||||
7. 增加 room-service 重启恢复测试。
|
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
|
## External HTTP API
|
||||||
|
|
||||||
新增 gateway 入口:
|
RTC token gateway 入口:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
POST /api/v1/rtc/token
|
POST /api/v1/rtc/token
|
||||||
@ -171,6 +171,53 @@ Response `data`:
|
|||||||
| RTC 配置缺失 | 500 | `INTERNAL_ERROR` | fail-closed,不返回假 token |
|
| RTC 配置缺失 | 500 | `INTERNAL_ERROR` | fail-closed,不返回假 token |
|
||||||
| room-service 不可用 | 502 | `UPSTREAM_ERROR` | gateway 不能自判 presence |
|
| 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
|
## Internal Flow
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@ -196,6 +243,18 @@ sequenceDiagram
|
|||||||
|
|
||||||
C->>T: enterRoom(SDKAppID, UserID, UserSig, strRoomId)
|
C->>T: enterRoom(SDKAppID, UserID, UserSig, strRoomId)
|
||||||
T-->>C: RTC room entered
|
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"
|
user_sig_ttl: "2h"
|
||||||
room_id_type: "string"
|
room_id_type: "string"
|
||||||
app_scene: "voice_chat_room"
|
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` 只能存在服务端配置或密钥系统,不能下发客户端。
|
- `secret_key` 只能存在服务端配置或密钥系统,不能下发客户端。
|
||||||
|
- `callback_sign_key` 只用于腾讯 RTC 回调验签,不要复用 UserSig `secret_key`。
|
||||||
- `tencent_rtc` 与 `tencent_im` 分开配置,避免误把 IM 应用和 RTC 应用混用。
|
- `tencent_rtc` 与 `tencent_im` 分开配置,避免误把 IM 应用和 RTC 应用混用。
|
||||||
- 如果腾讯控制台实际使用同一个应用承载 Chat/RTC,可以让两块配置填同一组 `sdk_app_id/secret_key`,但仍必须显式配置。
|
- 如果腾讯控制台实际使用同一个应用承载 Chat/RTC,可以让两块配置填同一组 `sdk_app_id/secret_key`,但仍必须显式配置。
|
||||||
- `user_sig_ttl` 不宜过长;建议 2h 到 24h,具体按客户端刷新能力和泄露窗口决定。
|
- `user_sig_ttl` 不宜过长;建议 2h 到 24h,具体按客户端刷新能力和泄露窗口决定。
|
||||||
|
|||||||
@ -54,8 +54,9 @@
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 创建房间 | `DONE` | `CreateRoom` 校验 room_id、actor、seat_count、mode;写 meta/command/outbox/snapshot | 真实 IM 建群失败注入和线上重试策略联调 |
|
| 创建房间 | `DONE` | `CreateRoom` 校验 room_id、actor、seat_count、mode;写 meta/command/outbox/snapshot | 真实 IM 建群失败注入和线上重试策略联调 |
|
||||||
| owner/host 收敛 | `DONE` | room-service 从 `actor_user_id` 默认 owner/host,拒绝外部冒用 | 后续转主持需独立命令 |
|
| owner/host 收敛 | `DONE` | room-service 从 `actor_user_id` 默认 owner/host,拒绝外部冒用 | 后续转主持需独立命令 |
|
||||||
| 进房 presence | `DONE` | `JoinRoom` 写 `OnlineUsers`,返回 user 和 snapshot | 客户端心跳或定期 Join 刷新策略需定稿 |
|
| 进房 presence | `DONE` | `JoinRoom` 写 `OnlineUsers`,返回 user 和 snapshot | 客户端断线重连自动化测试 |
|
||||||
| 重复 JoinRoom 刷新 | `DONE` | 重复进房刷新 `last_seen_at_ms`,不重复发进房事件 | 客户端断线重连自动化测试 |
|
| 显式心跳刷新 | `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 需联调 |
|
| 离房 | `DONE` | `LeaveRoom` 幂等,移除 presence,释放麦位,写 outbox | 客户端收到离房/被踢后退出 IM/RTC 需联调 |
|
||||||
| stale presence 清理 | `DONE` | `RunPresenceStaleWorker` 和 `SweepStalePresence` 存在 | 多实例下只扫描本节点已装载 Cell,需压测确认 |
|
| stale presence 清理 | `DONE` | `RunPresenceStaleWorker` 和 `SweepStalePresence` 存在 | 多实例下只扫描本节点已装载 Cell,需压测确认 |
|
||||||
| 房间恢复 | `DONE` | snapshot + command log + Redis lease 接管恢复测试存在 | 大房间 command log 回放性能和快照策略压测 |
|
| 房间恢复 | `DONE` | snapshot + command log + Redis lease 接管恢复测试存在 | 大房间 command log 回放性能和快照策略压测 |
|
||||||
@ -99,9 +100,10 @@
|
|||||||
| RTC room_id 映射 | `DONE` | 统一使用 TRTC `strRoomId`,校验 room_id 子集 | 当前入口 fail-closed,非法 room_id 不创建房间 |
|
| RTC room_id 映射 | `DONE` | 统一使用 TRTC `strRoomId`,校验 room_id 子集 | 当前入口 fail-closed,非法 room_id 不创建房间 |
|
||||||
| 进频道权限 | `DONE` | 签 RTC token 前调用 `VerifyRoomPresence` | 客户端必须按 JoinRoom 后再取 token |
|
| 进频道权限 | `DONE` | 签 RTC token 前调用 `VerifyRoomPresence` | 客户端必须按 JoinRoom 后再取 token |
|
||||||
| RTC 角色 | `PARTIAL` | token 返回默认 `audience` | 上麦后切 `anchor` 或 SDK role 切换未闭环 |
|
| 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 未验收 |
|
| 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` | 当前无设计和代码 | 后续接腾讯云审核/录制能力 |
|
| 音频审核/录制 | `TODO` | 当前无设计和代码 | 后续接腾讯云审核/录制能力 |
|
||||||
|
|
||||||
## Mic And Room Management
|
## 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"
|
user_sig_ttl: "2h"
|
||||||
room_id_type: "string"
|
room_id_type: "string"
|
||||||
app_scene: "voice_chat_room"
|
app_scene: "voice_chat_room"
|
||||||
|
callback_url: ""
|
||||||
|
callback_sign_key: ""
|
||||||
|
|
||||||
tencent-cos:
|
tencent-cos:
|
||||||
# Docker 本地联调 App 文件/头像上传;secret 只保存在 gateway 服务端配置。
|
# Docker 本地联调 App 文件/头像上传;secret 只保存在 gateway 服务端配置。
|
||||||
|
|||||||
@ -45,6 +45,10 @@ tencent_rtc:
|
|||||||
room_id_type: "string"
|
room_id_type: "string"
|
||||||
# 语音房场景固定使用 VoiceChatRoom,所有客户端必须保持一致。
|
# 语音房场景固定使用 VoiceChatRoom,所有客户端必须保持一致。
|
||||||
app_scene: "voice_chat_room"
|
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:
|
tencent-cos:
|
||||||
# App 文件/头像上传使用腾讯云 COS;SecretID/SecretKey 只允许保存在服务端配置。
|
# App 文件/头像上传使用腾讯云 COS;SecretID/SecretKey 只允许保存在服务端配置。
|
||||||
|
|||||||
@ -41,6 +41,10 @@ tencent_rtc:
|
|||||||
# 当前 room_id 是字符串,客户端只能使用 TRTC strRoomId,不能混用数字 roomId。
|
# 当前 room_id 是字符串,客户端只能使用 TRTC strRoomId,不能混用数字 roomId。
|
||||||
room_id_type: "string"
|
room_id_type: "string"
|
||||||
app_scene: "voice_chat_room"
|
app_scene: "voice_chat_room"
|
||||||
|
# 腾讯 RTC 控制台回调 URL。需要公网可访问;本地为空表示暂不启用回调。
|
||||||
|
callback_url: ""
|
||||||
|
# 腾讯 RTC 回调签名 key。启用回调后必须填写,gateway 会按原始 body 做 HMAC-SHA256 校验。
|
||||||
|
callback_sign_key: ""
|
||||||
|
|
||||||
tencent-cos:
|
tencent-cos:
|
||||||
# App 文件/头像上传使用腾讯云 COS;secret 只保存在 gateway 服务端配置。
|
# App 文件/头像上传使用腾讯云 COS;secret 只保存在 gateway 服务端配置。
|
||||||
|
|||||||
@ -91,12 +91,13 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
handler.SetAppConfigReader(appConfigReader)
|
handler.SetAppConfigReader(appConfigReader)
|
||||||
}
|
}
|
||||||
handler.SetTencentRTC(httptransport.TencentRTCConfig{
|
handler.SetTencentRTC(httptransport.TencentRTCConfig{
|
||||||
Enabled: cfg.TencentRTC.Enabled,
|
Enabled: cfg.TencentRTC.Enabled,
|
||||||
SDKAppID: cfg.TencentRTC.SDKAppID,
|
SDKAppID: cfg.TencentRTC.SDKAppID,
|
||||||
SecretKey: cfg.TencentRTC.SecretKey,
|
SecretKey: cfg.TencentRTC.SecretKey,
|
||||||
UserSigTTL: cfg.TencentRTC.UserSigTTL,
|
UserSigTTL: cfg.TencentRTC.UserSigTTL,
|
||||||
RoomIDType: cfg.TencentRTC.RoomIDType,
|
RoomIDType: cfg.TencentRTC.RoomIDType,
|
||||||
AppScene: cfg.TencentRTC.AppScene,
|
AppScene: cfg.TencentRTC.AppScene,
|
||||||
|
CallbackSignKey: cfg.TencentRTC.CallbackSignKey,
|
||||||
})
|
})
|
||||||
if cfg.TencentCOS.Enabled {
|
if cfg.TencentCOS.Enabled {
|
||||||
uploader, err := tencentcos.NewUploader(tencentcos.Config{
|
uploader, err := tencentcos.NewUploader(tencentcos.Config{
|
||||||
|
|||||||
@ -11,11 +11,13 @@ import (
|
|||||||
type RoomClient interface {
|
type RoomClient interface {
|
||||||
CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest) (*roomv1.CreateRoomResponse, error)
|
CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest) (*roomv1.CreateRoomResponse, error)
|
||||||
JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*roomv1.JoinRoomResponse, 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)
|
LeaveRoom(ctx context.Context, req *roomv1.LeaveRoomRequest) (*roomv1.LeaveRoomResponse, error)
|
||||||
MicUp(ctx context.Context, req *roomv1.MicUpRequest) (*roomv1.MicUpResponse, error)
|
MicUp(ctx context.Context, req *roomv1.MicUpRequest) (*roomv1.MicUpResponse, error)
|
||||||
MicDown(ctx context.Context, req *roomv1.MicDownRequest) (*roomv1.MicDownResponse, error)
|
MicDown(ctx context.Context, req *roomv1.MicDownRequest) (*roomv1.MicDownResponse, error)
|
||||||
ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatRequest) (*roomv1.ChangeMicSeatResponse, error)
|
ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatRequest) (*roomv1.ChangeMicSeatResponse, error)
|
||||||
ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmMicPublishingRequest) (*roomv1.ConfirmMicPublishingResponse, 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)
|
SetMicSeatLock(ctx context.Context, req *roomv1.SetMicSeatLockRequest) (*roomv1.SetMicSeatLockResponse, error)
|
||||||
SetChatEnabled(ctx context.Context, req *roomv1.SetChatEnabledRequest) (*roomv1.SetChatEnabledResponse, error)
|
SetChatEnabled(ctx context.Context, req *roomv1.SetChatEnabledRequest) (*roomv1.SetChatEnabledResponse, error)
|
||||||
SetRoomAdmin(ctx context.Context, req *roomv1.SetRoomAdminRequest) (*roomv1.SetRoomAdminResponse, 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)
|
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) {
|
func (c *grpcRoomClient) LeaveRoom(ctx context.Context, req *roomv1.LeaveRoomRequest) (*roomv1.LeaveRoomResponse, error) {
|
||||||
return c.client.LeaveRoom(ctx, req)
|
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)
|
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) {
|
func (c *grpcRoomClient) SetMicSeatLock(ctx context.Context, req *roomv1.SetMicSeatLockRequest) (*roomv1.SetMicSeatLockResponse, error) {
|
||||||
return c.client.SetMicSeatLock(ctx, req)
|
return c.client.SetMicSeatLock(ctx, req)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -85,6 +85,10 @@ type TencentRTCConfig struct {
|
|||||||
RoomIDType string `yaml:"room_id_type"`
|
RoomIDType string `yaml:"room_id_type"`
|
||||||
// AppScene 首版固定 voice_chat_room,所有端必须一致。
|
// AppScene 首版固定 voice_chat_room,所有端必须一致。
|
||||||
AppScene string `yaml:"app_scene"`
|
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 所需的服务端配置。
|
// 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.
|
// 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)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,20 +29,21 @@ type authTokenData struct {
|
|||||||
OnboardingStatus string `json:"onboarding_status"`
|
OnboardingStatus string `json:"onboarding_status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// loginPassword 处理 display_user_id + 密码登录。
|
// loginPassword 处理账号密码登录;账号就是当前有效短号。
|
||||||
func (h *Handler) loginPassword(writer http.ResponseWriter, request *http.Request) {
|
func (h *Handler) loginPassword(writer http.ResponseWriter, request *http.Request) {
|
||||||
var body struct {
|
var body struct {
|
||||||
DisplayUserID string `json:"display_user_id"`
|
Account string `json:"account"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
DeviceID string `json:"device_id"`
|
DeviceID string `json:"device_id"`
|
||||||
}
|
}
|
||||||
if !decode(writer, request, &body) {
|
if !decode(writer, request, &body) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
account := strings.TrimSpace(body.Account)
|
||||||
if !h.allowPublicAuthRequest(writer, request, authRateInput{
|
if !h.allowPublicAuthRequest(writer, request, authRateInput{
|
||||||
operation: authOperationPassword,
|
operation: authOperationPassword,
|
||||||
deviceID: body.DeviceID,
|
deviceID: body.DeviceID,
|
||||||
displayUserID: body.DisplayUserID,
|
displayUserID: account,
|
||||||
}) {
|
}) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -53,7 +54,7 @@ func (h *Handler) loginPassword(writer http.ResponseWriter, request *http.Reques
|
|||||||
|
|
||||||
resp, err := h.userClient.LoginPassword(request.Context(), &userv1.LoginPasswordRequest{
|
resp, err := h.userClient.LoginPassword(request.Context(), &userv1.LoginPasswordRequest{
|
||||||
Meta: authRequestMeta(request, body.DeviceID),
|
Meta: authRequestMeta(request, body.DeviceID),
|
||||||
DisplayUserId: body.DisplayUserID,
|
DisplayUserId: account,
|
||||||
Password: body.Password,
|
Password: body.Password,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -81,7 +81,7 @@ func TestAuthRateKeysHashUntrustedComponents(t *testing.T) {
|
|||||||
handler := NewHandler(&fakeRoomClient{}, &fakeUserAuthClient{})
|
handler := NewHandler(&fakeRoomClient{}, &fakeUserAuthClient{})
|
||||||
handler.SetAuthRateLimit(AuthRateLimitConfig{KeyPrefix: "test:"})
|
handler.SetAuthRateLimit(AuthRateLimitConfig{KeyPrefix: "test:"})
|
||||||
untrusted := strings.Repeat("x", 4096)
|
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)
|
request.Header.Set("X-Forwarded-For", untrusted)
|
||||||
|
|
||||||
keys := handler.authRateKeys(request, authRateInput{
|
keys := handler.authRateKeys(request, authRateInput{
|
||||||
@ -108,7 +108,7 @@ func TestAuthRateKeysHashUntrustedComponents(t *testing.T) {
|
|||||||
func TestPublicAuthRateLimitDisabledSkipsBackend(t *testing.T) {
|
func TestPublicAuthRateLimitDisabledSkipsBackend(t *testing.T) {
|
||||||
handler := NewHandler(&fakeRoomClient{}, &fakeUserAuthClient{})
|
handler := NewHandler(&fakeRoomClient{}, &fakeUserAuthClient{})
|
||||||
handler.SetAuthRateLimitBackend(AuthRateLimitConfig{Enabled: false}, &fakeDenyingAuthRateLimiter{})
|
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}) {
|
if !handler.allowPublicAuthRequest(httptest.NewRecorder(), request, authRateInput{operation: authOperationPassword}) {
|
||||||
t.Fatal("disabled auth rate limiter must skip backend")
|
t.Fatal("disabled auth rate limiter must skip backend")
|
||||||
|
|||||||
@ -46,12 +46,13 @@ type TencentIMConfig struct {
|
|||||||
|
|
||||||
// TencentRTCConfig 是 gateway transport 层签发客户端腾讯 RTC 进房票据所需的配置。
|
// TencentRTCConfig 是 gateway transport 层签发客户端腾讯 RTC 进房票据所需的配置。
|
||||||
type TencentRTCConfig struct {
|
type TencentRTCConfig struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
SDKAppID int64
|
SDKAppID int64
|
||||||
SecretKey string
|
SecretKey string
|
||||||
UserSigTTL time.Duration
|
UserSigTTL time.Duration
|
||||||
RoomIDType string
|
RoomIDType string
|
||||||
AppScene string
|
AppScene string
|
||||||
|
CallbackSignKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ObjectUploader 是 gateway 上传接口依赖的对象存储最小能力。
|
// ObjectUploader 是 gateway 上传接口依赖的对象存储最小能力。
|
||||||
|
|||||||
@ -3,6 +3,9 @@ package http
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
@ -29,11 +32,13 @@ import (
|
|||||||
type fakeRoomClient struct {
|
type fakeRoomClient struct {
|
||||||
lastCreate *roomv1.CreateRoomRequest
|
lastCreate *roomv1.CreateRoomRequest
|
||||||
lastJoin *roomv1.JoinRoomRequest
|
lastJoin *roomv1.JoinRoomRequest
|
||||||
|
lastHeartbeat *roomv1.RoomHeartbeatRequest
|
||||||
lastLeave *roomv1.LeaveRoomRequest
|
lastLeave *roomv1.LeaveRoomRequest
|
||||||
lastMicUp *roomv1.MicUpRequest
|
lastMicUp *roomv1.MicUpRequest
|
||||||
lastMicDown *roomv1.MicDownRequest
|
lastMicDown *roomv1.MicDownRequest
|
||||||
lastChangeMicSeat *roomv1.ChangeMicSeatRequest
|
lastChangeMicSeat *roomv1.ChangeMicSeatRequest
|
||||||
lastConfirmMic *roomv1.ConfirmMicPublishingRequest
|
lastConfirmMic *roomv1.ConfirmMicPublishingRequest
|
||||||
|
lastRTCEvent *roomv1.ApplyRTCEventRequest
|
||||||
lastMicSeatLock *roomv1.SetMicSeatLockRequest
|
lastMicSeatLock *roomv1.SetMicSeatLockRequest
|
||||||
lastChatEnabled *roomv1.SetChatEnabledRequest
|
lastChatEnabled *roomv1.SetChatEnabledRequest
|
||||||
lastSetAdmin *roomv1.SetRoomAdminRequest
|
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
|
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) {
|
func (f *fakeRoomClient) LeaveRoom(_ context.Context, req *roomv1.LeaveRoomRequest) (*roomv1.LeaveRoomResponse, error) {
|
||||||
f.lastLeave = req
|
f.lastLeave = req
|
||||||
return &roomv1.LeaveRoomResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 3}}, nil
|
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
|
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) {
|
func (f *fakeRoomClient) SetMicSeatLock(_ context.Context, req *roomv1.SetMicSeatLockRequest) (*roomv1.SetMicSeatLockResponse, error) {
|
||||||
f.lastMicSeatLock = req
|
f.lastMicSeatLock = req
|
||||||
return &roomv1.SetMicSeatLockResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 8}}, nil
|
return &roomv1.SetMicSeatLockResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 8}}, nil
|
||||||
@ -622,6 +637,15 @@ func TestRoomCommandsPropagateClientCommandID(t *testing.T) {
|
|||||||
return client.lastLeave.GetMeta()
|
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",
|
name: "mic_up",
|
||||||
path: "/api/v1/rooms/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{}
|
userClient := &fakeUserAuthClient{}
|
||||||
handler := NewHandler(&fakeRoomClient{}, userClient)
|
handler := NewHandler(&fakeRoomClient{}, userClient)
|
||||||
handler.SetAuthRateLimit(authRateLimitForTest(func(config *AuthRateLimitConfig) {
|
handler.SetAuthRateLimit(authRateLimitForTest(func(config *AuthRateLimitConfig) {
|
||||||
config.DisplayUserIDIPLimit = 1
|
config.DisplayUserIDIPLimit = 1
|
||||||
}))
|
}))
|
||||||
router := handler.Routes(auth.NewVerifier("secret"))
|
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++ {
|
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-Request-ID", "req-password-rate")
|
||||||
request.Header.Set("X-Forwarded-For", "203.0.113.20")
|
request.Header.Set("X-Forwarded-For", "203.0.113.20")
|
||||||
recorder := httptest.NewRecorder()
|
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) {
|
func TestRefreshRateLimitByDeviceID(t *testing.T) {
|
||||||
userClient := &fakeUserAuthClient{}
|
userClient := &fakeUserAuthClient{}
|
||||||
handler := NewHandler(&fakeRoomClient{}, userClient)
|
handler := NewHandler(&fakeRoomClient{}, userClient)
|
||||||
@ -1553,6 +1596,7 @@ func TestProfileGateRejectsIncompleteUsersForRoomIMRTCPaidCapabilities(t *testin
|
|||||||
{name: "room_list", method: http.MethodGet, path: "/api/v1/rooms"},
|
{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: "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: "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: "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: "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"}`},
|
{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)
|
router.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
assertEnvelope(t, recorder, http.StatusForbidden, codeProfileRequired, "req-profile-required")
|
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)
|
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) {
|
func TestAuthLoginMapsGRPCReason(t *testing.T) {
|
||||||
userClient := &fakeUserAuthClient{loginErr: xerr.ToGRPCError(xerr.New(xerr.AuthFailed, "authentication failed"))}
|
userClient := &fakeUserAuthClient{loginErr: xerr.ToGRPCError(xerr.New(xerr.AuthFailed, "authentication failed"))}
|
||||||
router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret"))
|
router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret"))
|
||||||
body := []byte(`{"display_user_id":"100001","password":"bad","device_id":"ios"}`)
|
body := []byte(`{"account":"100001","password":"bad","device_id":"ios"}`)
|
||||||
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-auth-failed")
|
request.Header.Set("X-Request-ID", "req-auth-failed")
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
@ -1986,6 +2030,73 @@ func TestTencentRTCTokenFailsClosedForConfigAndUpstreamErrors(t *testing.T) {
|
|||||||
assertEnvelope(t, upstreamRecorder, http.StatusBadGateway, codeUpstreamError, "req-rtc-upstream")
|
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) {
|
func TestTencentIMJoinCallbackUsesPresenceGuard(t *testing.T) {
|
||||||
guard := &fakeRoomGuardClient{}
|
guard := &fakeRoomGuardClient{}
|
||||||
router := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{
|
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 {
|
func authRateLimitForTest(mutator func(*AuthRateLimitConfig)) AuthRateLimitConfig {
|
||||||
config := AuthRateLimitConfig{
|
config := AuthRateLimitConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
|
|||||||
@ -143,6 +143,23 @@ func (h *Handler) joinRoom(writer http.ResponseWriter, request *http.Request) {
|
|||||||
write(writer, request, resp, err)
|
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 命令。
|
// leaveRoom 把退房 HTTP JSON 请求转换为 room-service LeaveRoom 命令。
|
||||||
func (h *Handler) leaveRoom(writer http.ResponseWriter, request *http.Request) {
|
func (h *Handler) leaveRoom(writer http.ResponseWriter, request *http.Request) {
|
||||||
var body struct {
|
var body struct {
|
||||||
|
|||||||
@ -13,7 +13,7 @@ const apiV1Prefix = "/api/v1"
|
|||||||
func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
||||||
mux := http.NewServeMux()
|
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/password/set", h.apiHandler(jwtVerifier, h.setPassword))
|
||||||
mux.Handle(apiV1Prefix+"/auth/third-party/login", h.publicAPIHandler(h.loginThirdParty))
|
mux.Handle(apiV1Prefix+"/auth/third-party/login", h.publicAPIHandler(h.loginThirdParty))
|
||||||
mux.Handle(apiV1Prefix+"/auth/token/refresh", h.publicAPIHandler(h.refreshToken))
|
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", h.profileAPIHandler(jwtVerifier, h.listRooms))
|
||||||
mux.Handle(apiV1Prefix+"/rooms/create", h.profileAPIHandler(jwtVerifier, h.createRoom))
|
mux.Handle(apiV1Prefix+"/rooms/create", h.profileAPIHandler(jwtVerifier, h.createRoom))
|
||||||
mux.Handle(apiV1Prefix+"/rooms/join", h.profileAPIHandler(jwtVerifier, h.joinRoom))
|
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/leave", h.profileAPIHandler(jwtVerifier, h.leaveRoom))
|
||||||
mux.Handle(apiV1Prefix+"/rooms/mic/up", h.profileAPIHandler(jwtVerifier, h.micUp))
|
mux.Handle(apiV1Prefix+"/rooms/mic/up", h.profileAPIHandler(jwtVerifier, h.micUp))
|
||||||
mux.Handle(apiV1Prefix+"/rooms/mic/down", h.profileAPIHandler(jwtVerifier, h.micDown))
|
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/kick", h.profileAPIHandler(jwtVerifier, h.kickUser))
|
||||||
mux.Handle(apiV1Prefix+"/rooms/user/unban", h.profileAPIHandler(jwtVerifier, h.unbanUser))
|
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+"/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/me/balances", h.profileAPIHandler(jwtVerifier, h.getMyBalances))
|
||||||
mux.Handle(apiV1Prefix+"/wallet/coin-seller/transfer", h.profileAPIHandler(jwtVerifier, h.transferCoinFromSeller))
|
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 返回命令类型。
|
// Type 返回命令类型。
|
||||||
func (JoinRoom) Type() string { return "join_room" }
|
func (JoinRoom) Type() string { return "join_room" }
|
||||||
|
|
||||||
|
// RoomHeartbeat 定义房间业务 presence 显式心跳。
|
||||||
|
type RoomHeartbeat struct {
|
||||||
|
Base
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type 返回命令类型。
|
||||||
|
func (RoomHeartbeat) Type() string { return "room_heartbeat" }
|
||||||
|
|
||||||
// LeaveRoom 定义业务离房请求。
|
// LeaveRoom 定义业务离房请求。
|
||||||
type LeaveRoom struct {
|
type LeaveRoom struct {
|
||||||
Base
|
Base
|
||||||
@ -143,6 +151,26 @@ type ConfirmMicPublishing struct {
|
|||||||
// Type 返回命令类型。
|
// Type 返回命令类型。
|
||||||
func (ConfirmMicPublishing) Type() string { return "confirm_mic_publishing" }
|
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 定义换麦位请求。
|
// ChangeMicSeat 定义换麦位请求。
|
||||||
type ChangeMicSeat struct {
|
type ChangeMicSeat struct {
|
||||||
Base
|
Base
|
||||||
@ -302,6 +330,8 @@ func Deserialize(commandType string, payload []byte) (Command, error) {
|
|||||||
cmd = &CreateRoom{}
|
cmd = &CreateRoom{}
|
||||||
case JoinRoom{}.Type():
|
case JoinRoom{}.Type():
|
||||||
cmd = &JoinRoom{}
|
cmd = &JoinRoom{}
|
||||||
|
case RoomHeartbeat{}.Type():
|
||||||
|
cmd = &RoomHeartbeat{}
|
||||||
case LeaveRoom{}.Type():
|
case LeaveRoom{}.Type():
|
||||||
cmd = &LeaveRoom{}
|
cmd = &LeaveRoom{}
|
||||||
case MicUp{}.Type():
|
case MicUp{}.Type():
|
||||||
@ -310,6 +340,8 @@ func Deserialize(commandType string, payload []byte) (Command, error) {
|
|||||||
cmd = &MicDown{}
|
cmd = &MicDown{}
|
||||||
case ConfirmMicPublishing{}.Type():
|
case ConfirmMicPublishing{}.Type():
|
||||||
cmd = &ConfirmMicPublishing{}
|
cmd = &ConfirmMicPublishing{}
|
||||||
|
case ApplyRTCEvent{}.Type():
|
||||||
|
cmd = &ApplyRTCEvent{}
|
||||||
case ChangeMicSeat{}.Type():
|
case ChangeMicSeat{}.Type():
|
||||||
cmd = &ChangeMicSeat{}
|
cmd = &ChangeMicSeat{}
|
||||||
case SetMicSeatLock{}.Type():
|
case SetMicSeatLock{}.Type():
|
||||||
|
|||||||
@ -94,6 +94,48 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
|
|||||||
}, nil
|
}, 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 把用户从房间业务态移出。
|
// LeaveRoom 把用户从房间业务态移出。
|
||||||
func (s *Service) LeaveRoom(ctx context.Context, req *roomv1.LeaveRoomRequest) (*roomv1.LeaveRoomResponse, error) {
|
func (s *Service) LeaveRoom(ctx context.Context, req *roomv1.LeaveRoomRequest) (*roomv1.LeaveRoomResponse, error) {
|
||||||
ctx = contextFromMeta(ctx, req.GetMeta())
|
ctx = contextFromMeta(ctx, req.GetMeta())
|
||||||
|
|||||||
@ -135,6 +135,14 @@ func replay(current *state.RoomState, cmd command.Command) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
current.Version++
|
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:
|
case *command.LeaveRoom:
|
||||||
// 离房回放只删除业务 presence,不涉及腾讯云 IM 连接态。
|
// 离房回放只删除业务 presence,不涉及腾讯云 IM 连接态。
|
||||||
delete(current.OnlineUsers, typed.ActorUserID())
|
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].PublishState = state.MicPublishPublishing
|
||||||
current.MicSeats[index].LastPublishEventTimeMS = typed.EventTimeMS
|
current.MicSeats[index].LastPublishEventTimeMS = typed.EventTimeMS
|
||||||
current.Version++
|
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:
|
case *command.ChangeMicSeat:
|
||||||
from, exists := current.SeatByUser(typed.TargetUserID)
|
from, exists := current.SeatByUser(typed.TargetUserID)
|
||||||
if !exists {
|
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) {
|
func TestMicPublishTimeoutMicDownsPendingSession(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repository := mysqltest.NewRepository(t)
|
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 和麦位占用。
|
// TestLeaveRoomReleasesSeatAndBlocksGuards 验证离房同时移除 presence 和麦位占用。
|
||||||
func TestLeaveRoomReleasesSeatAndBlocksGuards(t *testing.T) {
|
func TestLeaveRoomReleasesSeatAndBlocksGuards(t *testing.T) {
|
||||||
ctx := context.Background()
|
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))
|
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 代理到领域服务。
|
// LeaveRoom 代理到领域服务。
|
||||||
func (s *Server) LeaveRoom(ctx context.Context, req *roomv1.LeaveRoomRequest) (*roomv1.LeaveRoomResponse, error) {
|
func (s *Server) LeaveRoom(ctx context.Context, req *roomv1.LeaveRoomRequest) (*roomv1.LeaveRoomResponse, error) {
|
||||||
// LeaveRoom 不直接操作腾讯云 IM 连接,只修改房间业务态。
|
// LeaveRoom 不直接操作腾讯云 IM 连接,只修改房间业务态。
|
||||||
@ -89,6 +96,13 @@ func (s *Server) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmMi
|
|||||||
return mapServiceResult(s.svc.ConfirmMicPublishing(ctx, req))
|
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 代理到领域服务。
|
// SetMicSeatLock 代理到领域服务。
|
||||||
func (s *Server) SetMicSeatLock(ctx context.Context, req *roomv1.SetMicSeatLockRequest) (*roomv1.SetMicSeatLockResponse, error) {
|
func (s *Server) SetMicSeatLock(ctx context.Context, req *roomv1.SetMicSeatLockRequest) (*roomv1.SetMicSeatLockResponse, error) {
|
||||||
// 锁麦权限和目标麦位状态由 Room Cell 判定。
|
// 锁麦权限和目标麦位状态由 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_request_id (app_code, request_id),
|
||||||
KEY idx_login_audit_created_at (app_code, created_at_ms)
|
KEY idx_login_audit_created_at (app_code, created_at_ms)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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