主播结算相关和房间锁房
This commit is contained in:
parent
8b2181dee7
commit
82dfb9238f
@ -875,11 +875,13 @@ func (x *RoomChatEnabledChanged) GetEnabled() bool {
|
||||
|
||||
// RoomPasswordChanged 表达房间入房密码状态变更,不携带明文或哈希。
|
||||
type RoomPasswordChanged struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ActorUserId int64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||
Locked bool `protobuf:"varint,2,opt,name=locked,proto3" json:"locked,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ActorUserId int64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||
Locked bool `protobuf:"varint,2,opt,name=locked,proto3" json:"locked,omitempty"`
|
||||
// visible_region_id 是房间当前可见区域,activity-service 用它把锁房变化投到区域播报群。
|
||||
VisibleRegionId int64 `protobuf:"varint,3,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *RoomPasswordChanged) Reset() {
|
||||
@ -926,6 +928,13 @@ func (x *RoomPasswordChanged) GetLocked() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *RoomPasswordChanged) GetVisibleRegionId() int64 {
|
||||
if x != nil {
|
||||
return x.VisibleRegionId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// RoomAdminChanged 表达房间管理员集合变更。
|
||||
type RoomAdminChanged struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@ -2001,10 +2010,11 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
|
||||
"\x06locked\x18\x03 \x01(\bR\x06locked\"V\n" +
|
||||
"\x16RoomChatEnabledChanged\x12\"\n" +
|
||||
"\ractor_user_id\x18\x01 \x01(\x03R\vactorUserId\x12\x18\n" +
|
||||
"\aenabled\x18\x02 \x01(\bR\aenabled\"Q\n" +
|
||||
"\aenabled\x18\x02 \x01(\bR\aenabled\"}\n" +
|
||||
"\x13RoomPasswordChanged\x12\"\n" +
|
||||
"\ractor_user_id\x18\x01 \x01(\x03R\vactorUserId\x12\x16\n" +
|
||||
"\x06locked\x18\x02 \x01(\bR\x06locked\"v\n" +
|
||||
"\x06locked\x18\x02 \x01(\bR\x06locked\x12*\n" +
|
||||
"\x11visible_region_id\x18\x03 \x01(\x03R\x0fvisibleRegionId\"v\n" +
|
||||
"\x10RoomAdminChanged\x12\"\n" +
|
||||
"\ractor_user_id\x18\x01 \x01(\x03R\vactorUserId\x12$\n" +
|
||||
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x18\n" +
|
||||
|
||||
@ -112,6 +112,8 @@ message RoomChatEnabledChanged {
|
||||
message RoomPasswordChanged {
|
||||
int64 actor_user_id = 1;
|
||||
bool locked = 2;
|
||||
// visible_region_id 是房间当前可见区域,activity-service 用它把锁房变化投到区域播报群。
|
||||
int64 visible_region_id = 3;
|
||||
}
|
||||
|
||||
// RoomAdminChanged 表达房间管理员集合变更。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -65,6 +65,8 @@ message SeatState {
|
||||
bool mic_muted = 9;
|
||||
// seat_status 是服务端派生的展示状态:empty/locked/occupied/publishing/muted。
|
||||
string seat_status = 10;
|
||||
// mic_heartbeat_at_ms 是当前 mic_session 最近一次服务端接受麦上心跳的 UTC epoch ms。
|
||||
int64 mic_heartbeat_at_ms = 11;
|
||||
}
|
||||
|
||||
// RankItem 表达房间内本地礼物榜项目。
|
||||
@ -632,6 +634,23 @@ message ConfirmMicPublishingResponse {
|
||||
RoomSnapshot room = 3;
|
||||
}
|
||||
|
||||
// MicHeartbeatRequest 刷新当前 publishing 麦位会话的服务端麦上心跳。
|
||||
message MicHeartbeatRequest {
|
||||
RequestMeta meta = 1;
|
||||
// target_user_id 为空时默认刷新 actor_user_id 自己的麦上会话。
|
||||
int64 target_user_id = 2;
|
||||
// mic_session_id 必须匹配当前麦位上的会话,避免旧心跳续住新会话。
|
||||
string mic_session_id = 3;
|
||||
}
|
||||
|
||||
// MicHeartbeatResponse 返回刷新后的麦位和房间快照。
|
||||
message MicHeartbeatResponse {
|
||||
CommandResult result = 1;
|
||||
int32 seat_no = 2;
|
||||
RoomSnapshot room = 3;
|
||||
int64 mic_heartbeat_at_ms = 4;
|
||||
}
|
||||
|
||||
// SetMicMuteRequest 修改麦位上的服务端可见静音态。
|
||||
message SetMicMuteRequest {
|
||||
RequestMeta meta = 1;
|
||||
@ -1089,6 +1108,7 @@ service RoomCommandService {
|
||||
rpc MicDown(MicDownRequest) returns (MicDownResponse);
|
||||
rpc ChangeMicSeat(ChangeMicSeatRequest) returns (ChangeMicSeatResponse);
|
||||
rpc ConfirmMicPublishing(ConfirmMicPublishingRequest) returns (ConfirmMicPublishingResponse);
|
||||
rpc MicHeartbeat(MicHeartbeatRequest) returns (MicHeartbeatResponse);
|
||||
rpc SetMicMute(SetMicMuteRequest) returns (SetMicMuteResponse);
|
||||
rpc ApplyRTCEvent(ApplyRTCEventRequest) returns (ApplyRTCEventResponse);
|
||||
rpc SetMicSeatLock(SetMicSeatLockRequest) returns (SetMicSeatLockResponse);
|
||||
|
||||
@ -37,6 +37,7 @@ const (
|
||||
RoomCommandService_MicDown_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicDown"
|
||||
RoomCommandService_ChangeMicSeat_FullMethodName = "/hyapp.room.v1.RoomCommandService/ChangeMicSeat"
|
||||
RoomCommandService_ConfirmMicPublishing_FullMethodName = "/hyapp.room.v1.RoomCommandService/ConfirmMicPublishing"
|
||||
RoomCommandService_MicHeartbeat_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicHeartbeat"
|
||||
RoomCommandService_SetMicMute_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetMicMute"
|
||||
RoomCommandService_ApplyRTCEvent_FullMethodName = "/hyapp.room.v1.RoomCommandService/ApplyRTCEvent"
|
||||
RoomCommandService_SetMicSeatLock_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetMicSeatLock"
|
||||
@ -76,6 +77,7 @@ type RoomCommandServiceClient interface {
|
||||
MicDown(ctx context.Context, in *MicDownRequest, opts ...grpc.CallOption) (*MicDownResponse, error)
|
||||
ChangeMicSeat(ctx context.Context, in *ChangeMicSeatRequest, opts ...grpc.CallOption) (*ChangeMicSeatResponse, error)
|
||||
ConfirmMicPublishing(ctx context.Context, in *ConfirmMicPublishingRequest, opts ...grpc.CallOption) (*ConfirmMicPublishingResponse, error)
|
||||
MicHeartbeat(ctx context.Context, in *MicHeartbeatRequest, opts ...grpc.CallOption) (*MicHeartbeatResponse, error)
|
||||
SetMicMute(ctx context.Context, in *SetMicMuteRequest, opts ...grpc.CallOption) (*SetMicMuteResponse, error)
|
||||
ApplyRTCEvent(ctx context.Context, in *ApplyRTCEventRequest, opts ...grpc.CallOption) (*ApplyRTCEventResponse, error)
|
||||
SetMicSeatLock(ctx context.Context, in *SetMicSeatLockRequest, opts ...grpc.CallOption) (*SetMicSeatLockResponse, error)
|
||||
@ -279,6 +281,16 @@ func (c *roomCommandServiceClient) ConfirmMicPublishing(ctx context.Context, in
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomCommandServiceClient) MicHeartbeat(ctx context.Context, in *MicHeartbeatRequest, opts ...grpc.CallOption) (*MicHeartbeatResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(MicHeartbeatResponse)
|
||||
err := c.cc.Invoke(ctx, RoomCommandService_MicHeartbeat_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomCommandServiceClient) SetMicMute(ctx context.Context, in *SetMicMuteRequest, opts ...grpc.CallOption) (*SetMicMuteResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(SetMicMuteResponse)
|
||||
@ -433,6 +445,7 @@ type RoomCommandServiceServer interface {
|
||||
MicDown(context.Context, *MicDownRequest) (*MicDownResponse, error)
|
||||
ChangeMicSeat(context.Context, *ChangeMicSeatRequest) (*ChangeMicSeatResponse, error)
|
||||
ConfirmMicPublishing(context.Context, *ConfirmMicPublishingRequest) (*ConfirmMicPublishingResponse, error)
|
||||
MicHeartbeat(context.Context, *MicHeartbeatRequest) (*MicHeartbeatResponse, error)
|
||||
SetMicMute(context.Context, *SetMicMuteRequest) (*SetMicMuteResponse, error)
|
||||
ApplyRTCEvent(context.Context, *ApplyRTCEventRequest) (*ApplyRTCEventResponse, error)
|
||||
SetMicSeatLock(context.Context, *SetMicSeatLockRequest) (*SetMicSeatLockResponse, error)
|
||||
@ -510,6 +523,9 @@ func (UnimplementedRoomCommandServiceServer) ChangeMicSeat(context.Context, *Cha
|
||||
func (UnimplementedRoomCommandServiceServer) ConfirmMicPublishing(context.Context, *ConfirmMicPublishingRequest) (*ConfirmMicPublishingResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ConfirmMicPublishing not implemented")
|
||||
}
|
||||
func (UnimplementedRoomCommandServiceServer) MicHeartbeat(context.Context, *MicHeartbeatRequest) (*MicHeartbeatResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method MicHeartbeat not implemented")
|
||||
}
|
||||
func (UnimplementedRoomCommandServiceServer) SetMicMute(context.Context, *SetMicMuteRequest) (*SetMicMuteResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method SetMicMute not implemented")
|
||||
}
|
||||
@ -894,6 +910,24 @@ func _RoomCommandService_ConfirmMicPublishing_Handler(srv interface{}, ctx conte
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomCommandService_MicHeartbeat_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(MicHeartbeatRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(RoomCommandServiceServer).MicHeartbeat(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: RoomCommandService_MicHeartbeat_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(RoomCommandServiceServer).MicHeartbeat(ctx, req.(*MicHeartbeatRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomCommandService_SetMicMute_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SetMicMuteRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -1207,6 +1241,10 @@ var RoomCommandService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "ConfirmMicPublishing",
|
||||
Handler: _RoomCommandService_ConfirmMicPublishing_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "MicHeartbeat",
|
||||
Handler: _RoomCommandService_MicHeartbeat_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "SetMicMute",
|
||||
Handler: _RoomCommandService_SetMicMute_Handler,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -104,6 +104,66 @@ message GetBalancesResponse {
|
||||
repeated AssetBalance balances = 1;
|
||||
}
|
||||
|
||||
message HostSalaryPolicyLevel {
|
||||
int32 level_no = 1;
|
||||
int64 required_diamonds = 2;
|
||||
int64 host_salary_usd_minor = 3;
|
||||
int64 host_coin_reward = 4;
|
||||
int64 agency_salary_usd_minor = 5;
|
||||
string status = 6;
|
||||
int32 sort_order = 7;
|
||||
}
|
||||
|
||||
message HostSalaryPolicy {
|
||||
uint64 policy_id = 1;
|
||||
string name = 2;
|
||||
int64 region_id = 3;
|
||||
string status = 4;
|
||||
string settlement_mode = 5;
|
||||
string settlement_trigger_mode = 6;
|
||||
string gift_coin_to_diamond_ratio = 7;
|
||||
string residual_diamond_to_usd_rate = 8;
|
||||
int64 effective_from_ms = 9;
|
||||
int64 effective_to_ms = 10;
|
||||
repeated HostSalaryPolicyLevel levels = 11;
|
||||
}
|
||||
|
||||
message GetActiveHostSalaryPolicyRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
int64 region_id = 3;
|
||||
string settlement_mode = 4;
|
||||
string settlement_trigger_mode = 5;
|
||||
int64 now_ms = 6;
|
||||
}
|
||||
|
||||
message GetActiveHostSalaryPolicyResponse {
|
||||
bool found = 1;
|
||||
HostSalaryPolicy policy = 2;
|
||||
}
|
||||
|
||||
message HostSalaryProgress {
|
||||
int64 host_user_id = 1;
|
||||
string cycle_key = 2;
|
||||
int64 region_id = 3;
|
||||
int64 agency_owner_user_id = 4;
|
||||
int64 total_diamonds = 5;
|
||||
int64 gift_diamond_total = 6;
|
||||
int64 updated_at_ms = 7;
|
||||
}
|
||||
|
||||
message GetHostSalaryProgressRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
int64 host_user_id = 3;
|
||||
string cycle_key = 4;
|
||||
int64 now_ms = 5;
|
||||
}
|
||||
|
||||
message GetHostSalaryProgressResponse {
|
||||
HostSalaryProgress progress = 1;
|
||||
}
|
||||
|
||||
// AdminCreditAssetRequest 是后台手动入账/调账的最小审计命令。
|
||||
message AdminCreditAssetRequest {
|
||||
string command_id = 1;
|
||||
@ -1437,6 +1497,8 @@ service WalletService {
|
||||
rpc DebitGift(DebitGiftRequest) returns (DebitGiftResponse);
|
||||
rpc BatchDebitGift(BatchDebitGiftRequest) returns (BatchDebitGiftResponse);
|
||||
rpc GetBalances(GetBalancesRequest) returns (GetBalancesResponse);
|
||||
rpc GetActiveHostSalaryPolicy(GetActiveHostSalaryPolicyRequest) returns (GetActiveHostSalaryPolicyResponse);
|
||||
rpc GetHostSalaryProgress(GetHostSalaryProgressRequest) returns (GetHostSalaryProgressResponse);
|
||||
rpc AdminCreditAsset(AdminCreditAssetRequest) returns (AdminCreditAssetResponse);
|
||||
rpc AdminCreditCoinSellerStock(AdminCreditCoinSellerStockRequest) returns (AdminCreditCoinSellerStockResponse);
|
||||
rpc TransferCoinFromSeller(TransferCoinFromSellerRequest) returns (TransferCoinFromSellerResponse);
|
||||
|
||||
@ -204,6 +204,8 @@ const (
|
||||
WalletService_DebitGift_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitGift"
|
||||
WalletService_BatchDebitGift_FullMethodName = "/hyapp.wallet.v1.WalletService/BatchDebitGift"
|
||||
WalletService_GetBalances_FullMethodName = "/hyapp.wallet.v1.WalletService/GetBalances"
|
||||
WalletService_GetActiveHostSalaryPolicy_FullMethodName = "/hyapp.wallet.v1.WalletService/GetActiveHostSalaryPolicy"
|
||||
WalletService_GetHostSalaryProgress_FullMethodName = "/hyapp.wallet.v1.WalletService/GetHostSalaryProgress"
|
||||
WalletService_AdminCreditAsset_FullMethodName = "/hyapp.wallet.v1.WalletService/AdminCreditAsset"
|
||||
WalletService_AdminCreditCoinSellerStock_FullMethodName = "/hyapp.wallet.v1.WalletService/AdminCreditCoinSellerStock"
|
||||
WalletService_TransferCoinFromSeller_FullMethodName = "/hyapp.wallet.v1.WalletService/TransferCoinFromSeller"
|
||||
@ -274,6 +276,8 @@ type WalletServiceClient interface {
|
||||
DebitGift(ctx context.Context, in *DebitGiftRequest, opts ...grpc.CallOption) (*DebitGiftResponse, error)
|
||||
BatchDebitGift(ctx context.Context, in *BatchDebitGiftRequest, opts ...grpc.CallOption) (*BatchDebitGiftResponse, error)
|
||||
GetBalances(ctx context.Context, in *GetBalancesRequest, opts ...grpc.CallOption) (*GetBalancesResponse, error)
|
||||
GetActiveHostSalaryPolicy(ctx context.Context, in *GetActiveHostSalaryPolicyRequest, opts ...grpc.CallOption) (*GetActiveHostSalaryPolicyResponse, error)
|
||||
GetHostSalaryProgress(ctx context.Context, in *GetHostSalaryProgressRequest, opts ...grpc.CallOption) (*GetHostSalaryProgressResponse, error)
|
||||
AdminCreditAsset(ctx context.Context, in *AdminCreditAssetRequest, opts ...grpc.CallOption) (*AdminCreditAssetResponse, error)
|
||||
AdminCreditCoinSellerStock(ctx context.Context, in *AdminCreditCoinSellerStockRequest, opts ...grpc.CallOption) (*AdminCreditCoinSellerStockResponse, error)
|
||||
TransferCoinFromSeller(ctx context.Context, in *TransferCoinFromSellerRequest, opts ...grpc.CallOption) (*TransferCoinFromSellerResponse, error)
|
||||
@ -373,6 +377,26 @@ func (c *walletServiceClient) GetBalances(ctx context.Context, in *GetBalancesRe
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GetActiveHostSalaryPolicy(ctx context.Context, in *GetActiveHostSalaryPolicyRequest, opts ...grpc.CallOption) (*GetActiveHostSalaryPolicyResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetActiveHostSalaryPolicyResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_GetActiveHostSalaryPolicy_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GetHostSalaryProgress(ctx context.Context, in *GetHostSalaryProgressRequest, opts ...grpc.CallOption) (*GetHostSalaryProgressResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetHostSalaryProgressResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_GetHostSalaryProgress_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) AdminCreditAsset(ctx context.Context, in *AdminCreditAssetRequest, opts ...grpc.CallOption) (*AdminCreditAssetResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AdminCreditAssetResponse)
|
||||
@ -972,6 +996,8 @@ type WalletServiceServer interface {
|
||||
DebitGift(context.Context, *DebitGiftRequest) (*DebitGiftResponse, error)
|
||||
BatchDebitGift(context.Context, *BatchDebitGiftRequest) (*BatchDebitGiftResponse, error)
|
||||
GetBalances(context.Context, *GetBalancesRequest) (*GetBalancesResponse, error)
|
||||
GetActiveHostSalaryPolicy(context.Context, *GetActiveHostSalaryPolicyRequest) (*GetActiveHostSalaryPolicyResponse, error)
|
||||
GetHostSalaryProgress(context.Context, *GetHostSalaryProgressRequest) (*GetHostSalaryProgressResponse, error)
|
||||
AdminCreditAsset(context.Context, *AdminCreditAssetRequest) (*AdminCreditAssetResponse, error)
|
||||
AdminCreditCoinSellerStock(context.Context, *AdminCreditCoinSellerStockRequest) (*AdminCreditCoinSellerStockResponse, error)
|
||||
TransferCoinFromSeller(context.Context, *TransferCoinFromSellerRequest) (*TransferCoinFromSellerResponse, error)
|
||||
@ -1050,6 +1076,12 @@ func (UnimplementedWalletServiceServer) BatchDebitGift(context.Context, *BatchDe
|
||||
func (UnimplementedWalletServiceServer) GetBalances(context.Context, *GetBalancesRequest) (*GetBalancesResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetBalances not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GetActiveHostSalaryPolicy(context.Context, *GetActiveHostSalaryPolicyRequest) (*GetActiveHostSalaryPolicyResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetActiveHostSalaryPolicy not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GetHostSalaryProgress(context.Context, *GetHostSalaryProgressRequest) (*GetHostSalaryProgressResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetHostSalaryProgress not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) AdminCreditAsset(context.Context, *AdminCreditAssetRequest) (*AdminCreditAssetResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AdminCreditAsset not implemented")
|
||||
}
|
||||
@ -1302,6 +1334,42 @@ func _WalletService_GetBalances_Handler(srv interface{}, ctx context.Context, de
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GetActiveHostSalaryPolicy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetActiveHostSalaryPolicyRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).GetActiveHostSalaryPolicy(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_GetActiveHostSalaryPolicy_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).GetActiveHostSalaryPolicy(ctx, req.(*GetActiveHostSalaryPolicyRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GetHostSalaryProgress_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetHostSalaryProgressRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).GetHostSalaryProgress(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_GetHostSalaryProgress_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).GetHostSalaryProgress(ctx, req.(*GetHostSalaryProgressRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_AdminCreditAsset_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AdminCreditAssetRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -2383,6 +2451,14 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "GetBalances",
|
||||
Handler: _WalletService_GetBalances_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetActiveHostSalaryPolicy",
|
||||
Handler: _WalletService_GetActiveHostSalaryPolicy_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetHostSalaryProgress",
|
||||
Handler: _WalletService_GetHostSalaryProgress_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AdminCreditAsset",
|
||||
Handler: _WalletService_AdminCreditAsset_Handler,
|
||||
|
||||
88
docs/Flutter麦上心跳接口.md
Normal file
88
docs/Flutter麦上心跳接口.md
Normal file
@ -0,0 +1,88 @@
|
||||
# Flutter 麦上心跳接口
|
||||
|
||||
## 地址
|
||||
|
||||
```http
|
||||
POST /api/v1/rooms/mic/heartbeat
|
||||
Authorization: Bearer <access_token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `room_id` | string | 是 | 房间 ID。 |
|
||||
| `command_id` | string | 是 | 本次心跳命令 ID。同一次请求重试复用同一个值,下一次心跳换新的值。 |
|
||||
| `target_user_id` | int64 | 否 | 传 `0` 或不传表示当前登录用户自己。 |
|
||||
| `mic_session_id` | string | 是 | 当前麦位会话 ID,必须使用上麦返回的 `mic_session_id`。 |
|
||||
|
||||
请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"room_id": "lalu_xxx",
|
||||
"command_id": "cmd_mic_heartbeat_lalu_xxx_123_001",
|
||||
"target_user_id": 0,
|
||||
"mic_session_id": "mic_xxx"
|
||||
}
|
||||
```
|
||||
|
||||
## 返回值
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "OK",
|
||||
"request_id": "req_xxx",
|
||||
"data": {
|
||||
"result": {
|
||||
"applied": true,
|
||||
"room_version": 18,
|
||||
"server_time_ms": 1778000005000
|
||||
},
|
||||
"seat_no": 1,
|
||||
"mic_heartbeat_at_ms": 1778000005000,
|
||||
"room": {
|
||||
"room_id": "lalu_xxx",
|
||||
"version": 18,
|
||||
"mic_seats": [
|
||||
{
|
||||
"seat_no": 1,
|
||||
"user_id": 123,
|
||||
"publish_state": "publishing",
|
||||
"mic_session_id": "mic_xxx",
|
||||
"mic_heartbeat_at_ms": 1778000005000,
|
||||
"seat_status": "publishing"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `data.seat_no` | 当前用户所在麦位。 |
|
||||
| `data.mic_heartbeat_at_ms` | 服务端接受本次麦上心跳的时间,UTC epoch milliseconds。 |
|
||||
| `data.room` | 刷新后的房间快照。 |
|
||||
| `data.room.mic_seats[].mic_heartbeat_at_ms` | 麦位最近一次服务端接受心跳的时间。 |
|
||||
| `data.room.mic_seats[].publish_state` | `publishing` 表示当前会话已确认发流,可以继续心跳。 |
|
||||
| `data.room.mic_seats[].seat_status` | 展示状态,常见值为 `publishing`、`muted`、`occupied`、`empty`、`locked`。 |
|
||||
|
||||
## 相关 IM
|
||||
|
||||
| IM | 是否新增 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 无 | 否 | 麦上心跳不广播独立 IM。 |
|
||||
| `room_mic_publish_confirmed` | 已有 | 发流确认成功后进入 `publishing`,Flutter 收到后可以开始麦上心跳。 |
|
||||
| `room_mic_down` | 已有 | 用户下麦、离房、被踢、发流超时或 RTC 停止后释放麦位,Flutter 收到后停止麦上心跳。 |
|
||||
|
||||
## Flutter 调用规则
|
||||
|
||||
1. `POST /api/v1/rooms/mic/up` 成功后保存 `mic_session_id`。
|
||||
2. RTC 发流成功后调用 `POST /api/v1/rooms/mic/publishing/confirm`。
|
||||
3. 确认成功且当前麦位 `publish_state=publishing` 后,周期调用 `POST /api/v1/rooms/mic/heartbeat`。
|
||||
4. `pending_publish` 状态不能调用麦上心跳开始计时。
|
||||
5. 返回 `mic session mismatch` 时,本地保存的是旧 `mic_session_id`,需要刷新房间详情。
|
||||
6. 收到 `room_mic_down` 或接口返回用户不在麦上时,停止本地麦上心跳。
|
||||
@ -1,60 +1,45 @@
|
||||
# 房间锁房接口 Flutter 对接
|
||||
# 房间锁房 Flutter 对接
|
||||
|
||||
本文描述 Flutter App 对接房间锁房能力的 HTTP 接口。房间密码由 `room-service` 只保存 bcrypt 哈希;客户端只提交明文用于本次设置或进房校验,任何列表、搜索、详情、房间消息和响应体都不会返回明文或哈希。
|
||||
## 地址
|
||||
|
||||
## 接口地址
|
||||
|
||||
`POST /api/v1/rooms/password`
|
||||
|
||||
`POST /api/v1/rooms/join`
|
||||
|
||||
`GET /api/v1/rooms`
|
||||
|
||||
`GET /api/v1/rooms/feeds`
|
||||
|
||||
本地开发地址:
|
||||
本地:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:13000
|
||||
```
|
||||
|
||||
## 请求方法
|
||||
接口:
|
||||
|
||||
| 方法 | 接口 | 说明 |
|
||||
| 方法 | 地址 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api/v1/rooms/password` | 房主设置或清空房间入房密码。 |
|
||||
| `POST` | `/api/v1/rooms/join` | 进入房间;带锁房间需要传 `password`。 |
|
||||
| `GET` | `/api/v1/rooms` | 公共房间列表和搜索;房间卡片返回 `locked`。 |
|
||||
| `GET` | `/api/v1/rooms/feeds` | Mine 页房间流;房间卡片返回 `locked`。 |
|
||||
| `POST` | `/api/v1/rooms/password` | 房主设置或清空房间密码 |
|
||||
| `POST` | `/api/v1/rooms/join` | 进入房间;锁房时必须传密码 |
|
||||
| `GET` | `/api/v1/rooms` | 房间列表和搜索,返回 `locked` |
|
||||
| `GET` | `/api/v1/rooms/feeds` | Mine 页房间流,返回 `locked` |
|
||||
|
||||
## 请求头
|
||||
请求头:
|
||||
|
||||
| Header | 必填 | 模拟值 | 说明 |
|
||||
| Header | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `Authorization` | 是 | `Bearer <access_token>` |
|
||||
| `X-App-Code` | 否 | App 编码,例如 `lalu` |
|
||||
|
||||
## 设置房间密码
|
||||
|
||||
`POST /api/v1/rooms/password`
|
||||
|
||||
参数:
|
||||
|
||||
| 字段 | 必填 | 类型 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Authorization` | 是 | `Bearer <access_token>` | 登录 access token;服务端从 token 读取当前用户 ID。 |
|
||||
| `X-App-Code` | 否 | `lalu` | App 编码;登录态接口最终以 token 内的 `app_code` 为准。 |
|
||||
| `room_id` | 是 | string | 房间 ID |
|
||||
| `command_id` | 是 | string | 本次设置动作的幂等键,同一次重试复用 |
|
||||
| `locked` | 是 | bool | `true` 设置密码,`false` 清空密码 |
|
||||
| `password` | `locked=true` 必填 | string | 房间密码,服务端 trim 后最长 64 字符;`locked=false` 时忽略 |
|
||||
|
||||
## 设置或清空房间密码
|
||||
|
||||
只有房主可以调用。房管、主持人和普通观众调用会返回 `PERMISSION_DENIED`。
|
||||
|
||||
请求体:
|
||||
|
||||
| 字段 | 必填 | 类型 | 模拟值 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `room_id` | 是 | string | `room_10001` | 房间 ID。 |
|
||||
| `command_id` | 是 | string | `cmd_room_password_room_10001_1710000000` | 房间命令幂等键;同一次点击重试必须复用。 |
|
||||
| `locked` | 是 | bool | `true` | `true` 表示设置密码,`false` 表示清空密码。 |
|
||||
| `password` | `locked=true` 时必填 | string | `1234` | 入房密码;服务端 trim 后最长 64 个字符。`locked=false` 时忽略。 |
|
||||
|
||||
设置密码请求示例:
|
||||
|
||||
```http
|
||||
POST /api/v1/rooms/password
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
Content-Type: application/json
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"room_id": "room_10001",
|
||||
"command_id": "cmd_room_password_room_10001_1710000000",
|
||||
@ -63,22 +48,7 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
清空密码请求示例:
|
||||
|
||||
```http
|
||||
POST /api/v1/rooms/password
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"room_id": "room_10001",
|
||||
"command_id": "cmd_room_password_room_10001_1710000100",
|
||||
"locked": false
|
||||
}
|
||||
```
|
||||
|
||||
成功响应:
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
@ -114,42 +84,56 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
返回字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `data.result.applied` | bool | 本次命令是否产生状态变更;重复提交相同命令可能为 `false`。 |
|
||||
| `data.result.room_version` | int64 | 命令完成后房间版本。 |
|
||||
| `data.room.locked` | bool | 最新锁房状态;`true` 表示后续新用户进房需要密码。 |
|
||||
| `data.room.version` | int64 | 最新房间快照版本;客户端本地房间状态以更大版本覆盖旧状态。 |
|
||||
| `data.seats` | array | 当前座位列表;锁房接口成功后可直接复用该房间快照刷新页面。 |
|
||||
|
||||
## 带密码进入房间
|
||||
|
||||
客户端进入房间前先看房间卡片或详情里的 `locked`:
|
||||
|
||||
| `locked` | 客户端行为 |
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `false` | 直接调用 JoinRoom,`password` 可不传或传空字符串。 |
|
||||
| `true` | 先弹出密码输入,再调用 JoinRoom 并传 `password`。 |
|
||||
| `data.result.applied` | 本次命令是否改变房间状态 |
|
||||
| `data.result.room_version` | 房间最新版本 |
|
||||
| `data.room.locked` | 最新锁房状态 |
|
||||
| `data.room.version` | 房间快照版本 |
|
||||
| `data.seats` | 当前麦位列表 |
|
||||
|
||||
请求体:
|
||||
## 清空房间密码
|
||||
|
||||
| 字段 | 必填 | 类型 | 模拟值 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `room_id` | 是 | string | `room_10001` | 房间 ID。 |
|
||||
| `command_id` | 是 | string | `cmd_join_room_10001_20001_1710000000` | 进房命令幂等键;同一次进房重试必须复用。 |
|
||||
| `role` | 否 | string | `audience` | 当前固定传 `audience`。 |
|
||||
| `password` | 锁房时必填 | string | `1234` | 只用于本次进房校验,服务端不保存明文。 |
|
||||
`POST /api/v1/rooms/password`
|
||||
|
||||
请求示例:
|
||||
参数:
|
||||
|
||||
```http
|
||||
POST /api/v1/rooms/join
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
Content-Type: application/json
|
||||
| 字段 | 必填 | 类型 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `room_id` | 是 | string | 房间 ID |
|
||||
| `command_id` | 是 | string | 本次清空动作的幂等键 |
|
||||
| `locked` | 是 | bool | 固定传 `false` |
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"room_id": "room_10001",
|
||||
"command_id": "cmd_room_unlock_room_10001_1710000100",
|
||||
"locked": false
|
||||
}
|
||||
```
|
||||
|
||||
返回同设置密码接口,`data.room.locked=false`。
|
||||
|
||||
## 进入锁房房间
|
||||
|
||||
`POST /api/v1/rooms/join`
|
||||
|
||||
参数:
|
||||
|
||||
| 字段 | 必填 | 类型 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `room_id` | 是 | string | 房间 ID |
|
||||
| `command_id` | 是 | string | 本次进房动作的幂等键,同一次重试复用 |
|
||||
| `role` | 否 | string | 当前传 `audience` |
|
||||
| `password` | 锁房时必填 | string | 本次入房密码 |
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"room_id": "room_10001",
|
||||
"command_id": "cmd_join_room_10001_20001_1710000000",
|
||||
@ -158,7 +142,7 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
成功响应只展示锁房相关字段:
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
@ -196,35 +180,33 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
返回字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `data.room.locked` | 当前房间是否锁房 |
|
||||
| `data.viewer.user_id` | 当前用户 ID |
|
||||
| `data.im.group_id` | JoinRoom 成功后需要加入的腾讯 IM 房间群 |
|
||||
| `data.im.need_join_group` | `true` 时 Flutter 调腾讯 IM `joinGroup` |
|
||||
| `data.rtc.available` | 是否拿到 RTC token |
|
||||
|
||||
进房规则:
|
||||
|
||||
| 场景 | 结果 |
|
||||
| --- | --- |
|
||||
| 房间未锁 | 不校验 `password`。 |
|
||||
| 房间已锁且密码正确 | JoinRoom 成功,客户端再调用腾讯 IM `joinGroup` 和 RTC `enterRoom`。 |
|
||||
| 房间已锁且密码错误或缺失 | 返回 `403 PERMISSION_DENIED`,不会创建 presence,不会返回 RTC token。 |
|
||||
| 房主进入自己的房间 | 不需要密码。 |
|
||||
| 用户已经在房间内 | 再次 JoinRoom 按幂等恢复当前房间态,不会因为后续锁房被踢出。 |
|
||||
| 未锁房 | `password` 可不传 |
|
||||
| 已锁房且密码正确 | JoinRoom 成功,再加入 IM 房间群 |
|
||||
| 已锁房且密码错误或缺失 | 返回 `403 PERMISSION_DENIED`,不要加入 IM 房间群 |
|
||||
| 房主进自己房间 | 不需要密码 |
|
||||
| 已在房间内重复 JoinRoom | 非房主仍要按当前最新密码校验;密码错误返回 `403 PERMISSION_DENIED` |
|
||||
|
||||
## 房间列表和搜索 locked 字段
|
||||
## 房间列表 locked 字段
|
||||
|
||||
公共列表和搜索使用同一个接口:
|
||||
`GET /api/v1/rooms?tab=hot&query=100001`
|
||||
|
||||
```http
|
||||
GET /api/v1/rooms?tab=hot&query=100001
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
```
|
||||
`GET /api/v1/rooms/feeds?tab=followed`
|
||||
|
||||
Mine 页房间流:
|
||||
|
||||
```http
|
||||
GET /api/v1/rooms/feeds?tab=followed
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
```
|
||||
|
||||
列表响应示例:
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
@ -236,18 +218,10 @@ X-App-Code: lalu
|
||||
{
|
||||
"room_id": "room_10001",
|
||||
"im_group_id": "room_10001",
|
||||
"owner_user_id": "10001",
|
||||
"title": "Live Room",
|
||||
"cover_url": "https://cdn.example/room.png",
|
||||
"mode": "voice",
|
||||
"status": "active",
|
||||
"locked": true,
|
||||
"heat": 1000,
|
||||
"online_count": 12,
|
||||
"seat_count": 15,
|
||||
"occupied_seat_count": 2,
|
||||
"visible_region_id": 1,
|
||||
"app_code": "lalu",
|
||||
"room_short_id": "100001"
|
||||
}
|
||||
],
|
||||
@ -256,17 +230,87 @@ X-App-Code: lalu
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `rooms[].locked` | bool | 房间是否需要入房密码;搜索结果和普通列表一致。 |
|
||||
| `rooms[].im_group_id` | string | 腾讯 IM 群 ID;只用于 JoinRoom 成功后的 `joinGroup`,不能绕过 JoinRoom。 |
|
||||
| `next_cursor` | string | 下一页游标;为空表示没有下一页。 |
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `rooms[].locked` | 是否锁房;`true` 时进房前弹密码输入 |
|
||||
| `rooms[].im_group_id` | 房间 IM 群 ID,只能在 JoinRoom 成功后使用 |
|
||||
| `rooms[].visible_region_id` | 房间区域 |
|
||||
|
||||
## 错误处理
|
||||
## 相关 IM
|
||||
|
||||
失败返回仍使用统一 envelope:
|
||||
### JoinRoom 成功后加入房间群
|
||||
|
||||
接口 `POST /api/v1/rooms/join` 成功后,Flutter 使用:
|
||||
|
||||
```text
|
||||
data.im.group_id
|
||||
```
|
||||
|
||||
调用腾讯 IM SDK 加入房间群。
|
||||
|
||||
规则:
|
||||
|
||||
| 场景 | Flutter 行为 |
|
||||
| --- | --- |
|
||||
| JoinRoom 成功 | 调腾讯 IM `joinGroup(data.im.group_id)` |
|
||||
| JoinRoom 返回 `PERMISSION_DENIED` | 不调用 `joinGroup` |
|
||||
| JoinRoom 返回 `ROOM_CLOSED` 或 `NOT_FOUND` | 不调用 `joinGroup`,刷新列表 |
|
||||
|
||||
### 房间密码变更区域通知
|
||||
|
||||
房主设置或清空密码后,服务端会发区域播报 IM,不再发房间群 IM。
|
||||
|
||||
Flutter 需要监听用户所在区域播报群的自定义消息:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "room_password_changed:evt_room_xxx",
|
||||
"broadcast_type": "room_password_changed",
|
||||
"scope": "region",
|
||||
"app_code": "lalu",
|
||||
"region_id": 1,
|
||||
"room_id": "room_10001",
|
||||
"actor_user_id": 10001,
|
||||
"locked": true,
|
||||
"sent_at_ms": 1710000000456,
|
||||
"room_version": 12,
|
||||
"source_event_id": "evt_room_xxx",
|
||||
"action": {
|
||||
"type": "refresh_room",
|
||||
"room_id": "room_10001"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `broadcast_type` | 固定 `room_password_changed` |
|
||||
| `scope` | 固定 `region` |
|
||||
| `region_id` | 区域 ID |
|
||||
| `room_id` | 发生变化的房间 |
|
||||
| `locked` | 最新锁房状态 |
|
||||
| `room_version` | 房间版本;本地只用更大的版本覆盖旧状态 |
|
||||
| `action.type` | 固定 `refresh_room` |
|
||||
|
||||
处理:
|
||||
|
||||
| 当前页面 | 行为 |
|
||||
| --- | --- |
|
||||
| 房间列表页 | 更新对应房间卡片 `locked` |
|
||||
| 房间详情页 | 如果 `room_id` 相同,刷新房间快照或更新本地 `locked` |
|
||||
| 密码弹窗打开中 | 收到同房间更新后,按最新 `locked` 状态决定是否关闭弹窗 |
|
||||
|
||||
### 房间群系统消息
|
||||
|
||||
`room_password_changed` 不走房间群 IM。房间内用户刷新锁房状态依赖区域播报或接口返回的 `room.locked`。
|
||||
|
||||
## 错误返回
|
||||
|
||||
失败统一返回:
|
||||
|
||||
```json
|
||||
{
|
||||
@ -276,108 +320,13 @@ X-App-Code: lalu
|
||||
}
|
||||
```
|
||||
|
||||
| HTTP 状态码 | `code` | 典型场景 | 处理方式 |
|
||||
| --- | --- | --- | --- |
|
||||
| `400` | `INVALID_ARGUMENT` | `room_id`、`command_id` 缺失;设置锁房时 `password` 为空或超过 64 字符。 | 客户端修正参数,不自动重试。 |
|
||||
| `401` | `UNAUTHORIZED` | token 缺失、无效、过期或会话失效。 | 重新登录。 |
|
||||
| `403` | `PROFILE_REQUIRED` | 用户资料未完成。 | 跳转资料补全流程。 |
|
||||
| `403` | `PERMISSION_DENIED` | 非房主设置密码;进锁房密码错误;用户被 ban。 | 设置密码时隐藏入口或提示无权限;进房时提示密码错误。 |
|
||||
| `404` | `NOT_FOUND` | 房间不存在。 | 刷新列表或退出房间页。 |
|
||||
| `409` | `ROOM_CLOSED` | 房间已关闭。 | 刷新列表或退出房间页。 |
|
||||
| `409` | `CONFLICT` | 同一个 `command_id` 被不同请求体复用。 | 为新的用户动作生成新的 `command_id`。 |
|
||||
| `502` | `UPSTREAM_ERROR` | 内部服务暂不可用。 | 可提示稍后重试。 |
|
||||
常见错误:
|
||||
|
||||
幂等规则:
|
||||
|
||||
| 场景 | 返回 |
|
||||
| --- | --- |
|
||||
| 同一个 `command_id` 重试同一次设置密码 | 返回同一命令结果,不重复变更房间。 |
|
||||
| 同一个 `command_id` 换另一个密码 | `409 CONFLICT`。 |
|
||||
| 重复清空已解锁房间 | `200 OK`,`data.room.locked=false`。 |
|
||||
| 同一个用户重复 JoinRoom | 返回当前房间首屏数据,不重复创建 presence。 |
|
||||
|
||||
## Flutter 解析示例
|
||||
|
||||
```dart
|
||||
class RoomCommandResult {
|
||||
RoomCommandResult({
|
||||
required this.applied,
|
||||
required this.roomVersion,
|
||||
required this.serverTimeMs,
|
||||
});
|
||||
|
||||
final bool applied;
|
||||
final int roomVersion;
|
||||
final int serverTimeMs;
|
||||
|
||||
factory RoomCommandResult.fromJson(Map<String, dynamic> json) {
|
||||
return RoomCommandResult(
|
||||
applied: json['applied'] as bool? ?? false,
|
||||
roomVersion: (json['room_version'] as num?)?.toInt() ?? 0,
|
||||
serverTimeMs: (json['server_time_ms'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RoomCard {
|
||||
RoomCard({
|
||||
required this.roomId,
|
||||
required this.imGroupId,
|
||||
required this.title,
|
||||
required this.locked,
|
||||
required this.onlineCount,
|
||||
});
|
||||
|
||||
final String roomId;
|
||||
final String imGroupId;
|
||||
final String title;
|
||||
final bool locked;
|
||||
final int onlineCount;
|
||||
|
||||
factory RoomCard.fromJson(Map<String, dynamic> json) {
|
||||
return RoomCard(
|
||||
roomId: json['room_id'] as String? ?? '',
|
||||
imGroupId: json['im_group_id'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
locked: json['locked'] as bool? ?? false,
|
||||
onlineCount: (json['online_count'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RoomLockResult {
|
||||
RoomLockResult({
|
||||
required this.result,
|
||||
required this.room,
|
||||
required this.serverTimeMs,
|
||||
});
|
||||
|
||||
final RoomCommandResult result;
|
||||
final RoomCard room;
|
||||
final int serverTimeMs;
|
||||
|
||||
factory RoomLockResult.fromJson(Map<String, dynamic> json) {
|
||||
return RoomLockResult(
|
||||
result: RoomCommandResult.fromJson(
|
||||
json['result'] as Map<String, dynamic>? ?? const {},
|
||||
),
|
||||
room: RoomCard.fromJson(
|
||||
json['room'] as Map<String, dynamic>? ?? const {},
|
||||
),
|
||||
serverTimeMs: (json['server_time_ms'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
调用时先解析外层 envelope:只有 `code == "OK"` 时读取 `data`;其他 `code` 按登录态、参数错误、权限错误或服务错误处理,并把 `request_id` 写入客户端日志。
|
||||
|
||||
客户端生成 `command_id` 时按一次用户动作生成一次,例如:
|
||||
|
||||
```dart
|
||||
String newRoomCommandId(String action, String roomId, String userId) {
|
||||
return 'cmd_${action}_${roomId}_${userId}_${DateTime.now().millisecondsSinceEpoch}';
|
||||
}
|
||||
```
|
||||
|
||||
同一次 HTTP 重试复用原 `command_id`;用户重新输入密码或再次点击设置按钮时生成新的 `command_id`。
|
||||
| HTTP | code | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `400` | `INVALID_ARGUMENT` | 参数缺失、设置密码时密码为空或超过 64 字符 |
|
||||
| `401` | `UNAUTHORIZED` | token 无效或过期 |
|
||||
| `403` | `PERMISSION_DENIED` | 非房主设置密码、进房密码错误、用户被 ban |
|
||||
| `404` | `NOT_FOUND` | 房间不存在 |
|
||||
| `409` | `ROOM_CLOSED` | 房间已关闭 |
|
||||
| `409` | `CONFLICT` | 同一个 `command_id` 被不同请求体复用 |
|
||||
|
||||
@ -687,6 +687,46 @@ Rules:
|
||||
- `room_version` 和 `event_time_ms` 用于丢弃旧事件,避免旧 audience/leave 事件清理新会话。
|
||||
- 成功后麦位 `publish_state=publishing`。
|
||||
|
||||
### Mic Heartbeat
|
||||
|
||||
地址:
|
||||
|
||||
```http
|
||||
POST /api/v1/rooms/mic/heartbeat
|
||||
Authorization: Bearer <access_token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
```json
|
||||
{
|
||||
"room_id": "lalu_xxx",
|
||||
"command_id": "cmd_mic_heartbeat_<room_id>_<user_id>_<nonce>",
|
||||
"target_user_id": 0,
|
||||
"mic_session_id": "mic_xxx"
|
||||
}
|
||||
```
|
||||
|
||||
返回值:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {},
|
||||
"seat_no": 1,
|
||||
"mic_heartbeat_at_ms": 1778000005000,
|
||||
"room": {}
|
||||
}
|
||||
```
|
||||
|
||||
相关 IM:
|
||||
|
||||
- 无独立 IM。该接口只刷新当前 `publishing` 麦位会话的服务端心跳时间和房间 presence。
|
||||
- `target_user_id=0` 表示当前登录用户自己。
|
||||
- `mic_session_id` 必须等于当前麦位会话。
|
||||
- `pending_publish` 不能调用该接口开始计时,必须先调用 `POST /api/v1/rooms/mic/publishing/confirm`。
|
||||
- 重试同一次心跳复用同一个 `command_id`;下一次心跳必须换新的 `command_id`。
|
||||
|
||||
### Mic Down
|
||||
|
||||
```http
|
||||
|
||||
@ -16,6 +16,8 @@ const (
|
||||
TypeRedPacket = "red_packet"
|
||||
// TypeRoomTreasure 是语音房宝箱满能量后的开箱倒计时播报。
|
||||
TypeRoomTreasure = "room_treasure"
|
||||
// TypeRoomPasswordChanged 是锁房状态变化的区域通知,客户端用它刷新房间锁状态入口。
|
||||
TypeRoomPasswordChanged = "room_password_changed"
|
||||
// TypeLuckyGiftBigWin 是幸运礼物中奖区域飘屏;当前默认 1 倍及以上即进入播报 outbox。
|
||||
TypeLuckyGiftBigWin = "lucky_gift_big_win"
|
||||
|
||||
|
||||
@ -298,7 +298,7 @@ func (s *Service) ProcessPendingBroadcasts(ctx context.Context, options WorkerOp
|
||||
}
|
||||
|
||||
// HandleRoomEvent 把已提交的 room outbox 事实转换为服务端播报。
|
||||
// 只有 RoomGiftSent 且满足区域和礼物价值阈值时才生成区域播报,避免把展示策略侵入 Room Cell 主链路。
|
||||
// 礼物、宝箱和锁房只使用 room outbox 中的稳定事实,避免把展示策略侵入 Room Cell 主链路。
|
||||
func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (broadcastdomain.ConsumeRoomEventResult, error) {
|
||||
if envelope == nil {
|
||||
return broadcastdomain.ConsumeRoomEventResult{}, xerr.New(xerr.InvalidArgument, "room event envelope is required")
|
||||
@ -308,8 +308,11 @@ func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.Ev
|
||||
if envelope.GetEventType() == "RoomTreasureCountdownStarted" {
|
||||
return s.handleRoomTreasureCountdown(eventCtx, envelope)
|
||||
}
|
||||
if envelope.GetEventType() == "RoomPasswordChanged" {
|
||||
return s.handleRoomPasswordChanged(eventCtx, envelope)
|
||||
}
|
||||
if envelope.GetEventType() != "RoomGiftSent" {
|
||||
// 当前只消费礼物和房间宝箱事实;未来红包或运营事件应显式增加事件类型分支和测试。
|
||||
// 当前只消费礼物、房间宝箱和锁房事实;未来运营事件应显式增加事件类型分支和测试。
|
||||
return result, nil
|
||||
}
|
||||
var gift roomeventsv1.RoomGiftSent
|
||||
@ -343,6 +346,38 @@ func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.Ev
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) handleRoomPasswordChanged(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (broadcastdomain.ConsumeRoomEventResult, error) {
|
||||
result := broadcastdomain.ConsumeRoomEventResult{EventID: envelope.GetEventId(), Status: broadcastdomain.StatusSkipped}
|
||||
var event roomeventsv1.RoomPasswordChanged
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &event); err != nil {
|
||||
return broadcastdomain.ConsumeRoomEventResult{}, err
|
||||
}
|
||||
if event.GetVisibleRegionId() <= 0 {
|
||||
// 区域通知必须有明确区域,不能把无区域房间退化成全局播报扩大影响面。
|
||||
return result, nil
|
||||
}
|
||||
broadcastEventID := roomPasswordChangedBroadcastEventID(envelope)
|
||||
payloadJSON, err := roomPasswordChangedPayload(envelope, &event, broadcastEventID, s.now().UTC().UnixMilli())
|
||||
if err != nil {
|
||||
return broadcastdomain.ConsumeRoomEventResult{}, err
|
||||
}
|
||||
published, err := s.PublishRegionBroadcast(ctx, PublishInput{
|
||||
EventID: broadcastEventID,
|
||||
BroadcastType: broadcastdomain.TypeRoomPasswordChanged,
|
||||
RegionID: event.GetVisibleRegionId(),
|
||||
PayloadJSON: payloadJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return broadcastdomain.ConsumeRoomEventResult{}, err
|
||||
}
|
||||
return broadcastdomain.ConsumeRoomEventResult{
|
||||
EventID: envelope.GetEventId(),
|
||||
Status: published.Status,
|
||||
BroadcastEventID: published.EventID,
|
||||
BroadcastCreated: published.Created,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) handleRoomTreasureCountdown(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (broadcastdomain.ConsumeRoomEventResult, error) {
|
||||
result := broadcastdomain.ConsumeRoomEventResult{EventID: envelope.GetEventId(), Status: broadcastdomain.StatusSkipped}
|
||||
var treasure roomeventsv1.RoomTreasureCountdownStarted
|
||||
@ -519,6 +554,32 @@ func roomTreasureBroadcastEventID(envelope *roomeventsv1.EventEnvelope, treasure
|
||||
return "room_treasure_broadcast:" + envelope.GetEventId()
|
||||
}
|
||||
|
||||
func roomPasswordChangedBroadcastEventID(envelope *roomeventsv1.EventEnvelope) string {
|
||||
return "room_password_changed:" + envelope.GetEventId()
|
||||
}
|
||||
|
||||
func roomPasswordChangedPayload(envelope *roomeventsv1.EventEnvelope, event *roomeventsv1.RoomPasswordChanged, eventID string, sentAtMS int64) (string, error) {
|
||||
payload := map[string]any{
|
||||
"event_id": eventID,
|
||||
"broadcast_type": broadcastdomain.TypeRoomPasswordChanged,
|
||||
"scope": broadcastdomain.ScopeRegion,
|
||||
"app_code": envelope.GetAppCode(),
|
||||
"region_id": event.GetVisibleRegionId(),
|
||||
"room_id": envelope.GetRoomId(),
|
||||
"actor_user_id": event.GetActorUserId(),
|
||||
"locked": event.GetLocked(),
|
||||
"sent_at_ms": sentAtMS,
|
||||
"room_version": envelope.GetRoomVersion(),
|
||||
"source_event_id": envelope.GetEventId(),
|
||||
"action": map[string]any{
|
||||
"type": "refresh_room",
|
||||
"room_id": envelope.GetRoomId(),
|
||||
},
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
return string(encoded), err
|
||||
}
|
||||
|
||||
func roomTreasurePayload(envelope *roomeventsv1.EventEnvelope, treasure *roomeventsv1.RoomTreasureCountdownStarted, eventID string, sentAtMS int64) (string, error) {
|
||||
payload := map[string]any{
|
||||
"event_id": eventID,
|
||||
|
||||
@ -56,6 +56,40 @@ func TestHandleRoomGiftSentCreatesRegionBroadcast(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRoomPasswordChangedCreatesRegionBroadcast(t *testing.T) {
|
||||
repository := newFakeRepository()
|
||||
service := New(Config{NodeID: "node-a"}, repository, nil, nil)
|
||||
service.SetClock(func() time.Time { return time.UnixMilli(1_700_000_001_000) })
|
||||
envelope := mustPasswordChangedEnvelope(t, &roomeventsv1.RoomPasswordChanged{
|
||||
ActorUserId: 42,
|
||||
Locked: true,
|
||||
VisibleRegionId: 1001,
|
||||
})
|
||||
|
||||
result, err := service.HandleRoomEvent(appcode.WithContext(context.Background(), "lalu"), envelope)
|
||||
if err != nil {
|
||||
t.Fatalf("HandleRoomEvent password changed failed: %v", err)
|
||||
}
|
||||
if result.Status != broadcastdomain.StatusPending || !result.BroadcastCreated || result.BroadcastEventID != "room_password_changed:"+envelope.GetEventId() {
|
||||
t.Fatalf("unexpected password broadcast result: %+v", result)
|
||||
}
|
||||
record := repository.records[result.BroadcastEventID]
|
||||
if record.GroupID != "hy_lalu_bc_r_1001" || record.Scope != broadcastdomain.ScopeRegion || record.BroadcastType != broadcastdomain.TypeRoomPasswordChanged {
|
||||
t.Fatalf("unexpected password broadcast record: %+v", record)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil {
|
||||
t.Fatalf("password payload is not json: %v", err)
|
||||
}
|
||||
if payload["room_id"] != "room-1001" || payload["scope"] != "region" || payload["locked"] != true || payload["actor_user_id"] != float64(42) {
|
||||
t.Fatalf("password payload mismatch: %+v", payload)
|
||||
}
|
||||
action, ok := payload["action"].(map[string]any)
|
||||
if !ok || action["type"] != "refresh_room" || action["room_id"] != "room-1001" {
|
||||
t.Fatalf("password payload action mismatch: %+v", payload["action"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRoomGiftSentSkipsNonBroadcastCases(t *testing.T) {
|
||||
service := New(Config{SuperGiftMinValue: 1000}, newFakeRepository(), nil, nil)
|
||||
belowThreshold := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{GiftValue: 999, VisibleRegionId: 1001})
|
||||
@ -301,6 +335,23 @@ func mustGiftEnvelope(t *testing.T, gift *roomeventsv1.RoomGiftSent) *roomevents
|
||||
}
|
||||
}
|
||||
|
||||
func mustPasswordChangedEnvelope(t *testing.T, event *roomeventsv1.RoomPasswordChanged) *roomeventsv1.EventEnvelope {
|
||||
t.Helper()
|
||||
body, err := proto.Marshal(event)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal password changed failed: %v", err)
|
||||
}
|
||||
return &roomeventsv1.EventEnvelope{
|
||||
EventId: "evt-room-password-1",
|
||||
RoomId: "room-1001",
|
||||
EventType: "RoomPasswordChanged",
|
||||
RoomVersion: 8,
|
||||
OccurredAtMs: 1_700_000_001_000,
|
||||
AppCode: "lalu",
|
||||
Body: body,
|
||||
}
|
||||
}
|
||||
|
||||
func redPacketPayloadData(t *testing.T, payloadJSON string) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ type RoomClient interface {
|
||||
MicDown(ctx context.Context, req *roomv1.MicDownRequest) (*roomv1.MicDownResponse, error)
|
||||
ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatRequest) (*roomv1.ChangeMicSeatResponse, error)
|
||||
ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmMicPublishingRequest) (*roomv1.ConfirmMicPublishingResponse, error)
|
||||
MicHeartbeat(ctx context.Context, req *roomv1.MicHeartbeatRequest) (*roomv1.MicHeartbeatResponse, error)
|
||||
SetMicMute(ctx context.Context, req *roomv1.SetMicMuteRequest) (*roomv1.SetMicMuteResponse, error)
|
||||
ApplyRTCEvent(ctx context.Context, req *roomv1.ApplyRTCEventRequest) (*roomv1.ApplyRTCEventResponse, error)
|
||||
SetMicSeatLock(ctx context.Context, req *roomv1.SetMicSeatLockRequest) (*roomv1.SetMicSeatLockResponse, error)
|
||||
@ -132,6 +133,10 @@ func (c *grpcRoomClient) ConfirmMicPublishing(ctx context.Context, req *roomv1.C
|
||||
return c.client.ConfirmMicPublishing(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcRoomClient) MicHeartbeat(ctx context.Context, req *roomv1.MicHeartbeatRequest) (*roomv1.MicHeartbeatResponse, error) {
|
||||
return c.client.MicHeartbeat(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcRoomClient) SetMicMute(ctx context.Context, req *roomv1.SetMicMuteRequest) (*roomv1.SetMicMuteResponse, error) {
|
||||
return c.client.SetMicMute(ctx, req)
|
||||
}
|
||||
|
||||
@ -11,6 +11,8 @@ import (
|
||||
// WalletClient 抽象 gateway 对 wallet-service 余额查询能力的依赖。
|
||||
type WalletClient interface {
|
||||
GetBalances(ctx context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error)
|
||||
GetActiveHostSalaryPolicy(ctx context.Context, req *walletv1.GetActiveHostSalaryPolicyRequest) (*walletv1.GetActiveHostSalaryPolicyResponse, error)
|
||||
GetHostSalaryProgress(ctx context.Context, req *walletv1.GetHostSalaryProgressRequest) (*walletv1.GetHostSalaryProgressResponse, error)
|
||||
GetWalletOverview(ctx context.Context, req *walletv1.GetWalletOverviewRequest) (*walletv1.GetWalletOverviewResponse, error)
|
||||
GetWalletValueSummary(ctx context.Context, req *walletv1.GetWalletValueSummaryRequest) (*walletv1.GetWalletValueSummaryResponse, error)
|
||||
GetUserGiftWall(ctx context.Context, req *walletv1.GetUserGiftWallRequest) (*walletv1.GetUserGiftWallResponse, error)
|
||||
@ -54,6 +56,14 @@ func (c *grpcWalletClient) GetBalances(ctx context.Context, req *walletv1.GetBal
|
||||
return c.client.GetBalances(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcWalletClient) GetActiveHostSalaryPolicy(ctx context.Context, req *walletv1.GetActiveHostSalaryPolicyRequest) (*walletv1.GetActiveHostSalaryPolicyResponse, error) {
|
||||
return c.client.GetActiveHostSalaryPolicy(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcWalletClient) GetHostSalaryProgress(ctx context.Context, req *walletv1.GetHostSalaryProgressRequest) (*walletv1.GetHostSalaryProgressResponse, error) {
|
||||
return c.client.GetHostSalaryProgress(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcWalletClient) GetWalletOverview(ctx context.Context, req *walletv1.GetWalletOverviewRequest) (*walletv1.GetWalletOverviewResponse, error) {
|
||||
return c.client.GetWalletOverview(ctx, req)
|
||||
}
|
||||
|
||||
@ -72,6 +72,8 @@ type UserHandlers struct {
|
||||
GetMyRoleSummary http.HandlerFunc
|
||||
SearchHostAgencies http.HandlerFunc
|
||||
ApplyToHostAgency http.HandlerFunc
|
||||
GetHostCenterAgency http.HandlerFunc
|
||||
GetHostCenterPlatformPolicy http.HandlerFunc
|
||||
GetAgencyCenterOverview http.HandlerFunc
|
||||
ListAgencyCenterHosts http.HandlerFunc
|
||||
ListAgencyCenterApplications http.HandlerFunc
|
||||
@ -127,6 +129,7 @@ type RoomHandlers struct {
|
||||
MicDown http.HandlerFunc
|
||||
ChangeMicSeat http.HandlerFunc
|
||||
ConfirmMicPublishing http.HandlerFunc
|
||||
MicHeartbeat http.HandlerFunc
|
||||
SetMicMute http.HandlerFunc
|
||||
SetMicSeatLock http.HandlerFunc
|
||||
SetChatEnabled http.HandlerFunc
|
||||
@ -300,6 +303,8 @@ func (r routes) registerUserRoutes() {
|
||||
r.profile("/users/me/role-summary", "", h.GetMyRoleSummary)
|
||||
r.profile("/host/agencies/search", http.MethodGet, h.SearchHostAgencies)
|
||||
r.profile("/host/agency-applications", http.MethodPost, h.ApplyToHostAgency)
|
||||
r.profile("/host-center/agency", http.MethodGet, h.GetHostCenterAgency)
|
||||
r.profile("/host-center/platform-policy", http.MethodGet, h.GetHostCenterPlatformPolicy)
|
||||
r.profile("/agency-center/overview", http.MethodGet, h.GetAgencyCenterOverview)
|
||||
r.profile("/agency-center/hosts", http.MethodGet, h.ListAgencyCenterHosts)
|
||||
r.profile("/agency-center/applications", http.MethodGet, h.ListAgencyCenterApplications)
|
||||
@ -357,6 +362,7 @@ func (r routes) registerRoomRoutes() {
|
||||
r.profile("/rooms/mic/down", http.MethodPost, h.MicDown)
|
||||
r.profile("/rooms/mic/change", http.MethodPost, h.ChangeMicSeat)
|
||||
r.profile("/rooms/mic/publishing/confirm", http.MethodPost, h.ConfirmMicPublishing)
|
||||
r.profile("/rooms/mic/heartbeat", http.MethodPost, h.MicHeartbeat)
|
||||
r.profile("/rooms/mic/mute", http.MethodPost, h.SetMicMute)
|
||||
r.profile("/rooms/mic/lock", http.MethodPost, h.SetMicSeatLock)
|
||||
r.profile("/rooms/chat/enabled", http.MethodPost, h.SetChatEnabled)
|
||||
|
||||
@ -44,6 +44,7 @@ type fakeRoomClient struct {
|
||||
lastMicDown *roomv1.MicDownRequest
|
||||
lastChangeMicSeat *roomv1.ChangeMicSeatRequest
|
||||
lastConfirmMic *roomv1.ConfirmMicPublishingRequest
|
||||
lastMicHeartbeat *roomv1.MicHeartbeatRequest
|
||||
lastSetMicMute *roomv1.SetMicMuteRequest
|
||||
lastRTCEvent *roomv1.ApplyRTCEventRequest
|
||||
lastMicSeatLock *roomv1.SetMicSeatLockRequest
|
||||
@ -203,6 +204,11 @@ func (f *fakeRoomClient) ConfirmMicPublishing(_ context.Context, req *roomv1.Con
|
||||
return &roomv1.ConfirmMicPublishingResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 7}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeRoomClient) MicHeartbeat(_ context.Context, req *roomv1.MicHeartbeatRequest) (*roomv1.MicHeartbeatResponse, error) {
|
||||
f.lastMicHeartbeat = req
|
||||
return &roomv1.MicHeartbeatResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 8}, SeatNo: 2, MicHeartbeatAtMs: 1_700_000_001_000}, nil
|
||||
}
|
||||
|
||||
func (f *fakeRoomClient) SetMicMute(_ context.Context, req *roomv1.SetMicMuteRequest) (*roomv1.SetMicMuteResponse, error) {
|
||||
f.lastSetMicMute = req
|
||||
return &roomv1.SetMicMuteResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 7}}, nil
|
||||
@ -425,63 +431,69 @@ type fakeUserHostClient struct {
|
||||
}
|
||||
|
||||
type fakeWalletClient struct {
|
||||
last *walletv1.GetBalancesRequest
|
||||
balanceRequests []*walletv1.GetBalancesRequest
|
||||
resp *walletv1.GetBalancesResponse
|
||||
balancesByUserID map[int64]*walletv1.GetBalancesResponse
|
||||
err error
|
||||
lastOverview *walletv1.GetWalletOverviewRequest
|
||||
overviewResp *walletv1.GetWalletOverviewResponse
|
||||
overviewErr error
|
||||
lastValueSummary *walletv1.GetWalletValueSummaryRequest
|
||||
valueSummaryResp *walletv1.GetWalletValueSummaryResponse
|
||||
valueSummaryErr error
|
||||
lastGiftWall *walletv1.GetUserGiftWallRequest
|
||||
giftWallResp *walletv1.GetUserGiftWallResponse
|
||||
lastRechargeProducts *walletv1.ListRechargeProductsRequest
|
||||
rechargeProductsResp *walletv1.ListRechargeProductsResponse
|
||||
lastGoogleConfirm *walletv1.ConfirmGooglePaymentRequest
|
||||
googleConfirmResp *walletv1.ConfirmGooglePaymentResponse
|
||||
lastDiamondExchange *walletv1.GetDiamondExchangeConfigRequest
|
||||
diamondExchangeResp *walletv1.GetDiamondExchangeConfigResponse
|
||||
lastTransactions *walletv1.ListWalletTransactionsRequest
|
||||
transactionsResp *walletv1.ListWalletTransactionsResponse
|
||||
lastVipPackages *walletv1.ListVipPackagesRequest
|
||||
vipPackagesResp *walletv1.ListVipPackagesResponse
|
||||
lastMyVip *walletv1.GetMyVipRequest
|
||||
myVipResp *walletv1.GetMyVipResponse
|
||||
vipErr error
|
||||
lastPurchaseVip *walletv1.PurchaseVipRequest
|
||||
purchaseVipResp *walletv1.PurchaseVipResponse
|
||||
lastTransfer *walletv1.TransferCoinFromSellerRequest
|
||||
transferResp *walletv1.TransferCoinFromSellerResponse
|
||||
transferErr error
|
||||
lastListResources *walletv1.ListResourcesRequest
|
||||
listResourcesResp *walletv1.ListResourcesResponse
|
||||
lastGetResource *walletv1.GetResourceRequest
|
||||
resourcesByID map[int64]*walletv1.Resource
|
||||
lastGetResourceGroup *walletv1.GetResourceGroupRequest
|
||||
resourceGroupsByID map[int64]*walletv1.ResourceGroup
|
||||
lastResourceShop *walletv1.ListResourceShopItemsRequest
|
||||
resourceShopResp *walletv1.ListResourceShopItemsResponse
|
||||
lastPurchaseShop *walletv1.PurchaseResourceShopItemRequest
|
||||
purchaseShopResp *walletv1.PurchaseResourceShopItemResponse
|
||||
lastListGiftConfigs *walletv1.ListGiftConfigsRequest
|
||||
listGiftConfigsResp *walletv1.ListGiftConfigsResponse
|
||||
lastListGiftTypes *walletv1.ListGiftTypeConfigsRequest
|
||||
listGiftTypesResp *walletv1.ListGiftTypeConfigsResponse
|
||||
lastGrantResource *walletv1.GrantResourceRequest
|
||||
grantResourceResp *walletv1.ResourceGrantResponse
|
||||
grantResourceErr error
|
||||
lastBatchEquipped *walletv1.BatchGetUserEquippedResourcesRequest
|
||||
batchEquippedRequests []*walletv1.BatchGetUserEquippedResourcesRequest
|
||||
batchEquippedResp *walletv1.BatchGetUserEquippedResourcesResponse
|
||||
lastListRedPackets *walletv1.ListRedPacketsRequest
|
||||
listRedPacketsResp *walletv1.ListRedPacketsResponse
|
||||
lastGetRedPacket *walletv1.GetRedPacketRequest
|
||||
getRedPacketResp *walletv1.GetRedPacketResponse
|
||||
lastClaimRedPacket *walletv1.ClaimRedPacketRequest
|
||||
claimRedPacketResp *walletv1.ClaimRedPacketResponse
|
||||
last *walletv1.GetBalancesRequest
|
||||
balanceRequests []*walletv1.GetBalancesRequest
|
||||
resp *walletv1.GetBalancesResponse
|
||||
balancesByUserID map[int64]*walletv1.GetBalancesResponse
|
||||
err error
|
||||
lastHostSalaryPolicy *walletv1.GetActiveHostSalaryPolicyRequest
|
||||
hostSalaryPolicyResp *walletv1.GetActiveHostSalaryPolicyResponse
|
||||
hostSalaryPolicyErr error
|
||||
lastHostSalaryProgress *walletv1.GetHostSalaryProgressRequest
|
||||
hostSalaryProgressResp *walletv1.GetHostSalaryProgressResponse
|
||||
hostSalaryProgressErr error
|
||||
lastOverview *walletv1.GetWalletOverviewRequest
|
||||
overviewResp *walletv1.GetWalletOverviewResponse
|
||||
overviewErr error
|
||||
lastValueSummary *walletv1.GetWalletValueSummaryRequest
|
||||
valueSummaryResp *walletv1.GetWalletValueSummaryResponse
|
||||
valueSummaryErr error
|
||||
lastGiftWall *walletv1.GetUserGiftWallRequest
|
||||
giftWallResp *walletv1.GetUserGiftWallResponse
|
||||
lastRechargeProducts *walletv1.ListRechargeProductsRequest
|
||||
rechargeProductsResp *walletv1.ListRechargeProductsResponse
|
||||
lastGoogleConfirm *walletv1.ConfirmGooglePaymentRequest
|
||||
googleConfirmResp *walletv1.ConfirmGooglePaymentResponse
|
||||
lastDiamondExchange *walletv1.GetDiamondExchangeConfigRequest
|
||||
diamondExchangeResp *walletv1.GetDiamondExchangeConfigResponse
|
||||
lastTransactions *walletv1.ListWalletTransactionsRequest
|
||||
transactionsResp *walletv1.ListWalletTransactionsResponse
|
||||
lastVipPackages *walletv1.ListVipPackagesRequest
|
||||
vipPackagesResp *walletv1.ListVipPackagesResponse
|
||||
lastMyVip *walletv1.GetMyVipRequest
|
||||
myVipResp *walletv1.GetMyVipResponse
|
||||
vipErr error
|
||||
lastPurchaseVip *walletv1.PurchaseVipRequest
|
||||
purchaseVipResp *walletv1.PurchaseVipResponse
|
||||
lastTransfer *walletv1.TransferCoinFromSellerRequest
|
||||
transferResp *walletv1.TransferCoinFromSellerResponse
|
||||
transferErr error
|
||||
lastListResources *walletv1.ListResourcesRequest
|
||||
listResourcesResp *walletv1.ListResourcesResponse
|
||||
lastGetResource *walletv1.GetResourceRequest
|
||||
resourcesByID map[int64]*walletv1.Resource
|
||||
lastGetResourceGroup *walletv1.GetResourceGroupRequest
|
||||
resourceGroupsByID map[int64]*walletv1.ResourceGroup
|
||||
lastResourceShop *walletv1.ListResourceShopItemsRequest
|
||||
resourceShopResp *walletv1.ListResourceShopItemsResponse
|
||||
lastPurchaseShop *walletv1.PurchaseResourceShopItemRequest
|
||||
purchaseShopResp *walletv1.PurchaseResourceShopItemResponse
|
||||
lastListGiftConfigs *walletv1.ListGiftConfigsRequest
|
||||
listGiftConfigsResp *walletv1.ListGiftConfigsResponse
|
||||
lastListGiftTypes *walletv1.ListGiftTypeConfigsRequest
|
||||
listGiftTypesResp *walletv1.ListGiftTypeConfigsResponse
|
||||
lastGrantResource *walletv1.GrantResourceRequest
|
||||
grantResourceResp *walletv1.ResourceGrantResponse
|
||||
grantResourceErr error
|
||||
lastBatchEquipped *walletv1.BatchGetUserEquippedResourcesRequest
|
||||
batchEquippedRequests []*walletv1.BatchGetUserEquippedResourcesRequest
|
||||
batchEquippedResp *walletv1.BatchGetUserEquippedResourcesResponse
|
||||
lastListRedPackets *walletv1.ListRedPacketsRequest
|
||||
listRedPacketsResp *walletv1.ListRedPacketsResponse
|
||||
lastGetRedPacket *walletv1.GetRedPacketRequest
|
||||
getRedPacketResp *walletv1.GetRedPacketResponse
|
||||
lastClaimRedPacket *walletv1.ClaimRedPacketRequest
|
||||
claimRedPacketResp *walletv1.ClaimRedPacketResponse
|
||||
}
|
||||
|
||||
type fakeMessageInboxClient struct {
|
||||
@ -1212,6 +1224,28 @@ func (f *fakeWalletClient) GetBalances(_ context.Context, req *walletv1.GetBalan
|
||||
return &walletv1.GetBalancesResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWalletClient) GetActiveHostSalaryPolicy(_ context.Context, req *walletv1.GetActiveHostSalaryPolicyRequest) (*walletv1.GetActiveHostSalaryPolicyResponse, error) {
|
||||
f.lastHostSalaryPolicy = req
|
||||
if f.hostSalaryPolicyErr != nil {
|
||||
return nil, f.hostSalaryPolicyErr
|
||||
}
|
||||
if f.hostSalaryPolicyResp != nil {
|
||||
return f.hostSalaryPolicyResp, nil
|
||||
}
|
||||
return &walletv1.GetActiveHostSalaryPolicyResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWalletClient) GetHostSalaryProgress(_ context.Context, req *walletv1.GetHostSalaryProgressRequest) (*walletv1.GetHostSalaryProgressResponse, error) {
|
||||
f.lastHostSalaryProgress = req
|
||||
if f.hostSalaryProgressErr != nil {
|
||||
return nil, f.hostSalaryProgressErr
|
||||
}
|
||||
if f.hostSalaryProgressResp != nil {
|
||||
return f.hostSalaryProgressResp, nil
|
||||
}
|
||||
return &walletv1.GetHostSalaryProgressResponse{Progress: &walletv1.HostSalaryProgress{HostUserId: req.GetHostUserId()}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWalletClient) GetWalletOverview(_ context.Context, req *walletv1.GetWalletOverviewRequest) (*walletv1.GetWalletOverviewResponse, error) {
|
||||
f.lastOverview = req
|
||||
if f.overviewErr != nil {
|
||||
@ -1704,6 +1738,15 @@ func TestRoomCommandsPropagateClientCommandID(t *testing.T) {
|
||||
return client.lastConfirmMic.GetMeta()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mic_heartbeat",
|
||||
path: "/api/v1/rooms/mic/heartbeat",
|
||||
body: `{"room_id":"room-1","command_id":"cmd-mic-heartbeat-1","mic_session_id":"mic-1"}`,
|
||||
commandID: "cmd-mic-heartbeat-1",
|
||||
extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
|
||||
return client.lastMicHeartbeat.GetMeta()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mic_lock",
|
||||
path: "/api/v1/rooms/mic/lock",
|
||||
@ -3263,6 +3306,165 @@ func TestApplyToHostAgencyPropagatesClientCommandID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostCenterAgencyUsesCurrentHostAgencyAndOwnerProfile(t *testing.T) {
|
||||
hostClient := &fakeUserHostClient{
|
||||
hostProfile: &userv1.HostProfile{
|
||||
UserId: 42,
|
||||
Status: "active",
|
||||
RegionId: 30,
|
||||
CurrentAgencyId: 7001,
|
||||
},
|
||||
getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{
|
||||
AgencyId: 7001,
|
||||
OwnerUserId: 99,
|
||||
RegionId: 30,
|
||||
Name: "Yumi Star Agency",
|
||||
Status: "active",
|
||||
}},
|
||||
}
|
||||
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
|
||||
99: {UserId: 99, DisplayUserId: "163003", Username: "agency-owner", Avatar: "https://cdn.example/agency-owner.png"},
|
||||
}}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
|
||||
handler.SetUserHostClient(hostClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/host-center/agency?agency_id=9999", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-host-center-agency")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 42 {
|
||||
t.Fatalf("host profile request mismatch: %+v", hostClient.lastHost)
|
||||
}
|
||||
if hostClient.lastGetAgency == nil || hostClient.lastGetAgency.GetAgencyId() != 7001 {
|
||||
t.Fatalf("agency request must use current host agency: %+v", hostClient.lastGetAgency)
|
||||
}
|
||||
if profileClient.lastBatch == nil || len(profileClient.lastBatch.GetUserIds()) != 1 || profileClient.lastBatch.GetUserIds()[0] != 99 {
|
||||
t.Fatalf("owner profile request mismatch: %+v", profileClient.lastBatch)
|
||||
}
|
||||
var response httpkit.ResponseEnvelope
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode response failed: %v", err)
|
||||
}
|
||||
data := response.Data.(map[string]any)
|
||||
agency := data["agency"].(map[string]any)
|
||||
if agency["agency_id"] != "7001" || agency["short_id"] != "163003" || agency["avatar"] != "https://cdn.example/agency-owner.png" || agency["name"] != "Yumi Star Agency" {
|
||||
t.Fatalf("host center agency response mismatch: %+v", agency)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostCenterPlatformPolicyUsesCurrentHostRegion(t *testing.T) {
|
||||
hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{
|
||||
UserId: 42,
|
||||
Status: "active",
|
||||
RegionId: 30,
|
||||
CurrentAgencyId: 7001,
|
||||
}}
|
||||
walletClient := &fakeWalletClient{
|
||||
hostSalaryPolicyResp: &walletv1.GetActiveHostSalaryPolicyResponse{
|
||||
Found: true,
|
||||
Policy: &walletv1.HostSalaryPolicy{
|
||||
PolicyId: 9001,
|
||||
Name: "Host Growth Policy",
|
||||
RegionId: 30,
|
||||
Status: "active",
|
||||
SettlementMode: "half_month",
|
||||
SettlementTriggerMode: "automatic",
|
||||
GiftCoinToDiamondRatio: "1.0000",
|
||||
ResidualDiamondToUsdRate: "0.0100",
|
||||
EffectiveFromMs: 1700000000000,
|
||||
Levels: []*walletv1.HostSalaryPolicyLevel{
|
||||
{LevelNo: 1, RequiredDiamonds: 1000, HostSalaryUsdMinor: 1500, HostCoinReward: 200, AgencySalaryUsdMinor: 300, Status: "active", SortOrder: 1},
|
||||
{LevelNo: 2, RequiredDiamonds: 3000, HostSalaryUsdMinor: 4500, HostCoinReward: 500, AgencySalaryUsdMinor: 900, Status: "active", SortOrder: 2},
|
||||
},
|
||||
},
|
||||
},
|
||||
hostSalaryProgressResp: &walletv1.GetHostSalaryProgressResponse{Progress: &walletv1.HostSalaryProgress{
|
||||
HostUserId: 42,
|
||||
CycleKey: "2026-06",
|
||||
RegionId: 30,
|
||||
AgencyOwnerUserId: 99,
|
||||
TotalDiamonds: 1200,
|
||||
GiftDiamondTotal: 1200,
|
||||
}},
|
||||
}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||
handler.SetUserHostClient(hostClient)
|
||||
handler.SetWalletClient(walletClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/host-center/platform-policy", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-host-policy")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 42 {
|
||||
t.Fatalf("host profile request mismatch: %+v", hostClient.lastHost)
|
||||
}
|
||||
if walletClient.lastHostSalaryPolicy == nil || walletClient.lastHostSalaryPolicy.GetRegionId() != 30 || walletClient.lastHostSalaryPolicy.GetSettlementTriggerMode() != "automatic" || walletClient.lastHostSalaryPolicy.GetAppCode() == "" || walletClient.lastHostSalaryPolicy.GetRequestId() == "" {
|
||||
t.Fatalf("host salary policy request mismatch: %+v", walletClient.lastHostSalaryPolicy)
|
||||
}
|
||||
if walletClient.lastHostSalaryProgress == nil || walletClient.lastHostSalaryProgress.GetHostUserId() != 42 || walletClient.lastHostSalaryProgress.GetAppCode() == "" || walletClient.lastHostSalaryProgress.GetRequestId() == "" {
|
||||
t.Fatalf("host salary progress request mismatch: %+v", walletClient.lastHostSalaryProgress)
|
||||
}
|
||||
var response httpkit.ResponseEnvelope
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode response failed: %v", err)
|
||||
}
|
||||
data := response.Data.(map[string]any)
|
||||
policy := data["policy"].(map[string]any)
|
||||
levels := policy["levels"].([]any)
|
||||
firstLevel := levels[0].(map[string]any)
|
||||
progress := data["progress"].(map[string]any)
|
||||
levelProgress := data["level_progress"].(map[string]any)
|
||||
if data["found"] != true || data["host_region_id"] != float64(30) || policy["policy_id"] != "9001" || policy["settlement_mode"] != "half_month" || firstLevel["host_salary_usd"] != 15.0 || firstLevel["agency_salary_usd"] != 3.0 {
|
||||
t.Fatalf("host policy response mismatch: %+v", data)
|
||||
}
|
||||
if progress["cycle_key"] != "2026-06" || progress["total_diamonds"] != float64(1200) {
|
||||
t.Fatalf("host salary progress response mismatch: %+v", progress)
|
||||
}
|
||||
if levelProgress["current_level"] != float64(1) || levelProgress["current_value"] != float64(1200) || levelProgress["next_level"] != float64(2) || levelProgress["needed_for_next_level"] != float64(1800) {
|
||||
t.Fatalf("host level progress response mismatch: %+v", levelProgress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostCenterPlatformPolicyRejectsNonActiveHost(t *testing.T) {
|
||||
hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{
|
||||
UserId: 42,
|
||||
Status: "pending",
|
||||
RegionId: 30,
|
||||
}}
|
||||
walletClient := &fakeWalletClient{}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||
handler.SetUserHostClient(hostClient)
|
||||
handler.SetWalletClient(walletClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/host-center/platform-policy", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if walletClient.lastHostSalaryPolicy != nil {
|
||||
t.Fatalf("wallet should not be called for non-active host: %+v", walletClient.lastHostSalaryPolicy)
|
||||
}
|
||||
if walletClient.lastHostSalaryProgress != nil {
|
||||
t.Fatalf("progress should not be called for non-active host: %+v", walletClient.lastHostSalaryProgress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgencyCenterOverviewUsesOwnerAgencySalaryAndPendingApplications(t *testing.T) {
|
||||
hostClient := &fakeUserHostClient{
|
||||
roleSummary: &userv1.UserRoleSummary{UserId: 42, IsManager: true, AgencyId: 7001},
|
||||
@ -3463,6 +3665,27 @@ func TestConfirmMicPublishingForwardsSessionVersionAndEventTime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMicHeartbeatForwardsCurrentSession(t *testing.T) {
|
||||
client := &fakeRoomClient{}
|
||||
router := NewHandler(client).Routes(auth.NewVerifier("secret"))
|
||||
body := []byte(`{"room_id":"room-1","command_id":"cmd-mic-heartbeat","target_user_id":43,"mic_session_id":"mic-session-1"}`)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/mic/heartbeat", bytes.NewReader(body))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-mic-heartbeat")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
assertEnvelope(t, recorder, http.StatusOK, httpkit.CodeOK, "req-mic-heartbeat")
|
||||
if client.lastMicHeartbeat == nil {
|
||||
t.Fatal("MicHeartbeat request was not sent")
|
||||
}
|
||||
if client.lastMicHeartbeat.GetTargetUserId() != 43 ||
|
||||
client.lastMicHeartbeat.GetMicSessionId() != "mic-session-1" {
|
||||
t.Fatalf("mic heartbeat fields mismatch: %+v", client.lastMicHeartbeat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThirdPartyLoginUsesPublicEnvelopeAndPropagatesRequestID(t *testing.T) {
|
||||
userClient := &fakeUserAuthClient{}
|
||||
router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret"))
|
||||
@ -5335,6 +5558,7 @@ func TestProfileGateRejectsIncompleteUsersForRoomIMRTCPaidCapabilities(t *testin
|
||||
{name: "profile_update", method: http.MethodPost, path: "/api/v1/rooms/profile/update", 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: "mic_heartbeat", method: http.MethodPost, path: "/api/v1/rooms/mic/heartbeat", body: `{"room_id":"room-1","mic_session_id":"mic-1"}`},
|
||||
{name: "im_usersig", method: http.MethodGet, path: "/api/v1/im/usersig"},
|
||||
{name: "rtc_token", method: http.MethodPost, path: "/api/v1/rtc/token", body: `{"room_id":"room-1"}`},
|
||||
{name: "send_gift", method: http.MethodPost, path: "/api/v1/rooms/gift/send", body: `{"room_id":"room-1","gift_id":"rose"}`},
|
||||
|
||||
@ -77,6 +77,7 @@ func (h *Handler) RoomHandlers() httproutes.RoomHandlers {
|
||||
MicDown: h.micDown,
|
||||
ChangeMicSeat: h.changeMicSeat,
|
||||
ConfirmMicPublishing: h.confirmMicPublishing,
|
||||
MicHeartbeat: h.micHeartbeat,
|
||||
SetMicMute: h.setMicMute,
|
||||
SetMicSeatLock: h.setMicSeatLock,
|
||||
SetChatEnabled: h.setChatEnabled,
|
||||
|
||||
@ -1073,6 +1073,27 @@ func (h *Handler) confirmMicPublishing(writer http.ResponseWriter, request *http
|
||||
httpkit.Write(writer, request, resp, err)
|
||||
}
|
||||
|
||||
// micHeartbeat 把客户端麦上心跳转换为 room-service 当前 mic_session 刷新命令。
|
||||
func (h *Handler) micHeartbeat(writer http.ResponseWriter, request *http.Request) {
|
||||
var body struct {
|
||||
RoomID string `json:"room_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
TargetUserID int64 `json:"target_user_id"`
|
||||
MicSessionID string `json:"mic_session_id"`
|
||||
}
|
||||
|
||||
if !httpkit.Decode(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.roomClient.MicHeartbeat(request.Context(), &roomv1.MicHeartbeatRequest{
|
||||
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
|
||||
TargetUserId: body.TargetUserID,
|
||||
MicSessionId: body.MicSessionID,
|
||||
})
|
||||
httpkit.Write(writer, request, resp, err)
|
||||
}
|
||||
|
||||
// setMicMute 把麦克风静音 HTTP 请求转换为 room-service SetMicMute 命令。
|
||||
func (h *Handler) setMicMute(writer http.ResponseWriter, request *http.Request) {
|
||||
var body struct {
|
||||
|
||||
@ -332,6 +332,7 @@ type roomSeatData struct {
|
||||
PublishDeadlineMS int64 `json:"publish_deadline_ms,omitempty"`
|
||||
MicMuted bool `json:"mic_muted"`
|
||||
SeatStatus string `json:"seat_status,omitempty"`
|
||||
MicHeartbeatAtMS int64 `json:"mic_heartbeat_at_ms,omitempty"`
|
||||
}
|
||||
|
||||
type roomRankItemData struct {
|
||||
@ -653,6 +654,7 @@ func roomSeatDataFromSnapshot(snapshot *roomv1.RoomSnapshot) []roomSeatData {
|
||||
PublishDeadlineMS: seat.GetPublishDeadlineMs(),
|
||||
MicMuted: seat.GetMicMuted(),
|
||||
SeatStatus: seat.GetSeatStatus(),
|
||||
MicHeartbeatAtMS: seat.GetMicHeartbeatAtMs(),
|
||||
})
|
||||
}
|
||||
return items
|
||||
|
||||
@ -56,6 +56,8 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers {
|
||||
GetMyRoleSummary: h.getMyRoleSummary,
|
||||
SearchHostAgencies: h.searchHostAgencies,
|
||||
ApplyToHostAgency: h.applyToHostAgency,
|
||||
GetHostCenterAgency: h.getHostCenterAgency,
|
||||
GetHostCenterPlatformPolicy: h.getHostCenterPlatformPolicy,
|
||||
GetAgencyCenterOverview: h.getAgencyCenterOverview,
|
||||
ListAgencyCenterHosts: h.listAgencyCenterHosts,
|
||||
ListAgencyCenterApplications: h.listAgencyCenterApplications,
|
||||
|
||||
@ -0,0 +1,69 @@
|
||||
package userapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
)
|
||||
|
||||
type hostCenterAgencyData struct {
|
||||
AgencyID string `json:"agency_id"`
|
||||
ShortID string `json:"short_id"`
|
||||
Name string `json:"name"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) getHostCenterAgency(writer http.ResponseWriter, request *http.Request) {
|
||||
profile, ok := h.resolveCurrentActiveHostProfile(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if profile.GetCurrentAgencyId() <= 0 {
|
||||
httpkit.WriteOK(writer, request, map[string]any{"agency": nil})
|
||||
return
|
||||
}
|
||||
if h.userHostClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
|
||||
// 当前主播所属 agency 只能从 user-service 的 host_profile 快照读取,前端不能传任意 agency_id 查看。
|
||||
agencyResp, err := h.userHostClient.GetAgency(request.Context(), &userv1.GetAgencyRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
AgencyId: profile.GetCurrentAgencyId(),
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
agency := agencyResp.GetAgency()
|
||||
if agency == nil || agency.GetAgencyId() != profile.GetCurrentAgencyId() || agency.GetStatus() != agencyStatusActive {
|
||||
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
data := hostCenterAgencyData{
|
||||
AgencyID: int64String(agency.GetAgencyId()),
|
||||
ShortID: int64String(agency.GetAgencyId()),
|
||||
Name: agency.GetName(),
|
||||
Avatar: agency.GetAvatar(),
|
||||
}
|
||||
if agency.GetOwnerUserId() > 0 {
|
||||
profiles, err := h.userProfiles(request, []int64{agency.GetOwnerUserId()})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
if owner, exists := profiles[agency.GetOwnerUserId()]; exists {
|
||||
if owner.DisplayUserID != "" {
|
||||
data.ShortID = owner.DisplayUserID
|
||||
}
|
||||
if owner.Avatar != "" {
|
||||
data.Avatar = owner.Avatar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
httpkit.WriteOK(writer, request, map[string]any{"agency": data})
|
||||
}
|
||||
@ -0,0 +1,219 @@
|
||||
package userapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
)
|
||||
|
||||
const hostProfileStatusActive = "active"
|
||||
|
||||
type hostCenterPolicyData struct {
|
||||
PolicyID string `json:"policy_id"`
|
||||
Name string `json:"name"`
|
||||
RegionID int64 `json:"region_id"`
|
||||
Status string `json:"status"`
|
||||
SettlementMode string `json:"settlement_mode"`
|
||||
SettlementTriggerMode string `json:"settlement_trigger_mode"`
|
||||
GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"`
|
||||
ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"`
|
||||
EffectiveFromMS int64 `json:"effective_from_ms"`
|
||||
EffectiveToMS int64 `json:"effective_to_ms"`
|
||||
Levels []hostCenterPolicyLevelData `json:"levels"`
|
||||
}
|
||||
|
||||
type hostCenterPolicyLevelData struct {
|
||||
LevelNo int32 `json:"level_no"`
|
||||
RequiredDiamonds int64 `json:"required_diamonds"`
|
||||
HostSalaryUSDMinor int64 `json:"host_salary_usd_minor"`
|
||||
HostSalaryUSD float64 `json:"host_salary_usd"`
|
||||
HostCoinReward int64 `json:"host_coin_reward"`
|
||||
AgencySalaryUSDMinor int64 `json:"agency_salary_usd_minor"`
|
||||
AgencySalaryUSD float64 `json:"agency_salary_usd"`
|
||||
Status string `json:"status"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
}
|
||||
|
||||
type hostCenterSalaryProgressData struct {
|
||||
HostUserID int64 `json:"host_user_id"`
|
||||
CycleKey string `json:"cycle_key"`
|
||||
RegionID int64 `json:"region_id"`
|
||||
AgencyOwnerUserID int64 `json:"agency_owner_user_id"`
|
||||
TotalDiamonds int64 `json:"total_diamonds"`
|
||||
GiftDiamondTotal int64 `json:"gift_diamond_total"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
type hostCenterLevelProgressData struct {
|
||||
CurrentLevel int32 `json:"current_level"`
|
||||
CurrentRequiredDiamonds int64 `json:"current_required_diamonds"`
|
||||
CurrentValue int64 `json:"current_value"`
|
||||
NextLevel int32 `json:"next_level"`
|
||||
NextRequiredDiamonds int64 `json:"next_required_diamonds"`
|
||||
NeededForNextLevel int64 `json:"needed_for_next_level"`
|
||||
}
|
||||
|
||||
func (h *Handler) getHostCenterPlatformPolicy(writer http.ResponseWriter, request *http.Request) {
|
||||
profile, ok := h.resolveCurrentActiveHostProfile(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if h.walletClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
|
||||
// H5 不传 region 或 policy id;gateway 固定使用当前主播身份里的区域快照读取实际生效政策。
|
||||
resp, err := h.walletClient.GetActiveHostSalaryPolicy(request.Context(), &walletv1.GetActiveHostSalaryPolicyRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
RegionId: profile.GetRegionId(),
|
||||
SettlementTriggerMode: "automatic",
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 等级进度必须按工资周期钻石账户计算,不能使用用户成长等级,否则“距离下一级”会和后台工资政策不一致。
|
||||
progressResp, err := h.walletClient.GetHostSalaryProgress(request.Context(), &walletv1.GetHostSalaryProgressRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
HostUserId: profile.GetUserId(),
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
progress := progressResp.GetProgress()
|
||||
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"found": resp.GetFound(),
|
||||
"host_region_id": profile.GetRegionId(),
|
||||
"policy": hostCenterPolicyFromProto(resp.GetPolicy()),
|
||||
"progress": hostCenterSalaryProgressFromProto(progress),
|
||||
"level_progress": hostCenterLevelProgressFromPolicy(resp.GetPolicy(), progress.GetTotalDiamonds()),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) resolveCurrentActiveHostProfile(writer http.ResponseWriter, request *http.Request) (*userv1.HostProfile, bool) {
|
||||
if h.userHostClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return nil, false
|
||||
}
|
||||
userID := auth.UserIDFromContext(request.Context())
|
||||
resp, err := h.userHostClient.GetHostProfile(request.Context(), &userv1.GetHostProfileRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
UserId: userID,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return nil, false
|
||||
}
|
||||
profile := resp.GetHostProfile()
|
||||
if profile == nil || profile.GetUserId() != userID || profile.GetStatus() != hostProfileStatusActive || profile.GetRegionId() <= 0 {
|
||||
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
|
||||
return nil, false
|
||||
}
|
||||
return profile, true
|
||||
}
|
||||
|
||||
func hostCenterPolicyFromProto(policy *walletv1.HostSalaryPolicy) *hostCenterPolicyData {
|
||||
if policy == nil {
|
||||
return nil
|
||||
}
|
||||
levels := make([]hostCenterPolicyLevelData, 0, len(policy.GetLevels()))
|
||||
for _, level := range policy.GetLevels() {
|
||||
levels = append(levels, hostCenterPolicyLevelFromProto(level))
|
||||
}
|
||||
return &hostCenterPolicyData{
|
||||
PolicyID: strconv.FormatUint(policy.GetPolicyId(), 10),
|
||||
Name: policy.GetName(),
|
||||
RegionID: policy.GetRegionId(),
|
||||
Status: policy.GetStatus(),
|
||||
SettlementMode: policy.GetSettlementMode(),
|
||||
SettlementTriggerMode: policy.GetSettlementTriggerMode(),
|
||||
GiftCoinToDiamondRatio: policy.GetGiftCoinToDiamondRatio(),
|
||||
ResidualDiamondToUSDRate: policy.GetResidualDiamondToUsdRate(),
|
||||
EffectiveFromMS: policy.GetEffectiveFromMs(),
|
||||
EffectiveToMS: policy.GetEffectiveToMs(),
|
||||
Levels: levels,
|
||||
}
|
||||
}
|
||||
|
||||
func hostCenterPolicyLevelFromProto(level *walletv1.HostSalaryPolicyLevel) hostCenterPolicyLevelData {
|
||||
if level == nil {
|
||||
return hostCenterPolicyLevelData{}
|
||||
}
|
||||
return hostCenterPolicyLevelData{
|
||||
LevelNo: level.GetLevelNo(),
|
||||
RequiredDiamonds: level.GetRequiredDiamonds(),
|
||||
HostSalaryUSDMinor: level.GetHostSalaryUsdMinor(),
|
||||
HostSalaryUSD: float64(level.GetHostSalaryUsdMinor()) / 100,
|
||||
HostCoinReward: level.GetHostCoinReward(),
|
||||
AgencySalaryUSDMinor: level.GetAgencySalaryUsdMinor(),
|
||||
AgencySalaryUSD: float64(level.GetAgencySalaryUsdMinor()) / 100,
|
||||
Status: level.GetStatus(),
|
||||
SortOrder: level.GetSortOrder(),
|
||||
}
|
||||
}
|
||||
|
||||
func hostCenterSalaryProgressFromProto(progress *walletv1.HostSalaryProgress) hostCenterSalaryProgressData {
|
||||
if progress == nil {
|
||||
return hostCenterSalaryProgressData{}
|
||||
}
|
||||
return hostCenterSalaryProgressData{
|
||||
HostUserID: progress.GetHostUserId(),
|
||||
CycleKey: progress.GetCycleKey(),
|
||||
RegionID: progress.GetRegionId(),
|
||||
AgencyOwnerUserID: progress.GetAgencyOwnerUserId(),
|
||||
TotalDiamonds: progress.GetTotalDiamonds(),
|
||||
GiftDiamondTotal: progress.GetGiftDiamondTotal(),
|
||||
UpdatedAtMS: progress.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func hostCenterLevelProgressFromPolicy(policy *walletv1.HostSalaryPolicy, currentValue int64) *hostCenterLevelProgressData {
|
||||
levels := policy.GetLevels()
|
||||
if len(levels) == 0 {
|
||||
return nil
|
||||
}
|
||||
var current *walletv1.HostSalaryPolicyLevel
|
||||
var next *walletv1.HostSalaryPolicyLevel
|
||||
for _, level := range levels {
|
||||
if level == nil {
|
||||
continue
|
||||
}
|
||||
required := level.GetRequiredDiamonds()
|
||||
if required <= currentValue {
|
||||
if current == nil || required > current.GetRequiredDiamonds() {
|
||||
current = level
|
||||
}
|
||||
continue
|
||||
}
|
||||
if next == nil || required < next.GetRequiredDiamonds() {
|
||||
next = level
|
||||
}
|
||||
}
|
||||
result := &hostCenterLevelProgressData{
|
||||
CurrentValue: currentValue,
|
||||
}
|
||||
if current != nil {
|
||||
result.CurrentLevel = current.GetLevelNo()
|
||||
result.CurrentRequiredDiamonds = current.GetRequiredDiamonds()
|
||||
}
|
||||
if next != nil {
|
||||
result.NextLevel = next.GetLevelNo()
|
||||
result.NextRequiredDiamonds = next.GetRequiredDiamonds()
|
||||
result.NeededForNextLevel = next.GetRequiredDiamonds() - currentValue
|
||||
return result
|
||||
}
|
||||
result.NextLevel = result.CurrentLevel
|
||||
result.NextRequiredDiamonds = result.CurrentRequiredDiamonds
|
||||
return result
|
||||
}
|
||||
@ -212,14 +212,12 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
|
||||
base.Attributes = map[string]string{"enabled": fmt.Sprintf("%t", body.GetEnabled())}
|
||||
return base, true, nil
|
||||
case "RoomPasswordChanged":
|
||||
// 锁房事件只广播布尔状态,密码明文和哈希都不会进入 IM 自定义消息。
|
||||
// 锁房事件改由 activity-service 按 visible_region_id 生成区域通知;房间群不再重复投递。
|
||||
var body roomeventsv1.RoomPasswordChanged
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
|
||||
return tencentim.RoomEvent{}, false, err
|
||||
}
|
||||
base.ActorUserID = body.GetActorUserId()
|
||||
base.Attributes = map[string]string{"locked": fmt.Sprintf("%t", body.GetLocked())}
|
||||
return base, true, nil
|
||||
return tencentim.RoomEvent{}, false, nil
|
||||
case "RoomAdminChanged":
|
||||
// 管理员变更进入系统消息,客户端收到后仍应以最新 snapshot 修正本地权限 UI。
|
||||
var body roomeventsv1.RoomAdminChanged
|
||||
|
||||
@ -87,6 +87,25 @@ func TestRoomBackgroundChangedPublishesDedicatedIMEvent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomPasswordChangedSkipsRoomGroupIM(t *testing.T) {
|
||||
record, err := outbox.Build("room-lock", "RoomPasswordChanged", 8, time.Now(), &roomeventsv1.RoomPasswordChanged{
|
||||
ActorUserId: 42,
|
||||
Locked: true,
|
||||
VisibleRegionId: 86,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Build RoomPasswordChanged envelope failed: %v", err)
|
||||
}
|
||||
|
||||
event, publish, err := roomEventFromEnvelope(record.Envelope)
|
||||
if err != nil {
|
||||
t.Fatalf("RoomPasswordChanged should decode: %v", err)
|
||||
}
|
||||
if publish {
|
||||
t.Fatalf("RoomPasswordChanged must use activity region broadcast, not room group IM: %+v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTencentIMPublisherRemovesGroupMemberBeforeKickMessage(t *testing.T) {
|
||||
paths := make([]string, 0, 2)
|
||||
client, err := tencentim.NewRESTClient(tencentim.RESTConfig{
|
||||
|
||||
@ -225,6 +225,18 @@ type ConfirmMicPublishing struct {
|
||||
// Type 返回命令类型。
|
||||
func (ConfirmMicPublishing) Type() string { return "confirm_mic_publishing" }
|
||||
|
||||
// MicHeartbeat 定义当前麦位会话的服务端心跳刷新请求。
|
||||
type MicHeartbeat struct {
|
||||
Base
|
||||
// TargetUserID 是被刷新麦上心跳的用户;为空时服务层使用 ActorUserID。
|
||||
TargetUserID int64 `json:"target_user_id"`
|
||||
// MicSessionID 必须匹配当前麦位会话,避免旧客户端心跳续住新麦位。
|
||||
MicSessionID string `json:"mic_session_id"`
|
||||
}
|
||||
|
||||
// Type 返回命令类型。
|
||||
func (MicHeartbeat) Type() string { return "mic_heartbeat" }
|
||||
|
||||
// SetMicMute 定义麦克风静音状态同步请求。
|
||||
type SetMicMute struct {
|
||||
Base
|
||||
@ -563,6 +575,8 @@ func Deserialize(commandType string, payload []byte) (Command, error) {
|
||||
cmd = &MicDown{}
|
||||
case ConfirmMicPublishing{}.Type():
|
||||
cmd = &ConfirmMicPublishing{}
|
||||
case MicHeartbeat{}.Type():
|
||||
cmd = &MicHeartbeat{}
|
||||
case SetMicMute{}.Type():
|
||||
cmd = &SetMicMute{}
|
||||
case ApplyRTCEvent{}.Type():
|
||||
|
||||
@ -102,4 +102,26 @@ func TestRoomPasswordControlsJoinAndListLockedFlag(t *testing.T) {
|
||||
if joinResp.GetUser().GetUserId() != viewerID || !joinResp.GetRoom().GetLocked() {
|
||||
t.Fatalf("join response mismatch: user=%+v room=%+v", joinResp.GetUser(), joinResp.GetRoom())
|
||||
}
|
||||
|
||||
if _, err := svc.SetRoomPassword(ctx, &roomv1.SetRoomPasswordRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, ownerID),
|
||||
Locked: true,
|
||||
Password: "5678",
|
||||
}); err != nil {
|
||||
t.Fatalf("change room password after viewer joined failed: %v", err)
|
||||
}
|
||||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, viewerID),
|
||||
Role: "audience",
|
||||
Password: "1234",
|
||||
}); err == nil {
|
||||
t.Fatalf("existing online user must not bypass changed room password")
|
||||
}
|
||||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, viewerID),
|
||||
Role: "audience",
|
||||
Password: "5678",
|
||||
}); err != nil {
|
||||
t.Fatalf("existing online user should reconnect with latest room password: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -68,6 +68,7 @@ func (s *Service) MicUp(ctx context.Context, req *roomv1.MicUpRequest) (*roomv1.
|
||||
// 新 session 尚未接受任何 RTC/客户端事件;不能用服务端 MicUp 时间初始化,
|
||||
// 否则客户端设备时间略慢时,合法的首次确认会被误判为旧事件。
|
||||
current.MicSeats[index].LastPublishEventTimeMS = 0
|
||||
current.MicSeats[index].MicHeartbeatAtMS = 0
|
||||
current.MicSeats[index].MicMuted = false
|
||||
seatStatus := current.MicSeats[index].SeatStatus()
|
||||
|
||||
@ -283,6 +284,7 @@ func (s *Service) ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatRe
|
||||
current.MicSeats[fromIndex].PublishDeadlineMS = 0
|
||||
current.MicSeats[fromIndex].MicSessionRoomVersion = 0
|
||||
current.MicSeats[fromIndex].LastPublishEventTimeMS = 0
|
||||
current.MicSeats[fromIndex].MicHeartbeatAtMS = 0
|
||||
current.MicSeats[fromIndex].MicMuted = false
|
||||
current.MicSeats[toIndex].UserID = cmd.TargetUserID
|
||||
current.MicSeats[toIndex].PublishState = movedSeat.PublishState
|
||||
@ -290,6 +292,7 @@ func (s *Service) ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatRe
|
||||
current.MicSeats[toIndex].PublishDeadlineMS = movedSeat.PublishDeadlineMS
|
||||
current.MicSeats[toIndex].MicSessionRoomVersion = movedSeat.MicSessionRoomVersion
|
||||
current.MicSeats[toIndex].LastPublishEventTimeMS = movedSeat.LastPublishEventTimeMS
|
||||
current.MicSeats[toIndex].MicHeartbeatAtMS = movedSeat.MicHeartbeatAtMS
|
||||
current.MicSeats[toIndex].MicMuted = movedSeat.MicMuted
|
||||
current.Version++
|
||||
seatStatus := current.MicSeats[toIndex].SeatStatus()
|
||||
@ -391,6 +394,8 @@ func (s *Service) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmM
|
||||
index := current.SeatIndex(seat.SeatNo)
|
||||
current.MicSeats[index].PublishState = state.MicPublishPublishing
|
||||
current.MicSeats[index].LastPublishEventTimeMS = cmd.EventTimeMS
|
||||
// 确认发流本身代表当前会话刚被服务端接受,先初始化心跳时间,后续 MicHeartbeat 只负责刷新这个独立字段。
|
||||
current.MicSeats[index].MicHeartbeatAtMS = now.UnixMilli()
|
||||
if micPublishConfirmSourceIsMuted(cmd.Source) {
|
||||
current.MicSeats[index].MicMuted = true
|
||||
}
|
||||
@ -419,6 +424,7 @@ func (s *Service) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmM
|
||||
seatNo: seat.SeatNo,
|
||||
micSessionID: cmd.MicSessionID,
|
||||
publishDeadlineMS: seat.PublishDeadlineMS,
|
||||
micHeartbeatAtMS: current.MicSeats[index].MicHeartbeatAtMS,
|
||||
syncEvent: &tencentim.RoomEvent{
|
||||
EventID: micEvent.EventID,
|
||||
RoomID: current.RoomID,
|
||||
@ -450,6 +456,77 @@ func (s *Service) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmM
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MicHeartbeat 刷新当前 publishing 麦位会话的服务端心跳时间。
|
||||
func (s *Service) MicHeartbeat(ctx context.Context, req *roomv1.MicHeartbeatRequest) (*roomv1.MicHeartbeatResponse, error) {
|
||||
ctx = contextFromMeta(ctx, req.GetMeta())
|
||||
cmd := command.MicHeartbeat{
|
||||
Base: baseFromMeta(req.GetMeta()),
|
||||
TargetUserID: req.GetTargetUserId(),
|
||||
MicSessionID: req.GetMicSessionId(),
|
||||
}
|
||||
if cmd.TargetUserID == 0 {
|
||||
cmd.TargetUserID = cmd.ActorUserID()
|
||||
}
|
||||
|
||||
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 cmd.TargetUserID <= 0 || cmd.MicSessionID == "" {
|
||||
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "mic heartbeat is incomplete")
|
||||
}
|
||||
if err := requireActorPresent(current, cmd.ActorUserID()); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if cmd.ActorUserID() != cmd.TargetUserID {
|
||||
if err := canManageTarget(current, cmd.ActorUserID(), cmd.TargetUserID); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
seat, exists := current.SeatByUser(cmd.TargetUserID)
|
||||
if !exists {
|
||||
return mutationResult{}, nil, xerr.New(xerr.NotFound, "target not on seat")
|
||||
}
|
||||
if seat.MicSessionID != cmd.MicSessionID {
|
||||
// 旧客户端或旧 RTC 会话的心跳不能续住新麦位会话。
|
||||
return mutationResult{}, nil, xerr.New(xerr.Conflict, "mic session mismatch")
|
||||
}
|
||||
if seat.PublishState != state.MicPublishPublishing {
|
||||
// pending_publish 还没有有效发流,只能先走 ConfirmMicPublishing,不能靠心跳开始计时。
|
||||
return mutationResult{}, nil, xerr.New(xerr.Conflict, "mic is not publishing")
|
||||
}
|
||||
|
||||
heartbeatAtMS := now.UnixMilli()
|
||||
if existing, ok := current.OnlineUsers[cmd.TargetUserID]; ok {
|
||||
// 麦上心跳也证明用户仍处于房间页,顺手刷新通用 presence,避免客户端同时打两个心跳。
|
||||
existing.LastSeenAtMS = heartbeatAtMS
|
||||
}
|
||||
index := current.SeatIndex(seat.SeatNo)
|
||||
current.MicSeats[index].MicHeartbeatAtMS = heartbeatAtMS
|
||||
current.Version++
|
||||
|
||||
return mutationResult{
|
||||
snapshot: current.ToProto(),
|
||||
seatNo: seat.SeatNo,
|
||||
micHeartbeatAtMS: heartbeatAtMS,
|
||||
}, nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.micHeartbeatAtMS == 0 {
|
||||
result.seatNo, result.micHeartbeatAtMS = micHeartbeatFromSnapshot(result.snapshot, cmd.TargetUserID, cmd.MicSessionID)
|
||||
}
|
||||
|
||||
return &roomv1.MicHeartbeatResponse{
|
||||
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
||||
SeatNo: result.seatNo,
|
||||
Room: result.snapshot,
|
||||
MicHeartbeatAtMs: result.micHeartbeatAtMS,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetMicMute 修改麦位上的服务端可见静音态。
|
||||
func (s *Service) SetMicMute(ctx context.Context, req *roomv1.SetMicMuteRequest) (*roomv1.SetMicMuteResponse, error) {
|
||||
ctx = contextFromMeta(ctx, req.GetMeta())
|
||||
@ -555,6 +632,15 @@ func micPublishConfirmSourceIsMuted(source string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func micHeartbeatFromSnapshot(snapshot *roomv1.RoomSnapshot, userID int64, micSessionID string) (int32, int64) {
|
||||
for _, seat := range snapshot.GetMicSeats() {
|
||||
if seat.GetUserId() == userID && seat.GetMicSessionId() == micSessionID {
|
||||
return seat.GetSeatNo(), seat.GetMicHeartbeatAtMs()
|
||||
}
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
func protoSeatByUser(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.SeatState {
|
||||
if snapshot == nil {
|
||||
return nil
|
||||
|
||||
112
services/room-service/internal/room/service/mic_test.go
Normal file
112
services/room-service/internal/room/service/mic_test.go
Normal file
@ -0,0 +1,112 @@
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
"hyapp/services/room-service/internal/testutil/mysqltest"
|
||||
)
|
||||
|
||||
func TestMicHeartbeatRefreshesPublishingSessionAndPresence(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
now := &fixedRoomTreasureClock{now: time.Date(2026, 6, 4, 8, 0, 0, 0, time.UTC)}
|
||||
svc := newTreasureTestService(t, repository, &treasureTestWallet{}, now)
|
||||
|
||||
roomID := "room-mic-heartbeat"
|
||||
ownerID := int64(8601)
|
||||
speakerID := int64(8602)
|
||||
createTreasureRoom(t, ctx, svc, roomID, ownerID, 9101)
|
||||
joinTreasureRoom(t, ctx, svc, roomID, speakerID)
|
||||
|
||||
upResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
|
||||
Meta: treasureMeta(roomID, speakerID, "mic-heartbeat-up"),
|
||||
SeatNo: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("mic up failed: %v", err)
|
||||
}
|
||||
now.now = now.now.Add(500 * time.Millisecond)
|
||||
confirmResp, err := svc.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{
|
||||
Meta: treasureMeta(roomID, speakerID, "mic-heartbeat-confirm"),
|
||||
MicSessionId: upResp.GetMicSessionId(),
|
||||
RoomVersion: upResp.GetRoom().GetVersion(),
|
||||
EventTimeMs: now.Now().UnixMilli(),
|
||||
Source: "client",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("confirm mic publishing failed: %v", err)
|
||||
}
|
||||
confirmedSeat := seatByNo(confirmResp.GetRoom(), 2)
|
||||
if confirmedSeat == nil || confirmedSeat.GetPublishState() != "publishing" || confirmedSeat.GetMicHeartbeatAtMs() == 0 {
|
||||
t.Fatalf("publish confirmation must initialize heartbeat: %+v", confirmedSeat)
|
||||
}
|
||||
|
||||
now.now = now.now.Add(3 * time.Second)
|
||||
heartbeatAtMS := now.Now().UnixMilli()
|
||||
heartbeatResp, err := svc.MicHeartbeat(ctx, &roomv1.MicHeartbeatRequest{
|
||||
Meta: treasureMeta(roomID, speakerID, "mic-heartbeat-refresh"),
|
||||
MicSessionId: upResp.GetMicSessionId(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("mic heartbeat failed: %v", err)
|
||||
}
|
||||
if heartbeatResp.GetSeatNo() != 2 || heartbeatResp.GetMicHeartbeatAtMs() != heartbeatAtMS {
|
||||
t.Fatalf("heartbeat response mismatch: %+v want_at=%d", heartbeatResp, heartbeatAtMS)
|
||||
}
|
||||
seat := seatByNo(heartbeatResp.GetRoom(), 2)
|
||||
if seat == nil || seat.GetMicHeartbeatAtMs() != heartbeatAtMS {
|
||||
t.Fatalf("seat heartbeat must be visible in snapshot: %+v", seat)
|
||||
}
|
||||
user := onlineUserByID(heartbeatResp.GetRoom(), speakerID)
|
||||
if user == nil || user.GetLastSeenAtMs() != heartbeatAtMS {
|
||||
t.Fatalf("mic heartbeat must refresh room presence: %+v", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMicHeartbeatRejectsPendingSession(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
now := &fixedRoomTreasureClock{now: time.Date(2026, 6, 4, 8, 0, 0, 0, time.UTC)}
|
||||
svc := newTreasureTestService(t, repository, &treasureTestWallet{}, now)
|
||||
|
||||
roomID := "room-mic-heartbeat-pending"
|
||||
ownerID := int64(8701)
|
||||
speakerID := int64(8702)
|
||||
createTreasureRoom(t, ctx, svc, roomID, ownerID, 9101)
|
||||
joinTreasureRoom(t, ctx, svc, roomID, speakerID)
|
||||
|
||||
upResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
|
||||
Meta: treasureMeta(roomID, speakerID, "mic-heartbeat-pending-up"),
|
||||
SeatNo: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("mic up failed: %v", err)
|
||||
}
|
||||
if _, err := svc.MicHeartbeat(ctx, &roomv1.MicHeartbeatRequest{
|
||||
Meta: treasureMeta(roomID, speakerID, "mic-heartbeat-pending-refresh"),
|
||||
MicSessionId: upResp.GetMicSessionId(),
|
||||
}); err == nil {
|
||||
t.Fatalf("pending_publish session must not accept mic heartbeat before publish confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func seatByNo(snapshot *roomv1.RoomSnapshot, seatNo int32) *roomv1.SeatState {
|
||||
for _, seat := range snapshot.GetMicSeats() {
|
||||
if seat.GetSeatNo() == seatNo {
|
||||
return seat
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func onlineUserByID(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.RoomUser {
|
||||
for _, user := range snapshot.GetOnlineUsers() {
|
||||
if user.GetUserId() == userID {
|
||||
return user
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -49,6 +49,11 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
|
||||
delete(current.BanUsers, cmd.ActorUserID())
|
||||
}
|
||||
|
||||
if current.RoomPasswordHash != "" && current.OwnerUserID != cmd.ActorUserID() && !roomPasswordMatches(current.RoomPasswordHash, password) {
|
||||
// 锁房校验必须覆盖新进房和已有 presence 的重连;否则房主改密后,悬浮保活或 stale 未清理用户会绕过最新密码。
|
||||
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "room password is invalid")
|
||||
}
|
||||
|
||||
if existing, exists := current.OnlineUsers[cmd.ActorUserID()]; exists {
|
||||
// 重复 JoinRoom 表示重连或刷新业务 presence,只更新 last_seen,不重复发进房系统消息。
|
||||
existing.LastSeenAtMS = now.UnixMilli()
|
||||
@ -60,11 +65,6 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
|
||||
user: findProtoUser(snapshot, cmd.ActorUserID()),
|
||||
}, nil, nil
|
||||
}
|
||||
if current.RoomPasswordHash != "" && current.OwnerUserID != cmd.ActorUserID() && !roomPasswordMatches(current.RoomPasswordHash, password) {
|
||||
// 锁房只拦截新进入的非房主用户;已在房内的用户和 owner 不因为锁房状态重设被挤出。
|
||||
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "room password is invalid")
|
||||
}
|
||||
|
||||
current.OnlineUsers[cmd.ActorUserID()] = &state.RoomUserState{
|
||||
UserID: cmd.ActorUserID(),
|
||||
Role: cmd.Role,
|
||||
|
||||
@ -228,6 +228,22 @@ func replay(current *state.RoomState, cmd command.Command, committedAtMS int64)
|
||||
index := current.SeatIndex(seat.SeatNo)
|
||||
current.MicSeats[index].PublishState = state.MicPublishPublishing
|
||||
current.MicSeats[index].LastPublishEventTimeMS = typed.EventTimeMS
|
||||
current.MicSeats[index].MicHeartbeatAtMS = typed.SentAtMS
|
||||
current.Version++
|
||||
case *command.MicHeartbeat:
|
||||
seat, exists := current.SeatByUser(typed.TargetUserID)
|
||||
if !exists {
|
||||
return fmt.Errorf("mic_heartbeat replay target is not on seat: %d", typed.TargetUserID)
|
||||
}
|
||||
if seat.MicSessionID != typed.MicSessionID || seat.PublishState != state.MicPublishPublishing {
|
||||
return fmt.Errorf("mic_heartbeat replay target session is not publishing: %d", typed.TargetUserID)
|
||||
}
|
||||
if existing, ok := current.OnlineUsers[typed.TargetUserID]; ok {
|
||||
// 麦上心跳回放要恢复同步刷新的 presence 时间,避免 snapshot 落后时重启后又被 stale worker 清理。
|
||||
existing.LastSeenAtMS = typed.SentAtMS
|
||||
}
|
||||
index := current.SeatIndex(seat.SeatNo)
|
||||
current.MicSeats[index].MicHeartbeatAtMS = typed.SentAtMS
|
||||
current.Version++
|
||||
case *command.ApplyRTCEvent:
|
||||
switch typed.EventType {
|
||||
|
||||
@ -9,7 +9,6 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
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"
|
||||
@ -61,8 +60,9 @@ func (s *Service) SetRoomPassword(ctx context.Context, req *roomv1.SetRoomPasswo
|
||||
current.Version++
|
||||
currentHash := current.RoomPasswordHash
|
||||
passwordEvent, err := outbox.Build(current.RoomID, "RoomPasswordChanged", current.Version, now, &roomeventsv1.RoomPasswordChanged{
|
||||
ActorUserId: cmd.ActorUserID(),
|
||||
Locked: cmd.Locked,
|
||||
ActorUserId: cmd.ActorUserID(),
|
||||
Locked: cmd.Locked,
|
||||
VisibleRegionId: current.VisibleRegionID,
|
||||
})
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
@ -71,16 +71,6 @@ func (s *Service) SetRoomPassword(ctx context.Context, req *roomv1.SetRoomPasswo
|
||||
return mutationResult{
|
||||
snapshot: current.ToProto(),
|
||||
roomPasswordHash: ¤tHash,
|
||||
syncEvent: &tencentim.RoomEvent{
|
||||
EventID: passwordEvent.EventID,
|
||||
RoomID: current.RoomID,
|
||||
EventType: "room_password_changed",
|
||||
ActorUserID: cmd.ActorUserID(),
|
||||
RoomVersion: current.Version,
|
||||
Attributes: map[string]string{
|
||||
"locked": boolString(cmd.Locked),
|
||||
},
|
||||
},
|
||||
}, []outbox.Record{passwordEvent}, nil
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@ -107,6 +107,7 @@ func (s *Service) applyRTCAudioStarted(now time.Time, current *state.RoomState,
|
||||
index := current.SeatIndex(seat.SeatNo)
|
||||
current.MicSeats[index].PublishState = state.MicPublishPublishing
|
||||
current.MicSeats[index].LastPublishEventTimeMS = cmd.EventTimeMS
|
||||
current.MicSeats[index].MicHeartbeatAtMS = now.UnixMilli()
|
||||
current.Version++
|
||||
seatStatus := current.MicSeats[index].SeatStatus()
|
||||
|
||||
|
||||
@ -105,6 +105,8 @@ type mutationResult struct {
|
||||
micSessionID string
|
||||
// publishDeadlineMS 是 pending_publish 自动释放的截止时间。
|
||||
publishDeadlineMS int64
|
||||
// micHeartbeatAtMS 是服务端接受当前麦上心跳的时间,只属于麦位活跃探测,不改变时长口径。
|
||||
micHeartbeatAtMS int64
|
||||
// billingReceiptID 是 SendGift 从 wallet-service 得到的账务回执。
|
||||
billingReceiptID string
|
||||
// roomHeat 是 SendGift 后的房间热度。
|
||||
|
||||
@ -62,6 +62,8 @@ type MicSeat struct {
|
||||
MicSessionRoomVersion int64
|
||||
// LastPublishEventTimeMS 是已接受的最新 RTC/客户端发布事件时间。
|
||||
LastPublishEventTimeMS int64
|
||||
// MicHeartbeatAtMS 是当前 mic_session 最近一次服务端接受麦上心跳的时间;它不参与 RTC 事件新旧判断。
|
||||
MicHeartbeatAtMS int64
|
||||
// MicMuted 是服务端可见的麦克风静音状态,用于同步其他用户 UI。
|
||||
MicMuted bool
|
||||
}
|
||||
@ -379,6 +381,7 @@ func (s *RoomState) ToProto() *roomv1.RoomSnapshot {
|
||||
LastPublishEventTimeMs: seat.LastPublishEventTimeMS,
|
||||
MicMuted: seat.MicMuted,
|
||||
SeatStatus: seat.SeatStatus(),
|
||||
MicHeartbeatAtMs: seat.MicHeartbeatAtMS,
|
||||
})
|
||||
}
|
||||
|
||||
@ -455,6 +458,7 @@ func FromProto(snapshot *roomv1.RoomSnapshot) *RoomState {
|
||||
PublishDeadlineMS: seat.GetPublishDeadlineMs(),
|
||||
MicSessionRoomVersion: seat.GetMicSessionRoomVersion(),
|
||||
LastPublishEventTimeMS: seat.GetLastPublishEventTimeMs(),
|
||||
MicHeartbeatAtMS: seat.GetMicHeartbeatAtMs(),
|
||||
MicMuted: seat.GetMicMuted(),
|
||||
})
|
||||
}
|
||||
|
||||
@ -235,6 +235,18 @@ func (s *Server) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmMi
|
||||
return mapServiceResult(s.svc.ConfirmMicPublishing(ctx, req))
|
||||
}
|
||||
|
||||
// MicHeartbeat 代理到领域服务。
|
||||
func (s *Server) MicHeartbeat(ctx context.Context, req *roomv1.MicHeartbeatRequest) (*roomv1.MicHeartbeatResponse, error) {
|
||||
// 心跳仍由 Room Cell 串行提交,保证 presence、麦位快照和 command log 同版本。
|
||||
ctx = contextWithMetaApp(ctx, req.GetMeta())
|
||||
if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.MicHeartbeatResponse, error) {
|
||||
return client.MicHeartbeat(callCtx, req)
|
||||
}); forwarded {
|
||||
return resp, err
|
||||
}
|
||||
return mapServiceResult(s.svc.MicHeartbeat(ctx, req))
|
||||
}
|
||||
|
||||
// SetMicMute 代理到领域服务。
|
||||
func (s *Server) SetMicMute(ctx context.Context, req *roomv1.SetMicMuteRequest) (*roomv1.SetMicMuteResponse, error) {
|
||||
// 服务端可见静音态必须经 Room Cell 提交,确保其他用户 UI 和系统消息一致。
|
||||
|
||||
@ -227,6 +227,17 @@ type HostSalaryPolicyLevel struct {
|
||||
SortOrder int
|
||||
}
|
||||
|
||||
// HostSalaryProgress 是主播当前工资周期的钻石累计投影,用于 H5 按工资政策计算距离下一等级的差值。
|
||||
type HostSalaryProgress struct {
|
||||
HostUserID int64
|
||||
CycleKey string
|
||||
RegionID int64
|
||||
AgencyOwnerUserID int64
|
||||
TotalDiamonds int64
|
||||
GiftDiamondTotal int64
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
// CoinSellerTransferCommand 是币商给玩家转普通金币的账务命令。
|
||||
type CoinSellerTransferCommand struct {
|
||||
AppCode string
|
||||
|
||||
@ -21,6 +21,8 @@ type Repository interface {
|
||||
DebitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error)
|
||||
BatchDebitGift(ctx context.Context, command ledger.BatchDebitGiftCommand) (ledger.BatchGiftReceipt, error)
|
||||
GetBalances(ctx context.Context, userID int64, assetTypes []string) ([]ledger.AssetBalance, error)
|
||||
GetActiveHostSalaryPolicy(ctx context.Context, regionID int64, settlementMode string, triggerMode string, nowMs int64) (ledger.HostSalaryPolicy, bool, error)
|
||||
GetHostSalaryProgress(ctx context.Context, userID int64, cycleKey string) (ledger.HostSalaryProgress, error)
|
||||
AdminCreditAsset(ctx context.Context, command ledger.AdminCreditAssetCommand) (ledger.AssetBalance, string, error)
|
||||
AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error)
|
||||
TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error)
|
||||
@ -238,6 +240,54 @@ func (s *Service) GetBalances(ctx context.Context, userID int64, assetTypes []st
|
||||
return s.repository.GetBalances(ctx, userID, normalized)
|
||||
}
|
||||
|
||||
// GetActiveHostSalaryPolicy 读取主播所在区域当前生效的工资政策,返回的 policy 与工资结算使用同一张 runtime 表。
|
||||
func (s *Service) GetActiveHostSalaryPolicy(ctx context.Context, appCode string, regionID int64, settlementMode string, triggerMode string, nowMs int64) (ledger.HostSalaryPolicy, bool, error) {
|
||||
if regionID <= 0 {
|
||||
return ledger.HostSalaryPolicy{}, false, xerr.New(xerr.InvalidArgument, "region_id is required")
|
||||
}
|
||||
if s.repository == nil {
|
||||
return ledger.HostSalaryPolicy{}, false, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
||||
}
|
||||
settlementMode = strings.TrimSpace(settlementMode)
|
||||
if settlementMode != "" && settlementMode != ledger.HostSalarySettlementModeDaily && settlementMode != ledger.HostSalarySettlementModeHalfMonth {
|
||||
return ledger.HostSalaryPolicy{}, false, xerr.New(xerr.InvalidArgument, "settlement_mode is invalid")
|
||||
}
|
||||
triggerMode = strings.TrimSpace(triggerMode)
|
||||
if triggerMode == "" {
|
||||
triggerMode = ledger.HostSalarySettlementTriggerAutomatic
|
||||
}
|
||||
if triggerMode != ledger.HostSalarySettlementTriggerAutomatic && triggerMode != ledger.HostSalarySettlementTriggerManual {
|
||||
return ledger.HostSalaryPolicy{}, false, xerr.New(xerr.InvalidArgument, "settlement_trigger_mode is invalid")
|
||||
}
|
||||
if nowMs <= 0 {
|
||||
nowMs = s.now().UTC().UnixMilli()
|
||||
}
|
||||
appCode = appcode.Normalize(appCode)
|
||||
ctx = appcode.WithContext(ctx, appCode)
|
||||
// 不传 settlement_mode 时按同区域最新 active policy 返回,用于 H5 展示平台当前规则。
|
||||
return s.repository.GetActiveHostSalaryPolicy(ctx, regionID, settlementMode, triggerMode, nowMs)
|
||||
}
|
||||
|
||||
// GetHostSalaryProgress 返回主播当前工资周期的钻石累计;没有收礼账户时返回 0,避免 H5 用其他等级系统兜底导致进度口径错误。
|
||||
func (s *Service) GetHostSalaryProgress(ctx context.Context, appCode string, userID int64, cycleKey string, nowMs int64) (ledger.HostSalaryProgress, error) {
|
||||
if userID <= 0 {
|
||||
return ledger.HostSalaryProgress{}, xerr.New(xerr.InvalidArgument, "host_user_id is required")
|
||||
}
|
||||
if s.repository == nil {
|
||||
return ledger.HostSalaryProgress{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
||||
}
|
||||
if nowMs <= 0 {
|
||||
nowMs = s.now().UTC().UnixMilli()
|
||||
}
|
||||
cycleKey = strings.TrimSpace(cycleKey)
|
||||
if cycleKey == "" {
|
||||
cycleKey = hostSalaryCycleKey(nowMs)
|
||||
}
|
||||
appCode = appcode.Normalize(appCode)
|
||||
ctx = appcode.WithContext(ctx, appCode)
|
||||
return s.repository.GetHostSalaryProgress(ctx, userID, cycleKey)
|
||||
}
|
||||
|
||||
// AdminCreditAsset 执行后台手动调账;amount 为正数入账、负数扣账,审计字段必须随交易一起落库。
|
||||
func (s *Service) AdminCreditAsset(ctx context.Context, command ledger.AdminCreditAssetCommand) (ledger.AssetBalance, string, error) {
|
||||
if command.CommandID == "" || command.TargetUserID <= 0 || command.OperatorUserID <= 0 || command.Amount == 0 {
|
||||
|
||||
@ -287,6 +287,84 @@ func TestDebitGiftRejectsHostPeriodWithoutRegion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetActiveHostSalaryPolicyReadsRuntimePolicy 验证 H5 展示读取的是工资结算 runtime 表里的生效政策和档位。
|
||||
func TestGetActiveHostSalaryPolicyReadsRuntimePolicy(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
nowMs := time.Now().UnixMilli()
|
||||
repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{
|
||||
PolicyID: 901,
|
||||
Name: "current host policy",
|
||||
RegionID: 8801,
|
||||
Status: "active",
|
||||
SettlementMode: ledger.HostSalarySettlementModeHalfMonth,
|
||||
SettlementTriggerMode: ledger.HostSalarySettlementTriggerAutomatic,
|
||||
GiftCoinToDiamondRatio: "1.0000",
|
||||
ResidualDiamondToUSDRate: "0.0100",
|
||||
EffectiveFromMs: nowMs - 1000,
|
||||
EffectiveToMs: 0,
|
||||
Levels: []ledger.HostSalaryPolicyLevel{
|
||||
{LevelNo: 1, RequiredDiamonds: 1000, HostSalaryUSDMinor: 1500, HostCoinReward: 200, AgencySalaryUSDMinor: 300, SortOrder: 1},
|
||||
{LevelNo: 2, RequiredDiamonds: 3000, HostSalaryUSDMinor: 4500, HostCoinReward: 500, AgencySalaryUSDMinor: 900, SortOrder: 2},
|
||||
},
|
||||
})
|
||||
svc := walletservice.New(repository)
|
||||
|
||||
policy, found, err := svc.GetActiveHostSalaryPolicy(context.Background(), "lalu", 8801, "", ledger.HostSalarySettlementTriggerAutomatic, nowMs)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActiveHostSalaryPolicy failed: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected active host salary policy")
|
||||
}
|
||||
if policy.PolicyID != 901 || policy.Name != "current host policy" || policy.SettlementMode != ledger.HostSalarySettlementModeHalfMonth || policy.RegionID != 8801 {
|
||||
t.Fatalf("policy mismatch: %+v", policy)
|
||||
}
|
||||
if len(policy.Levels) != 2 || policy.Levels[1].LevelNo != 2 || policy.Levels[1].HostSalaryUSDMinor != 4500 || policy.Levels[1].AgencySalaryUSDMinor != 900 {
|
||||
t.Fatalf("policy levels mismatch: %+v", policy.Levels)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetHostSalaryProgressReadsCurrentCycleDiamonds 验证 H5 等级进度读取工资周期钻石账户,查询不到时保持 0 累计。
|
||||
func TestGetHostSalaryProgressReadsCurrentCycleDiamonds(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
repository.SetBalance(10001, 100)
|
||||
repository.SetGiftPrice("rose", "salary-progress", 200, 10, 10)
|
||||
svc := walletservice.New(repository)
|
||||
|
||||
receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
|
||||
CommandID: "cmd-salary-progress",
|
||||
RoomID: "room-1",
|
||||
SenderUserID: 10001,
|
||||
TargetUserID: 13002,
|
||||
GiftID: "rose",
|
||||
GiftCount: 1,
|
||||
PriceVersion: "salary-progress",
|
||||
SenderRegionID: 8801,
|
||||
TargetIsHost: true,
|
||||
TargetHostRegionID: 8801,
|
||||
TargetAgencyOwnerUserID: 30001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("salary gift failed: %v", err)
|
||||
}
|
||||
|
||||
progress, err := svc.GetHostSalaryProgress(context.Background(), "lalu", 13002, receipt.HostPeriodCycleKey, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetHostSalaryProgress failed: %v", err)
|
||||
}
|
||||
if progress.HostUserID != 13002 || progress.CycleKey != receipt.HostPeriodCycleKey || progress.RegionID != 8801 || progress.AgencyOwnerUserID != 30001 || progress.TotalDiamonds != 14 || progress.GiftDiamondTotal != 14 {
|
||||
t.Fatalf("host salary progress mismatch: %+v", progress)
|
||||
}
|
||||
|
||||
missing, err := svc.GetHostSalaryProgress(context.Background(), "lalu", 99999, "2026-06", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetHostSalaryProgress missing account failed: %v", err)
|
||||
}
|
||||
if missing.HostUserID != 99999 || missing.CycleKey != "2026-06" || missing.TotalDiamonds != 0 {
|
||||
t.Fatalf("missing progress must return zero account: %+v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostSalarySettlementCreditsIncrementalRewards 走真实送礼入钻石账户后,验证日结只发放等级累计差额。
|
||||
func TestHostSalarySettlementCreditsIncrementalRewards(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
|
||||
@ -68,6 +68,11 @@ type hostSalarySettlementMetadata struct {
|
||||
ProcessedAtMS int64 `json:"processed_at_ms"`
|
||||
}
|
||||
|
||||
type hostSalaryPolicyQuerier interface {
|
||||
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||||
}
|
||||
|
||||
// ProcessHostSalarySettlementBatch 按周期账户扫描并结算主播美元、主播金币奖励和代理美元工资。
|
||||
// 每个主播单独开事务,避免某个主播配置异常拖垮整个批次,也让 cron-service 可按 has_more 继续拉下一页。
|
||||
func (r *Repository) ProcessHostSalarySettlementBatch(ctx context.Context, command ledger.HostSalarySettlementBatchCommand) (ledger.HostSalarySettlementBatchResult, error) {
|
||||
@ -345,13 +350,53 @@ func (r *Repository) lockHostSalaryProgress(ctx context.Context, tx *sql.Tx, use
|
||||
return progress, nil
|
||||
}
|
||||
|
||||
// GetActiveHostSalaryPolicy 返回当前 app、区域和触发模式下正在生效的主播工资政策;这是 H5 展示平台政策的只读入口。
|
||||
func (r *Repository) GetActiveHostSalaryPolicy(ctx context.Context, regionID int64, settlementMode string, triggerMode string, nowMs int64) (ledger.HostSalaryPolicy, bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return ledger.HostSalaryPolicy{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
return r.queryActiveHostSalaryPolicy(ctx, r.db, regionID, settlementMode, triggerMode, nowMs)
|
||||
}
|
||||
|
||||
// GetHostSalaryProgress 读取主播工资周期钻石账户;没有任何收礼时返回空累计,前端据此展示距离首档还差多少钻石。
|
||||
func (r *Repository) GetHostSalaryProgress(ctx context.Context, userID int64, cycleKey string) (ledger.HostSalaryProgress, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return ledger.HostSalaryProgress{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
progress := ledger.HostSalaryProgress{
|
||||
HostUserID: userID,
|
||||
CycleKey: cycleKey,
|
||||
}
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT user_id, cycle_key, region_id, agency_owner_user_id, total_diamonds,
|
||||
gift_diamond_total, updated_at_ms
|
||||
FROM host_period_diamond_accounts
|
||||
WHERE app_code = ? AND user_id = ? AND cycle_key = ?`,
|
||||
appcode.FromContext(ctx), userID, cycleKey)
|
||||
if err := row.Scan(&progress.HostUserID, &progress.CycleKey, &progress.RegionID, &progress.AgencyOwnerUserID, &progress.TotalDiamonds, &progress.GiftDiamondTotal, &progress.UpdatedAtMS); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return progress, nil
|
||||
}
|
||||
return ledger.HostSalaryProgress{}, err
|
||||
}
|
||||
return progress, nil
|
||||
}
|
||||
|
||||
func (r *Repository) resolveHostSalaryPolicy(ctx context.Context, tx *sql.Tx, regionID int64, settlementType string, triggerMode string, nowMs int64) (ledger.HostSalaryPolicy, bool, error) {
|
||||
settlementMode := ""
|
||||
if settlementType != ledger.HostSalarySettlementTypeMonthEnd {
|
||||
settlementMode = settlementType
|
||||
}
|
||||
return r.queryActiveHostSalaryPolicy(ctx, tx, regionID, settlementMode, triggerMode, nowMs)
|
||||
}
|
||||
|
||||
func (r *Repository) queryActiveHostSalaryPolicy(ctx context.Context, q hostSalaryPolicyQuerier, regionID int64, settlementMode string, triggerMode string, nowMs int64) (ledger.HostSalaryPolicy, bool, error) {
|
||||
modeClause := ""
|
||||
args := []any{appcode.FromContext(ctx), regionID, nowMs, nowMs, triggerMode}
|
||||
if settlementType != ledger.HostSalarySettlementTypeMonthEnd {
|
||||
// 人工/自动触发模式也参与政策匹配,避免自动任务误结算只允许手动处理的政策。
|
||||
if strings.TrimSpace(settlementMode) != "" {
|
||||
// 结算任务必须精确匹配结算模式;H5 展示不传模式时只按区域和触发模式取最新生效政策。
|
||||
modeClause = "AND settlement_mode = ?"
|
||||
args = append(args, settlementType)
|
||||
args = append(args, settlementMode)
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
SELECT policy_id, name, region_id, status, settlement_mode, settlement_trigger_mode,
|
||||
@ -369,14 +414,14 @@ func (r *Repository) resolveHostSalaryPolicy(ctx context.Context, tx *sql.Tx, re
|
||||
ORDER BY effective_from_ms DESC, policy_id DESC
|
||||
LIMIT 1`, modeClause)
|
||||
var policy ledger.HostSalaryPolicy
|
||||
err := tx.QueryRowContext(ctx, query, args...).Scan(&policy.PolicyID, &policy.Name, &policy.RegionID, &policy.Status, &policy.SettlementMode, &policy.SettlementTriggerMode, &policy.GiftCoinToDiamondRatio, &policy.ResidualDiamondToUSDRate, &policy.EffectiveFromMs, &policy.EffectiveToMs)
|
||||
err := q.QueryRowContext(ctx, query, args...).Scan(&policy.PolicyID, &policy.Name, &policy.RegionID, &policy.Status, &policy.SettlementMode, &policy.SettlementTriggerMode, &policy.GiftCoinToDiamondRatio, &policy.ResidualDiamondToUSDRate, &policy.EffectiveFromMs, &policy.EffectiveToMs)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ledger.HostSalaryPolicy{}, false, nil
|
||||
}
|
||||
return ledger.HostSalaryPolicy{}, false, err
|
||||
}
|
||||
levels, err := r.listHostSalaryPolicyLevels(ctx, tx, policy.PolicyID)
|
||||
levels, err := r.listHostSalaryPolicyLevels(ctx, q, policy.PolicyID)
|
||||
if err != nil {
|
||||
return ledger.HostSalaryPolicy{}, false, err
|
||||
}
|
||||
@ -384,9 +429,9 @@ func (r *Repository) resolveHostSalaryPolicy(ctx context.Context, tx *sql.Tx, re
|
||||
return policy, true, nil
|
||||
}
|
||||
|
||||
func (r *Repository) listHostSalaryPolicyLevels(ctx context.Context, tx *sql.Tx, policyID uint64) ([]ledger.HostSalaryPolicyLevel, error) {
|
||||
func (r *Repository) listHostSalaryPolicyLevels(ctx context.Context, q hostSalaryPolicyQuerier, policyID uint64) ([]ledger.HostSalaryPolicyLevel, error) {
|
||||
// 等级必须按 required_diamonds 升序返回,highestHostSalaryLevel 依赖这个顺序找到最高已达档位。
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
rows, err := q.QueryContext(ctx, `
|
||||
SELECT level_no, required_diamonds, host_salary_usd_minor, host_coin_reward,
|
||||
agency_salary_usd_minor, status, sort_order
|
||||
FROM host_agency_salary_policy_levels
|
||||
|
||||
@ -130,6 +130,74 @@ func (s *Server) GetBalances(ctx context.Context, req *walletv1.GetBalancesReque
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// GetActiveHostSalaryPolicy 读取当前生效的主播工资政策,供 gateway 按当前主播区域展示平台规则。
|
||||
func (s *Server) GetActiveHostSalaryPolicy(ctx context.Context, req *walletv1.GetActiveHostSalaryPolicyRequest) (*walletv1.GetActiveHostSalaryPolicyResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
||||
policy, found, err := s.svc.GetActiveHostSalaryPolicy(ctx, req.GetAppCode(), req.GetRegionId(), req.GetSettlementMode(), req.GetSettlementTriggerMode(), req.GetNowMs())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
response := &walletv1.GetActiveHostSalaryPolicyResponse{Found: found}
|
||||
if found {
|
||||
response.Policy = hostSalaryPolicyToProto(policy)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// GetHostSalaryProgress 读取主播当前工资周期钻石累计,gateway 用它计算工资政策等级进度。
|
||||
func (s *Server) GetHostSalaryProgress(ctx context.Context, req *walletv1.GetHostSalaryProgressRequest) (*walletv1.GetHostSalaryProgressResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
||||
progress, err := s.svc.GetHostSalaryProgress(ctx, req.GetAppCode(), req.GetHostUserId(), req.GetCycleKey(), req.GetNowMs())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &walletv1.GetHostSalaryProgressResponse{Progress: hostSalaryProgressToProto(progress)}, nil
|
||||
}
|
||||
|
||||
func hostSalaryProgressToProto(progress ledger.HostSalaryProgress) *walletv1.HostSalaryProgress {
|
||||
return &walletv1.HostSalaryProgress{
|
||||
HostUserId: progress.HostUserID,
|
||||
CycleKey: progress.CycleKey,
|
||||
RegionId: progress.RegionID,
|
||||
AgencyOwnerUserId: progress.AgencyOwnerUserID,
|
||||
TotalDiamonds: progress.TotalDiamonds,
|
||||
GiftDiamondTotal: progress.GiftDiamondTotal,
|
||||
UpdatedAtMs: progress.UpdatedAtMS,
|
||||
}
|
||||
}
|
||||
|
||||
func hostSalaryPolicyToProto(policy ledger.HostSalaryPolicy) *walletv1.HostSalaryPolicy {
|
||||
levels := make([]*walletv1.HostSalaryPolicyLevel, 0, len(policy.Levels))
|
||||
for _, level := range policy.Levels {
|
||||
levels = append(levels, hostSalaryPolicyLevelToProto(level))
|
||||
}
|
||||
return &walletv1.HostSalaryPolicy{
|
||||
PolicyId: policy.PolicyID,
|
||||
Name: policy.Name,
|
||||
RegionId: policy.RegionID,
|
||||
Status: policy.Status,
|
||||
SettlementMode: policy.SettlementMode,
|
||||
SettlementTriggerMode: policy.SettlementTriggerMode,
|
||||
GiftCoinToDiamondRatio: policy.GiftCoinToDiamondRatio,
|
||||
ResidualDiamondToUsdRate: policy.ResidualDiamondToUSDRate,
|
||||
EffectiveFromMs: policy.EffectiveFromMs,
|
||||
EffectiveToMs: policy.EffectiveToMs,
|
||||
Levels: levels,
|
||||
}
|
||||
}
|
||||
|
||||
func hostSalaryPolicyLevelToProto(level ledger.HostSalaryPolicyLevel) *walletv1.HostSalaryPolicyLevel {
|
||||
return &walletv1.HostSalaryPolicyLevel{
|
||||
LevelNo: int32(level.LevelNo),
|
||||
RequiredDiamonds: level.RequiredDiamonds,
|
||||
HostSalaryUsdMinor: level.HostSalaryUSDMinor,
|
||||
HostCoinReward: level.HostCoinReward,
|
||||
AgencySalaryUsdMinor: level.AgencySalaryUSDMinor,
|
||||
Status: level.Status,
|
||||
SortOrder: int32(level.SortOrder),
|
||||
}
|
||||
}
|
||||
|
||||
// GetWalletOverview 返回钱包首页摘要,供我的页和钱包二级页复用。
|
||||
func (s *Server) GetWalletOverview(ctx context.Context, req *walletv1.GetWalletOverviewRequest) (*walletv1.GetWalletOverviewResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user