主播结算相关和房间锁房

This commit is contained in:
zhx 2026-06-04 18:31:06 +08:00
parent 8b2181dee7
commit 82dfb9238f
42 changed files with 3737 additions and 1460 deletions

View File

@ -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" +

View File

@ -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

View File

@ -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);

View File

@ -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

View File

@ -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);

View File

@ -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,

View 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` 或接口返回用户不在麦上时,停止本地麦上心跳。

View File

@ -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` 被不同请求体复用 |

View File

@ -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

View File

@ -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"

View File

@ -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,

View File

@ -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()

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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"}`},

View File

@ -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,

View File

@ -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 {

View File

@ -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

View File

@ -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,

View File

@ -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})
}

View File

@ -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 idgateway 固定使用当前主播身份里的区域快照读取实际生效政策。
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
}

View File

@ -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

View File

@ -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{

View File

@ -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():

View File

@ -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)
}
}

View File

@ -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

View 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
}

View File

@ -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,

View File

@ -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 {

View File

@ -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: &currentHash,
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 {

View File

@ -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()

View File

@ -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 后的房间热度。

View File

@ -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(),
})
}

View File

@ -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 和系统消息一致。

View File

@ -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

View File

@ -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 {

View File

@ -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)

View File

@ -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

View File

@ -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())