子币商和经理中心改版

This commit is contained in:
zhx 2026-07-09 13:02:49 +08:00
parent ba35148c94
commit a13fcd61c7
74 changed files with 14755 additions and 9241 deletions

File diff suppressed because it is too large Load Diff

View File

@ -488,6 +488,19 @@ message AdminSetRobotRoomStatusResponse {
int64 server_time_ms = 2; int64 server_time_ms = 2;
} }
message AdminRenameOwnerCountryCodeRequest {
RequestMeta meta = 1;
string old_country_code = 2;
string new_country_code = 3;
uint64 admin_id = 4;
}
message AdminRenameOwnerCountryCodeResponse {
int64 room_list_entries = 1;
int64 room_snapshots = 2;
int64 server_time_ms = 3;
}
message AdminFilterAvailableRoomRobotsRequest { message AdminFilterAvailableRoomRobotsRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
repeated int64 user_ids = 2; repeated int64 user_ids = 2;
@ -1462,6 +1475,7 @@ service RoomCommandService {
rpc AdminCancelRoomPin(AdminCancelRoomPinRequest) returns (AdminCancelRoomPinResponse); rpc AdminCancelRoomPin(AdminCancelRoomPinRequest) returns (AdminCancelRoomPinResponse);
rpc AdminCreateRobotRoom(AdminCreateRobotRoomRequest) returns (AdminCreateRobotRoomResponse); rpc AdminCreateRobotRoom(AdminCreateRobotRoomRequest) returns (AdminCreateRobotRoomResponse);
rpc AdminSetRobotRoomStatus(AdminSetRobotRoomStatusRequest) returns (AdminSetRobotRoomStatusResponse); rpc AdminSetRobotRoomStatus(AdminSetRobotRoomStatusRequest) returns (AdminSetRobotRoomStatusResponse);
rpc AdminRenameOwnerCountryCode(AdminRenameOwnerCountryCodeRequest) returns (AdminRenameOwnerCountryCodeResponse);
rpc MicUp(MicUpRequest) returns (MicUpResponse); rpc MicUp(MicUpRequest) returns (MicUpResponse);
rpc MicDown(MicDownRequest) returns (MicDownResponse); rpc MicDown(MicDownRequest) returns (MicDownResponse);
rpc ChangeMicSeat(ChangeMicSeatRequest) returns (ChangeMicSeatResponse); rpc ChangeMicSeat(ChangeMicSeatRequest) returns (ChangeMicSeatResponse);

View File

@ -36,6 +36,7 @@ const (
RoomCommandService_AdminCancelRoomPin_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminCancelRoomPin" RoomCommandService_AdminCancelRoomPin_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminCancelRoomPin"
RoomCommandService_AdminCreateRobotRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminCreateRobotRoom" RoomCommandService_AdminCreateRobotRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminCreateRobotRoom"
RoomCommandService_AdminSetRobotRoomStatus_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminSetRobotRoomStatus" RoomCommandService_AdminSetRobotRoomStatus_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminSetRobotRoomStatus"
RoomCommandService_AdminRenameOwnerCountryCode_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminRenameOwnerCountryCode"
RoomCommandService_MicUp_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicUp" RoomCommandService_MicUp_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicUp"
RoomCommandService_MicDown_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicDown" RoomCommandService_MicDown_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicDown"
RoomCommandService_ChangeMicSeat_FullMethodName = "/hyapp.room.v1.RoomCommandService/ChangeMicSeat" RoomCommandService_ChangeMicSeat_FullMethodName = "/hyapp.room.v1.RoomCommandService/ChangeMicSeat"
@ -79,6 +80,7 @@ type RoomCommandServiceClient interface {
AdminCancelRoomPin(ctx context.Context, in *AdminCancelRoomPinRequest, opts ...grpc.CallOption) (*AdminCancelRoomPinResponse, error) AdminCancelRoomPin(ctx context.Context, in *AdminCancelRoomPinRequest, opts ...grpc.CallOption) (*AdminCancelRoomPinResponse, error)
AdminCreateRobotRoom(ctx context.Context, in *AdminCreateRobotRoomRequest, opts ...grpc.CallOption) (*AdminCreateRobotRoomResponse, error) AdminCreateRobotRoom(ctx context.Context, in *AdminCreateRobotRoomRequest, opts ...grpc.CallOption) (*AdminCreateRobotRoomResponse, error)
AdminSetRobotRoomStatus(ctx context.Context, in *AdminSetRobotRoomStatusRequest, opts ...grpc.CallOption) (*AdminSetRobotRoomStatusResponse, error) AdminSetRobotRoomStatus(ctx context.Context, in *AdminSetRobotRoomStatusRequest, opts ...grpc.CallOption) (*AdminSetRobotRoomStatusResponse, error)
AdminRenameOwnerCountryCode(ctx context.Context, in *AdminRenameOwnerCountryCodeRequest, opts ...grpc.CallOption) (*AdminRenameOwnerCountryCodeResponse, error)
MicUp(ctx context.Context, in *MicUpRequest, opts ...grpc.CallOption) (*MicUpResponse, error) MicUp(ctx context.Context, in *MicUpRequest, opts ...grpc.CallOption) (*MicUpResponse, error)
MicDown(ctx context.Context, in *MicDownRequest, opts ...grpc.CallOption) (*MicDownResponse, error) MicDown(ctx context.Context, in *MicDownRequest, opts ...grpc.CallOption) (*MicDownResponse, error)
ChangeMicSeat(ctx context.Context, in *ChangeMicSeatRequest, opts ...grpc.CallOption) (*ChangeMicSeatResponse, error) ChangeMicSeat(ctx context.Context, in *ChangeMicSeatRequest, opts ...grpc.CallOption) (*ChangeMicSeatResponse, error)
@ -277,6 +279,16 @@ func (c *roomCommandServiceClient) AdminSetRobotRoomStatus(ctx context.Context,
return out, nil return out, nil
} }
func (c *roomCommandServiceClient) AdminRenameOwnerCountryCode(ctx context.Context, in *AdminRenameOwnerCountryCodeRequest, opts ...grpc.CallOption) (*AdminRenameOwnerCountryCodeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminRenameOwnerCountryCodeResponse)
err := c.cc.Invoke(ctx, RoomCommandService_AdminRenameOwnerCountryCode_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomCommandServiceClient) MicUp(ctx context.Context, in *MicUpRequest, opts ...grpc.CallOption) (*MicUpResponse, error) { func (c *roomCommandServiceClient) MicUp(ctx context.Context, in *MicUpRequest, opts ...grpc.CallOption) (*MicUpResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(MicUpResponse) out := new(MicUpResponse)
@ -480,6 +492,7 @@ type RoomCommandServiceServer interface {
AdminCancelRoomPin(context.Context, *AdminCancelRoomPinRequest) (*AdminCancelRoomPinResponse, error) AdminCancelRoomPin(context.Context, *AdminCancelRoomPinRequest) (*AdminCancelRoomPinResponse, error)
AdminCreateRobotRoom(context.Context, *AdminCreateRobotRoomRequest) (*AdminCreateRobotRoomResponse, error) AdminCreateRobotRoom(context.Context, *AdminCreateRobotRoomRequest) (*AdminCreateRobotRoomResponse, error)
AdminSetRobotRoomStatus(context.Context, *AdminSetRobotRoomStatusRequest) (*AdminSetRobotRoomStatusResponse, error) AdminSetRobotRoomStatus(context.Context, *AdminSetRobotRoomStatusRequest) (*AdminSetRobotRoomStatusResponse, error)
AdminRenameOwnerCountryCode(context.Context, *AdminRenameOwnerCountryCodeRequest) (*AdminRenameOwnerCountryCodeResponse, error)
MicUp(context.Context, *MicUpRequest) (*MicUpResponse, error) MicUp(context.Context, *MicUpRequest) (*MicUpResponse, error)
MicDown(context.Context, *MicDownRequest) (*MicDownResponse, error) MicDown(context.Context, *MicDownRequest) (*MicDownResponse, error)
ChangeMicSeat(context.Context, *ChangeMicSeatRequest) (*ChangeMicSeatResponse, error) ChangeMicSeat(context.Context, *ChangeMicSeatRequest) (*ChangeMicSeatResponse, error)
@ -559,6 +572,9 @@ func (UnimplementedRoomCommandServiceServer) AdminCreateRobotRoom(context.Contex
func (UnimplementedRoomCommandServiceServer) AdminSetRobotRoomStatus(context.Context, *AdminSetRobotRoomStatusRequest) (*AdminSetRobotRoomStatusResponse, error) { func (UnimplementedRoomCommandServiceServer) AdminSetRobotRoomStatus(context.Context, *AdminSetRobotRoomStatusRequest) (*AdminSetRobotRoomStatusResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method AdminSetRobotRoomStatus not implemented") return nil, status.Errorf(codes.Unimplemented, "method AdminSetRobotRoomStatus not implemented")
} }
func (UnimplementedRoomCommandServiceServer) AdminRenameOwnerCountryCode(context.Context, *AdminRenameOwnerCountryCodeRequest) (*AdminRenameOwnerCountryCodeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method AdminRenameOwnerCountryCode not implemented")
}
func (UnimplementedRoomCommandServiceServer) MicUp(context.Context, *MicUpRequest) (*MicUpResponse, error) { func (UnimplementedRoomCommandServiceServer) MicUp(context.Context, *MicUpRequest) (*MicUpResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method MicUp not implemented") return nil, status.Errorf(codes.Unimplemented, "method MicUp not implemented")
} }
@ -940,6 +956,24 @@ func _RoomCommandService_AdminSetRobotRoomStatus_Handler(srv interface{}, ctx co
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _RoomCommandService_AdminRenameOwnerCountryCode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminRenameOwnerCountryCodeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomCommandServiceServer).AdminRenameOwnerCountryCode(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomCommandService_AdminRenameOwnerCountryCode_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomCommandServiceServer).AdminRenameOwnerCountryCode(ctx, req.(*AdminRenameOwnerCountryCodeRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_MicUp_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { func _RoomCommandService_MicUp_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(MicUpRequest) in := new(MicUpRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@ -1339,6 +1373,10 @@ var RoomCommandService_ServiceDesc = grpc.ServiceDesc{
MethodName: "AdminSetRobotRoomStatus", MethodName: "AdminSetRobotRoomStatus",
Handler: _RoomCommandService_AdminSetRobotRoomStatus_Handler, Handler: _RoomCommandService_AdminSetRobotRoomStatus_Handler,
}, },
{
MethodName: "AdminRenameOwnerCountryCode",
Handler: _RoomCommandService_AdminRenameOwnerCountryCode_Handler,
},
{ {
MethodName: "MicUp", MethodName: "MicUp",
Handler: _RoomCommandService_MicUp_Handler, Handler: _RoomCommandService_MicUp_Handler,

File diff suppressed because it is too large Load Diff

View File

@ -59,6 +59,7 @@ message CoinSellerProfile {
int64 created_by_user_id = 4; int64 created_by_user_id = 4;
int64 created_at_ms = 5; int64 created_at_ms = 5;
int64 updated_at_ms = 6; int64 updated_at_ms = 6;
bool can_manage_sub_coin_sellers = 7;
} }
// CoinSellerListItem App // CoinSellerListItem App
@ -78,6 +79,18 @@ message CoinSellerListItem {
string merchant_asset_type = 13; string merchant_asset_type = 13;
int64 updated_at_ms = 14; int64 updated_at_ms = 14;
string contact_info = 15; string contact_info = 15;
bool can_manage_sub_coin_sellers = 16;
}
// CoinSellerSubRelation
message CoinSellerSubRelation {
int64 relation_id = 1;
int64 parent_user_id = 2;
int64 child_user_id = 3;
string status = 4;
int64 created_by_user_id = 5;
int64 created_at_ms = 6;
int64 updated_at_ms = 7;
} }
// UserRoleSummary App 使 // UserRoleSummary App 使
@ -263,9 +276,17 @@ message ListManagerTeamAgenciesRequest {
int32 page_size = 3; int32 page_size = 3;
} }
// ManagerTeamAgency
// Agency //
message ManagerTeamAgency {
int64 agency_id = 1;
int64 owner_user_id = 2;
int64 parent_bd_user_id = 3;
}
message ListManagerTeamAgenciesResponse { message ListManagerTeamAgenciesResponse {
// agencies BD Leader Agency + Leader BD Agency // agencies BD Leader Agency + Leader BD Agency
repeated Agency agencies = 1; repeated ManagerTeamAgency agencies = 1;
int32 total_bd_leaders = 2; int32 total_bd_leaders = 2;
int32 total_bds = 3; int32 total_bds = 3;
bool truncated = 4; bool truncated = 4;
@ -366,6 +387,44 @@ message ListActiveCoinSellersInMyRegionResponse {
repeated CoinSellerListItem coin_sellers = 1; repeated CoinSellerListItem coin_sellers = 1;
} }
message CreateSubCoinSellerRequest {
RequestMeta meta = 1;
string command_id = 2;
int64 parent_user_id = 3;
int64 child_user_id = 4;
}
message CreateSubCoinSellerResponse {
CoinSellerSubRelation relation = 1;
CoinSellerListItem child = 2;
}
message ListSubCoinSellersRequest {
RequestMeta meta = 1;
int64 parent_user_id = 2;
int32 page = 3;
int32 page_size = 4;
}
message ListSubCoinSellersResponse {
repeated CoinSellerListItem children = 1;
int64 total = 2;
int32 page = 3;
int32 page_size = 4;
}
message CheckCoinSellerSubRelationRequest {
RequestMeta meta = 1;
int64 parent_user_id = 2;
int64 child_user_id = 3;
}
message CheckCoinSellerSubRelationResponse {
bool allowed = 1;
string reason = 2;
CoinSellerListItem child = 3;
}
message GetUserRoleSummaryRequest { message GetUserRoleSummaryRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
int64 user_id = 2; int64 user_id = 2;
@ -460,6 +519,7 @@ message CreateCoinSellerRequest {
int64 admin_user_id = 3; int64 admin_user_id = 3;
int64 target_user_id = 4; int64 target_user_id = 4;
string reason = 5; string reason = 5;
optional bool can_manage_sub_coin_sellers = 6;
} }
message CreateCoinSellerResponse { message CreateCoinSellerResponse {
@ -473,6 +533,7 @@ message SetCoinSellerStatusRequest {
int64 target_user_id = 4; int64 target_user_id = 4;
string status = 5; string status = 5;
string reason = 6; string reason = 6;
optional bool can_manage_sub_coin_sellers = 7;
} }
message SetCoinSellerStatusResponse { message SetCoinSellerStatusResponse {
@ -598,6 +659,9 @@ service UserHostService {
rpc GetBDProfile(GetBDProfileRequest) returns (GetBDProfileResponse); rpc GetBDProfile(GetBDProfileRequest) returns (GetBDProfileResponse);
rpc GetCoinSellerProfile(GetCoinSellerProfileRequest) returns (GetCoinSellerProfileResponse); rpc GetCoinSellerProfile(GetCoinSellerProfileRequest) returns (GetCoinSellerProfileResponse);
rpc ListActiveCoinSellersInMyRegion(ListActiveCoinSellersInMyRegionRequest) returns (ListActiveCoinSellersInMyRegionResponse); rpc ListActiveCoinSellersInMyRegion(ListActiveCoinSellersInMyRegionRequest) returns (ListActiveCoinSellersInMyRegionResponse);
rpc CreateSubCoinSeller(CreateSubCoinSellerRequest) returns (CreateSubCoinSellerResponse);
rpc ListSubCoinSellers(ListSubCoinSellersRequest) returns (ListSubCoinSellersResponse);
rpc CheckCoinSellerSubRelation(CheckCoinSellerSubRelationRequest) returns (CheckCoinSellerSubRelationResponse);
rpc GetUserRoleSummary(GetUserRoleSummaryRequest) returns (GetUserRoleSummaryResponse); rpc GetUserRoleSummary(GetUserRoleSummaryRequest) returns (GetUserRoleSummaryResponse);
rpc GetAgency(GetAgencyRequest) returns (GetAgencyResponse); rpc GetAgency(GetAgencyRequest) returns (GetAgencyResponse);
rpc CheckBusinessCapability(CheckBusinessCapabilityRequest) returns (CheckBusinessCapabilityResponse); rpc CheckBusinessCapability(CheckBusinessCapabilityRequest) returns (CheckBusinessCapabilityResponse);

View File

@ -38,6 +38,9 @@ const (
UserHostService_GetBDProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetBDProfile" UserHostService_GetBDProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetBDProfile"
UserHostService_GetCoinSellerProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetCoinSellerProfile" UserHostService_GetCoinSellerProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetCoinSellerProfile"
UserHostService_ListActiveCoinSellersInMyRegion_FullMethodName = "/hyapp.user.v1.UserHostService/ListActiveCoinSellersInMyRegion" UserHostService_ListActiveCoinSellersInMyRegion_FullMethodName = "/hyapp.user.v1.UserHostService/ListActiveCoinSellersInMyRegion"
UserHostService_CreateSubCoinSeller_FullMethodName = "/hyapp.user.v1.UserHostService/CreateSubCoinSeller"
UserHostService_ListSubCoinSellers_FullMethodName = "/hyapp.user.v1.UserHostService/ListSubCoinSellers"
UserHostService_CheckCoinSellerSubRelation_FullMethodName = "/hyapp.user.v1.UserHostService/CheckCoinSellerSubRelation"
UserHostService_GetUserRoleSummary_FullMethodName = "/hyapp.user.v1.UserHostService/GetUserRoleSummary" UserHostService_GetUserRoleSummary_FullMethodName = "/hyapp.user.v1.UserHostService/GetUserRoleSummary"
UserHostService_GetAgency_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgency" UserHostService_GetAgency_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgency"
UserHostService_CheckBusinessCapability_FullMethodName = "/hyapp.user.v1.UserHostService/CheckBusinessCapability" UserHostService_CheckBusinessCapability_FullMethodName = "/hyapp.user.v1.UserHostService/CheckBusinessCapability"
@ -72,6 +75,9 @@ type UserHostServiceClient interface {
GetBDProfile(ctx context.Context, in *GetBDProfileRequest, opts ...grpc.CallOption) (*GetBDProfileResponse, error) GetBDProfile(ctx context.Context, in *GetBDProfileRequest, opts ...grpc.CallOption) (*GetBDProfileResponse, error)
GetCoinSellerProfile(ctx context.Context, in *GetCoinSellerProfileRequest, opts ...grpc.CallOption) (*GetCoinSellerProfileResponse, error) GetCoinSellerProfile(ctx context.Context, in *GetCoinSellerProfileRequest, opts ...grpc.CallOption) (*GetCoinSellerProfileResponse, error)
ListActiveCoinSellersInMyRegion(ctx context.Context, in *ListActiveCoinSellersInMyRegionRequest, opts ...grpc.CallOption) (*ListActiveCoinSellersInMyRegionResponse, error) ListActiveCoinSellersInMyRegion(ctx context.Context, in *ListActiveCoinSellersInMyRegionRequest, opts ...grpc.CallOption) (*ListActiveCoinSellersInMyRegionResponse, error)
CreateSubCoinSeller(ctx context.Context, in *CreateSubCoinSellerRequest, opts ...grpc.CallOption) (*CreateSubCoinSellerResponse, error)
ListSubCoinSellers(ctx context.Context, in *ListSubCoinSellersRequest, opts ...grpc.CallOption) (*ListSubCoinSellersResponse, error)
CheckCoinSellerSubRelation(ctx context.Context, in *CheckCoinSellerSubRelationRequest, opts ...grpc.CallOption) (*CheckCoinSellerSubRelationResponse, error)
GetUserRoleSummary(ctx context.Context, in *GetUserRoleSummaryRequest, opts ...grpc.CallOption) (*GetUserRoleSummaryResponse, error) GetUserRoleSummary(ctx context.Context, in *GetUserRoleSummaryRequest, opts ...grpc.CallOption) (*GetUserRoleSummaryResponse, error)
GetAgency(ctx context.Context, in *GetAgencyRequest, opts ...grpc.CallOption) (*GetAgencyResponse, error) GetAgency(ctx context.Context, in *GetAgencyRequest, opts ...grpc.CallOption) (*GetAgencyResponse, error)
CheckBusinessCapability(ctx context.Context, in *CheckBusinessCapabilityRequest, opts ...grpc.CallOption) (*CheckBusinessCapabilityResponse, error) CheckBusinessCapability(ctx context.Context, in *CheckBusinessCapabilityRequest, opts ...grpc.CallOption) (*CheckBusinessCapabilityResponse, error)
@ -278,6 +284,36 @@ func (c *userHostServiceClient) ListActiveCoinSellersInMyRegion(ctx context.Cont
return out, nil return out, nil
} }
func (c *userHostServiceClient) CreateSubCoinSeller(ctx context.Context, in *CreateSubCoinSellerRequest, opts ...grpc.CallOption) (*CreateSubCoinSellerResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreateSubCoinSellerResponse)
err := c.cc.Invoke(ctx, UserHostService_CreateSubCoinSeller_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userHostServiceClient) ListSubCoinSellers(ctx context.Context, in *ListSubCoinSellersRequest, opts ...grpc.CallOption) (*ListSubCoinSellersResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListSubCoinSellersResponse)
err := c.cc.Invoke(ctx, UserHostService_ListSubCoinSellers_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userHostServiceClient) CheckCoinSellerSubRelation(ctx context.Context, in *CheckCoinSellerSubRelationRequest, opts ...grpc.CallOption) (*CheckCoinSellerSubRelationResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CheckCoinSellerSubRelationResponse)
err := c.cc.Invoke(ctx, UserHostService_CheckCoinSellerSubRelation_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userHostServiceClient) GetUserRoleSummary(ctx context.Context, in *GetUserRoleSummaryRequest, opts ...grpc.CallOption) (*GetUserRoleSummaryResponse, error) { func (c *userHostServiceClient) GetUserRoleSummary(ctx context.Context, in *GetUserRoleSummaryRequest, opts ...grpc.CallOption) (*GetUserRoleSummaryResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetUserRoleSummaryResponse) out := new(GetUserRoleSummaryResponse)
@ -364,6 +400,9 @@ type UserHostServiceServer interface {
GetBDProfile(context.Context, *GetBDProfileRequest) (*GetBDProfileResponse, error) GetBDProfile(context.Context, *GetBDProfileRequest) (*GetBDProfileResponse, error)
GetCoinSellerProfile(context.Context, *GetCoinSellerProfileRequest) (*GetCoinSellerProfileResponse, error) GetCoinSellerProfile(context.Context, *GetCoinSellerProfileRequest) (*GetCoinSellerProfileResponse, error)
ListActiveCoinSellersInMyRegion(context.Context, *ListActiveCoinSellersInMyRegionRequest) (*ListActiveCoinSellersInMyRegionResponse, error) ListActiveCoinSellersInMyRegion(context.Context, *ListActiveCoinSellersInMyRegionRequest) (*ListActiveCoinSellersInMyRegionResponse, error)
CreateSubCoinSeller(context.Context, *CreateSubCoinSellerRequest) (*CreateSubCoinSellerResponse, error)
ListSubCoinSellers(context.Context, *ListSubCoinSellersRequest) (*ListSubCoinSellersResponse, error)
CheckCoinSellerSubRelation(context.Context, *CheckCoinSellerSubRelationRequest) (*CheckCoinSellerSubRelationResponse, error)
GetUserRoleSummary(context.Context, *GetUserRoleSummaryRequest) (*GetUserRoleSummaryResponse, error) GetUserRoleSummary(context.Context, *GetUserRoleSummaryRequest) (*GetUserRoleSummaryResponse, error)
GetAgency(context.Context, *GetAgencyRequest) (*GetAgencyResponse, error) GetAgency(context.Context, *GetAgencyRequest) (*GetAgencyResponse, error)
CheckBusinessCapability(context.Context, *CheckBusinessCapabilityRequest) (*CheckBusinessCapabilityResponse, error) CheckBusinessCapability(context.Context, *CheckBusinessCapabilityRequest) (*CheckBusinessCapabilityResponse, error)
@ -437,6 +476,15 @@ func (UnimplementedUserHostServiceServer) GetCoinSellerProfile(context.Context,
func (UnimplementedUserHostServiceServer) ListActiveCoinSellersInMyRegion(context.Context, *ListActiveCoinSellersInMyRegionRequest) (*ListActiveCoinSellersInMyRegionResponse, error) { func (UnimplementedUserHostServiceServer) ListActiveCoinSellersInMyRegion(context.Context, *ListActiveCoinSellersInMyRegionRequest) (*ListActiveCoinSellersInMyRegionResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListActiveCoinSellersInMyRegion not implemented") return nil, status.Errorf(codes.Unimplemented, "method ListActiveCoinSellersInMyRegion not implemented")
} }
func (UnimplementedUserHostServiceServer) CreateSubCoinSeller(context.Context, *CreateSubCoinSellerRequest) (*CreateSubCoinSellerResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateSubCoinSeller not implemented")
}
func (UnimplementedUserHostServiceServer) ListSubCoinSellers(context.Context, *ListSubCoinSellersRequest) (*ListSubCoinSellersResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListSubCoinSellers not implemented")
}
func (UnimplementedUserHostServiceServer) CheckCoinSellerSubRelation(context.Context, *CheckCoinSellerSubRelationRequest) (*CheckCoinSellerSubRelationResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CheckCoinSellerSubRelation not implemented")
}
func (UnimplementedUserHostServiceServer) GetUserRoleSummary(context.Context, *GetUserRoleSummaryRequest) (*GetUserRoleSummaryResponse, error) { func (UnimplementedUserHostServiceServer) GetUserRoleSummary(context.Context, *GetUserRoleSummaryRequest) (*GetUserRoleSummaryResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetUserRoleSummary not implemented") return nil, status.Errorf(codes.Unimplemented, "method GetUserRoleSummary not implemented")
} }
@ -818,6 +866,60 @@ func _UserHostService_ListActiveCoinSellersInMyRegion_Handler(srv interface{}, c
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _UserHostService_CreateSubCoinSeller_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateSubCoinSellerRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserHostServiceServer).CreateSubCoinSeller(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserHostService_CreateSubCoinSeller_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserHostServiceServer).CreateSubCoinSeller(ctx, req.(*CreateSubCoinSellerRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserHostService_ListSubCoinSellers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListSubCoinSellersRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserHostServiceServer).ListSubCoinSellers(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserHostService_ListSubCoinSellers_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserHostServiceServer).ListSubCoinSellers(ctx, req.(*ListSubCoinSellersRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserHostService_CheckCoinSellerSubRelation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CheckCoinSellerSubRelationRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserHostServiceServer).CheckCoinSellerSubRelation(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserHostService_CheckCoinSellerSubRelation_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserHostServiceServer).CheckCoinSellerSubRelation(ctx, req.(*CheckCoinSellerSubRelationRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserHostService_GetUserRoleSummary_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { func _UserHostService_GetUserRoleSummary_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetUserRoleSummaryRequest) in := new(GetUserRoleSummaryRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@ -1009,6 +1111,18 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListActiveCoinSellersInMyRegion", MethodName: "ListActiveCoinSellersInMyRegion",
Handler: _UserHostService_ListActiveCoinSellersInMyRegion_Handler, Handler: _UserHostService_ListActiveCoinSellersInMyRegion_Handler,
}, },
{
MethodName: "CreateSubCoinSeller",
Handler: _UserHostService_CreateSubCoinSeller_Handler,
},
{
MethodName: "ListSubCoinSellers",
Handler: _UserHostService_ListSubCoinSellers_Handler,
},
{
MethodName: "CheckCoinSellerSubRelation",
Handler: _UserHostService_CheckCoinSellerSubRelation_Handler,
},
{ {
MethodName: "GetUserRoleSummary", MethodName: "GetUserRoleSummary",
Handler: _UserHostService_GetUserRoleSummary_Handler, Handler: _UserHostService_GetUserRoleSummary_Handler,

File diff suppressed because it is too large Load Diff

View File

@ -330,6 +330,25 @@ message TransferCoinFromSellerResponse {
int64 recharge_policy_usd_minor_amount = 10; int64 recharge_policy_usd_minor_amount = 10;
} }
// TransferCoinSellerStockToChildRequest
message TransferCoinSellerStockToChildRequest {
string command_id = 1;
int64 parent_user_id = 2;
int64 child_user_id = 3;
int64 amount = 4;
string reason = 5;
string app_code = 6;
int64 region_id = 7;
}
message TransferCoinSellerStockToChildResponse {
string transaction_id = 1;
int64 parent_balance_after = 2;
int64 child_balance_after = 3;
int64 amount = 4;
int64 created_at_ms = 5;
}
// CoinSellerSalaryExchangeRateTier // CoinSellerSalaryExchangeRateTier
message CoinSellerSalaryExchangeRateTier { message CoinSellerSalaryExchangeRateTier {
int64 region_id = 1; int64 region_id = 1;
@ -2430,6 +2449,7 @@ service WalletService {
rpc AdminCreditAsset(AdminCreditAssetRequest) returns (AdminCreditAssetResponse); rpc AdminCreditAsset(AdminCreditAssetRequest) returns (AdminCreditAssetResponse);
rpc AdminCreditCoinSellerStock(AdminCreditCoinSellerStockRequest) returns (AdminCreditCoinSellerStockResponse); rpc AdminCreditCoinSellerStock(AdminCreditCoinSellerStockRequest) returns (AdminCreditCoinSellerStockResponse);
rpc TransferCoinFromSeller(TransferCoinFromSellerRequest) returns (TransferCoinFromSellerResponse); rpc TransferCoinFromSeller(TransferCoinFromSellerRequest) returns (TransferCoinFromSellerResponse);
rpc TransferCoinSellerStockToChild(TransferCoinSellerStockToChildRequest) returns (TransferCoinSellerStockToChildResponse);
rpc ListCoinSellerSalaryExchangeRateTiers(ListCoinSellerSalaryExchangeRateTiersRequest) returns (ListCoinSellerSalaryExchangeRateTiersResponse); rpc ListCoinSellerSalaryExchangeRateTiers(ListCoinSellerSalaryExchangeRateTiersRequest) returns (ListCoinSellerSalaryExchangeRateTiersResponse);
rpc ExchangeSalaryToCoin(ExchangeSalaryToCoinRequest) returns (ExchangeSalaryToCoinResponse); rpc ExchangeSalaryToCoin(ExchangeSalaryToCoinRequest) returns (ExchangeSalaryToCoinResponse);
rpc TransferSalaryToCoinSeller(TransferSalaryToCoinSellerRequest) returns (TransferSalaryToCoinSellerResponse); rpc TransferSalaryToCoinSeller(TransferSalaryToCoinSellerRequest) returns (TransferSalaryToCoinSellerResponse);

View File

@ -212,6 +212,7 @@ const (
WalletService_AdminCreditAsset_FullMethodName = "/hyapp.wallet.v1.WalletService/AdminCreditAsset" WalletService_AdminCreditAsset_FullMethodName = "/hyapp.wallet.v1.WalletService/AdminCreditAsset"
WalletService_AdminCreditCoinSellerStock_FullMethodName = "/hyapp.wallet.v1.WalletService/AdminCreditCoinSellerStock" WalletService_AdminCreditCoinSellerStock_FullMethodName = "/hyapp.wallet.v1.WalletService/AdminCreditCoinSellerStock"
WalletService_TransferCoinFromSeller_FullMethodName = "/hyapp.wallet.v1.WalletService/TransferCoinFromSeller" WalletService_TransferCoinFromSeller_FullMethodName = "/hyapp.wallet.v1.WalletService/TransferCoinFromSeller"
WalletService_TransferCoinSellerStockToChild_FullMethodName = "/hyapp.wallet.v1.WalletService/TransferCoinSellerStockToChild"
WalletService_ListCoinSellerSalaryExchangeRateTiers_FullMethodName = "/hyapp.wallet.v1.WalletService/ListCoinSellerSalaryExchangeRateTiers" WalletService_ListCoinSellerSalaryExchangeRateTiers_FullMethodName = "/hyapp.wallet.v1.WalletService/ListCoinSellerSalaryExchangeRateTiers"
WalletService_ExchangeSalaryToCoin_FullMethodName = "/hyapp.wallet.v1.WalletService/ExchangeSalaryToCoin" WalletService_ExchangeSalaryToCoin_FullMethodName = "/hyapp.wallet.v1.WalletService/ExchangeSalaryToCoin"
WalletService_TransferSalaryToCoinSeller_FullMethodName = "/hyapp.wallet.v1.WalletService/TransferSalaryToCoinSeller" WalletService_TransferSalaryToCoinSeller_FullMethodName = "/hyapp.wallet.v1.WalletService/TransferSalaryToCoinSeller"
@ -327,6 +328,7 @@ type WalletServiceClient interface {
AdminCreditAsset(ctx context.Context, in *AdminCreditAssetRequest, opts ...grpc.CallOption) (*AdminCreditAssetResponse, error) AdminCreditAsset(ctx context.Context, in *AdminCreditAssetRequest, opts ...grpc.CallOption) (*AdminCreditAssetResponse, error)
AdminCreditCoinSellerStock(ctx context.Context, in *AdminCreditCoinSellerStockRequest, opts ...grpc.CallOption) (*AdminCreditCoinSellerStockResponse, error) AdminCreditCoinSellerStock(ctx context.Context, in *AdminCreditCoinSellerStockRequest, opts ...grpc.CallOption) (*AdminCreditCoinSellerStockResponse, error)
TransferCoinFromSeller(ctx context.Context, in *TransferCoinFromSellerRequest, opts ...grpc.CallOption) (*TransferCoinFromSellerResponse, error) TransferCoinFromSeller(ctx context.Context, in *TransferCoinFromSellerRequest, opts ...grpc.CallOption) (*TransferCoinFromSellerResponse, error)
TransferCoinSellerStockToChild(ctx context.Context, in *TransferCoinSellerStockToChildRequest, opts ...grpc.CallOption) (*TransferCoinSellerStockToChildResponse, error)
ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, in *ListCoinSellerSalaryExchangeRateTiersRequest, opts ...grpc.CallOption) (*ListCoinSellerSalaryExchangeRateTiersResponse, error) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, in *ListCoinSellerSalaryExchangeRateTiersRequest, opts ...grpc.CallOption) (*ListCoinSellerSalaryExchangeRateTiersResponse, error)
ExchangeSalaryToCoin(ctx context.Context, in *ExchangeSalaryToCoinRequest, opts ...grpc.CallOption) (*ExchangeSalaryToCoinResponse, error) ExchangeSalaryToCoin(ctx context.Context, in *ExchangeSalaryToCoinRequest, opts ...grpc.CallOption) (*ExchangeSalaryToCoinResponse, error)
TransferSalaryToCoinSeller(ctx context.Context, in *TransferSalaryToCoinSellerRequest, opts ...grpc.CallOption) (*TransferSalaryToCoinSellerResponse, error) TransferSalaryToCoinSeller(ctx context.Context, in *TransferSalaryToCoinSellerRequest, opts ...grpc.CallOption) (*TransferSalaryToCoinSellerResponse, error)
@ -542,6 +544,16 @@ func (c *walletServiceClient) TransferCoinFromSeller(ctx context.Context, in *Tr
return out, nil return out, nil
} }
func (c *walletServiceClient) TransferCoinSellerStockToChild(ctx context.Context, in *TransferCoinSellerStockToChildRequest, opts ...grpc.CallOption) (*TransferCoinSellerStockToChildResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(TransferCoinSellerStockToChildResponse)
err := c.cc.Invoke(ctx, WalletService_TransferCoinSellerStockToChild_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, in *ListCoinSellerSalaryExchangeRateTiersRequest, opts ...grpc.CallOption) (*ListCoinSellerSalaryExchangeRateTiersResponse, error) { func (c *walletServiceClient) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, in *ListCoinSellerSalaryExchangeRateTiersRequest, opts ...grpc.CallOption) (*ListCoinSellerSalaryExchangeRateTiersResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListCoinSellerSalaryExchangeRateTiersResponse) out := new(ListCoinSellerSalaryExchangeRateTiersResponse)
@ -1510,6 +1522,7 @@ type WalletServiceServer interface {
AdminCreditAsset(context.Context, *AdminCreditAssetRequest) (*AdminCreditAssetResponse, error) AdminCreditAsset(context.Context, *AdminCreditAssetRequest) (*AdminCreditAssetResponse, error)
AdminCreditCoinSellerStock(context.Context, *AdminCreditCoinSellerStockRequest) (*AdminCreditCoinSellerStockResponse, error) AdminCreditCoinSellerStock(context.Context, *AdminCreditCoinSellerStockRequest) (*AdminCreditCoinSellerStockResponse, error)
TransferCoinFromSeller(context.Context, *TransferCoinFromSellerRequest) (*TransferCoinFromSellerResponse, error) TransferCoinFromSeller(context.Context, *TransferCoinFromSellerRequest) (*TransferCoinFromSellerResponse, error)
TransferCoinSellerStockToChild(context.Context, *TransferCoinSellerStockToChildRequest) (*TransferCoinSellerStockToChildResponse, error)
ListCoinSellerSalaryExchangeRateTiers(context.Context, *ListCoinSellerSalaryExchangeRateTiersRequest) (*ListCoinSellerSalaryExchangeRateTiersResponse, error) ListCoinSellerSalaryExchangeRateTiers(context.Context, *ListCoinSellerSalaryExchangeRateTiersRequest) (*ListCoinSellerSalaryExchangeRateTiersResponse, error)
ExchangeSalaryToCoin(context.Context, *ExchangeSalaryToCoinRequest) (*ExchangeSalaryToCoinResponse, error) ExchangeSalaryToCoin(context.Context, *ExchangeSalaryToCoinRequest) (*ExchangeSalaryToCoinResponse, error)
TransferSalaryToCoinSeller(context.Context, *TransferSalaryToCoinSellerRequest) (*TransferSalaryToCoinSellerResponse, error) TransferSalaryToCoinSeller(context.Context, *TransferSalaryToCoinSellerRequest) (*TransferSalaryToCoinSellerResponse, error)
@ -1648,6 +1661,9 @@ func (UnimplementedWalletServiceServer) AdminCreditCoinSellerStock(context.Conte
func (UnimplementedWalletServiceServer) TransferCoinFromSeller(context.Context, *TransferCoinFromSellerRequest) (*TransferCoinFromSellerResponse, error) { func (UnimplementedWalletServiceServer) TransferCoinFromSeller(context.Context, *TransferCoinFromSellerRequest) (*TransferCoinFromSellerResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method TransferCoinFromSeller not implemented") return nil, status.Errorf(codes.Unimplemented, "method TransferCoinFromSeller not implemented")
} }
func (UnimplementedWalletServiceServer) TransferCoinSellerStockToChild(context.Context, *TransferCoinSellerStockToChildRequest) (*TransferCoinSellerStockToChildResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method TransferCoinSellerStockToChild not implemented")
}
func (UnimplementedWalletServiceServer) ListCoinSellerSalaryExchangeRateTiers(context.Context, *ListCoinSellerSalaryExchangeRateTiersRequest) (*ListCoinSellerSalaryExchangeRateTiersResponse, error) { func (UnimplementedWalletServiceServer) ListCoinSellerSalaryExchangeRateTiers(context.Context, *ListCoinSellerSalaryExchangeRateTiersRequest) (*ListCoinSellerSalaryExchangeRateTiersResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListCoinSellerSalaryExchangeRateTiers not implemented") return nil, status.Errorf(codes.Unimplemented, "method ListCoinSellerSalaryExchangeRateTiers not implemented")
} }
@ -2152,6 +2168,24 @@ func _WalletService_TransferCoinFromSeller_Handler(srv interface{}, ctx context.
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _WalletService_TransferCoinSellerStockToChild_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(TransferCoinSellerStockToChildRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).TransferCoinSellerStockToChild(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_TransferCoinSellerStockToChild_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).TransferCoinSellerStockToChild(ctx, req.(*TransferCoinSellerStockToChildRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListCoinSellerSalaryExchangeRateTiers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { func _WalletService_ListCoinSellerSalaryExchangeRateTiers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListCoinSellerSalaryExchangeRateTiersRequest) in := new(ListCoinSellerSalaryExchangeRateTiersRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@ -3913,6 +3947,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "TransferCoinFromSeller", MethodName: "TransferCoinFromSeller",
Handler: _WalletService_TransferCoinFromSeller_Handler, Handler: _WalletService_TransferCoinFromSeller_Handler,
}, },
{
MethodName: "TransferCoinSellerStockToChild",
Handler: _WalletService_TransferCoinSellerStockToChild_Handler,
},
{ {
MethodName: "ListCoinSellerSalaryExchangeRateTiers", MethodName: "ListCoinSellerSalaryExchangeRateTiers",
Handler: _WalletService_ListCoinSellerSalaryExchangeRateTiers_Handler, Handler: _WalletService_ListCoinSellerSalaryExchangeRateTiers_Handler,

View File

@ -0,0 +1,57 @@
# 经理中心团队工资统计
经理中心首页 4 张卡片(本月团队工资、上月团队工资、活跃主播数、环比增长)的数据链路。
## 口径
- 团队范围:经理 → BD Leader`bd_leader_profiles.created_by_user_id`)→ BD`bd_profiles.parent_leader_user_id`)→ Agency`agencies.parent_bd_user_id`Agency 可直接挂 Leader 或挂 Leader 下属 BD全链路只统计 active 状态。
- 主播归属:按 wallet 周期账户 `host_period_diamond_accounts.agency_owner_user_id`(送礼入账时的收款人快照)圈定,与工资结算同一口径。
- 预收入estimated进行中周期 = max(已结算累计, 当前档位累计工资 + 剩余钻石按月底费率折美元),即“立刻月结能拿多少”;已 month_end 关闭的周期直接取已结算累计(含剩余折算),不用当前政策重估,避免政策上调后显示从未发放的金额。政策取区域当前生效政策(不区分结算模式/触发方式),与 H5 平台政策展示一致。
- 周期UTC 月(`cycle_key = yyyy-MM`),本月 + 上月两个周期。
- 活跃主播:该周期累计钻石 > 0 的主播数。
- 环比:`(本月预收入 - 上月预收入) / 上月预收入`,上月为 0 时有收入记 +100%,无收入记 0%。
- 时效gateway 内存缓存 2 分钟(按 app_code + 经理 user_id产品接受分钟级延迟。
## HTTP 接口
`GET /api/v1/manager-center/overview`JWT`manager_center` 能力)
响应 `data` 在原有身份/权限字段基础上新增 `dashboard`统计链路故障时省略该字段H5 显示占位符:
```json
{
"dashboard": {
"month_label": "Jul 2026",
"cycle_key": "2026-07",
"last_cycle_key": "2026-06",
"this_month_salary": 12152,
"this_month_salary_usd_minor": 1215200,
"last_month_salary": 9269,
"last_month_salary_usd_minor": 926900,
"active_hosts": 128,
"active_hosts_last_month": 96,
"growth": "+31%",
"team_bd_leaders": 2,
"team_bds": 5,
"team_agencies": 3,
"team_stats_truncated": false,
"updated_at_ms": 1783500000000
}
}
```
金额字段 `*_salary` 是美元数值(分转元),`*_usd_minor` 是美分整数;`growth` 直接下发带符号百分比字符串。`team_stats_truncated=true` 表示团队 Agency 超过单次拉取上限5000统计口径不完整。
## 内部 RPC
- user-service `UserHostService.ListManagerTeamAgencies(manager_user_id, page_size)` → 团队树内全部有效 Agency + BD Leader/BD 数量 + truncated 标记;经理身份停用返回 PermissionDenied。
- wallet-service `WalletService.GetTeamHostSalaryStats(agency_owner_user_ids[], cycle_keys[], now_ms)` → 每个周期的 estimated/settled 主播工资美分、活跃主播数、总钻石。只读聚合,不加锁不推进结算进度。
## 索引
- `bd_leader_profiles`:新增 `idx_bd_leader_profiles_created_by (app_code, created_by_user_id, status)`migration 007 + initdb
- `host_period_diamond_accounts`:新增 `idx_host_period_diamond_cycle_owner (app_code, cycle_key, agency_owner_user_id, total_diamonds)`initdb 幂等补建)。
## 对应 H5
`hyapp-h5/gonghui/manager-center``renderDashboard` 读取 `overview.dashboard.*`;拿到 overview 但缺 `dashboard` 时 4 张卡片显示 `--` 且月份标签按 UTC 当月计算,只有本地预览(无 API 注入)才显示设计稿假数据。

View File

@ -267,16 +267,19 @@ func main() {
defer gameConn.Close() defer gameConn.Close()
auth := service.NewAuthService(cfg.JWTSecret, cfg.AccessTokenTTL) auth := service.NewAuthService(cfg.JWTSecret, cfg.AccessTokenTTL)
auditHandler := auditmodule.New(store)
countryRegionHandler := countryregionmodule.New(userclient.NewGRPC(userConn), userDB, store, roomClient, cfg, auditHandler)
var runner *jobrunner.Runner var runner *jobrunner.Runner
var jobStatus healthmodule.JobStatusProvider var jobStatus healthmodule.JobStatusProvider
if cfg.Jobs.Enabled { if cfg.Jobs.Enabled {
runner = jobrunner.NewRunner(store, redisClient, cfg) runner = jobrunner.NewRunner(store, redisClient, cfg,
jobrunner.WithHandler(countryregionmodule.CountryCodeRenameJobType, countryRegionHandler.Service().HandleCountryCodeRenameJob),
)
runner.Start() runner.Start()
defer runner.Close() defer runner.Close()
jobStatus = runner jobStatus = runner
} }
auditHandler := auditmodule.New(store)
teamSalarySettlementHandler := teamsalarysettlementmodule.New(sqlDB, walletDB, userDB, auditHandler) teamSalarySettlementHandler := teamsalarysettlementmodule.New(sqlDB, walletDB, userDB, auditHandler)
if cfg.Jobs.Enabled { if cfg.Jobs.Enabled {
teamSalaryRunner := teamsalarysettlementmodule.NewAutoRunner(teamSalarySettlementHandler.Service(), "lalu", cfg.NodeID) teamSalaryRunner := teamsalarysettlementmodule.NewAutoRunner(teamSalarySettlementHandler.Service(), "lalu", cfg.NodeID)
@ -337,7 +340,7 @@ func main() {
AppRegistry: appregistrymodule.New(userDB), AppRegistry: appregistrymodule.New(userDB),
AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler), AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler),
CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler), CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler),
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler), CountryRegion: countryRegionHandler,
CPRelation: cprelationmodule.NewWithActivity(userDB, activityclient.NewGRPC(activityConn), auditHandler), CPRelation: cprelationmodule.NewWithActivity(userDB, activityclient.NewGRPC(activityConn), auditHandler),
CPWeeklyRank: cpweeklyrankmodule.New(activityclient.NewGRPC(activityConn), auditHandler), CPWeeklyRank: cpweeklyrankmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
CumulativeRecharge: cumulativerechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler), CumulativeRecharge: cumulativerechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),

View File

@ -30,6 +30,7 @@ type Client interface {
CreateRobotRoom(ctx context.Context, req CreateRobotRoomRequest) (RobotRoom, error) CreateRobotRoom(ctx context.Context, req CreateRobotRoomRequest) (RobotRoom, error)
SetRobotRoomStatus(ctx context.Context, req SetRobotRoomStatusRequest) (RobotRoom, error) SetRobotRoomStatus(ctx context.Context, req SetRobotRoomStatusRequest) (RobotRoom, error)
FilterAvailableRoomRobots(ctx context.Context, userIDs []int64) (FilterAvailableRoomRobotsResult, error) FilterAvailableRoomRobots(ctx context.Context, userIDs []int64) (FilterAvailableRoomRobotsResult, error)
RenameOwnerCountryCode(ctx context.Context, req RenameOwnerCountryCodeRequest) (RenameOwnerCountryCodeResult, error)
CloseRoom(ctx context.Context, req CloseRoomRequest) (*CloseRoomResult, error) CloseRoom(ctx context.Context, req CloseRoomRequest) (*CloseRoomResult, error)
ReopenRoom(ctx context.Context, req ReopenRoomRequest) (*CloseRoomResult, error) ReopenRoom(ctx context.Context, req ReopenRoomRequest) (*CloseRoomResult, error)
} }
@ -330,6 +331,19 @@ type FilterAvailableRoomRobotsResult struct {
OccupiedUserIDs []int64 OccupiedUserIDs []int64
} }
type RenameOwnerCountryCodeRequest struct {
RequestID string
OldCountryCode string
NewCountryCode string
AdminID uint64
}
type RenameOwnerCountryCodeResult struct {
RoomListEntries int64
RoomSnapshots int64
ServerTimeMs int64
}
type CloseRoomRequest struct { type CloseRoomRequest struct {
RequestID string RequestID string
RoomID string RoomID string
@ -600,6 +614,27 @@ func (c *GRPCClient) FilterAvailableRoomRobots(ctx context.Context, userIDs []in
}, nil }, nil
} }
func (c *GRPCClient) RenameOwnerCountryCode(ctx context.Context, req RenameOwnerCountryCodeRequest) (RenameOwnerCountryCodeResult, error) {
if c == nil || c.client == nil {
return RenameOwnerCountryCodeResult{}, fmt.Errorf("room service client is not configured")
}
requestID := firstNonEmpty(req.RequestID, fmt.Sprintf("admin-country-code-rename-%d", time.Now().UnixMilli()))
resp, err := c.client.AdminRenameOwnerCountryCode(ctx, &roomv1.AdminRenameOwnerCountryCodeRequest{
Meta: requestMeta(ctx, "", int64(req.AdminID), requestID),
OldCountryCode: strings.ToUpper(strings.TrimSpace(req.OldCountryCode)),
NewCountryCode: strings.ToUpper(strings.TrimSpace(req.NewCountryCode)),
AdminId: req.AdminID,
})
if err != nil {
return RenameOwnerCountryCodeResult{}, err
}
return RenameOwnerCountryCodeResult{
RoomListEntries: resp.GetRoomListEntries(),
RoomSnapshots: resp.GetRoomSnapshots(),
ServerTimeMs: resp.GetServerTimeMs(),
}, nil
}
func (c *GRPCClient) CloseRoom(ctx context.Context, req CloseRoomRequest) (*CloseRoomResult, error) { func (c *GRPCClient) CloseRoom(ctx context.Context, req CloseRoomRequest) (*CloseRoomResult, error) {
return c.setRoomLifecycle(ctx, req.RequestID, req.RoomID, req.ActorUserID, firstNonEmpty(req.Reason, "admin_closed")) return c.setRoomLifecycle(ctx, req.RequestID, req.RoomID, req.ActorUserID, firstNonEmpty(req.Reason, "admin_closed"))
} }

View File

@ -512,22 +512,24 @@ type SetBDStatusRequest struct {
} }
type CreateCoinSellerRequest struct { type CreateCoinSellerRequest struct {
RequestID string RequestID string
Caller string Caller string
CommandID string CommandID string
AdminUserID int64 AdminUserID int64
TargetUserID int64 TargetUserID int64
Reason string Reason string
CanManageSubCoinSellers *bool
} }
type SetCoinSellerStatusRequest struct { type SetCoinSellerStatusRequest struct {
RequestID string RequestID string
Caller string Caller string
CommandID string CommandID string
AdminUserID int64 AdminUserID int64
TargetUserID int64 TargetUserID int64
Status string Status string
Reason string Reason string
CanManageSubCoinSellers *bool
} }
type GetCoinSellerProfileRequest struct { type GetCoinSellerProfileRequest struct {
@ -687,12 +689,13 @@ type SalaryWallet struct {
} }
type CoinSellerProfile struct { type CoinSellerProfile struct {
UserID int64 `json:"userId,string"` UserID int64 `json:"userId,string"`
Status string `json:"status"` Status string `json:"status"`
MerchantAssetType string `json:"merchantAssetType"` MerchantAssetType string `json:"merchantAssetType"`
CreatedByUserID int64 `json:"createdByUserId,string"` CanManageSubCoinSellers bool `json:"canManageSubCoinSellers"`
CreatedAtMs int64 `json:"createdAtMs"` CreatedByUserID int64 `json:"createdByUserId,string"`
UpdatedAtMs int64 `json:"updatedAtMs"` CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
} }
type UserRoleSummary struct { type UserRoleSummary struct {
@ -778,13 +781,18 @@ func (c *GRPCClient) SetBDStatus(ctx context.Context, req SetBDStatusRequest) (*
} }
func (c *GRPCClient) CreateCoinSeller(ctx context.Context, req CreateCoinSellerRequest) (*CoinSellerProfile, error) { func (c *GRPCClient) CreateCoinSeller(ctx context.Context, req CreateCoinSellerRequest) (*CoinSellerProfile, error) {
resp, err := c.hostAdminClient.CreateCoinSeller(ctx, &userv1.CreateCoinSellerRequest{ protoReq := &userv1.CreateCoinSellerRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller), Meta: requestMeta(ctx, req.RequestID, req.Caller),
CommandId: req.CommandID, CommandId: req.CommandID,
AdminUserId: req.AdminUserID, AdminUserId: req.AdminUserID,
TargetUserId: req.TargetUserID, TargetUserId: req.TargetUserID,
Reason: req.Reason, Reason: req.Reason,
}) }
if req.CanManageSubCoinSellers != nil {
// optional bool 只在后台显式提交时设置nil 表示沿用 user-service 默认值。
protoReq.CanManageSubCoinSellers = req.CanManageSubCoinSellers
}
resp, err := c.hostAdminClient.CreateCoinSeller(ctx, protoReq)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -792,14 +800,19 @@ func (c *GRPCClient) CreateCoinSeller(ctx context.Context, req CreateCoinSellerR
} }
func (c *GRPCClient) SetCoinSellerStatus(ctx context.Context, req SetCoinSellerStatusRequest) (*CoinSellerProfile, error) { func (c *GRPCClient) SetCoinSellerStatus(ctx context.Context, req SetCoinSellerStatusRequest) (*CoinSellerProfile, error) {
resp, err := c.hostAdminClient.SetCoinSellerStatus(ctx, &userv1.SetCoinSellerStatusRequest{ protoReq := &userv1.SetCoinSellerStatusRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller), Meta: requestMeta(ctx, req.RequestID, req.Caller),
CommandId: req.CommandID, CommandId: req.CommandID,
AdminUserId: req.AdminUserID, AdminUserId: req.AdminUserID,
TargetUserId: req.TargetUserID, TargetUserId: req.TargetUserID,
Status: req.Status, Status: req.Status,
Reason: req.Reason, Reason: req.Reason,
}) }
if req.CanManageSubCoinSellers != nil {
// nil 表示“只改状态”false 才表示关闭子币商管理权限。
protoReq.CanManageSubCoinSellers = req.CanManageSubCoinSellers
}
resp, err := c.hostAdminClient.SetCoinSellerStatus(ctx, protoReq)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -994,12 +1007,13 @@ func fromProtoCoinSellerProfile(profile *userv1.CoinSellerProfile) *CoinSellerPr
return nil return nil
} }
return &CoinSellerProfile{ return &CoinSellerProfile{
UserID: profile.GetUserId(), UserID: profile.GetUserId(),
Status: profile.GetStatus(), Status: profile.GetStatus(),
MerchantAssetType: profile.GetMerchantAssetType(), MerchantAssetType: profile.GetMerchantAssetType(),
CreatedByUserID: profile.GetCreatedByUserId(), CanManageSubCoinSellers: profile.GetCanManageSubCoinSellers(),
CreatedAtMs: profile.GetCreatedAtMs(), CreatedByUserID: profile.GetCreatedByUserId(),
UpdatedAtMs: profile.GetUpdatedAtMs(), CreatedAtMs: profile.GetCreatedAtMs(),
UpdatedAtMs: profile.GetUpdatedAtMs(),
} }
} }

View File

@ -24,6 +24,10 @@ import (
const userExportJobType = "user-export" const userExportJobType = "user-export"
type HandlerFunc func(ctx context.Context, job *model.AdminJob) (string, error)
type Option func(*Runner)
type Store interface { type Store interface {
LeaseNextJob(workerID string, lease time.Duration, maxAttempts int) (*model.AdminJob, error) LeaseNextJob(workerID string, lease time.Duration, maxAttempts int) (*model.AdminJob, error)
CompleteJobWithArtifact(id uint, resultJSON string, artifactPath string) error CompleteJobWithArtifact(id uint, resultJSON string, artifactPath string) error
@ -41,6 +45,7 @@ type Runner struct {
interval time.Duration interval time.Duration
leaseTTL time.Duration leaseTTL time.Duration
locker Locker locker Locker
handlers map[string]HandlerFunc
maxAttempts int maxAttempts int
store Store store Store
workerID string workerID string
@ -50,7 +55,17 @@ type Runner struct {
stop chan struct{} stop chan struct{}
} }
func NewRunner(store Store, locker Locker, cfg config.Config) *Runner { func WithHandler(jobType string, handler HandlerFunc) Option {
return func(r *Runner) {
jobType = strings.TrimSpace(jobType)
if jobType == "" || handler == nil {
return
}
r.handlers[jobType] = handler
}
}
func NewRunner(store Store, locker Locker, cfg config.Config, opts ...Option) *Runner {
interval := cfg.Jobs.WorkerInterval interval := cfg.Jobs.WorkerInterval
if interval <= 0 { if interval <= 0 {
interval = 5 * time.Second interval = 5 * time.Second
@ -71,17 +86,24 @@ func NewRunner(store Store, locker Locker, cfg config.Config) *Runner {
if workerID == "" { if workerID == "" {
workerID = "admin-job-worker" workerID = "admin-job-worker"
} }
return &Runner{ runner := &Runner{
artifactDir: artifactDir, artifactDir: artifactDir,
interval: interval, interval: interval,
leaseTTL: leaseTTL, leaseTTL: leaseTTL,
locker: locker, locker: locker,
handlers: make(map[string]HandlerFunc),
maxAttempts: maxAttempts, maxAttempts: maxAttempts,
store: store, store: store,
workerID: workerID, workerID: workerID,
done: make(chan struct{}), done: make(chan struct{}),
stop: make(chan struct{}), stop: make(chan struct{}),
} }
for _, opt := range opts {
if opt != nil {
opt(runner)
}
}
return runner
} }
func (r *Runner) Start() { func (r *Runner) Start() {
@ -156,7 +178,15 @@ func (r *Runner) handle(ctx context.Context, job *model.AdminJob) error {
case userExportJobType: case userExportJobType:
return r.exportAdminUsers(ctx, job) return r.exportAdminUsers(ctx, job)
default: default:
return fmt.Errorf("unsupported job type: %s", job.Type) handler := r.handlers[strings.TrimSpace(job.Type)]
if handler == nil {
return fmt.Errorf("unsupported job type: %s", job.Type)
}
resultJSON, err := handler(ctx, job)
if err != nil {
return err
}
return r.store.CompleteJobWithArtifact(job.ID, resultJSON, "")
} }
} }

View File

@ -0,0 +1,404 @@
package countryregion
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/roomclient"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/repository"
)
const CountryCodeRenameJobType = "country-code-rename"
var errCountryRenameJobConflict = errors.New("country code rename job already exists")
type countryCodeRenameJobStore interface {
CreateJob(job *model.AdminJob) error
ListActiveJobsByType(jobType string) ([]model.AdminJob, error)
RenameCountryCodeAdminReferences(appCode string, oldCountryCode string, newCountryCode string, nowMS int64) (repository.CountryCodeAdminReferenceCounts, error)
}
type countryCodeRoomClient interface {
RenameOwnerCountryCode(ctx context.Context, req roomclient.RenameOwnerCountryCodeRequest) (roomclient.RenameOwnerCountryCodeResult, error)
}
type countryCodeRenameResponse struct {
JobID uint `json:"jobId"`
Status string `json:"status"`
CountryID int64 `json:"countryId"`
OldCountryCode string `json:"oldCountryCode"`
NewCountryCode string `json:"newCountryCode"`
}
type countryCodeRenameJobPayload struct {
AppCode string `json:"appCode"`
CountryID int64 `json:"countryId"`
OldCountryCode string `json:"oldCountryCode"`
NewCountryCode string `json:"newCountryCode"`
CountryName string `json:"countryName"`
CountryDisplayName string `json:"countryDisplayName"`
SortOrder int32 `json:"sortOrder"`
ISOAlpha3 string `json:"isoAlpha3"`
ISONumeric string `json:"isoNumeric"`
PhoneCountryCode string `json:"phoneCountryCode"`
Flag string `json:"flag"`
ActorID int64 `json:"actorId"`
ActorName string `json:"actorName"`
RequestID string `json:"requestId"`
}
type countryCodeRenameResult struct {
CountryID int64 `json:"countryId"`
OldCountryCode string `json:"oldCountryCode"`
NewCountryCode string `json:"newCountryCode"`
Users int64 `json:"users"`
RegionMappings int64 `json:"regionMappings"`
RebuildTasks int64 `json:"rebuildTasks"`
Banners int64 `json:"banners"`
SplashScreens int64 `json:"splashScreens"`
RoomListEntries int64 `json:"roomListEntries"`
RoomSnapshots int64 `json:"roomSnapshots"`
CompletedAtMs int64 `json:"completedAtMs"`
}
type countryCodeRenameUserCounts struct {
Users int64
RegionMappings int64
RebuildTasks int64
}
func (s *Service) RenameCountryCode(ctx context.Context, actorID int64, actorName string, countryID int64, requestID string, req renameCountryCodeRequest) (*countryCodeRenameResponse, error) {
if s.userDB == nil {
return nil, errors.New("user db is not configured")
}
if s.jobStore == nil {
return nil, errors.New("job store is not configured")
}
req = normalizeRenameCountryCodeRequest(req)
if countryID <= 0 || strings.TrimSpace(requestID) == "" || !validCountryRenameRequest(req) {
return nil, errCountryInvalid
}
tx, err := s.userDB.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
country, err := queryCountryForUpdate(ctx, tx, countryID)
if err != nil {
return nil, err
}
oldCountryCode := strings.ToUpper(strings.TrimSpace(country.CountryCode))
if oldCountryCode == req.CountryCode {
return nil, errCountryInvalid
}
if err := ensureCountryCodeAvailable(ctx, tx, countryID, req.CountryCode); err != nil {
return nil, err
}
if err := s.ensureNoActiveCountryCodeRenameJob(ctx, appctx.FromContext(ctx), countryID, oldCountryCode, req.CountryCode); err != nil {
return nil, err
}
payload := countryCodeRenameJobPayload{
AppCode: appctx.FromContext(ctx),
CountryID: countryID,
OldCountryCode: oldCountryCode,
NewCountryCode: req.CountryCode,
CountryName: req.CountryName,
CountryDisplayName: req.CountryDisplayName,
SortOrder: req.SortOrder,
ISOAlpha3: req.ISOAlpha3,
ISONumeric: req.ISONumeric,
PhoneCountryCode: req.PhoneCountryCode,
Flag: req.Flag,
ActorID: actorID,
ActorName: strings.TrimSpace(actorName),
RequestID: strings.TrimSpace(requestID),
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
job := model.AdminJob{
Type: CountryCodeRenameJobType,
Status: model.JobStatusPending,
PayloadJSON: string(body),
CreatedBy: uint(actorID),
CreatedByName: strings.TrimSpace(actorName),
}
if err := s.jobStore.CreateJob(&job); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return &countryCodeRenameResponse{
JobID: job.ID,
Status: job.Status,
CountryID: countryID,
OldCountryCode: oldCountryCode,
NewCountryCode: req.CountryCode,
}, nil
}
func (s *Service) HandleCountryCodeRenameJob(ctx context.Context, job *model.AdminJob) (string, error) {
if job == nil {
return "", errors.New("job is required")
}
if s.userDB == nil {
return "", errors.New("user db is not configured")
}
if s.jobStore == nil {
return "", errors.New("job store is not configured")
}
if s.roomClient == nil {
return "", errors.New("room service client is not configured")
}
var payload countryCodeRenameJobPayload
if err := json.Unmarshal([]byte(job.PayloadJSON), &payload); err != nil {
return "", err
}
payload = normalizeCountryCodeRenamePayload(payload)
if !validCountryCodeRenamePayload(payload) {
return "", errCountryInvalid
}
ctx = appctx.WithContext(ctx, payload.AppCode)
userCounts, err := s.renameCountryCodeInUserDB(ctx, payload)
if err != nil {
return "", err
}
nowMS := time.Now().UTC().UnixMilli()
adminCounts, err := s.jobStore.RenameCountryCodeAdminReferences(payload.AppCode, payload.OldCountryCode, payload.NewCountryCode, nowMS)
if err != nil {
return "", err
}
roomCounts, err := s.roomClient.RenameOwnerCountryCode(ctx, roomclient.RenameOwnerCountryCodeRequest{
RequestID: firstNonEmptyString(payload.RequestID, fmt.Sprintf("country-code-rename-job-%d", job.ID)),
OldCountryCode: payload.OldCountryCode,
NewCountryCode: payload.NewCountryCode,
AdminID: uint64(payload.ActorID),
})
if err != nil {
return "", err
}
result := countryCodeRenameResult{
CountryID: payload.CountryID,
OldCountryCode: payload.OldCountryCode,
NewCountryCode: payload.NewCountryCode,
Users: userCounts.Users,
RegionMappings: userCounts.RegionMappings,
RebuildTasks: userCounts.RebuildTasks,
Banners: adminCounts.Banners,
SplashScreens: adminCounts.SplashScreens,
RoomListEntries: roomCounts.RoomListEntries,
RoomSnapshots: roomCounts.RoomSnapshots,
CompletedAtMs: time.Now().UTC().UnixMilli(),
}
body, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(body), nil
}
func (s *Service) renameCountryCodeInUserDB(ctx context.Context, payload countryCodeRenameJobPayload) (countryCodeRenameUserCounts, error) {
tx, err := s.userDB.BeginTx(ctx, nil)
if err != nil {
return countryCodeRenameUserCounts{}, err
}
defer tx.Rollback()
country, err := queryCountryForUpdate(ctx, tx, payload.CountryID)
if err != nil {
return countryCodeRenameUserCounts{}, err
}
currentCode := strings.ToUpper(strings.TrimSpace(country.CountryCode))
if currentCode != payload.OldCountryCode && currentCode != payload.NewCountryCode {
return countryCodeRenameUserCounts{}, errCountryConflict
}
if err := ensureCountryCodeAvailable(ctx, tx, payload.CountryID, payload.NewCountryCode); err != nil {
return countryCodeRenameUserCounts{}, err
}
nowMS := time.Now().UTC().UnixMilli()
if _, err := tx.ExecContext(ctx, `
UPDATE countries
SET country_code = ?, country_name = ?, country_display_name = ?,
iso_alpha3 = ?, iso_numeric = ?, phone_country_code = ?, flag = ?, sort_order = ?,
updated_by_user_id = ?, updated_at_ms = ?
WHERE app_code = ? AND country_id = ?
`, payload.NewCountryCode, payload.CountryName, payload.CountryDisplayName, nullableString(payload.ISOAlpha3), nullableString(payload.ISONumeric), nullableString(payload.PhoneCountryCode), payload.Flag, payload.SortOrder, nullableInt64(payload.ActorID), nowMS, payload.AppCode, payload.CountryID); err != nil {
return countryCodeRenameUserCounts{}, mapCountryWriteError(err)
}
regionResult, err := tx.ExecContext(ctx, `
UPDATE region_countries
SET country_code = ?, updated_by_user_id = ?, updated_at_ms = ?
WHERE app_code = ? AND country_code = ?
`, payload.NewCountryCode, nullableInt64(payload.ActorID), nowMS, payload.AppCode, payload.OldCountryCode)
if err != nil {
return countryCodeRenameUserCounts{}, err
}
userResult, err := tx.ExecContext(ctx, `
UPDATE users
SET country = ?, updated_at_ms = ?
WHERE app_code = ? AND country = ?
`, payload.NewCountryCode, nowMS, payload.AppCode, payload.OldCountryCode)
if err != nil {
return countryCodeRenameUserCounts{}, err
}
taskResult, err := tx.ExecContext(ctx, `
UPDATE user_region_rebuild_tasks
SET country_code = ?, updated_at_ms = ?
WHERE app_code = ? AND country_code = ? AND status IN (?, ?)
`, payload.NewCountryCode, nowMS, payload.AppCode, payload.OldCountryCode, regionRebuildTaskStatusPending, regionRebuildTaskStatusRunning)
if err != nil {
return countryCodeRenameUserCounts{}, err
}
if err := tx.Commit(); err != nil {
return countryCodeRenameUserCounts{}, err
}
regionRows, _ := regionResult.RowsAffected()
userRows, _ := userResult.RowsAffected()
taskRows, _ := taskResult.RowsAffected()
return countryCodeRenameUserCounts{Users: userRows, RegionMappings: regionRows, RebuildTasks: taskRows}, nil
}
func queryCountryForUpdate(ctx context.Context, tx *sql.Tx, countryID int64) (*userclientCountryForRename, error) {
var country userclientCountryForRename
if err := tx.QueryRowContext(ctx, `
SELECT country_id, country_code
FROM countries
WHERE app_code = ? AND country_id = ?
FOR UPDATE
`, appctx.FromContext(ctx), countryID).Scan(&country.CountryID, &country.CountryCode); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, errCountryNotFound
}
return nil, err
}
return &country, nil
}
type userclientCountryForRename struct {
CountryID int64
CountryCode string
}
func ensureCountryCodeAvailable(ctx context.Context, tx *sql.Tx, countryID int64, countryCode string) error {
var existingID int64
err := tx.QueryRowContext(ctx, `
SELECT country_id
FROM countries
WHERE app_code = ? AND country_code = ? AND country_id <> ?
LIMIT 1
FOR UPDATE
`, appctx.FromContext(ctx), countryCode, countryID).Scan(&existingID)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
return err
}
return errCountryConflict
}
func (s *Service) ensureNoActiveCountryCodeRenameJob(ctx context.Context, appCode string, countryID int64, oldCountryCode string, newCountryCode string) error {
jobs, err := s.jobStore.ListActiveJobsByType(CountryCodeRenameJobType)
if err != nil {
return err
}
for _, job := range jobs {
var payload countryCodeRenameJobPayload
if err := json.Unmarshal([]byte(job.PayloadJSON), &payload); err != nil {
continue
}
payload = normalizeCountryCodeRenamePayload(payload)
if payload.AppCode != appctx.Normalize(appCode) {
continue
}
if payload.CountryID == countryID ||
payload.OldCountryCode == oldCountryCode ||
payload.OldCountryCode == newCountryCode ||
payload.NewCountryCode == oldCountryCode ||
payload.NewCountryCode == newCountryCode {
return errCountryRenameJobConflict
}
}
return nil
}
func normalizeRenameCountryCodeRequest(req renameCountryCodeRequest) renameCountryCodeRequest {
req.CountryName = strings.TrimSpace(req.CountryName)
req.CountryCode = strings.ToUpper(strings.TrimSpace(req.CountryCode))
req.CountryDisplayName = strings.TrimSpace(req.CountryDisplayName)
req.ISOAlpha3 = strings.ToUpper(strings.TrimSpace(req.ISOAlpha3))
req.ISONumeric = strings.TrimSpace(req.ISONumeric)
req.PhoneCountryCode = strings.TrimSpace(req.PhoneCountryCode)
req.Flag = strings.TrimSpace(req.Flag)
return req
}
func validCountryRenameRequest(req renameCountryCodeRequest) bool {
return validCountryCreateRequest(createCountryRequest{
CountryName: req.CountryName,
CountryCode: req.CountryCode,
CountryDisplayName: req.CountryDisplayName,
SortOrder: req.SortOrder,
ISOAlpha3: req.ISOAlpha3,
ISONumeric: req.ISONumeric,
PhoneCountryCode: req.PhoneCountryCode,
Flag: req.Flag,
})
}
func normalizeCountryCodeRenamePayload(payload countryCodeRenameJobPayload) countryCodeRenameJobPayload {
payload.AppCode = appctx.Normalize(payload.AppCode)
payload.OldCountryCode = strings.ToUpper(strings.TrimSpace(payload.OldCountryCode))
payload.NewCountryCode = strings.ToUpper(strings.TrimSpace(payload.NewCountryCode))
payload.CountryName = strings.TrimSpace(payload.CountryName)
payload.CountryDisplayName = strings.TrimSpace(payload.CountryDisplayName)
payload.ISOAlpha3 = strings.ToUpper(strings.TrimSpace(payload.ISOAlpha3))
payload.ISONumeric = strings.TrimSpace(payload.ISONumeric)
payload.PhoneCountryCode = strings.TrimSpace(payload.PhoneCountryCode)
payload.Flag = strings.TrimSpace(payload.Flag)
payload.ActorName = strings.TrimSpace(payload.ActorName)
payload.RequestID = strings.TrimSpace(payload.RequestID)
return payload
}
func validCountryCodeRenamePayload(payload countryCodeRenameJobPayload) bool {
return payload.CountryID > 0 &&
payload.ActorID >= 0 &&
payload.OldCountryCode != payload.NewCountryCode &&
countryCodePattern.MatchString(payload.OldCountryCode) &&
validCountryRenameRequest(renameCountryCodeRequest{
CountryName: payload.CountryName,
CountryCode: payload.NewCountryCode,
CountryDisplayName: payload.CountryDisplayName,
SortOrder: payload.SortOrder,
ISOAlpha3: payload.ISOAlpha3,
ISONumeric: payload.ISONumeric,
PhoneCountryCode: payload.PhoneCountryCode,
Flag: payload.Flag,
})
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}

View File

@ -0,0 +1,296 @@
package countryregion
import (
"context"
"database/sql"
"encoding/json"
"errors"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/roomclient"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/repository"
)
func TestRenameCountryCodeRejectsInvalidCode(t *testing.T) {
db, mock, closeDB := newCountryRenameMockDB(t)
defer closeDB()
store := &fakeCountryRenameJobStore{}
service := NewService(nil, db, store, nil)
_, err := service.RenameCountryCode(appctx.WithContext(context.Background(), "lalu"), 7, "admin", 12, "req-1", validRenameCountryCodeRequest("P1"))
if !errors.Is(err, errCountryInvalid) {
t.Fatalf("expected invalid country error, got %v", err)
}
assertCountryRenameExpectations(t, mock)
if len(store.created) != 0 {
t.Fatalf("invalid request should not create job, got %d", len(store.created))
}
}
func TestRenameCountryCodeRejectsDuplicateCode(t *testing.T) {
db, mock, closeDB := newCountryRenameMockDB(t)
defer closeDB()
store := &fakeCountryRenameJobStore{}
service := NewService(nil, db, store, nil)
mock.ExpectBegin()
expectCountryRenameLockedCountry(mock, "PH")
expectCountryRenameDuplicateCode(mock, "PHL", 88)
mock.ExpectRollback()
_, err := service.RenameCountryCode(appctx.WithContext(context.Background(), "lalu"), 7, "admin", 12, "req-1", validRenameCountryCodeRequest("PHL"))
if !errors.Is(err, errCountryConflict) {
t.Fatalf("expected duplicate country error, got %v", err)
}
assertCountryRenameExpectations(t, mock)
if len(store.created) != 0 {
t.Fatalf("duplicate country code should not create job, got %d", len(store.created))
}
}
func TestRenameCountryCodeRejectsActiveJobConflict(t *testing.T) {
db, mock, closeDB := newCountryRenameMockDB(t)
defer closeDB()
payload := countryCodeRenameJobPayload{
AppCode: "lalu",
CountryID: 12,
OldCountryCode: "PH",
NewCountryCode: "PHL",
}
body, _ := json.Marshal(payload)
store := &fakeCountryRenameJobStore{active: []model.AdminJob{{PayloadJSON: string(body)}}}
service := NewService(nil, db, store, nil)
mock.ExpectBegin()
expectCountryRenameLockedCountry(mock, "PH")
expectCountryRenameAvailableCode(mock, "PHL")
mock.ExpectRollback()
_, err := service.RenameCountryCode(appctx.WithContext(context.Background(), "lalu"), 7, "admin", 12, "req-1", validRenameCountryCodeRequest("PHL"))
if !errors.Is(err, errCountryRenameJobConflict) {
t.Fatalf("expected active job conflict, got %v", err)
}
assertCountryRenameExpectations(t, mock)
if len(store.created) != 0 {
t.Fatalf("active conflict should not create job, got %d", len(store.created))
}
}
func TestRenameCountryCodeCreatesPendingJob(t *testing.T) {
db, mock, closeDB := newCountryRenameMockDB(t)
defer closeDB()
store := &fakeCountryRenameJobStore{}
service := NewService(nil, db, store, nil)
mock.ExpectBegin()
expectCountryRenameLockedCountry(mock, "PH")
expectCountryRenameAvailableCode(mock, "PHL")
mock.ExpectCommit()
resp, err := service.RenameCountryCode(appctx.WithContext(context.Background(), "lalu"), 7, "admin", 12, "req-1", validRenameCountryCodeRequest("phl"))
if err != nil {
t.Fatalf("rename country code failed: %v", err)
}
assertCountryRenameExpectations(t, mock)
if resp.JobID != 101 || resp.Status != model.JobStatusPending || resp.OldCountryCode != "PH" || resp.NewCountryCode != "PHL" {
t.Fatalf("unexpected response: %+v", resp)
}
if len(store.created) != 1 {
t.Fatalf("expected one created job, got %d", len(store.created))
}
var payload countryCodeRenameJobPayload
if err := json.Unmarshal([]byte(store.created[0].PayloadJSON), &payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
if payload.CountryID != 12 || payload.OldCountryCode != "PH" || payload.NewCountryCode != "PHL" || payload.ActorID != 7 {
t.Fatalf("unexpected payload: %+v", payload)
}
}
func TestHandleCountryCodeRenameJobUpdatesCurrentStateAndReturnsCounts(t *testing.T) {
db, mock, closeDB := newCountryRenameMockDB(t)
defer closeDB()
store := &fakeCountryRenameJobStore{
adminCounts: repository.CountryCodeAdminReferenceCounts{Banners: 2, SplashScreens: 3},
}
room := &fakeCountryRenameRoomClient{
result: roomclient.RenameOwnerCountryCodeResult{RoomListEntries: 4, RoomSnapshots: 5},
}
service := NewService(nil, db, store, room)
mock.ExpectBegin()
expectCountryRenameLockedCountry(mock, "PH")
expectCountryRenameAvailableCode(mock, "PHL")
mock.ExpectExec("UPDATE countries").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE region_countries").WillReturnResult(sqlmock.NewResult(0, 6))
mock.ExpectExec("UPDATE users").WillReturnResult(sqlmock.NewResult(0, 7))
mock.ExpectExec("UPDATE user_region_rebuild_tasks").WillReturnResult(sqlmock.NewResult(0, 8))
mock.ExpectCommit()
payload := countryCodeRenameJobPayload{
AppCode: "lalu",
CountryID: 12,
OldCountryCode: "PH",
NewCountryCode: "PHL",
CountryName: "Philippines",
CountryDisplayName: "菲律宾",
ActorID: 7,
RequestID: "req-1",
}
body, _ := json.Marshal(payload)
resultJSON, err := service.HandleCountryCodeRenameJob(context.Background(), &model.AdminJob{ID: 101, PayloadJSON: string(body)})
if err != nil {
t.Fatalf("handle job failed: %v", err)
}
assertCountryRenameExpectations(t, mock)
var result countryCodeRenameResult
if err := json.Unmarshal([]byte(resultJSON), &result); err != nil {
t.Fatalf("decode result: %v", err)
}
if result.Users != 7 || result.RegionMappings != 6 || result.RebuildTasks != 8 || result.Banners != 2 || result.SplashScreens != 3 || result.RoomListEntries != 4 || result.RoomSnapshots != 5 {
t.Fatalf("unexpected result counts: %+v", result)
}
if store.renamedAppCode != "lalu" || store.renamedOld != "PH" || store.renamedNew != "PHL" {
t.Fatalf("admin references not renamed with expected codes: %+v", store)
}
if room.req.OldCountryCode != "PH" || room.req.NewCountryCode != "PHL" || room.req.AdminID != 7 {
t.Fatalf("room rename request mismatch: %+v", room.req)
}
}
func TestHandleCountryCodeRenameJobIsIdempotentAfterUserDBAlreadyRenamed(t *testing.T) {
db, mock, closeDB := newCountryRenameMockDB(t)
defer closeDB()
store := &fakeCountryRenameJobStore{
adminCounts: repository.CountryCodeAdminReferenceCounts{Banners: 1, SplashScreens: 1},
}
room := &fakeCountryRenameRoomClient{
result: roomclient.RenameOwnerCountryCodeResult{RoomListEntries: 1, RoomSnapshots: 1},
}
service := NewService(nil, db, store, room)
mock.ExpectBegin()
expectCountryRenameLockedCountry(mock, "PHL")
expectCountryRenameAvailableCode(mock, "PHL")
mock.ExpectExec("UPDATE countries").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE region_countries").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("UPDATE users").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("UPDATE user_region_rebuild_tasks").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectCommit()
payload := countryCodeRenameJobPayload{
AppCode: "lalu",
CountryID: 12,
OldCountryCode: "PH",
NewCountryCode: "PHL",
CountryName: "Philippines",
CountryDisplayName: "菲律宾",
ActorID: 7,
RequestID: "req-1",
}
body, _ := json.Marshal(payload)
resultJSON, err := service.HandleCountryCodeRenameJob(context.Background(), &model.AdminJob{ID: 101, PayloadJSON: string(body)})
if err != nil {
t.Fatalf("handle retry job failed: %v", err)
}
assertCountryRenameExpectations(t, mock)
var result countryCodeRenameResult
if err := json.Unmarshal([]byte(resultJSON), &result); err != nil {
t.Fatalf("decode result: %v", err)
}
if result.Users != 0 || result.RegionMappings != 0 || result.RebuildTasks != 0 || result.Banners != 1 || result.SplashScreens != 1 || result.RoomListEntries != 1 || result.RoomSnapshots != 1 {
t.Fatalf("unexpected idempotent result counts: %+v", result)
}
}
func validRenameCountryCodeRequest(countryCode string) renameCountryCodeRequest {
return renameCountryCodeRequest{
CountryName: "Philippines",
CountryCode: countryCode,
CountryDisplayName: "菲律宾",
ISOAlpha3: "PHL",
ISONumeric: "608",
PhoneCountryCode: "+63",
Flag: "PH",
SortOrder: 1770,
}
}
func newCountryRenameMockDB(t *testing.T) (*sql.DB, sqlmock.Sqlmock, func()) {
t.Helper()
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("create sqlmock: %v", err)
}
return db, mock, func() {
_ = db.Close()
}
}
func expectCountryRenameLockedCountry(mock sqlmock.Sqlmock, countryCode string) {
mock.ExpectQuery(`(?s)SELECT country_id, country_code.*FROM countries.*FOR UPDATE`).
WithArgs("lalu", int64(12)).
WillReturnRows(sqlmock.NewRows([]string{"country_id", "country_code"}).AddRow(12, countryCode))
}
func expectCountryRenameDuplicateCode(mock sqlmock.Sqlmock, countryCode string, existingID int64) {
mock.ExpectQuery(`(?s)SELECT country_id.*FROM countries.*country_id <>`).
WithArgs("lalu", countryCode, int64(12)).
WillReturnRows(sqlmock.NewRows([]string{"country_id"}).AddRow(existingID))
}
func expectCountryRenameAvailableCode(mock sqlmock.Sqlmock, countryCode string) {
mock.ExpectQuery(`(?s)SELECT country_id.*FROM countries.*country_id <>`).
WithArgs("lalu", countryCode, int64(12)).
WillReturnError(sql.ErrNoRows)
}
func assertCountryRenameExpectations(t *testing.T, mock sqlmock.Sqlmock) {
t.Helper()
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
type fakeCountryRenameJobStore struct {
active []model.AdminJob
adminCounts repository.CountryCodeAdminReferenceCounts
created []model.AdminJob
renamedAppCode string
renamedOld string
renamedNew string
}
func (s *fakeCountryRenameJobStore) CreateJob(job *model.AdminJob) error {
job.ID = 101
job.Status = model.JobStatusPending
s.created = append(s.created, *job)
return nil
}
func (s *fakeCountryRenameJobStore) ListActiveJobsByType(string) ([]model.AdminJob, error) {
return s.active, nil
}
func (s *fakeCountryRenameJobStore) RenameCountryCodeAdminReferences(appCode string, oldCountryCode string, newCountryCode string, _ int64) (repository.CountryCodeAdminReferenceCounts, error) {
s.renamedAppCode = appCode
s.renamedOld = oldCountryCode
s.renamedNew = newCountryCode
return s.adminCounts, nil
}
type fakeCountryRenameRoomClient struct {
req roomclient.RenameOwnerCountryCodeRequest
result roomclient.RenameOwnerCountryCodeResult
}
func (c *fakeCountryRenameRoomClient) RenameOwnerCountryCode(_ context.Context, req roomclient.RenameOwnerCountryCodeRequest) (roomclient.RenameOwnerCountryCodeResult, error) {
c.req = req
return c.result, nil
}

View File

@ -9,9 +9,11 @@ import (
"strings" "strings"
"hyapp-admin-server/internal/config" "hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/integration/roomclient"
"hyapp-admin-server/internal/integration/userclient" "hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response" "hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -25,8 +27,15 @@ type Handler struct {
audit shared.OperationLogger audit shared.OperationLogger
} }
func New(userClient userclient.Client, userDB *sql.DB, cfg config.Config, audit shared.OperationLogger) *Handler { func New(userClient userclient.Client, userDB *sql.DB, jobStore *repository.Store, roomClient roomclient.Client, cfg config.Config, audit shared.OperationLogger) *Handler {
return &Handler{cfg: cfg, service: NewService(userClient, userDB), audit: audit} return &Handler{cfg: cfg, service: NewService(userClient, userDB, jobStore, roomClient), audit: audit}
}
func (h *Handler) Service() *Service {
if h == nil {
return nil
}
return h.service
} }
// ListCountries 返回国家主数据列表enabled 不传时返回全部国家。 // ListCountries 返回国家主数据列表enabled 不传时返回全部国家。
@ -112,6 +121,33 @@ func (h *Handler) UpdateCountry(c *gin.Context) {
response.OK(c, country) response.OK(c, country)
} }
// RenameCountryCode 创建国家码重命名后台任务;实际改码由 admin_jobs runner 串行执行并同步下游当前投影。
func (h *Handler) RenameCountryCode(c *gin.Context) {
countryID, ok := parseInt64ID(c, "country_id")
if !ok {
return
}
var req renameCountryCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "国家参数不正确")
return
}
ctx, cancel := h.userServiceContext(c)
defer cancel()
actor := shared.ActorFromContext(c)
job, err := h.service.RenameCountryCode(ctx, int64(actor.UserID), actor.Username, countryID, middleware.CurrentRequestID(c), req)
if err != nil {
writeCountryRegionError(c, err, "创建国家码重命名任务失败")
return
}
writeCountryRegionAuditLog(c, h.audit, "rename-country-code", "countries", countryID,
fmt.Sprintf("country_id=%d old_country_code=%s new_country_code=%s job_id=%d", job.CountryID, job.OldCountryCode, job.NewCountryCode, job.JobID))
response.Created(c, job)
}
// EnableCountry 重新开放国家给 App 注册和用户改国家入口。 // EnableCountry 重新开放国家给 App 注册和用户改国家入口。
func (h *Handler) EnableCountry(c *gin.Context) { func (h *Handler) EnableCountry(c *gin.Context) {
countryID, ok := parseInt64ID(c, "country_id") countryID, ok := parseInt64ID(c, "country_id")
@ -338,6 +374,10 @@ func writeCountryRegionError(c *gin.Context, err error, fallback string) {
response.BadRequest(c, "国家已存在") response.BadRequest(c, "国家已存在")
return return
} }
if errors.Is(err, errCountryRenameJobConflict) {
response.BadRequest(c, "国家码重命名任务正在处理中")
return
}
if errors.Is(err, errRegionNotFound) { if errors.Is(err, errRegionNotFound) {
response.NotFound(c, "区域不存在") response.NotFound(c, "区域不存在")
return return

View File

@ -34,6 +34,18 @@ type updateCountryRequest struct {
Flag string `json:"flag"` Flag string `json:"flag"`
} }
// renameCountryCodeRequest 是国家码重命名任务的创建入参;除新国家码外,携带当前编辑表单字段,保证 job 执行时一起提交展示字段。
type renameCountryCodeRequest struct {
CountryName string `json:"countryName" binding:"required"`
CountryCode string `json:"countryCode" binding:"required"`
CountryDisplayName string `json:"countryDisplayName" binding:"required"`
SortOrder int32 `json:"sortOrder"`
ISOAlpha3 string `json:"isoAlpha3"`
ISONumeric string `json:"isoNumeric"`
PhoneCountryCode string `json:"phoneCountryCode"`
Flag string `json:"flag"`
}
// createRegionRequest 创建业务区域,并提交首版 active 国家码列表。 // createRegionRequest 创建业务区域,并提交首版 active 国家码列表。
type createRegionRequest struct { type createRegionRequest struct {
// RegionCode 是稳定业务编码user-service 负责唯一性和格式校验。 // RegionCode 是稳定业务编码user-service 负责唯一性和格式校验。

View File

@ -15,6 +15,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/admin/countries/:country_id", middleware.RequirePermission("country:view"), h.GetCountry) protected.GET("/admin/countries/:country_id", middleware.RequirePermission("country:view"), h.GetCountry)
protected.POST("/admin/countries", middleware.RequirePermission("country:create"), h.CreateCountry) protected.POST("/admin/countries", middleware.RequirePermission("country:create"), h.CreateCountry)
protected.PATCH("/admin/countries/:country_id", middleware.RequirePermission("country:update"), h.UpdateCountry) protected.PATCH("/admin/countries/:country_id", middleware.RequirePermission("country:update"), h.UpdateCountry)
protected.POST("/admin/countries/:country_id/rename-code", middleware.RequirePermission("country:update"), h.RenameCountryCode)
protected.POST("/admin/countries/:country_id/enable", middleware.RequirePermission("country:status"), h.EnableCountry) protected.POST("/admin/countries/:country_id/enable", middleware.RequirePermission("country:status"), h.EnableCountry)
protected.POST("/admin/countries/:country_id/disable", middleware.RequirePermission("country:status"), h.DisableCountry) protected.POST("/admin/countries/:country_id/disable", middleware.RequirePermission("country:status"), h.DisableCountry)
protected.DELETE("/admin/countries/:country_id", middleware.RequirePermission("country:status"), h.DisableCountry) protected.DELETE("/admin/countries/:country_id", middleware.RequirePermission("country:status"), h.DisableCountry)

View File

@ -21,6 +21,7 @@ const globalRegionCode = "GLOBAL"
const regionStatusActive = "active" const regionStatusActive = "active"
const regionStatusDisabled = "disabled" const regionStatusDisabled = "disabled"
const regionRebuildTaskStatusPending = "pending" const regionRebuildTaskStatusPending = "pending"
const regionRebuildTaskStatusRunning = "running"
const retiredRegionCodeSQLList = "'Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED'" const retiredRegionCodeSQLList = "'Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED'"
const maxCountryNameLength = 128 const maxCountryNameLength = 128
const maxCountryDisplayNameLength = 128 const maxCountryDisplayNameLength = 128
@ -48,10 +49,12 @@ var (
type Service struct { type Service struct {
userClient userclient.Client userClient userclient.Client
userDB *sql.DB userDB *sql.DB
jobStore countryCodeRenameJobStore
roomClient countryCodeRoomClient
} }
func NewService(userClient userclient.Client, userDB *sql.DB) *Service { func NewService(userClient userclient.Client, userDB *sql.DB, jobStore countryCodeRenameJobStore, roomClient countryCodeRoomClient) *Service {
return &Service{userClient: userClient, userDB: userDB} return &Service{userClient: userClient, userDB: userDB, jobStore: jobStore, roomClient: roomClient}
} }
// ListCountries 只编排 user-service 已有 ListCountries RPC不直接读 user-service 数据库。 // ListCountries 只编排 user-service 已有 ListCountries RPC不直接读 user-service 数据库。

View File

@ -30,16 +30,17 @@ type CoinSellerListItem struct {
MerchantAssetType string `json:"merchantAssetType"` MerchantAssetType string `json:"merchantAssetType"`
MerchantBalance int64 `json:"merchantBalance"` MerchantBalance int64 `json:"merchantBalance"`
// TotalRechargeUSDTMicro 是币商 USDT 进货累计付款微单位,只统计 counts_as_seller_recharge 的库存记录。 // TotalRechargeUSDTMicro 是币商 USDT 进货累计付款微单位,只统计 counts_as_seller_recharge 的库存记录。
TotalRechargeUSDTMicro int64 `json:"totalRechargeUsdtMicro"` TotalRechargeUSDTMicro int64 `json:"totalRechargeUsdtMicro"`
Contact string `json:"contact"` Contact string `json:"contact"`
DisplayUserID string `json:"displayUserId"` CanManageSubCoinSellers bool `json:"canManageSubCoinSellers"`
Username string `json:"username"` DisplayUserID string `json:"displayUserId"`
Avatar string `json:"avatar"` Username string `json:"username"`
RegionID int64 `json:"regionId"` Avatar string `json:"avatar"`
RegionName string `json:"regionName"` RegionID int64 `json:"regionId"`
CreatedByUserID int64 `json:"createdByUserId"` RegionName string `json:"regionName"`
CreatedAtMs int64 `json:"createdAtMs"` CreatedByUserID int64 `json:"createdByUserId"`
UpdatedAtMs int64 `json:"updatedAtMs"` CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
} }
type ManagerListItem struct { type ManagerListItem struct {
@ -1056,6 +1057,9 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
if err := r.ensureCoinSellerContactColumn(ctx); err != nil { if err := r.ensureCoinSellerContactColumn(ctx); err != nil {
return nil, 0, err return nil, 0, err
} }
if err := r.ensureCoinSellerSubPermissionColumn(ctx); err != nil {
return nil, 0, err
}
whereSQL := ` whereSQL := `
FROM coin_seller_profiles csp FROM coin_seller_profiles csp
LEFT JOIN users u ON u.app_code = csp.app_code AND u.user_id = csp.user_id LEFT JOIN users u ON u.app_code = csp.app_code AND u.user_id = csp.user_id
@ -1096,6 +1100,7 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(` rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT csp.user_id, csp.status, csp.merchant_asset_type, SELECT csp.user_id, csp.status, csp.merchant_asset_type,
COALESCE(NULLIF(u.contact_info, ''), csp.contact_info, ''), COALESCE(NULLIF(u.contact_info, ''), csp.contact_info, ''),
csp.can_manage_sub_coin_sellers,
csp.created_by_user_id, csp.created_at_ms, csp.updated_at_ms, csp.created_by_user_id, csp.created_at_ms, csp.updated_at_ms,
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''), COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
COALESCE(u.avatar, ''), COALESCE(u.region_id, 0), COALESCE(u.avatar, ''), COALESCE(u.region_id, 0),
@ -1118,6 +1123,7 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
&item.Status, &item.Status,
&item.MerchantAssetType, &item.MerchantAssetType,
&item.Contact, &item.Contact,
&item.CanManageSubCoinSellers,
&item.CreatedByUserID, &item.CreatedByUserID,
&item.CreatedAtMs, &item.CreatedAtMs,
&item.UpdatedAtMs, &item.UpdatedAtMs,
@ -1306,6 +1312,27 @@ func (r *Reader) ensureCoinSellerContactColumn(ctx context.Context) error {
return err return err
} }
func (r *Reader) ensureCoinSellerSubPermissionColumn(ctx context.Context) error {
var count int
if err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'coin_seller_profiles'
AND column_name = 'can_manage_sub_coin_sellers'
`).Scan(&count); err != nil {
return err
}
if count > 0 {
return nil
}
_, err := r.db.ExecContext(ctx, `
ALTER TABLE coin_seller_profiles
ADD COLUMN can_manage_sub_coin_sellers TINYINT(1) NOT NULL DEFAULT 1 AFTER contact_info
`)
return err
}
func (r *Reader) coinSellerBalances(ctx context.Context, appCode string, userIDs []int64) (map[int64]int64, error) { func (r *Reader) coinSellerBalances(ctx context.Context, appCode string, userIDs []int64) (map[int64]int64, error) {
result := make(map[int64]int64, len(userIDs)) result := make(map[int64]int64, len(userIDs))
if r == nil || r.walletDB == nil || len(userIDs) == 0 { if r == nil || r.walletDB == nil || len(userIDs) == 0 {

View File

@ -186,17 +186,19 @@ type bdLeaderPositionAliasRequest struct {
} }
type createCoinSellerRequest struct { type createCoinSellerRequest struct {
CommandID string `json:"commandId" binding:"required"` CommandID string `json:"commandId" binding:"required"`
Contact string `json:"contact"` Contact string `json:"contact"`
TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"` TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"`
Reason string `json:"reason"` Reason string `json:"reason"`
CanManageSubCoinSellers *bool `json:"canManageSubCoinSellers"`
} }
type coinSellerStatusRequest struct { type coinSellerStatusRequest struct {
CommandID string `json:"commandId" binding:"required"` CommandID string `json:"commandId" binding:"required"`
Contact *string `json:"contact"` Contact *string `json:"contact"`
Status string `json:"status" binding:"required"` Status string `json:"status" binding:"required"`
Reason string `json:"reason"` Reason string `json:"reason"`
CanManageSubCoinSellers *bool `json:"canManageSubCoinSellers"`
} }
type coinSellerStockCreditRequest struct { type coinSellerStockCreditRequest struct {

View File

@ -244,12 +244,13 @@ func (s *Service) CreateCoinSeller(ctx context.Context, actorID int64, requestID
return nil, err return nil, err
} }
profile, err := s.userClient.CreateCoinSeller(ctx, userclient.CreateCoinSellerRequest{ profile, err := s.userClient.CreateCoinSeller(ctx, userclient.CreateCoinSellerRequest{
RequestID: requestID, RequestID: requestID,
Caller: "hyapp-admin-server", Caller: "hyapp-admin-server",
CommandID: strings.TrimSpace(req.CommandID), CommandID: strings.TrimSpace(req.CommandID),
AdminUserID: actorID, AdminUserID: actorID,
TargetUserID: targetUserID, TargetUserID: targetUserID,
Reason: strings.TrimSpace(req.Reason), Reason: strings.TrimSpace(req.Reason),
CanManageSubCoinSellers: req.CanManageSubCoinSellers,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@ -265,13 +266,14 @@ func (s *Service) CreateCoinSeller(ctx context.Context, actorID int64, requestID
func (s *Service) SetCoinSellerStatus(ctx context.Context, actorID int64, targetUserID int64, requestID string, req coinSellerStatusRequest) (*userclient.CoinSellerProfile, error) { func (s *Service) SetCoinSellerStatus(ctx context.Context, actorID int64, targetUserID int64, requestID string, req coinSellerStatusRequest) (*userclient.CoinSellerProfile, error) {
profile, err := s.userClient.SetCoinSellerStatus(ctx, userclient.SetCoinSellerStatusRequest{ profile, err := s.userClient.SetCoinSellerStatus(ctx, userclient.SetCoinSellerStatusRequest{
RequestID: requestID, RequestID: requestID,
Caller: "hyapp-admin-server", Caller: "hyapp-admin-server",
CommandID: strings.TrimSpace(req.CommandID), CommandID: strings.TrimSpace(req.CommandID),
AdminUserID: actorID, AdminUserID: actorID,
TargetUserID: targetUserID, TargetUserID: targetUserID,
Status: strings.TrimSpace(req.Status), Status: strings.TrimSpace(req.Status),
Reason: strings.TrimSpace(req.Reason), Reason: strings.TrimSpace(req.Reason),
CanManageSubCoinSellers: req.CanManageSubCoinSellers,
}) })
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -31,6 +31,19 @@ func (h *Handler) ListJobs(c *gin.Context) {
response.OK(c, response.Page{Items: jobs, Page: options.Page, PageSize: options.PageSize, Total: total}) response.OK(c, response.Page{Items: jobs, Page: options.Page, PageSize: options.PageSize, Total: total})
} }
func (h *Handler) GetJob(c *gin.Context) {
id, ok := shared.ParseID(c, "id")
if !ok {
return
}
job, err := h.service.GetJob(id)
if err != nil {
response.BadRequest(c, "任务不存在")
return
}
response.OK(c, job)
}
func (h *Handler) CreateUserExportJob(c *gin.Context) { func (h *Handler) CreateUserExportJob(c *gin.Context) {
job, err := h.service.CreateUserExportJob(shared.ActorFromContext(c), shared.ListOptions(c)) job, err := h.service.CreateUserExportJob(shared.ActorFromContext(c), shared.ListOptions(c))
if err != nil { if err != nil {

View File

@ -8,6 +8,7 @@ import (
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/jobs", middleware.RequirePermission("job:view"), h.ListJobs) protected.GET("/jobs", middleware.RequirePermission("job:view"), h.ListJobs)
protected.GET("/jobs/:id", middleware.RequireAnyPermission("job:view", "country:update"), h.GetJob)
protected.POST("/jobs/:id/cancel", middleware.RequirePermission("job:cancel"), h.CancelJob) protected.POST("/jobs/:id/cancel", middleware.RequirePermission("job:cancel"), h.CancelJob)
protected.GET("/jobs/:id/artifact", middleware.RequirePermission("job:view"), h.DownloadJobArtifact) protected.GET("/jobs/:id/artifact", middleware.RequirePermission("job:view"), h.DownloadJobArtifact)
protected.POST("/exports/users", middleware.RequirePermission("export:create"), h.CreateUserExportJob) protected.POST("/exports/users", middleware.RequirePermission("export:create"), h.CreateUserExportJob)

View File

@ -28,6 +28,10 @@ func (s *JobService) ListJobs(options repository.ListOptions) ([]model.AdminJob,
return s.store.ListJobs(options) return s.store.ListJobs(options)
} }
func (s *JobService) GetJob(id uint) (*model.AdminJob, error) {
return s.store.FindJobByID(id)
}
func (s *JobService) CreateUserExportJob(actor shared.Actor, options repository.ListOptions) (*model.AdminJob, error) { func (s *JobService) CreateUserExportJob(actor shared.Actor, options repository.ListOptions) (*model.AdminJob, error) {
payload, err := json.Marshal(options) payload, err := json.Marshal(options)
if err != nil { if err != nil {

View File

@ -132,6 +132,14 @@ func (s *Store) ListJobs(options ListOptions) ([]model.AdminJob, int64, error) {
return jobs, total, err return jobs, total, err
} }
func (s *Store) ListActiveJobsByType(jobType string) ([]model.AdminJob, error) {
var jobs []model.AdminJob
err := s.db.Where("type = ? AND status IN ?", strings.TrimSpace(jobType), []string{model.JobStatusPending, model.JobStatusRunning}).
Order("created_at_ms ASC, id ASC").
Find(&jobs).Error
return jobs, err
}
func (s *Store) LeaseNextJob(workerID string, lease time.Duration, maxAttempts int) (*model.AdminJob, error) { func (s *Store) LeaseNextJob(workerID string, lease time.Duration, maxAttempts int) (*model.AdminJob, error) {
if strings.TrimSpace(workerID) == "" { if strings.TrimSpace(workerID) == "" {
return nil, errors.New("worker id is required") return nil, errors.New("worker id is required")
@ -195,6 +203,37 @@ func (s *Store) CompleteJobWithArtifact(id uint, resultJSON string, artifactPath
}).Error }).Error
} }
type CountryCodeAdminReferenceCounts struct {
Banners int64 `json:"banners"`
SplashScreens int64 `json:"splashScreens"`
}
func (s *Store) RenameCountryCodeAdminReferences(appCode string, oldCountryCode string, newCountryCode string, nowMS int64) (CountryCodeAdminReferenceCounts, error) {
var counts CountryCodeAdminReferenceCounts
err := s.db.Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.AppBanner{}).
Where("app_code = ? AND country_code = ?", strings.TrimSpace(appCode), strings.TrimSpace(oldCountryCode)).
Updates(map[string]any{"country_code": strings.TrimSpace(newCountryCode), "updated_at_ms": nowMS})
if result.Error != nil {
return result.Error
}
counts.Banners = result.RowsAffected
result = tx.Model(&model.AppSplashScreen{}).
Where("app_code = ? AND country_code = ?", strings.TrimSpace(appCode), strings.TrimSpace(oldCountryCode)).
Updates(map[string]any{"country_code": strings.TrimSpace(newCountryCode), "updated_at_ms": nowMS})
if result.Error != nil {
return result.Error
}
counts.SplashScreens = result.RowsAffected
return nil
})
if err != nil {
return CountryCodeAdminReferenceCounts{}, err
}
return counts, nil
}
func (s *Store) FailJobAttempt(id uint, message string) error { func (s *Store) FailJobAttempt(id uint, message string) error {
var job model.AdminJob var job model.AdminJob
if err := s.db.First(&job, id).Error; err != nil { if err := s.db.First(&job, id).Error; err != nil {

View File

@ -113,6 +113,7 @@ type UserHostClient interface {
ListBDLeaderBDs(ctx context.Context, req *userv1.ListBDLeaderBDsRequest) (*userv1.ListBDLeaderBDsResponse, error) ListBDLeaderBDs(ctx context.Context, req *userv1.ListBDLeaderBDsRequest) (*userv1.ListBDLeaderBDsResponse, error)
ListBDLeaderAgencies(ctx context.Context, req *userv1.ListBDLeaderAgenciesRequest) (*userv1.ListBDLeaderAgenciesResponse, error) ListBDLeaderAgencies(ctx context.Context, req *userv1.ListBDLeaderAgenciesRequest) (*userv1.ListBDLeaderAgenciesResponse, error)
ListBDAgencies(ctx context.Context, req *userv1.ListBDAgenciesRequest) (*userv1.ListBDAgenciesResponse, error) ListBDAgencies(ctx context.Context, req *userv1.ListBDAgenciesRequest) (*userv1.ListBDAgenciesResponse, error)
ListManagerTeamAgencies(ctx context.Context, req *userv1.ListManagerTeamAgenciesRequest) (*userv1.ListManagerTeamAgenciesResponse, error)
ListRoleInvitations(ctx context.Context, req *userv1.ListRoleInvitationsRequest) (*userv1.ListRoleInvitationsResponse, error) ListRoleInvitations(ctx context.Context, req *userv1.ListRoleInvitationsRequest) (*userv1.ListRoleInvitationsResponse, error)
ProcessRoleInvitation(ctx context.Context, req *userv1.ProcessRoleInvitationRequest) (*userv1.ProcessRoleInvitationResponse, error) ProcessRoleInvitation(ctx context.Context, req *userv1.ProcessRoleInvitationRequest) (*userv1.ProcessRoleInvitationResponse, error)
GetHostProfile(ctx context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) GetHostProfile(ctx context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error)
@ -120,6 +121,9 @@ type UserHostClient interface {
GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error) GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error)
GetCoinSellerProfile(ctx context.Context, req *userv1.GetCoinSellerProfileRequest) (*userv1.GetCoinSellerProfileResponse, error) GetCoinSellerProfile(ctx context.Context, req *userv1.GetCoinSellerProfileRequest) (*userv1.GetCoinSellerProfileResponse, error)
ListActiveCoinSellersInMyRegion(ctx context.Context, req *userv1.ListActiveCoinSellersInMyRegionRequest) (*userv1.ListActiveCoinSellersInMyRegionResponse, error) ListActiveCoinSellersInMyRegion(ctx context.Context, req *userv1.ListActiveCoinSellersInMyRegionRequest) (*userv1.ListActiveCoinSellersInMyRegionResponse, error)
CreateSubCoinSeller(ctx context.Context, req *userv1.CreateSubCoinSellerRequest) (*userv1.CreateSubCoinSellerResponse, error)
ListSubCoinSellers(ctx context.Context, req *userv1.ListSubCoinSellersRequest) (*userv1.ListSubCoinSellersResponse, error)
CheckCoinSellerSubRelation(ctx context.Context, req *userv1.CheckCoinSellerSubRelationRequest) (*userv1.CheckCoinSellerSubRelationResponse, error)
GetUserRoleSummary(ctx context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error) GetUserRoleSummary(ctx context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error)
GetAgency(ctx context.Context, req *userv1.GetAgencyRequest) (*userv1.GetAgencyResponse, error) GetAgency(ctx context.Context, req *userv1.GetAgencyRequest) (*userv1.GetAgencyResponse, error)
UpdateAgencyProfile(ctx context.Context, req *userv1.UpdateAgencyProfileRequest) (*userv1.UpdateAgencyProfileResponse, error) UpdateAgencyProfile(ctx context.Context, req *userv1.UpdateAgencyProfileRequest) (*userv1.UpdateAgencyProfileResponse, error)
@ -540,6 +544,10 @@ func (c *grpcUserHostClient) ListBDAgencies(ctx context.Context, req *userv1.Lis
return c.client.ListBDAgencies(ctx, req) return c.client.ListBDAgencies(ctx, req)
} }
func (c *grpcUserHostClient) ListManagerTeamAgencies(ctx context.Context, req *userv1.ListManagerTeamAgenciesRequest) (*userv1.ListManagerTeamAgenciesResponse, error) {
return c.client.ListManagerTeamAgencies(ctx, req)
}
func (c *grpcUserHostClient) ListRoleInvitations(ctx context.Context, req *userv1.ListRoleInvitationsRequest) (*userv1.ListRoleInvitationsResponse, error) { func (c *grpcUserHostClient) ListRoleInvitations(ctx context.Context, req *userv1.ListRoleInvitationsRequest) (*userv1.ListRoleInvitationsResponse, error) {
return c.client.ListRoleInvitations(ctx, req) return c.client.ListRoleInvitations(ctx, req)
} }
@ -568,6 +576,18 @@ func (c *grpcUserHostClient) ListActiveCoinSellersInMyRegion(ctx context.Context
return c.client.ListActiveCoinSellersInMyRegion(ctx, req) return c.client.ListActiveCoinSellersInMyRegion(ctx, req)
} }
func (c *grpcUserHostClient) CreateSubCoinSeller(ctx context.Context, req *userv1.CreateSubCoinSellerRequest) (*userv1.CreateSubCoinSellerResponse, error) {
return c.client.CreateSubCoinSeller(ctx, req)
}
func (c *grpcUserHostClient) ListSubCoinSellers(ctx context.Context, req *userv1.ListSubCoinSellersRequest) (*userv1.ListSubCoinSellersResponse, error) {
return c.client.ListSubCoinSellers(ctx, req)
}
func (c *grpcUserHostClient) CheckCoinSellerSubRelation(ctx context.Context, req *userv1.CheckCoinSellerSubRelationRequest) (*userv1.CheckCoinSellerSubRelationResponse, error) {
return c.client.CheckCoinSellerSubRelation(ctx, req)
}
func (c *grpcUserHostClient) GetUserRoleSummary(ctx context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error) { func (c *grpcUserHostClient) GetUserRoleSummary(ctx context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error) {
return c.client.GetUserRoleSummary(ctx, req) return c.client.GetUserRoleSummary(ctx, req)
} }

View File

@ -14,6 +14,7 @@ type WalletClient interface {
DebitDirectGift(ctx context.Context, req *walletv1.DebitDirectGiftRequest) (*walletv1.DebitGiftResponse, error) DebitDirectGift(ctx context.Context, req *walletv1.DebitDirectGiftRequest) (*walletv1.DebitGiftResponse, error)
GetActiveHostSalaryPolicy(ctx context.Context, req *walletv1.GetActiveHostSalaryPolicyRequest) (*walletv1.GetActiveHostSalaryPolicyResponse, error) GetActiveHostSalaryPolicy(ctx context.Context, req *walletv1.GetActiveHostSalaryPolicyRequest) (*walletv1.GetActiveHostSalaryPolicyResponse, error)
GetHostSalaryProgress(ctx context.Context, req *walletv1.GetHostSalaryProgressRequest) (*walletv1.GetHostSalaryProgressResponse, error) GetHostSalaryProgress(ctx context.Context, req *walletv1.GetHostSalaryProgressRequest) (*walletv1.GetHostSalaryProgressResponse, error)
GetTeamHostSalaryStats(ctx context.Context, req *walletv1.GetTeamHostSalaryStatsRequest) (*walletv1.GetTeamHostSalaryStatsResponse, error)
GetWalletOverview(ctx context.Context, req *walletv1.GetWalletOverviewRequest) (*walletv1.GetWalletOverviewResponse, error) GetWalletOverview(ctx context.Context, req *walletv1.GetWalletOverviewRequest) (*walletv1.GetWalletOverviewResponse, error)
GetWalletValueSummary(ctx context.Context, req *walletv1.GetWalletValueSummaryRequest) (*walletv1.GetWalletValueSummaryResponse, error) GetWalletValueSummary(ctx context.Context, req *walletv1.GetWalletValueSummaryRequest) (*walletv1.GetWalletValueSummaryResponse, error)
GetUserGiftWall(ctx context.Context, req *walletv1.GetUserGiftWallRequest) (*walletv1.GetUserGiftWallResponse, error) GetUserGiftWall(ctx context.Context, req *walletv1.GetUserGiftWallRequest) (*walletv1.GetUserGiftWallResponse, error)
@ -38,6 +39,7 @@ type WalletClient interface {
DebitWheelDraw(ctx context.Context, req *walletv1.DebitWheelDrawRequest) (*walletv1.DebitWheelDrawResponse, error) DebitWheelDraw(ctx context.Context, req *walletv1.DebitWheelDrawRequest) (*walletv1.DebitWheelDrawResponse, error)
GrantVip(ctx context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error) GrantVip(ctx context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error)
TransferCoinFromSeller(ctx context.Context, req *walletv1.TransferCoinFromSellerRequest) (*walletv1.TransferCoinFromSellerResponse, error) TransferCoinFromSeller(ctx context.Context, req *walletv1.TransferCoinFromSellerRequest) (*walletv1.TransferCoinFromSellerResponse, error)
TransferCoinSellerStockToChild(ctx context.Context, req *walletv1.TransferCoinSellerStockToChildRequest) (*walletv1.TransferCoinSellerStockToChildResponse, error)
ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, req *walletv1.ListCoinSellerSalaryExchangeRateTiersRequest) (*walletv1.ListCoinSellerSalaryExchangeRateTiersResponse, error) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, req *walletv1.ListCoinSellerSalaryExchangeRateTiersRequest) (*walletv1.ListCoinSellerSalaryExchangeRateTiersResponse, error)
ExchangeSalaryToCoin(ctx context.Context, req *walletv1.ExchangeSalaryToCoinRequest) (*walletv1.ExchangeSalaryToCoinResponse, error) ExchangeSalaryToCoin(ctx context.Context, req *walletv1.ExchangeSalaryToCoinRequest) (*walletv1.ExchangeSalaryToCoinResponse, error)
TransferSalaryToCoinSeller(ctx context.Context, req *walletv1.TransferSalaryToCoinSellerRequest) (*walletv1.TransferSalaryToCoinSellerResponse, error) TransferSalaryToCoinSeller(ctx context.Context, req *walletv1.TransferSalaryToCoinSellerRequest) (*walletv1.TransferSalaryToCoinSellerResponse, error)
@ -88,6 +90,10 @@ func (c *grpcWalletClient) GetHostSalaryProgress(ctx context.Context, req *walle
return c.client.GetHostSalaryProgress(ctx, req) return c.client.GetHostSalaryProgress(ctx, req)
} }
func (c *grpcWalletClient) GetTeamHostSalaryStats(ctx context.Context, req *walletv1.GetTeamHostSalaryStatsRequest) (*walletv1.GetTeamHostSalaryStatsResponse, error) {
return c.client.GetTeamHostSalaryStats(ctx, req)
}
func (c *grpcWalletClient) GetWalletOverview(ctx context.Context, req *walletv1.GetWalletOverviewRequest) (*walletv1.GetWalletOverviewResponse, error) { func (c *grpcWalletClient) GetWalletOverview(ctx context.Context, req *walletv1.GetWalletOverviewRequest) (*walletv1.GetWalletOverviewResponse, error) {
return c.client.GetWalletOverview(ctx, req) return c.client.GetWalletOverview(ctx, req)
} }
@ -184,6 +190,10 @@ func (c *grpcWalletClient) TransferCoinFromSeller(ctx context.Context, req *wall
return c.client.TransferCoinFromSeller(ctx, req) return c.client.TransferCoinFromSeller(ctx, req)
} }
func (c *grpcWalletClient) TransferCoinSellerStockToChild(ctx context.Context, req *walletv1.TransferCoinSellerStockToChildRequest) (*walletv1.TransferCoinSellerStockToChildResponse, error) {
return c.client.TransferCoinSellerStockToChild(ctx, req)
}
func (c *grpcWalletClient) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, req *walletv1.ListCoinSellerSalaryExchangeRateTiersRequest) (*walletv1.ListCoinSellerSalaryExchangeRateTiersResponse, error) { func (c *grpcWalletClient) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, req *walletv1.ListCoinSellerSalaryExchangeRateTiersRequest) (*walletv1.ListCoinSellerSalaryExchangeRateTiersResponse, error) {
return c.client.ListCoinSellerSalaryExchangeRateTiers(ctx, req) return c.client.ListCoinSellerSalaryExchangeRateTiers(ctx, req)
} }

View File

@ -264,6 +264,14 @@ type WalletHandlers struct {
ListWalletTransactions http.HandlerFunc ListWalletTransactions http.HandlerFunc
ListCoinSellers http.HandlerFunc ListCoinSellers http.HandlerFunc
TransferCoinFromSeller http.HandlerFunc TransferCoinFromSeller http.HandlerFunc
GetCoinSellerSubSellerPermission http.HandlerFunc
InviteCoinSellerSubSeller http.HandlerFunc
ListCoinSellerSubSellers http.HandlerFunc
TransferCoinSellerToSubSeller http.HandlerFunc
GetCoinSellerOverview http.HandlerFunc
CreateSubCoinSeller http.HandlerFunc
ListSubCoinSellers http.HandlerFunc
TransferCoinSellerStockToChild http.HandlerFunc
SendDirectGift http.HandlerFunc SendDirectGift http.HandlerFunc
GetSalaryWalletOverview http.HandlerFunc GetSalaryWalletOverview http.HandlerFunc
GetSalaryWalletHistory http.HandlerFunc GetSalaryWalletHistory http.HandlerFunc
@ -628,6 +636,30 @@ func (r routes) registerWalletRoutes() {
r.profile("/wallet/transactions", http.MethodGet, h.ListWalletTransactions) r.profile("/wallet/transactions", http.MethodGet, h.ListWalletTransactions)
r.profile("/wallet/coin-sellers", http.MethodGet, h.ListCoinSellers) r.profile("/wallet/coin-sellers", http.MethodGet, h.ListCoinSellers)
r.profile("/wallet/coin-seller/transfer", "", h.TransferCoinFromSeller) r.profile("/wallet/coin-seller/transfer", "", h.TransferCoinFromSeller)
r.profile("/wallet/coin-seller/sub-sellers/permission", http.MethodGet, h.GetCoinSellerSubSellerPermission)
r.profile("/wallet/coin-seller/sub-sellers", "", func(writer http.ResponseWriter, request *http.Request) {
switch request.Method {
case http.MethodGet:
h.ListCoinSellerSubSellers(writer, request)
case http.MethodPost:
h.InviteCoinSellerSubSeller(writer, request)
default:
httpkit.RequireMethod(http.MethodGet, h.ListCoinSellerSubSellers)(writer, request)
}
})
r.profile("/wallet/coin-seller/sub-sellers/transfer", http.MethodPost, h.TransferCoinSellerToSubSeller)
r.profile("/coin-seller/overview", http.MethodGet, h.GetCoinSellerOverview)
r.profile("/coin-seller/sub-sellers", "", func(writer http.ResponseWriter, request *http.Request) {
switch request.Method {
case http.MethodGet:
h.ListSubCoinSellers(writer, request)
case http.MethodPost:
h.CreateSubCoinSeller(writer, request)
default:
httpkit.RequireMethod(http.MethodGet, h.ListSubCoinSellers)(writer, request)
}
})
r.profile("/coin-seller/sub-sellers/{child_user_id}/transfer", http.MethodPost, h.TransferCoinSellerStockToChild)
r.profile("/messages/direct-gifts/send", http.MethodPost, h.SendDirectGift) r.profile("/messages/direct-gifts/send", http.MethodPost, h.SendDirectGift)
r.profile("/salary-wallet/overview", http.MethodGet, h.GetSalaryWalletOverview) r.profile("/salary-wallet/overview", http.MethodGet, h.GetSalaryWalletOverview)
r.profile("/salary-wallet/history", http.MethodGet, h.GetSalaryWalletHistory) r.profile("/salary-wallet/history", http.MethodGet, h.GetSalaryWalletHistory)

View File

@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time"
activityv1 "hyapp.local/api/proto/activity/v1" activityv1 "hyapp.local/api/proto/activity/v1"
userv1 "hyapp.local/api/proto/user/v1" userv1 "hyapp.local/api/proto/user/v1"
@ -175,7 +176,18 @@ func TestSearchManagerUsersDefaultsSameCountryAndAllowsAllForCountryTransfer(t *
}, },
} }
hostClient := &fakeUserHostClient{} hostClient := &fakeUserHostClient{}
router := newManagerCenterTestRouter(&fakeWalletClient{}, hostClient, profileClient) walletClient := &fakeWalletClient{
myVipResp: &walletv1.GetMyVipResponse{Vip: &walletv1.UserVip{
UserId: 900001,
Level: 7,
Name: "VIP7",
Active: true,
StartedAtMs: 1000,
ExpiresAtMs: 9000,
UpdatedAtMs: 2000,
}},
}
router := newManagerCenterTestRouter(walletClient, hostClient, profileClient)
sameCountryReq := httptest.NewRequest(http.MethodGet, "/api/v1/manager-center/users/search?keyword=163&page_size=20", nil) sameCountryReq := httptest.NewRequest(http.MethodGet, "/api/v1/manager-center/users/search?keyword=163&page_size=20", nil)
sameCountryReq.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) sameCountryReq.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
@ -203,6 +215,9 @@ func TestSearchManagerUsersDefaultsSameCountryAndAllowsAllForCountryTransfer(t *
if req := profileClient.lastBusinessLookup; req == nil || req.GetScene() != "manager_resource_grant" { if req := profileClient.lastBusinessLookup; req == nil || req.GetScene() != "manager_resource_grant" {
t.Fatalf("default manager search should use resource grant scene, got %+v", req) t.Fatalf("default manager search should use resource grant scene, got %+v", req)
} }
if walletClient.lastMyVip != nil {
t.Fatalf("default manager search must not query VIP status: %+v", walletClient.lastMyVip)
}
vipReq := httptest.NewRequest(http.MethodGet, "/api/v1/manager-center/users/search?keyword=163&page_size=20&action=send-vip", nil) vipReq := httptest.NewRequest(http.MethodGet, "/api/v1/manager-center/users/search?keyword=163&page_size=20&action=send-vip", nil)
vipReq.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) vipReq.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
@ -216,8 +231,54 @@ func TestSearchManagerUsersDefaultsSameCountryAndAllowsAllForCountryTransfer(t *
if req := profileClient.lastBusinessLookup; req == nil || req.GetScene() != "manager_vip_grant" { if req := profileClient.lastBusinessLookup; req == nil || req.GetScene() != "manager_vip_grant" {
t.Fatalf("VIP manager search should use vip grant scene, got %+v", req) t.Fatalf("VIP manager search should use vip grant scene, got %+v", req)
} }
var vipEnvelope struct {
Data struct {
Items []struct {
CurrentVIP struct {
Level int32 `json:"level"`
Name string `json:"name"`
Active bool `json:"active"`
StartedAtMS int64 `json:"started_at_ms"`
ExpiresAtMS int64 `json:"expires_at_ms"`
} `json:"current_vip"`
} `json:"items"`
} `json:"data"`
}
if err := json.NewDecoder(vipRecorder.Body).Decode(&vipEnvelope); err != nil {
t.Fatalf("decode vip response failed: %v", err)
}
if walletClient.lastMyVip == nil || walletClient.lastMyVip.GetUserId() != 900001 {
t.Fatalf("VIP manager search should query visible target VIP status: %+v", walletClient.lastMyVip)
}
if len(vipEnvelope.Data.Items) != 1 || vipEnvelope.Data.Items[0].CurrentVIP.Level != 7 || !vipEnvelope.Data.Items[0].CurrentVIP.Active || vipEnvelope.Data.Items[0].CurrentVIP.ExpiresAtMS != 9000 {
t.Fatalf("VIP manager search current_vip mismatch: %+v", vipEnvelope.Data.Items)
}
allReq := httptest.NewRequest(http.MethodGet, "/api/v1/manager-center/users/search?keyword=163&page_size=20&scope=all", nil) for _, item := range []struct {
action string
scene string
}{
{action: "block-user", scene: "manager_block_user"},
{action: "update-user-level", scene: "manager_update_user_level"},
{action: "add-bd-leader", scene: "manager_add_bd_leader"},
{action: "add-admin", scene: "manager_add_admin"},
{action: "add-superadmin", scene: "manager_add_superadmin"},
{action: "transfer-user-country", scene: "manager_transfer_user_country"},
} {
req := httptest.NewRequest(http.MethodGet, "/api/v1/manager-center/users/search?keyword=163&page_size=20&action="+item.action, nil)
req.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
req.Header.Set("X-Request-ID", "req-manager-search-"+item.action)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("%s search status mismatch: got %d body=%s", item.action, recorder.Code, recorder.Body.String())
}
if got := profileClient.lastBusinessLookup.GetScene(); got != item.scene {
t.Fatalf("%s manager search scene = %q, want %q", item.action, got, item.scene)
}
}
allReq := httptest.NewRequest(http.MethodGet, "/api/v1/manager-center/users/search?keyword=163&page_size=20&scope=all&action=transfer-user-country", nil)
allReq.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) allReq.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
allReq.Header.Set("X-Request-ID", "req-manager-search-all") allReq.Header.Set("X-Request-ID", "req-manager-search-all")
allRecorder := httptest.NewRecorder() allRecorder := httptest.NewRecorder()
@ -272,6 +333,58 @@ func TestGrantManagerResourceUsesServerControlledGrantShape(t *testing.T) {
} }
} }
func TestGrantManagerResourceAllowsMultipleResources(t *testing.T) {
walletClient := &fakeWalletClient{
resourcesByID: map[int64]*walletv1.Resource{
202: {ResourceId: 202, ResourceType: "vehicle"},
203: {ResourceId: 203, ResourceType: "vehicle"},
},
}
hostClient := &fakeUserHostClient{}
router := newManagerCenterTestRouter(walletClient, hostClient, &fakeUserProfileClient{})
body := []byte(`{"command_id":"mgr-grant-batch","target_user_id":"900001","resource_ids":["202",203],"duration_ms":7776000000}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/resource-grants", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-manager-grant-batch")
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 len(walletClient.grantResourceRequests) != 2 {
t.Fatalf("wallet grant count mismatch: %+v", walletClient.grantResourceRequests)
}
first := walletClient.grantResourceRequests[0]
second := walletClient.grantResourceRequests[1]
if first.GetCommandId() != "mgr-grant-batch:resource:202" || first.GetResourceId() != 202 || first.GetQuantity() != 1 || first.GetDurationMs() != 7776000000 {
t.Fatalf("first batch grant mismatch: %+v", first)
}
if second.GetCommandId() != "mgr-grant-batch:resource:203" || second.GetResourceId() != 203 || second.GetQuantity() != 1 || second.GetDurationMs() != 7776000000 {
t.Fatalf("second batch grant mismatch: %+v", second)
}
if !hostClient.sawCapability("manager_grant_vehicle") {
t.Fatalf("batch vehicle grant must check manager_grant_vehicle, saw %#v", hostClient.capabilities)
}
var envelope struct {
Code string `json:"code"`
Data struct {
Total int `json:"total"`
Items []struct {
CommandID string `json:"command_id"`
ResourceID string `json:"resource_id"`
} `json:"items"`
} `json:"data"`
}
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
t.Fatalf("decode response: %v", err)
}
if envelope.Code != httpkit.CodeOK || envelope.Data.Total != 2 || len(envelope.Data.Items) != 2 || envelope.Data.Items[0].ResourceID != "202" || envelope.Data.Items[1].CommandID != "mgr-grant-batch:resource:203" {
t.Fatalf("batch response mismatch: %+v", envelope)
}
}
func TestGrantManagerResourceAllowsConfiguredDuration(t *testing.T) { func TestGrantManagerResourceAllowsConfiguredDuration(t *testing.T) {
walletClient := &fakeWalletClient{} walletClient := &fakeWalletClient{}
router := newManagerCenterTestRouter(walletClient, &fakeUserHostClient{}, &fakeUserProfileClient{}) router := newManagerCenterTestRouter(walletClient, &fakeUserHostClient{}, &fakeUserProfileClient{})
@ -573,6 +686,124 @@ func TestTransferManagerUserCountryUsesAdminCountryChangeWithoutSameCountryFilte
} }
} }
// TestGetManagerOverviewIncludesTeamSalaryDashboard 验证经理概览聚合团队工资卡片:
// user-service 组织树 Agency owner 去重后传给 wallet-service按本月/上月两个 UTC 周期取回预收入并计算环比。
func TestGetManagerOverviewIncludesTeamSalaryDashboard(t *testing.T) {
monthStart := time.Date(time.Now().UTC().Year(), time.Now().UTC().Month(), 1, 0, 0, 0, 0, time.UTC)
thisCycle := monthStart.Format("2006-01")
lastCycle := monthStart.AddDate(0, -1, 0).Format("2006-01")
hostClient := &fakeUserHostClient{
managerTeamResp: &userv1.ListManagerTeamAgenciesResponse{
Agencies: []*userv1.ManagerTeamAgency{
{AgencyId: 1, OwnerUserId: 501},
{AgencyId: 2, OwnerUserId: 502},
// 同一 owner 拥有多个 Agency 时必须去重,否则 wallet 侧 IN 查询携带重复收款人。
{AgencyId: 3, OwnerUserId: 501},
},
TotalBdLeaders: 2,
TotalBds: 5,
},
}
walletClient := &fakeWalletClient{
teamSalaryStatsResp: &walletv1.GetTeamHostSalaryStatsResponse{
Stats: []*walletv1.TeamHostSalaryCycleStat{
{CycleKey: thisCycle, EstimatedHostSalaryUsdMinor: 1215200, ActiveHostCount: 128},
{CycleKey: lastCycle, EstimatedHostSalaryUsdMinor: 926900, ActiveHostCount: 96},
},
},
}
router := newManagerCenterTestRouter(walletClient, hostClient, &fakeUserProfileClient{})
request := httptest.NewRequest(http.MethodGet, "/api/v1/manager-center/overview", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
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.lastManagerTeam == nil || hostClient.lastManagerTeam.GetManagerUserId() != 42 {
t.Fatalf("manager team request mismatch: %+v", hostClient.lastManagerTeam)
}
statsReq := walletClient.lastTeamSalaryStats
if statsReq == nil || statsReq.GetAppCode() != "lalu" || statsReq.GetNowMs() <= 0 {
t.Fatalf("team salary stats request mismatch: %+v", statsReq)
}
if owners := statsReq.GetAgencyOwnerUserIds(); len(owners) != 2 || owners[0] != 501 || owners[1] != 502 {
t.Fatalf("agency owner ids must be deduped: %v", owners)
}
if cycles := statsReq.GetCycleKeys(); len(cycles) != 2 || cycles[0] != thisCycle || cycles[1] != lastCycle {
t.Fatalf("cycle keys mismatch: %v", cycles)
}
var envelope struct {
Data struct {
Dashboard struct {
MonthLabel string `json:"month_label"`
ThisMonthSalary float64 `json:"this_month_salary"`
LastMonthSalary float64 `json:"last_month_salary"`
ActiveHosts int32 `json:"active_hosts"`
ActiveHostsLastMonth int32 `json:"active_hosts_last_month"`
Growth string `json:"growth"`
TeamBDLeaders int32 `json:"team_bd_leaders"`
TeamBDs int32 `json:"team_bds"`
TeamAgencies int32 `json:"team_agencies"`
} `json:"dashboard"`
} `json:"data"`
}
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
t.Fatalf("decode response failed: %v", err)
}
dashboard := envelope.Data.Dashboard
if dashboard.MonthLabel != monthStart.Format("Jan 2006") {
t.Fatalf("month label mismatch: %+v", dashboard)
}
if dashboard.ThisMonthSalary != 12152 || dashboard.LastMonthSalary != 9269 {
t.Fatalf("salary amounts mismatch: %+v", dashboard)
}
if dashboard.ActiveHosts != 128 || dashboard.ActiveHostsLastMonth != 96 {
t.Fatalf("active hosts mismatch: %+v", dashboard)
}
// (12152 - 9269) / 9269 = 31.1% → 四舍五入 +31%。
if dashboard.Growth != "+31%" {
t.Fatalf("growth mismatch: %+v", dashboard)
}
if dashboard.TeamBDLeaders != 2 || dashboard.TeamBDs != 5 || dashboard.TeamAgencies != 3 {
t.Fatalf("team structure mismatch: %+v", dashboard)
}
}
// TestGetManagerOverviewDegradesWhenTeamStatsUnavailable 验证统计链路故障时经理概览仍返回身份与权限,不返回 dashboard。
func TestGetManagerOverviewDegradesWhenTeamStatsUnavailable(t *testing.T) {
hostClient := &fakeUserHostClient{managerTeamErr: context.DeadlineExceeded}
walletClient := &fakeWalletClient{}
router := newManagerCenterTestRouter(walletClient, hostClient, &fakeUserProfileClient{})
request := httptest.NewRequest(http.MethodGet, "/api/v1/manager-center/overview", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
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())
}
var envelope struct {
Data map[string]json.RawMessage `json:"data"`
}
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
t.Fatalf("decode response failed: %v", err)
}
if _, ok := envelope.Data["dashboard"]; ok {
t.Fatal("dashboard must be omitted when team stats aggregation fails")
}
if _, ok := envelope.Data["permissions"]; !ok {
t.Fatal("permissions must still be returned when team stats aggregation fails")
}
if walletClient.lastTeamSalaryStats != nil {
t.Fatal("wallet stats must not be called when team resolution fails")
}
}
type fakeGrowthLevelClient struct { type fakeGrowthLevelClient struct {
lastSet *activityv1.SetUserLevelRequest lastSet *activityv1.SetUserLevelRequest
} }

View File

@ -13,6 +13,8 @@ type Handler struct {
userHostAdminClient client.UserHostAdminClient userHostAdminClient client.UserHostAdminClient
userProfileClient client.UserProfileClient userProfileClient client.UserProfileClient
growthLevelClient client.GrowthLevelClient growthLevelClient client.GrowthLevelClient
// dashboardCache 缓存经理团队工资统计,避免每次进入经理中心都做组织树 + 周期账户双聚合。
dashboardCache managerDashboardCache
} }
type Config struct { type Config struct {

View File

@ -30,6 +30,7 @@ const managerBlockUserCapability = "manager_block_user"
const managerTransferUserCountryCapability = "manager_transfer_user_country" const managerTransferUserCountryCapability = "manager_transfer_user_country"
const managerGrantDayMS int64 = 24 * 60 * 60 * 1000 const managerGrantDayMS int64 = 24 * 60 * 60 * 1000
const managerGrantDefaultDurationMS int64 = 7 * managerGrantDayMS const managerGrantDefaultDurationMS int64 = 7 * managerGrantDayMS
const managerGrantMaxResourceIDs = 20
const managerGrantMaxVIPLevel int32 = 5 const managerGrantMaxVIPLevel int32 = 5
type managerGrantResourceData struct { type managerGrantResourceData struct {
@ -65,6 +66,7 @@ type managerUserData struct {
RegionCode string `json:"region_code,omitempty"` RegionCode string `json:"region_code,omitempty"`
RegionName string `json:"region_name,omitempty"` RegionName string `json:"region_name,omitempty"`
Levels map[string]managerLevelData `json:"levels"` Levels map[string]managerLevelData `json:"levels"`
CurrentVIP *managerVipData `json:"current_vip,omitempty"`
} }
type managerLevelData struct { type managerLevelData struct {
@ -73,6 +75,15 @@ type managerLevelData struct {
TierID string `json:"tier_id,omitempty"` TierID string `json:"tier_id,omitempty"`
} }
type managerVipData struct {
Level int32 `json:"level"`
Name string `json:"name"`
Active bool `json:"active"`
StartedAtMS int64 `json:"started_at_ms"`
ExpiresAtMS int64 `json:"expires_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type managerResourceGrantData struct { type managerResourceGrantData struct {
GrantID string `json:"grant_id"` GrantID string `json:"grant_id"`
CommandID string `json:"command_id"` CommandID string `json:"command_id"`
@ -130,7 +141,7 @@ func (h *Handler) getManagerOverview(writer http.ResponseWriter, request *http.R
if !ok { if !ok {
return return
} }
httpkit.WriteOK(writer, request, map[string]any{ payload := map[string]any{
"is_manager": true, "is_manager": true,
"user_id": httpkit.UserIDString(actor.GetUserId()), "user_id": httpkit.UserIDString(actor.GetUserId()),
"display_user_id": actor.GetDisplayUserId(), "display_user_id": actor.GetDisplayUserId(),
@ -141,7 +152,12 @@ func (h *Handler) getManagerOverview(writer http.ResponseWriter, request *http.R
"region_code": actor.GetRegionCode(), "region_code": actor.GetRegionCode(),
"region_name": actor.GetRegionName(), "region_name": actor.GetRegionName(),
"permissions": h.managerPermissionOverview(request), "permissions": h.managerPermissionOverview(request),
}) }
// 团队工资统计是增强信息聚合失败只丢卡片数据不阻断经理中心H5 对缺失字段做占位展示。
if dashboard := h.managerTeamDashboard(request, actor.GetUserId()); dashboard != nil {
payload["dashboard"] = dashboard
}
httpkit.WriteOK(writer, request, payload)
} }
func (h *Handler) searchManagerUsers(writer http.ResponseWriter, request *http.Request) { func (h *Handler) searchManagerUsers(writer http.ResponseWriter, request *http.Request) {
@ -201,13 +217,20 @@ func (h *Handler) searchManagerUsers(writer http.ResponseWriter, request *http.R
} }
levels := h.managerLevelsByUser(request, userIDs) levels := h.managerLevelsByUser(request, userIDs)
items := make([]managerUserData, 0, len(userIDs)) items := make([]managerUserData, 0, len(userIDs))
visibleUserIDs := make([]int64, 0, len(userIDs))
for _, userID := range userIDs { for _, userID := range userIDs {
user := usersResp.GetUsers()[userID] user := usersResp.GetUsers()[userID]
// 默认搜索必须和写入口一样收敛到同国家;国家转移已单独校验权限,允许在这里跨国家挑选目标。 // 默认搜索必须和写入口一样收敛到同国家;国家转移已单独校验权限,允许在这里跨国家挑选目标。
if user == nil || user.GetStatus() != userv1.UserStatus_USER_STATUS_ACTIVE || (!allCountries && !sameCountry(actor.GetCountry(), user.GetCountry())) { if user == nil || user.GetStatus() != userv1.UserStatus_USER_STATUS_ACTIVE || (!allCountries && !sameCountry(actor.GetCountry(), user.GetCountry())) {
continue continue
} }
items = append(items, managerUserFromProto(user, levels[userID])) item := managerUserFromProto(user, levels[userID])
items = append(items, item)
visibleUserIDs = append(visibleUserIDs, userID)
}
vips := h.managerCurrentVIPByUser(request, visibleUserIDs, managerSearchScene(request))
for index, userID := range visibleUserIDs {
items[index].CurrentVIP = vips[userID]
} }
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": len(items), "page_size": pageSize}) httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": len(items), "page_size": pageSize})
} }
@ -286,16 +309,18 @@ func (h *Handler) grantManagerResource(writer http.ResponseWriter, request *http
return return
} }
var body struct { var body struct {
CommandID string `json:"command_id"` CommandID string `json:"command_id"`
CommandIDAlt string `json:"commandId"` CommandIDAlt string `json:"commandId"`
TargetUserID json.RawMessage `json:"target_user_id"` TargetUserID json.RawMessage `json:"target_user_id"`
TargetUserIDAlt json.RawMessage `json:"targetUserId"` TargetUserIDAlt json.RawMessage `json:"targetUserId"`
ResourceID json.RawMessage `json:"resource_id"` ResourceID json.RawMessage `json:"resource_id"`
ResourceIDAlt json.RawMessage `json:"resourceId"` ResourceIDAlt json.RawMessage `json:"resourceId"`
Reason string `json:"reason"` ResourceIDs []json.RawMessage `json:"resource_ids"`
Quantity *int64 `json:"quantity"` ResourceIDsAlt []json.RawMessage `json:"resourceIds"`
DurationMS *int64 `json:"duration_ms"` Reason string `json:"reason"`
DurationMSAlt *int64 `json:"durationMs"` Quantity *int64 `json:"quantity"`
DurationMS *int64 `json:"duration_ms"`
DurationMSAlt *int64 `json:"durationMs"`
} }
if !httpkit.Decode(writer, request, &body) { if !httpkit.Decode(writer, request, &body) {
return return
@ -314,28 +339,42 @@ func (h *Handler) grantManagerResource(writer http.ResponseWriter, request *http
commandID = strings.TrimSpace(body.CommandIDAlt) commandID = strings.TrimSpace(body.CommandIDAlt)
} }
targetUserID, targetOK := rawInt64(body.TargetUserID, body.TargetUserIDAlt) targetUserID, targetOK := rawInt64(body.TargetUserID, body.TargetUserIDAlt)
resourceID, resourceOK := rawInt64(body.ResourceID, body.ResourceIDAlt) resourceIDs, listPresent, listOK := rawInt64List(body.ResourceIDs, body.ResourceIDsAlt)
if commandID == "" || !targetOK || targetUserID <= 0 || !resourceOK || resourceID <= 0 { if !listPresent {
resourceID, resourceOK := rawInt64(body.ResourceID, body.ResourceIDAlt)
if resourceOK {
resourceIDs = []int64{resourceID}
}
}
if commandID == "" || !targetOK || targetUserID <= 0 || (listPresent && !listOK) || len(resourceIDs) == 0 || len(resourceIDs) > managerGrantMaxResourceIDs {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return return
} }
resourceResp, err := h.walletClient.GetResource(request.Context(), &walletv1.GetResourceRequest{ resources := make([]*walletv1.Resource, 0, len(resourceIDs))
RequestId: httpkit.RequestIDFromContext(request.Context()), for _, resourceID := range resourceIDs {
AppCode: appcode.FromContext(request.Context()), if resourceID <= 0 {
ResourceId: resourceID, httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
}) return
if err != nil { }
httpkit.WriteRPCError(writer, request, err) resourceResp, err := h.walletClient.GetResource(request.Context(), &walletv1.GetResourceRequest{
return RequestId: httpkit.RequestIDFromContext(request.Context()),
} AppCode: appcode.FromContext(request.Context()),
resource := resourceResp.GetResource() ResourceId: resourceID,
if resource == nil || resource.GetResourceId() <= 0 { })
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found") if err != nil {
return httpkit.WriteRPCError(writer, request, err)
} return
// 写入口不能信任 H5 自报的资源类型;必须先从 wallet-service 读取 resource_id 的真实类型,再按头像框或座驾权限拒绝。 }
if !h.requireManagerCapability(writer, request, managerCapabilityForResourceType(resource.GetResourceType())) { resource := resourceResp.GetResource()
return if resource == nil || resource.GetResourceId() <= 0 {
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
return
}
// 写入口不能信任 H5 自报的资源类型;必须逐个从 wallet-service 读取 resource_id 的真实类型,再按头像框、座驾或徽章权限拒绝。
if !h.requireManagerCapability(writer, request, managerCapabilityForResourceType(resource.GetResourceType())) {
return
}
resources = append(resources, resource)
} }
// 赠送资源前先确认目标用户仍然 active 且与经理同国家wallet-service 还会在事务内复验资源白名单。 // 赠送资源前先确认目标用户仍然 active 且与经理同国家wallet-service 还会在事务内复验资源白名单。
if _, ok := h.requireSameCountryTarget(writer, request, targetUserID); !ok { if _, ok := h.requireSameCountryTarget(writer, request, targetUserID); !ok {
@ -346,36 +385,50 @@ func (h *Handler) grantManagerResource(writer http.ResponseWriter, request *http
reason = "manager_center" reason = "manager_center"
} }
actorUserID := auth.UserIDFromContext(request.Context()) actorUserID := auth.UserIDFromContext(request.Context())
resp, err := h.walletClient.GrantResource(request.Context(), &walletv1.GrantResourceRequest{ grants := make([]managerResourceGrantData, 0, len(resources))
CommandId: commandID, for _, resource := range resources {
AppCode: appcode.FromContext(request.Context()), resourceID := resource.GetResourceId()
TargetUserId: targetUserID, grantCommandID := commandID
ResourceId: resourceID, if len(resources) > 1 {
Quantity: 1, // 批量素材赠送复用 wallet 的单资源事务;每个素材派生稳定 command_id前半批成功后重试也只补未完成的资源。
// 经理中心只能赠送产品允许的三个限时档位;数量、来源和操作人仍由网关固定,避免 H5 自行组装账务形状。 grantCommandID = commandID + ":resource:" + strconv.FormatInt(resourceID, 10)
DurationMs: durationMS, }
Reason: reason, resp, err := h.walletClient.GrantResource(request.Context(), &walletv1.GrantResourceRequest{
OperatorUserId: actorUserID, CommandId: grantCommandID,
GrantSource: "manager_center", AppCode: appcode.FromContext(request.Context()),
}) TargetUserId: targetUserID,
if err != nil { ResourceId: resourceID,
logx.Warn(request.Context(), "manager_resource_grant_failed", Quantity: 1,
// 经理中心只能赠送产品允许的三个限时档位;数量、来源和操作人仍由网关固定,避免 H5 自行组装账务形状。
DurationMs: durationMS,
Reason: reason,
OperatorUserId: actorUserID,
GrantSource: "manager_center",
})
if err != nil {
logx.Warn(request.Context(), "manager_resource_grant_failed",
slog.Int64("actor_user_id", actorUserID),
slog.Int64("target_user_id", targetUserID),
slog.Int64("resource_id", resourceID),
slog.String("command_id", grantCommandID),
)
httpkit.WriteRPCError(writer, request, err)
return
}
grant := managerResourceGrantFromProto(resp.GetGrant())
logx.Info(request.Context(), "manager_resource_grant_succeeded",
slog.Int64("actor_user_id", actorUserID), slog.Int64("actor_user_id", actorUserID),
slog.Int64("target_user_id", targetUserID), slog.Int64("target_user_id", targetUserID),
slog.Int64("resource_id", resourceID), slog.Int64("resource_id", resourceID),
slog.String("command_id", commandID), slog.String("grant_id", grant.GrantID),
) )
httpkit.WriteRPCError(writer, request, err) grants = append(grants, grant)
}
if len(grants) == 1 {
httpkit.WriteOK(writer, request, grants[0])
return return
} }
grant := managerResourceGrantFromProto(resp.GetGrant()) httpkit.WriteOK(writer, request, map[string]any{"items": grants, "total": len(grants)})
logx.Info(request.Context(), "manager_resource_grant_succeeded",
slog.Int64("actor_user_id", actorUserID),
slog.Int64("target_user_id", targetUserID),
slog.Int64("resource_id", resourceID),
slog.String("grant_id", grant.GrantID),
)
httpkit.WriteOK(writer, request, grant)
} }
func (h *Handler) grantManagerVIP(writer http.ResponseWriter, request *http.Request) { func (h *Handler) grantManagerVIP(writer http.ResponseWriter, request *http.Request) {
@ -749,10 +802,45 @@ func (h *Handler) requireManagerResourceGrantCapability(writer http.ResponseWrit
} }
func managerSearchScene(request *http.Request) string { func managerSearchScene(request *http.Request) string {
if strings.EqualFold(strings.TrimSpace(request.URL.Query().Get("action")), "send-vip") { switch strings.ToLower(strings.TrimSpace(request.URL.Query().Get("action"))) {
case "send-vip":
return "manager_vip_grant" return "manager_vip_grant"
case "update-user-level":
return "manager_update_user_level"
case "add-bd-leader":
return "manager_add_bd_leader"
case "add-admin":
return "manager_add_admin"
case "add-superadmin":
return "manager_add_superadmin"
case "block-user":
return "manager_block_user"
case "transfer-user-country":
return "manager_transfer_user_country"
default:
return "manager_resource_grant"
} }
return "manager_resource_grant" }
func (h *Handler) managerCurrentVIPByUser(request *http.Request, userIDs []int64, scene string) map[int64]*managerVipData {
if scene != "manager_vip_grant" || h.walletClient == nil || len(userIDs) == 0 {
return nil
}
result := make(map[int64]*managerVipData, len(userIDs))
for _, userID := range userIDs {
// VIP 状态是赠送弹窗的辅助展示;查询失败不阻断查人,最终赠送仍由 wallet-service 在事务内复验等级。
resp, err := h.walletClient.GetMyVip(request.Context(), &walletv1.GetMyVipRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
UserId: userID,
})
if err != nil {
logx.Warn(request.Context(), "manager_vip_status_lookup_failed", slog.Int64("target_user_id", userID))
continue
}
result[userID] = managerVipFromProto(resp.GetVip())
}
return result
} }
func (h *Handler) requireManagerCenterActor(writer http.ResponseWriter, request *http.Request) (*userv1.User, bool) { func (h *Handler) requireManagerCenterActor(writer http.ResponseWriter, request *http.Request) (*userv1.User, bool) {
@ -1060,6 +1148,20 @@ func businessUserLookupFromProto(user *userv1.BusinessUserLookupItem) businessUs
} }
} }
func managerVipFromProto(vip *walletv1.UserVip) *managerVipData {
if vip == nil {
return &managerVipData{}
}
return &managerVipData{
Level: vip.GetLevel(),
Name: vip.GetName(),
Active: vip.GetActive(),
StartedAtMS: vip.GetStartedAtMs(),
ExpiresAtMS: vip.GetExpiresAtMs(),
UpdatedAtMS: vip.GetUpdatedAtMs(),
}
}
func managerResourceGrantFromProto(grant *walletv1.ResourceGrant) managerResourceGrantData { func managerResourceGrantFromProto(grant *walletv1.ResourceGrant) managerResourceGrantData {
if grant == nil { if grant == nil {
return managerResourceGrantData{} return managerResourceGrantData{}
@ -1112,6 +1214,32 @@ func rawInt64(values ...json.RawMessage) (int64, bool) {
return 0, false return 0, false
} }
func rawInt64List(values ...[]json.RawMessage) ([]int64, bool, bool) {
for _, raws := range values {
if raws == nil {
continue
}
if len(raws) == 0 {
return nil, true, false
}
ids := make([]int64, 0, len(raws))
seen := make(map[int64]struct{}, len(raws))
for _, raw := range raws {
value, ok := parseRawInt64(raw)
if !ok || value <= 0 {
return nil, true, false
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
ids = append(ids, value)
}
return ids, true, len(ids) > 0
}
return nil, false, false
}
func parseRawInt64(raw json.RawMessage) (int64, bool) { func parseRawInt64(raw json.RawMessage) (int64, bool) {
text := strings.TrimSpace(string(raw)) text := strings.TrimSpace(string(raw))
if text == "" || text == "null" { if text == "" || text == "null" {

View File

@ -0,0 +1,178 @@
package managerapi
import (
"context"
"fmt"
"log/slog"
"net/http"
"sync"
"time"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
// managerDashboardCacheTTL 是团队工资统计的展示缓存时长。
// 统计跨 user-service 组织树和 wallet-service 周期账户两次聚合,产品接受分钟级延迟展示,
// 用短 TTL 换掉每次进入经理中心都全量聚合的成本;缓存是单节点内存态,不做跨副本一致性。
const managerDashboardCacheTTL = 2 * time.Minute
// managerDashboardFailureCacheTTL 是聚合失败的负缓存时长:
// 下游持续故障时避免每个 overview 请求都重跑双聚合打崩上游,同时保证故障恢复后半分钟内回到正常展示。
const managerDashboardFailureCacheTTL = 30 * time.Second
// managerTeamAgencyPageSize 是一次拉取经理团队 Agency 的上限,与 user-service 服务端上限保持一致。
const managerTeamAgencyPageSize = 5000
type managerDashboardCacheEntry struct {
data map[string]any
expiresAt time.Time
}
type managerDashboardCache struct {
mu sync.Mutex
entries map[string]managerDashboardCacheEntry
}
func (c *managerDashboardCache) get(key string, now time.Time) (map[string]any, bool) {
c.mu.Lock()
defer c.mu.Unlock()
entry, ok := c.entries[key]
if !ok {
return nil, false
}
if now.After(entry.expiresAt) {
delete(c.entries, key)
return nil, false
}
// data 为 nil 表示负缓存命中:负缓存期内直接按“统计不可用”降级,不再重跑聚合。
return entry.data, true
}
func (c *managerDashboardCache) set(key string, data map[string]any, now time.Time, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
if c.entries == nil {
c.entries = map[string]managerDashboardCacheEntry{}
}
// 顺手清理已过期条目;经理数量有限,线性清理成本可忽略,避免长期运行只进不出。
for cacheKey, entry := range c.entries {
if now.After(entry.expiresAt) {
delete(c.entries, cacheKey)
}
}
c.entries[key] = managerDashboardCacheEntry{data: data, expiresAt: now.Add(ttl)}
}
// managerDashboardAggregateTimeout 是团队统计两次聚合 RPC 的总预算。
// 概览主链路(身份+权限)已有十余次串行 RPC统计只是增强信息超时直接降级而不是拖垮整个 overview。
const managerDashboardAggregateTimeout = 5 * time.Second
// managerTeamDashboard 聚合经理团队工资统计卡片数据:
// user-service 解析经理整棵团队树的 Agency 收款人集合wallet-service 按收款人聚合主播预收入工资。
// 统计失败时返回 nil 并记录日志经理中心身份信息照常返回H5 对缺失字段做占位展示。
func (h *Handler) managerTeamDashboard(request *http.Request, managerUserID int64) map[string]any {
if h.userHostClient == nil || h.walletClient == nil {
return nil
}
now := time.Now().UTC()
cacheKey := fmt.Sprintf("%s:%d", appcode.FromContext(request.Context()), managerUserID)
if cached, ok := h.dashboardCache.get(cacheKey, now); ok {
return cached
}
ctx, cancel := context.WithTimeout(request.Context(), managerDashboardAggregateTimeout)
defer cancel()
request = request.WithContext(ctx)
teamResp, err := h.userHostClient.ListManagerTeamAgencies(request.Context(), &userv1.ListManagerTeamAgenciesRequest{
Meta: httpkit.UserMeta(request, ""),
ManagerUserId: managerUserID,
PageSize: managerTeamAgencyPageSize,
})
if err != nil {
logx.Warn(request.Context(), "manager_team_agencies_failed", slog.Int64("manager_user_id", managerUserID))
h.dashboardCache.set(cacheKey, nil, now, managerDashboardFailureCacheTTL)
return nil
}
ownerIDs := make([]int64, 0, len(teamResp.GetAgencies()))
seen := map[int64]bool{}
for _, agency := range teamResp.GetAgencies() {
if id := agency.GetOwnerUserId(); id > 0 && !seen[id] {
seen[id] = true
ownerIDs = append(ownerIDs, id)
}
}
// 工资周期与结算保持同一口径UTC 月。上月用月初回退一个月计算,避免 AddDate 在月末产生进位漂移。
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
thisCycle := monthStart.Format("2006-01")
lastCycle := monthStart.AddDate(0, -1, 0).Format("2006-01")
statsResp, err := h.walletClient.GetTeamHostSalaryStats(request.Context(), &walletv1.GetTeamHostSalaryStatsRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
AgencyOwnerUserIds: ownerIDs,
CycleKeys: []string{thisCycle, lastCycle},
NowMs: now.UnixMilli(),
})
if err != nil {
logx.Warn(request.Context(), "manager_team_salary_stats_failed", slog.Int64("manager_user_id", managerUserID))
h.dashboardCache.set(cacheKey, nil, now, managerDashboardFailureCacheTTL)
return nil
}
statsByCycle := map[string]*walletv1.TeamHostSalaryCycleStat{}
for _, stat := range statsResp.GetStats() {
statsByCycle[stat.GetCycleKey()] = stat
}
thisStat := statsByCycle[thisCycle]
lastStat := statsByCycle[lastCycle]
dashboard := map[string]any{
"month_label": monthStart.Format("Jan 2006"),
"cycle_key": thisCycle,
"last_cycle_key": lastCycle,
// 金额下发美元数值分转元H5 formatMoney 负责千分位和货币符号。
"this_month_salary": usdMinorToDollars(thisStat.GetEstimatedHostSalaryUsdMinor()),
"this_month_salary_usd_minor": thisStat.GetEstimatedHostSalaryUsdMinor(),
"last_month_salary": usdMinorToDollars(lastStat.GetEstimatedHostSalaryUsdMinor()),
"last_month_salary_usd_minor": lastStat.GetEstimatedHostSalaryUsdMinor(),
"active_hosts": thisStat.GetActiveHostCount(),
"active_hosts_last_month": lastStat.GetActiveHostCount(),
// growth 直接下发带符号百分比字符串,避免 H5 formatPercent 对 0~1 数值的“小数当比例”歧义。
"growth": formatGrowthPercent(thisStat.GetEstimatedHostSalaryUsdMinor(), lastStat.GetEstimatedHostSalaryUsdMinor()),
"team_bd_leaders": teamResp.GetTotalBdLeaders(),
"team_bds": teamResp.GetTotalBds(),
"team_agencies": len(teamResp.GetAgencies()),
// truncated 时统计口径不完整(团队 Agency 超过单次拉取上限),透传给前端而不是静默当全量。
"team_stats_truncated": teamResp.GetTruncated(),
"updated_at_ms": now.UnixMilli(),
}
h.dashboardCache.set(cacheKey, dashboard, now, managerDashboardCacheTTL)
return dashboard
}
func usdMinorToDollars(minor int64) float64 {
return float64(minor) / 100
}
// formatGrowthPercent 计算本月对上月的环比:上月为 0 时有收入记 +100%,无收入记 0%。
func formatGrowthPercent(current int64, previous int64) string {
if previous <= 0 {
if current > 0 {
return "+100%"
}
return "0%"
}
percent := (float64(current) - float64(previous)) / float64(previous) * 100
rounded := int64(percent)
if percent > 0 {
rounded = int64(percent + 0.5)
return fmt.Sprintf("+%d%%", rounded)
}
if percent < 0 {
rounded = int64(percent - 0.5)
}
return fmt.Sprintf("%d%%", rounded)
}

View File

@ -488,6 +488,9 @@ type fakeUserHostClient struct {
lastBDAgencies *userv1.ListBDAgenciesRequest lastBDAgencies *userv1.ListBDAgenciesRequest
bdAgenciesResp *userv1.ListBDAgenciesResponse bdAgenciesResp *userv1.ListBDAgenciesResponse
bdAgenciesErr error bdAgenciesErr error
lastManagerTeam *userv1.ListManagerTeamAgenciesRequest
managerTeamResp *userv1.ListManagerTeamAgenciesResponse
managerTeamErr error
lastRoleInvitations *userv1.ListRoleInvitationsRequest lastRoleInvitations *userv1.ListRoleInvitationsRequest
roleInvitationsResp *userv1.ListRoleInvitationsResponse roleInvitationsResp *userv1.ListRoleInvitationsResponse
roleInvitationsErr error roleInvitationsErr error
@ -500,6 +503,15 @@ type fakeUserHostClient struct {
lastListCoinSellers *userv1.ListActiveCoinSellersInMyRegionRequest lastListCoinSellers *userv1.ListActiveCoinSellersInMyRegionRequest
listCoinSellersResp *userv1.ListActiveCoinSellersInMyRegionResponse listCoinSellersResp *userv1.ListActiveCoinSellersInMyRegionResponse
listCoinSellersErr error listCoinSellersErr error
lastCreateSubSeller *userv1.CreateSubCoinSellerRequest
createSubSellerResp *userv1.CreateSubCoinSellerResponse
createSubSellerErr error
lastListSubSellers *userv1.ListSubCoinSellersRequest
listSubSellersResp *userv1.ListSubCoinSellersResponse
listSubSellersErr error
lastCheckSubSeller *userv1.CheckCoinSellerSubRelationRequest
checkSubSellerResp *userv1.CheckCoinSellerSubRelationResponse
checkSubSellerErr error
lastHost *userv1.GetHostProfileRequest lastHost *userv1.GetHostProfileRequest
lastBatchHost *userv1.BatchGetHostProfilesRequest lastBatchHost *userv1.BatchGetHostProfilesRequest
hostCalls int hostCalls int
@ -559,6 +571,9 @@ type fakeWalletClient struct {
lastHostSalaryProgress *walletv1.GetHostSalaryProgressRequest lastHostSalaryProgress *walletv1.GetHostSalaryProgressRequest
hostSalaryProgressResp *walletv1.GetHostSalaryProgressResponse hostSalaryProgressResp *walletv1.GetHostSalaryProgressResponse
hostSalaryProgressErr error hostSalaryProgressErr error
lastTeamSalaryStats *walletv1.GetTeamHostSalaryStatsRequest
teamSalaryStatsResp *walletv1.GetTeamHostSalaryStatsResponse
teamSalaryStatsErr error
lastOverview *walletv1.GetWalletOverviewRequest lastOverview *walletv1.GetWalletOverviewRequest
overviewResp *walletv1.GetWalletOverviewResponse overviewResp *walletv1.GetWalletOverviewResponse
overviewErr error overviewErr error
@ -607,6 +622,9 @@ type fakeWalletClient struct {
lastTransfer *walletv1.TransferCoinFromSellerRequest lastTransfer *walletv1.TransferCoinFromSellerRequest
transferResp *walletv1.TransferCoinFromSellerResponse transferResp *walletv1.TransferCoinFromSellerResponse
transferErr error transferErr error
lastSubTransfer *walletv1.TransferCoinSellerStockToChildRequest
subTransferResp *walletv1.TransferCoinSellerStockToChildResponse
subTransferErr error
lastListExchangeTiers *walletv1.ListCoinSellerSalaryExchangeRateTiersRequest lastListExchangeTiers *walletv1.ListCoinSellerSalaryExchangeRateTiersRequest
listExchangeTiersResp *walletv1.ListCoinSellerSalaryExchangeRateTiersResponse listExchangeTiersResp *walletv1.ListCoinSellerSalaryExchangeRateTiersResponse
lastExchangeSalary *walletv1.ExchangeSalaryToCoinRequest lastExchangeSalary *walletv1.ExchangeSalaryToCoinRequest
@ -646,6 +664,7 @@ type fakeWalletClient struct {
listUserResourcesCalls int listUserResourcesCalls int
listUserResourcesResp *walletv1.ListUserResourcesResponse listUserResourcesResp *walletv1.ListUserResourcesResponse
lastGrantResource *walletv1.GrantResourceRequest lastGrantResource *walletv1.GrantResourceRequest
grantResourceRequests []*walletv1.GrantResourceRequest
grantResourceResp *walletv1.ResourceGrantResponse grantResourceResp *walletv1.ResourceGrantResponse
grantResourceErr error grantResourceErr error
lastBatchEquipped *walletv1.BatchGetUserEquippedResourcesRequest lastBatchEquipped *walletv1.BatchGetUserEquippedResourcesRequest
@ -1711,6 +1730,17 @@ func (f *fakeUserHostClient) ListBDAgencies(_ context.Context, req *userv1.ListB
return &userv1.ListBDAgenciesResponse{}, nil return &userv1.ListBDAgenciesResponse{}, nil
} }
func (f *fakeUserHostClient) ListManagerTeamAgencies(_ context.Context, req *userv1.ListManagerTeamAgenciesRequest) (*userv1.ListManagerTeamAgenciesResponse, error) {
f.lastManagerTeam = req
if f.managerTeamErr != nil {
return nil, f.managerTeamErr
}
if f.managerTeamResp != nil {
return f.managerTeamResp, nil
}
return &userv1.ListManagerTeamAgenciesResponse{}, nil
}
func (f *fakeUserHostClient) ListRoleInvitations(_ context.Context, req *userv1.ListRoleInvitationsRequest) (*userv1.ListRoleInvitationsResponse, error) { func (f *fakeUserHostClient) ListRoleInvitations(_ context.Context, req *userv1.ListRoleInvitationsRequest) (*userv1.ListRoleInvitationsResponse, error) {
f.lastRoleInvitations = req f.lastRoleInvitations = req
if f.roleInvitationsErr != nil { if f.roleInvitationsErr != nil {
@ -1805,6 +1835,39 @@ func (f *fakeUserHostClient) ListActiveCoinSellersInMyRegion(_ context.Context,
return &userv1.ListActiveCoinSellersInMyRegionResponse{}, nil return &userv1.ListActiveCoinSellersInMyRegionResponse{}, nil
} }
func (f *fakeUserHostClient) CreateSubCoinSeller(_ context.Context, req *userv1.CreateSubCoinSellerRequest) (*userv1.CreateSubCoinSellerResponse, error) {
f.lastCreateSubSeller = req
if f.createSubSellerErr != nil {
return nil, f.createSubSellerErr
}
if f.createSubSellerResp != nil {
return f.createSubSellerResp, nil
}
return &userv1.CreateSubCoinSellerResponse{}, nil
}
func (f *fakeUserHostClient) ListSubCoinSellers(_ context.Context, req *userv1.ListSubCoinSellersRequest) (*userv1.ListSubCoinSellersResponse, error) {
f.lastListSubSellers = req
if f.listSubSellersErr != nil {
return nil, f.listSubSellersErr
}
if f.listSubSellersResp != nil {
return f.listSubSellersResp, nil
}
return &userv1.ListSubCoinSellersResponse{}, nil
}
func (f *fakeUserHostClient) CheckCoinSellerSubRelation(_ context.Context, req *userv1.CheckCoinSellerSubRelationRequest) (*userv1.CheckCoinSellerSubRelationResponse, error) {
f.lastCheckSubSeller = req
if f.checkSubSellerErr != nil {
return nil, f.checkSubSellerErr
}
if f.checkSubSellerResp != nil {
return f.checkSubSellerResp, nil
}
return &userv1.CheckCoinSellerSubRelationResponse{}, nil
}
func (f *fakeUserHostClient) GetUserRoleSummary(_ context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error) { func (f *fakeUserHostClient) GetUserRoleSummary(_ context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error) {
f.lastRoleSummary = req f.lastRoleSummary = req
if f.roleSummaryErr != nil { if f.roleSummaryErr != nil {
@ -1977,6 +2040,17 @@ func (f *fakeWalletClient) GetHostSalaryProgress(_ context.Context, req *walletv
return &walletv1.GetHostSalaryProgressResponse{Progress: &walletv1.HostSalaryProgress{HostUserId: req.GetHostUserId()}}, nil return &walletv1.GetHostSalaryProgressResponse{Progress: &walletv1.HostSalaryProgress{HostUserId: req.GetHostUserId()}}, nil
} }
func (f *fakeWalletClient) GetTeamHostSalaryStats(_ context.Context, req *walletv1.GetTeamHostSalaryStatsRequest) (*walletv1.GetTeamHostSalaryStatsResponse, error) {
f.lastTeamSalaryStats = req
if f.teamSalaryStatsErr != nil {
return nil, f.teamSalaryStatsErr
}
if f.teamSalaryStatsResp != nil {
return f.teamSalaryStatsResp, nil
}
return &walletv1.GetTeamHostSalaryStatsResponse{}, nil
}
func (f *fakeWalletClient) GetWalletOverview(_ context.Context, req *walletv1.GetWalletOverviewRequest) (*walletv1.GetWalletOverviewResponse, error) { func (f *fakeWalletClient) GetWalletOverview(_ context.Context, req *walletv1.GetWalletOverviewRequest) (*walletv1.GetWalletOverviewResponse, error) {
f.lastOverview = req f.lastOverview = req
if f.overviewErr != nil { if f.overviewErr != nil {
@ -2257,6 +2331,17 @@ func (f *fakeWalletClient) TransferCoinFromSeller(_ context.Context, req *wallet
return &walletv1.TransferCoinFromSellerResponse{}, nil return &walletv1.TransferCoinFromSellerResponse{}, nil
} }
func (f *fakeWalletClient) TransferCoinSellerStockToChild(_ context.Context, req *walletv1.TransferCoinSellerStockToChildRequest) (*walletv1.TransferCoinSellerStockToChildResponse, error) {
f.lastSubTransfer = req
if f.subTransferErr != nil {
return nil, f.subTransferErr
}
if f.subTransferResp != nil {
return f.subTransferResp, nil
}
return &walletv1.TransferCoinSellerStockToChildResponse{}, nil
}
func (f *fakeWalletClient) ListCoinSellerSalaryExchangeRateTiers(_ context.Context, req *walletv1.ListCoinSellerSalaryExchangeRateTiersRequest) (*walletv1.ListCoinSellerSalaryExchangeRateTiersResponse, error) { func (f *fakeWalletClient) ListCoinSellerSalaryExchangeRateTiers(_ context.Context, req *walletv1.ListCoinSellerSalaryExchangeRateTiersRequest) (*walletv1.ListCoinSellerSalaryExchangeRateTiersResponse, error) {
f.lastListExchangeTiers = req f.lastListExchangeTiers = req
if f.err != nil { if f.err != nil {
@ -2436,6 +2521,7 @@ func (f *fakeWalletClient) ListGiftTypeConfigs(_ context.Context, req *walletv1.
func (f *fakeWalletClient) GrantResource(_ context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error) { func (f *fakeWalletClient) GrantResource(_ context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error) {
f.lastGrantResource = req f.lastGrantResource = req
f.grantResourceRequests = append(f.grantResourceRequests, req)
if f.grantResourceErr != nil { if f.grantResourceErr != nil {
return nil, f.grantResourceErr return nil, f.grantResourceErr
} }
@ -9852,6 +9938,108 @@ func TestCoinSellerTransferChecksIdentityAndPropagatesWalletCommand(t *testing.T
} }
} }
func TestCoinSellerSubSellerPermissionUsesCoinSellerProfile(t *testing.T) {
hostClient := &fakeUserHostClient{profile: &userv1.CoinSellerProfile{
UserId: 42,
Status: "active",
MerchantAssetType: "COIN_SELLER_COIN",
CanManageSubCoinSellers: true,
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, &fakeUserIdentityClient{}, &fakeUserProfileClient{})
handler.SetUserHostClient(hostClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/wallet/coin-seller/sub-sellers/permission", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-sub-seller-permission")
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.last == nil || hostClient.last.GetUserId() != 42 {
t.Fatalf("coin seller permission profile request mismatch: %+v", hostClient.last)
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != httpkit.CodeOK || !ok ||
data["visible"] != true ||
data["can_invite_sub_seller"] != true ||
data["can_transfer_sub_seller"] != true ||
data["canInviteSubSeller"] != true ||
data["canTransferSubSeller"] != true {
t.Fatalf("sub seller permission response mismatch: %+v", response)
}
}
func TestCoinSellerSubSellerTransferChecksRelationBeforeWallet(t *testing.T) {
walletClient := &fakeWalletClient{subTransferResp: &walletv1.TransferCoinSellerStockToChildResponse{
TransactionId: "wtx-sub-seller-transfer",
ParentBalanceAfter: 7000,
ChildBalanceAfter: 3000,
Amount: 1200,
CreatedAtMs: 1_783_000_000_000,
}}
hostClient := &fakeUserHostClient{checkSubSellerResp: &userv1.CheckCoinSellerSubRelationResponse{
Allowed: true,
Child: &userv1.CoinSellerListItem{
UserId: 900002,
DisplayUserId: "163002",
Username: "sub seller",
RegionId: 1001,
Status: "active",
MerchantAssetType: "COIN_SELLER_COIN",
},
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, &fakeUserIdentityClient{}, &fakeUserProfileClient{})
handler.SetWalletClient(walletClient)
handler.SetUserHostClient(hostClient)
router := handler.Routes(auth.NewVerifier("secret"))
body := []byte(`{"command_id":"cmd-sub-seller-transfer","child_user_id":"900002","amount":1200,"reason":"stock refill"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/wallet/coin-seller/sub-sellers/transfer", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-sub-seller-transfer")
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.lastCheckSubSeller == nil ||
hostClient.lastCheckSubSeller.GetParentUserId() != 42 ||
hostClient.lastCheckSubSeller.GetChildUserId() != 900002 {
t.Fatalf("sub seller relation check mismatch: %+v", hostClient.lastCheckSubSeller)
}
if walletClient.lastSubTransfer == nil ||
walletClient.lastSubTransfer.GetCommandId() != "cmd-sub-seller-transfer" ||
walletClient.lastSubTransfer.GetParentUserId() != 42 ||
walletClient.lastSubTransfer.GetChildUserId() != 900002 ||
walletClient.lastSubTransfer.GetRegionId() != 1001 ||
walletClient.lastSubTransfer.GetAmount() != 1200 ||
walletClient.lastSubTransfer.GetReason() != "stock refill" ||
walletClient.lastSubTransfer.GetAppCode() != "lalu" {
t.Fatalf("sub seller wallet transfer request mismatch: %+v", walletClient.lastSubTransfer)
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != httpkit.CodeOK || !ok ||
data["transaction_id"] != "wtx-sub-seller-transfer" ||
data["parent_balance_after"] != float64(7000) ||
data["child_balance_after"] != float64(3000) ||
data["amount"] != float64(1200) ||
data["created_at_ms"] != float64(1_783_000_000_000) {
t.Fatalf("sub seller transfer response mismatch: %+v", response)
}
}
func TestDirectGiftUsesServerSideRegionAndHostScope(t *testing.T) { func TestDirectGiftUsesServerSideRegionAndHostScope(t *testing.T) {
imPublisher := &fakeTencentIMUserMessagePublisher{} imPublisher := &fakeTencentIMUserMessagePublisher{}
walletClient := &fakeWalletClient{directGiftResp: &walletv1.DebitGiftResponse{ walletClient := &fakeWalletClient{directGiftResp: &walletv1.DebitGiftResponse{

View File

@ -11,19 +11,20 @@ import (
) )
type coinSellerData struct { type coinSellerData struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
DisplayUserID string `json:"display_user_id"` DisplayUserID string `json:"display_user_id"`
Username string `json:"username"` Username string `json:"username"`
Avatar string `json:"avatar,omitempty"` Avatar string `json:"avatar,omitempty"`
CountryID int64 `json:"country_id"` CountryID int64 `json:"country_id"`
CountryCode string `json:"country_code,omitempty"` CountryCode string `json:"country_code,omitempty"`
CountryName string `json:"country_name,omitempty"` CountryName string `json:"country_name,omitempty"`
CountryDisplayName string `json:"country_display_name,omitempty"` CountryDisplayName string `json:"country_display_name,omitempty"`
RegionID int64 `json:"region_id"` RegionID int64 `json:"region_id"`
RegionCode string `json:"region_code,omitempty"` RegionCode string `json:"region_code,omitempty"`
RegionName string `json:"region_name,omitempty"` RegionName string `json:"region_name,omitempty"`
Status string `json:"status"` Status string `json:"status"`
MerchantAssetType string `json:"merchant_asset_type"` MerchantAssetType string `json:"merchant_asset_type"`
CanManageSubCoinSellers bool `json:"can_manage_sub_coin_sellers"`
// WhatsApp 字段固定输出,空联系方式也保留空字符串,避免充值页逐项读取时出现 schema 漂移。 // WhatsApp 字段固定输出,空联系方式也保留空字符串,避免充值页逐项读取时出现 schema 漂移。
WhatsappPhone string `json:"whatsapp_phone"` WhatsappPhone string `json:"whatsapp_phone"`
WhatsappURL string `json:"whatsapp_url"` WhatsappURL string `json:"whatsapp_url"`
@ -60,22 +61,23 @@ func coinSellerFromProto(item *userv1.CoinSellerListItem) coinSellerData {
} }
contactInfo := strings.TrimSpace(item.GetContactInfo()) contactInfo := strings.TrimSpace(item.GetContactInfo())
return coinSellerData{ return coinSellerData{
UserID: int64IDString(item.GetUserId()), UserID: int64IDString(item.GetUserId()),
DisplayUserID: item.GetDisplayUserId(), DisplayUserID: item.GetDisplayUserId(),
Username: item.GetUsername(), Username: item.GetUsername(),
Avatar: item.GetAvatar(), Avatar: item.GetAvatar(),
CountryID: item.GetCountryId(), CountryID: item.GetCountryId(),
CountryCode: item.GetCountryCode(), CountryCode: item.GetCountryCode(),
CountryName: item.GetCountryName(), CountryName: item.GetCountryName(),
CountryDisplayName: item.GetCountryDisplayName(), CountryDisplayName: item.GetCountryDisplayName(),
RegionID: item.GetRegionId(), RegionID: item.GetRegionId(),
RegionCode: item.GetRegionCode(), RegionCode: item.GetRegionCode(),
RegionName: item.GetRegionName(), RegionName: item.GetRegionName(),
Status: item.GetStatus(), Status: item.GetStatus(),
MerchantAssetType: item.GetMerchantAssetType(), MerchantAssetType: item.GetMerchantAssetType(),
WhatsappPhone: contactInfo, CanManageSubCoinSellers: item.GetCanManageSubCoinSellers(),
WhatsappURL: coinSellerWhatsAppURL(contactInfo), WhatsappPhone: contactInfo,
UpdatedAtMS: item.GetUpdatedAtMs(), WhatsappURL: coinSellerWhatsAppURL(contactInfo),
UpdatedAtMS: item.GetUpdatedAtMs(),
} }
} }

View File

@ -0,0 +1,285 @@
package walletapi
import (
"net/http"
"strconv"
"strings"
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"
)
type coinSellerOverviewData struct {
Profile coinSellerProfileData `json:"profile"`
Permissions coinSellerPermissionData `json:"permissions"`
Balance assetBalanceData `json:"balance"`
}
type coinSellerProfileData struct {
UserID string `json:"user_id"`
Status string `json:"status"`
MerchantAssetType string `json:"merchant_asset_type"`
CanManageSubCoinSellers bool `json:"can_manage_sub_coin_sellers"`
CreatedByUserID string `json:"created_by_user_id"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type coinSellerPermissionData struct {
CanManageSubCoinSellers bool `json:"can_manage_sub_coin_sellers"`
}
type createSubCoinSellerRequestBody struct {
CommandID string `json:"command_id"`
CommandIDCamel string `json:"commandId"`
TargetDisplayUserID string `json:"target_display_user_id"`
TargetDisplayUserIDCamel string `json:"targetDisplayUserId"`
}
type coinSellerSubTransferRequestBody struct {
CommandID string `json:"command_id"`
CommandIDCamel string `json:"commandId"`
Amount int64 `json:"amount"`
Reason string `json:"reason"`
}
type coinSellerSubTransferData struct {
TransactionID string `json:"transaction_id"`
ParentBalanceAfter int64 `json:"parent_balance_after"`
ChildBalanceAfter int64 `json:"child_balance_after"`
Amount int64 `json:"amount"`
CreatedAtMS int64 `json:"created_at_ms"`
}
// getCoinSellerOverview 返回当前币商身份、子币商权限和专用库存余额。
func (h *Handler) getCoinSellerOverview(writer http.ResponseWriter, request *http.Request) {
if h.userHostClient == nil || h.walletClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
profile, ok := h.activeCoinSellerProfile(writer, request)
if !ok {
return
}
balance := assetBalanceData{AssetType: "COIN_SELLER_COIN"}
resp, err := h.walletClient.GetBalances(request.Context(), &walletv1.GetBalancesRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
UserId: auth.UserIDFromContext(request.Context()),
AssetTypes: []string{"COIN_SELLER_COIN"},
AppCode: appcode.FromContext(request.Context()),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
for _, item := range resp.GetBalances() {
if item.GetAssetType() == "COIN_SELLER_COIN" {
balance = assetBalanceData{
AssetType: item.GetAssetType(),
AvailableAmount: item.GetAvailableAmount(),
FrozenAmount: item.GetFrozenAmount(),
Version: item.GetVersion(),
}
break
}
}
httpkit.WriteOK(writer, request, coinSellerOverviewData{
Profile: coinSellerProfileFromProto(profile),
Permissions: coinSellerPermissionData{CanManageSubCoinSellers: profile.GetCanManageSubCoinSellers()},
Balance: balance,
})
}
// createSubCoinSeller 通过短 ID 直开或绑定直属子币商;目标解析在 gateway关系事实在 user-service 事务内落库。
func (h *Handler) createSubCoinSeller(writer http.ResponseWriter, request *http.Request) {
if h.userHostClient == nil || h.userIdentityClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body createSubCoinSellerRequestBody
if !httpkit.Decode(writer, request, &body) {
return
}
commandID := firstTrimmed(body.CommandID, body.CommandIDCamel)
targetDisplayID := firstTrimmed(body.TargetDisplayUserID, body.TargetDisplayUserIDCamel)
if commandID == "" || targetDisplayID == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
targetUserID, ok := h.resolveCoinSellerTransferTargetUserID(writer, request, targetDisplayID)
if !ok {
return
}
resp, err := h.userHostClient.CreateSubCoinSeller(request.Context(), &userv1.CreateSubCoinSellerRequest{
Meta: httpkit.UserMeta(request, ""),
CommandId: commandID,
ParentUserId: auth.UserIDFromContext(request.Context()),
ChildUserId: targetUserID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, map[string]any{
"relation": coinSellerSubRelationFromProto(resp.GetRelation()),
"child": coinSellerFromProto(resp.GetChild()),
})
}
// listSubCoinSellers 返回当前父币商直属子币商列表user-service 会在读取前校验权限开关。
func (h *Handler) listSubCoinSellers(writer http.ResponseWriter, request *http.Request) {
if h.userHostClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
page := positiveQueryInt(request, "page", 1)
pageSize := positiveQueryInt(request, "page_size", 20)
resp, err := h.userHostClient.ListSubCoinSellers(request.Context(), &userv1.ListSubCoinSellersRequest{
Meta: httpkit.UserMeta(request, ""),
ParentUserId: auth.UserIDFromContext(request.Context()),
Page: int32(page),
PageSize: int32(pageSize),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
items := make([]coinSellerData, 0, len(resp.GetChildren()))
for _, item := range resp.GetChildren() {
items = append(items, coinSellerFromProto(item))
}
httpkit.WriteOK(writer, request, map[string]any{
"items": items,
"total": resp.GetTotal(),
"page": resp.GetPage(),
"page_size": resp.GetPageSize(),
})
}
// transferCoinSellerStockToChild 把当前父币商库存转入直属子币商库存;校验关系后才调用 wallet-service。
func (h *Handler) transferCoinSellerStockToChild(writer http.ResponseWriter, request *http.Request) {
if h.userHostClient == nil || h.walletClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
childUserID, ok := httpkit.PositiveInt64PathValue(request, "child_user_id")
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
var body coinSellerSubTransferRequestBody
if !httpkit.Decode(writer, request, &body) {
return
}
commandID := firstTrimmed(body.CommandID, body.CommandIDCamel)
reason := strings.TrimSpace(body.Reason)
if commandID == "" || body.Amount <= 0 {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
if reason == "" {
reason = "coin seller sub transfer"
}
parentUserID := auth.UserIDFromContext(request.Context())
checkResp, err := h.userHostClient.CheckCoinSellerSubRelation(request.Context(), &userv1.CheckCoinSellerSubRelationRequest{
Meta: httpkit.UserMeta(request, ""),
ParentUserId: parentUserID,
ChildUserId: childUserID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
child := checkResp.GetChild()
if !checkResp.GetAllowed() || child == nil || child.GetRegionId() <= 0 {
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
return
}
resp, err := h.walletClient.TransferCoinSellerStockToChild(request.Context(), &walletv1.TransferCoinSellerStockToChildRequest{
CommandId: commandID,
ParentUserId: parentUserID,
ChildUserId: childUserID,
Amount: body.Amount,
Reason: reason,
AppCode: appcode.FromContext(request.Context()),
RegionId: child.GetRegionId(),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, coinSellerSubTransferData{
TransactionID: resp.GetTransactionId(),
ParentBalanceAfter: resp.GetParentBalanceAfter(),
ChildBalanceAfter: resp.GetChildBalanceAfter(),
Amount: resp.GetAmount(),
CreatedAtMS: resp.GetCreatedAtMs(),
})
}
func (h *Handler) activeCoinSellerProfile(writer http.ResponseWriter, request *http.Request) (*userv1.CoinSellerProfile, bool) {
resp, err := h.userHostClient.GetCoinSellerProfile(request.Context(), &userv1.GetCoinSellerProfileRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return nil, false
}
profile := resp.GetCoinSellerProfile()
if profile == nil || profile.GetStatus() != "active" || profile.GetMerchantAssetType() != "COIN_SELLER_COIN" {
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
return nil, false
}
return profile, true
}
func coinSellerProfileFromProto(profile *userv1.CoinSellerProfile) coinSellerProfileData {
if profile == nil {
return coinSellerProfileData{}
}
return coinSellerProfileData{
UserID: int64IDString(profile.GetUserId()),
Status: profile.GetStatus(),
MerchantAssetType: profile.GetMerchantAssetType(),
CanManageSubCoinSellers: profile.GetCanManageSubCoinSellers(),
CreatedByUserID: int64IDString(profile.GetCreatedByUserId()),
CreatedAtMS: profile.GetCreatedAtMs(),
UpdatedAtMS: profile.GetUpdatedAtMs(),
}
}
func coinSellerSubRelationFromProto(relation *userv1.CoinSellerSubRelation) map[string]any {
if relation == nil {
return map[string]any{}
}
return map[string]any{
"relation_id": int64IDString(relation.GetRelationId()),
"parent_user_id": int64IDString(relation.GetParentUserId()),
"child_user_id": int64IDString(relation.GetChildUserId()),
"status": relation.GetStatus(),
"created_by_user_id": int64IDString(relation.GetCreatedByUserId()),
"created_at_ms": relation.GetCreatedAtMs(),
"updated_at_ms": relation.GetUpdatedAtMs(),
}
}
func firstTrimmed(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func positiveQueryInt(request *http.Request, key string, fallback int) int {
value, err := strconv.Atoi(strings.TrimSpace(request.URL.Query().Get(key)))
if err != nil || value <= 0 {
return fallback
}
return value
}

View File

@ -97,6 +97,14 @@ func (h *Handler) WalletHandlers() httproutes.WalletHandlers {
ListWalletTransactions: h.listWalletTransactions, ListWalletTransactions: h.listWalletTransactions,
ListCoinSellers: h.listCoinSellers, ListCoinSellers: h.listCoinSellers,
TransferCoinFromSeller: h.transferCoinFromSeller, TransferCoinFromSeller: h.transferCoinFromSeller,
GetCoinSellerSubSellerPermission: h.getCoinSellerSubSellerPermission,
InviteCoinSellerSubSeller: h.inviteCoinSellerSubSeller,
ListCoinSellerSubSellers: h.listCoinSellerSubSellers,
TransferCoinSellerToSubSeller: h.transferCoinSellerToSubSeller,
GetCoinSellerOverview: h.getCoinSellerOverview,
CreateSubCoinSeller: h.createSubCoinSeller,
ListSubCoinSellers: h.listSubCoinSellers,
TransferCoinSellerStockToChild: h.transferCoinSellerStockToChild,
SendDirectGift: h.sendDirectGift, SendDirectGift: h.sendDirectGift,
GetSalaryWalletOverview: h.getSalaryWalletOverview, GetSalaryWalletOverview: h.getSalaryWalletOverview,
GetSalaryWalletHistory: h.getSalaryWalletHistory, GetSalaryWalletHistory: h.getSalaryWalletHistory,

View File

@ -0,0 +1,413 @@
package walletapi
import (
"net/http"
"strconv"
"strings"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
const coinSellerSubSellerAssetType = "COIN_SELLER_COIN"
type flexibleJSONInt64 int64
func (v *flexibleJSONInt64) UnmarshalJSON(data []byte) error {
raw := strings.TrimSpace(string(data))
if raw == "" || raw == "null" {
*v = 0
return nil
}
if strings.HasPrefix(raw, `"`) {
text, err := strconv.Unquote(raw)
if err != nil {
return err
}
raw = strings.TrimSpace(text)
if raw == "" {
*v = 0
return nil
}
}
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return err
}
*v = flexibleJSONInt64(parsed)
return nil
}
func (v flexibleJSONInt64) Int64() int64 {
return int64(v)
}
type coinSellerSubSellerPermissionData struct {
Visible bool `json:"visible"`
CanInviteSubSeller bool `json:"can_invite_sub_seller"`
CanTransferSubSeller bool `json:"can_transfer_sub_seller"`
CanInviteSubSellerCamel bool `json:"canInviteSubSeller"`
CanTransferSubSellerCamel bool `json:"canTransferSubSeller"`
}
type coinSellerSubSellerData struct {
UserID string `json:"user_id"`
DisplayUserID string `json:"display_user_id"`
Username string `json:"username"`
Avatar string `json:"avatar,omitempty"`
CountryID int64 `json:"country_id"`
CountryCode string `json:"country_code,omitempty"`
CountryName string `json:"country_name,omitempty"`
CountryDisplayName string `json:"country_display_name,omitempty"`
RegionID int64 `json:"region_id"`
RegionCode string `json:"region_code,omitempty"`
RegionName string `json:"region_name,omitempty"`
Status string `json:"status"`
MerchantAssetType string `json:"merchant_asset_type"`
ContactInfo string `json:"contact_info"`
WhatsappPhone string `json:"whatsapp_phone"`
WhatsappURL string `json:"whatsapp_url"`
CanManageSubCoinSellers bool `json:"can_manage_sub_coin_sellers"`
CoinSellerBalance int64 `json:"coin_seller_balance"`
AvailableAmount int64 `json:"available_amount"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type coinSellerSubSellerCreateRequestBody struct {
CommandID string `json:"command_id"`
CommandIDCamel string `json:"commandId"`
ChildUserID flexibleJSONInt64 `json:"child_user_id"`
ChildUserIDCamel flexibleJSONInt64 `json:"childUserId"`
TargetUserID flexibleJSONInt64 `json:"target_user_id"`
TargetUserIDCamel flexibleJSONInt64 `json:"targetUserId"`
ChildDisplayUserID string `json:"child_display_user_id"`
ChildDisplayUserIDCamel string `json:"childDisplayUserId"`
TargetDisplayUserID string `json:"target_display_user_id"`
TargetDisplayUserIDCamel string `json:"targetDisplayUserId"`
}
func (b coinSellerSubSellerCreateRequestBody) normalizedCommandID() string {
if value := strings.TrimSpace(b.CommandID); value != "" {
return value
}
return strings.TrimSpace(b.CommandIDCamel)
}
func (b coinSellerSubSellerCreateRequestBody) normalizedChildUserID() int64 {
for _, value := range []int64{b.ChildUserID.Int64(), b.ChildUserIDCamel.Int64(), b.TargetUserID.Int64(), b.TargetUserIDCamel.Int64()} {
if value > 0 {
return value
}
}
return 0
}
func (b coinSellerSubSellerCreateRequestBody) normalizedChildDisplayUserID() string {
for _, value := range []string{b.ChildDisplayUserID, b.ChildDisplayUserIDCamel, b.TargetDisplayUserID, b.TargetDisplayUserIDCamel} {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
type coinSellerSubSellerTransferRequestBody struct {
CommandID string `json:"command_id"`
CommandIDCamel string `json:"commandId"`
ChildUserID flexibleJSONInt64 `json:"child_user_id"`
ChildUserIDCamel flexibleJSONInt64 `json:"childUserId"`
TargetUserID flexibleJSONInt64 `json:"target_user_id"`
TargetUserIDCamel flexibleJSONInt64 `json:"targetUserId"`
ChildDisplayUserID string `json:"child_display_user_id"`
ChildDisplayUserIDCamel string `json:"childDisplayUserId"`
TargetDisplayUserID string `json:"target_display_user_id"`
TargetDisplayUserIDCamel string `json:"targetDisplayUserId"`
Amount int64 `json:"amount"`
Reason string `json:"reason"`
}
func (b coinSellerSubSellerTransferRequestBody) normalizedCommandID() string {
if value := strings.TrimSpace(b.CommandID); value != "" {
return value
}
return strings.TrimSpace(b.CommandIDCamel)
}
func (b coinSellerSubSellerTransferRequestBody) normalizedChildUserID() int64 {
for _, value := range []int64{b.ChildUserID.Int64(), b.ChildUserIDCamel.Int64(), b.TargetUserID.Int64(), b.TargetUserIDCamel.Int64()} {
if value > 0 {
return value
}
}
return 0
}
func (b coinSellerSubSellerTransferRequestBody) normalizedChildDisplayUserID() string {
for _, value := range []string{b.ChildDisplayUserID, b.ChildDisplayUserIDCamel, b.TargetDisplayUserID, b.TargetDisplayUserIDCamel} {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
type coinSellerSubSellerTransferData struct {
TransactionID string `json:"transaction_id"`
ParentBalanceAfter int64 `json:"parent_balance_after"`
ChildBalanceAfter int64 `json:"child_balance_after"`
Amount int64 `json:"amount"`
CreatedAtMS int64 `json:"created_at_ms"`
}
// getCoinSellerSubSellerPermission 返回 H5 币商中心子币商入口权限。
// 非币商或被关闭子币商权限时返回 false而不是 4xx让 H5 可以用同一个接口稳定决定入口显隐。
func (h *Handler) getCoinSellerSubSellerPermission(writer http.ResponseWriter, request *http.Request) {
if h.userHostClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resp, err := h.userHostClient.GetCoinSellerProfile(request.Context(), &userv1.GetCoinSellerProfileRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
if xerr.ReasonFromGRPC(err) == xerr.NotFound {
httpkit.WriteOK(writer, request, coinSellerSubSellerPermission(false))
return
}
httpkit.WriteRPCError(writer, request, err)
return
}
profile := resp.GetCoinSellerProfile()
allowed := profile != nil &&
profile.GetStatus() == "active" &&
profile.GetMerchantAssetType() == coinSellerSubSellerAssetType &&
profile.GetCanManageSubCoinSellers()
httpkit.WriteOK(writer, request, coinSellerSubSellerPermission(allowed))
}
func coinSellerSubSellerPermission(allowed bool) coinSellerSubSellerPermissionData {
return coinSellerSubSellerPermissionData{
Visible: allowed,
CanInviteSubSeller: allowed,
CanTransferSubSeller: allowed,
CanInviteSubSellerCamel: allowed,
CanTransferSubSellerCamel: allowed,
}
}
// inviteCoinSellerSubSeller 通过短 ID 或内部 user_id 创建直属子币商关系。
// user-service 在事务内校验父级权限、同区域、无环和子级唯一 active 父级gateway 不复制这些规则。
func (h *Handler) inviteCoinSellerSubSeller(writer http.ResponseWriter, request *http.Request) {
if h.userHostClient == nil || h.userIdentityClient == nil || h.walletClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body coinSellerSubSellerCreateRequestBody
if !httpkit.Decode(writer, request, &body) {
return
}
commandID := body.normalizedCommandID()
childUserID, ok := h.resolveSubCoinSellerChildUserID(writer, request, body.normalizedChildUserID(), body.normalizedChildDisplayUserID())
if !ok {
return
}
if commandID == "" || childUserID <= 0 {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.userHostClient.CreateSubCoinSeller(request.Context(), &userv1.CreateSubCoinSellerRequest{
Meta: httpkit.UserMeta(request, ""),
CommandId: commandID,
ParentUserId: auth.UserIDFromContext(request.Context()),
ChildUserId: childUserID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
balance, err := h.coinSellerSubBalance(request, resp.GetChild().GetUserId())
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, map[string]any{
"relation": resp.GetRelation(),
"child": coinSellerSubSellerFromProto(resp.GetChild(), balance),
})
}
// listCoinSellerSubSellers 返回直属子币商列表并补齐子币商专用库存余额。
func (h *Handler) listCoinSellerSubSellers(writer http.ResponseWriter, request *http.Request) {
if h.userHostClient == nil || h.walletClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
page, ok := positiveQueryInt32(request, "page", 1, 100000)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
pageSize, ok := positiveQueryInt32(request, "page_size", 20, 100)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.userHostClient.ListSubCoinSellers(request.Context(), &userv1.ListSubCoinSellersRequest{
Meta: httpkit.UserMeta(request, ""),
ParentUserId: auth.UserIDFromContext(request.Context()),
Page: page,
PageSize: pageSize,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
items := make([]coinSellerSubSellerData, 0, len(resp.GetChildren()))
for _, child := range resp.GetChildren() {
balance, err := h.coinSellerSubBalance(request, child.GetUserId())
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
items = append(items, coinSellerSubSellerFromProto(child, balance))
}
httpkit.WriteOK(writer, request, map[string]any{
"items": items,
"children": items,
"total": resp.GetTotal(),
"page": resp.GetPage(),
"page_size": resp.GetPageSize(),
})
}
// transferCoinSellerToSubSeller 先向 user-service 校验直属关系,再请求 wallet-service 做同资产库存划拨。
// 这个服务端校验是权限边界:前端隐藏按钮不能成为防绕过手段。
func (h *Handler) transferCoinSellerToSubSeller(writer http.ResponseWriter, request *http.Request) {
if h.userHostClient == nil || h.userIdentityClient == nil || h.walletClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body coinSellerSubSellerTransferRequestBody
if !httpkit.Decode(writer, request, &body) {
return
}
commandID := body.normalizedCommandID()
childUserID, ok := h.resolveSubCoinSellerChildUserID(writer, request, body.normalizedChildUserID(), body.normalizedChildDisplayUserID())
if !ok {
return
}
if commandID == "" || childUserID <= 0 || body.Amount <= 0 {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
parentUserID := auth.UserIDFromContext(request.Context())
checkResp, err := h.userHostClient.CheckCoinSellerSubRelation(request.Context(), &userv1.CheckCoinSellerSubRelationRequest{
Meta: httpkit.UserMeta(request, ""),
ParentUserId: parentUserID,
ChildUserId: childUserID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
if !checkResp.GetAllowed() || checkResp.GetChild() == nil || checkResp.GetChild().GetRegionId() <= 0 {
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
return
}
resp, err := h.walletClient.TransferCoinSellerStockToChild(request.Context(), &walletv1.TransferCoinSellerStockToChildRequest{
CommandId: commandID,
ParentUserId: parentUserID,
ChildUserId: childUserID,
Amount: body.Amount,
Reason: strings.TrimSpace(body.Reason),
AppCode: appcode.FromContext(request.Context()),
RegionId: checkResp.GetChild().GetRegionId(),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, coinSellerSubSellerTransferData{
TransactionID: resp.GetTransactionId(),
ParentBalanceAfter: resp.GetParentBalanceAfter(),
ChildBalanceAfter: resp.GetChildBalanceAfter(),
Amount: resp.GetAmount(),
CreatedAtMS: resp.GetCreatedAtMs(),
})
}
func (h *Handler) resolveSubCoinSellerChildUserID(writer http.ResponseWriter, request *http.Request, childUserID int64, displayUserID string) (int64, bool) {
if childUserID > 0 {
return childUserID, true
}
displayUserID = strings.TrimSpace(displayUserID)
if displayUserID == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return 0, false
}
return h.resolveCoinSellerTransferTargetUserID(writer, request, displayUserID)
}
func (h *Handler) coinSellerSubBalance(request *http.Request, userID int64) (int64, error) {
resp, err := h.walletClient.GetBalances(request.Context(), &walletv1.GetBalancesRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
UserId: userID,
AssetTypes: []string{coinSellerSubSellerAssetType},
AppCode: appcode.FromContext(request.Context()),
})
if err != nil {
return 0, err
}
for _, balance := range resp.GetBalances() {
if strings.EqualFold(balance.GetAssetType(), coinSellerSubSellerAssetType) {
return balance.GetAvailableAmount(), nil
}
}
return 0, nil
}
func coinSellerSubSellerFromProto(item *userv1.CoinSellerListItem, balance int64) coinSellerSubSellerData {
if item == nil {
return coinSellerSubSellerData{}
}
contactInfo := strings.TrimSpace(item.GetContactInfo())
return coinSellerSubSellerData{
UserID: int64IDString(item.GetUserId()),
DisplayUserID: item.GetDisplayUserId(),
Username: item.GetUsername(),
Avatar: item.GetAvatar(),
CountryID: item.GetCountryId(),
CountryCode: item.GetCountryCode(),
CountryName: item.GetCountryName(),
CountryDisplayName: item.GetCountryDisplayName(),
RegionID: item.GetRegionId(),
RegionCode: item.GetRegionCode(),
RegionName: item.GetRegionName(),
Status: item.GetStatus(),
MerchantAssetType: item.GetMerchantAssetType(),
ContactInfo: contactInfo,
WhatsappPhone: contactInfo,
WhatsappURL: coinSellerWhatsAppURL(contactInfo),
CanManageSubCoinSellers: item.GetCanManageSubCoinSellers(),
CoinSellerBalance: balance,
AvailableAmount: balance,
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func positiveQueryInt32(request *http.Request, name string, fallback int32, max int32) (int32, bool) {
raw := strings.TrimSpace(request.URL.Query().Get(name))
if raw == "" {
return fallback, true
}
value, err := strconv.ParseInt(raw, 10, 32)
if err != nil || value <= 0 || value > int64(max) {
return 0, false
}
return int32(value), true
}

View File

@ -0,0 +1,40 @@
package service
import (
"context"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/xerr"
)
// AdminRenameOwnerCountryCode 批量刷新房主国家码当前投影;它不是房间命令,不写 command log。
func (s *Service) AdminRenameOwnerCountryCode(ctx context.Context, req *roomv1.AdminRenameOwnerCountryCodeRequest) (*roomv1.AdminRenameOwnerCountryCodeResponse, error) {
if s == nil || s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "room repository is not configured")
}
if req == nil {
return nil, xerr.New(xerr.InvalidArgument, "country code rename request is required")
}
oldCountryCode := normalizeRoomCountryCode(req.GetOldCountryCode())
newCountryCode := normalizeRoomCountryCode(req.GetNewCountryCode())
if oldCountryCode == "" || newCountryCode == "" || oldCountryCode == newCountryCode {
return nil, xerr.New(xerr.InvalidArgument, "country code rename is invalid")
}
nowMS := s.clock.Now().UTC().UnixMilli()
result, err := s.repository.RenameOwnerCountryCode(ctx, oldCountryCode, newCountryCode, nowMS)
if err != nil {
return nil, err
}
if s.roomListCache != nil {
// Redis 只承载发现页展示顺序,但国家 tab 会优先命中它;改码任务必须同步刷新缓存,避免成功后客户端短暂读到旧码。
if err := s.RefreshRoomListCache(ctx); err != nil {
return nil, err
}
}
return &roomv1.AdminRenameOwnerCountryCodeResponse{
RoomListEntries: result.RoomListEntries,
RoomSnapshots: result.RoomSnapshots,
ServerTimeMs: nowMS,
}, nil
}

View File

@ -195,6 +195,13 @@ type RoomListEntry struct {
PinnedUntilMS int64 PinnedUntilMS int64
} }
type OwnerCountryCodeRenameResult struct {
// RoomListEntries 是 room_list_entries 中从旧国家码切到新国家码的当前列表投影行数。
RoomListEntries int64
// RoomSnapshots 是 room_snapshots 当前 protobuf 快照中 owner_country_code 被改写的房间数。
RoomSnapshots int64
}
// RoomListPinEntry 是公共发现页置顶读模型的最小事实。 // RoomListPinEntry 是公共发现页置顶读模型的最小事实。
type RoomListPinEntry struct { type RoomListPinEntry struct {
// ID 是 room_region_pins 的自增主键,缓存增量更新和删除都用它定位旧 zset member。 // ID 是 room_region_pins 的自增主键,缓存增量更新和删除都用它定位旧 zset member。
@ -683,6 +690,7 @@ type Repository interface {
RoomMetaStore RoomMetaStore
CommandStore CommandStore
SnapshotStore SnapshotStore
RoomOwnerProjectionStore
RoomConfigStore RoomConfigStore
OutboxStore OutboxStore
RoomListStore RoomListStore
@ -692,6 +700,11 @@ type Repository interface {
RobotRoomStore RobotRoomStore
} }
// RoomOwnerProjectionStore 管理房主低频资料投影;只改当前读模型,不写 command log。
type RoomOwnerProjectionStore interface {
RenameOwnerCountryCode(ctx context.Context, oldCountryCode string, newCountryCode string, nowMS int64) (OwnerCountryCodeRenameResult, error)
}
// RoomListCacheStore 是公共发现页 Redis 展示读模型边界。 // RoomListCacheStore 是公共发现页 Redis 展示读模型边界。
// //
// MySQL 仍然是房间列表和置顶事实 owner缓存只服务高频 hot/new 发现页, // MySQL 仍然是房间列表和置顶事实 owner缓存只服务高频 hot/new 发现页,

View File

@ -0,0 +1,100 @@
package mysql
import (
"context"
"database/sql"
"google.golang.org/protobuf/proto"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
roomservice "hyapp/services/room-service/internal/room/service"
)
const ownerCountryRoomExtKey = "owner_country_code"
type ownerCountrySnapshotUpdate struct {
roomID string
roomVersion int64
payload []byte
}
// RenameOwnerCountryCode 同事务刷新房间当前列表投影和最新快照中的房主国家码。
// command_log 保留房间发生时事实,因此这里明确只碰当前读模型,供后台国家码重命名任务收敛线上展示态。
func (r *Repository) RenameOwnerCountryCode(ctx context.Context, oldCountryCode string, newCountryCode string, nowMS int64) (roomservice.OwnerCountryCodeRenameResult, error) {
app := appcode.FromContext(ctx)
var result roomservice.OwnerCountryCodeRenameResult
tx, err := r.db.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
return roomservice.OwnerCountryCodeRenameResult{}, err
}
defer tx.Rollback()
rows, err := tx.QueryContext(ctx, `
SELECT room_id, room_version, payload
FROM room_snapshots
WHERE app_code = ?
FOR UPDATE
`, app)
if err != nil {
return roomservice.OwnerCountryCodeRenameResult{}, err
}
updates := []ownerCountrySnapshotUpdate{}
for rows.Next() {
var update ownerCountrySnapshotUpdate
if err := rows.Scan(&update.roomID, &update.roomVersion, &update.payload); err != nil {
_ = rows.Close()
return roomservice.OwnerCountryCodeRenameResult{}, err
}
var snapshot roomv1.RoomSnapshot
if err := proto.Unmarshal(update.payload, &snapshot); err != nil {
_ = rows.Close()
return roomservice.OwnerCountryCodeRenameResult{}, err
}
if snapshot.RoomExt == nil || snapshot.RoomExt[ownerCountryRoomExtKey] != oldCountryCode {
continue
}
snapshot.RoomExt[ownerCountryRoomExtKey] = newCountryCode
body, err := proto.Marshal(&snapshot)
if err != nil {
_ = rows.Close()
return roomservice.OwnerCountryCodeRenameResult{}, err
}
update.payload = body
updates = append(updates, update)
}
if err := rows.Close(); err != nil {
return roomservice.OwnerCountryCodeRenameResult{}, err
}
if err := rows.Err(); err != nil {
return roomservice.OwnerCountryCodeRenameResult{}, err
}
listResult, err := tx.ExecContext(ctx, `
UPDATE room_list_entries
SET owner_country_code = ?, updated_at_ms = ?
WHERE app_code = ? AND owner_country_code = ?
`, newCountryCode, nowMS, app, oldCountryCode)
if err != nil {
return roomservice.OwnerCountryCodeRenameResult{}, err
}
result.RoomListEntries, _ = listResult.RowsAffected()
for _, update := range updates {
// room_version 不递增,避免把低频后台投影修复伪装成 Room Cell 状态版本推进。
affected, err := tx.ExecContext(ctx, `
UPDATE room_snapshots
SET payload = ?, updated_at_ms = ?
WHERE app_code = ? AND room_id = ? AND room_version = ?
`, update.payload, nowMS, app, update.roomID, update.roomVersion)
if err != nil {
return roomservice.OwnerCountryCodeRenameResult{}, err
}
rowsAffected, _ := affected.RowsAffected()
result.RoomSnapshots += rowsAffected
}
if err := tx.Commit(); err != nil {
return roomservice.OwnerCountryCodeRenameResult{}, err
}
return result, nil
}

View File

@ -203,6 +203,11 @@ func (s *Server) AdminSetRobotRoomStatus(ctx context.Context, req *roomv1.AdminS
return mapServiceResult(s.svc.AdminSetRobotRoomStatus(ctx, req)) return mapServiceResult(s.svc.AdminSetRobotRoomStatus(ctx, req))
} }
func (s *Server) AdminRenameOwnerCountryCode(ctx context.Context, req *roomv1.AdminRenameOwnerCountryCodeRequest) (*roomv1.AdminRenameOwnerCountryCodeResponse, error) {
ctx = contextWithMetaApp(ctx, req.GetMeta())
return mapServiceResult(s.svc.AdminRenameOwnerCountryCode(ctx, req))
}
// MicUp 代理到领域服务。 // MicUp 代理到领域服务。
func (s *Server) MicUp(ctx context.Context, req *roomv1.MicUpRequest) (*roomv1.MicUpResponse, error) { func (s *Server) MicUp(ctx context.Context, req *roomv1.MicUpRequest) (*roomv1.MicUpResponse, error) {
// 麦位修改由 Room Cell 串行执行gRPC 层不做状态判断。 // 麦位修改由 Room Cell 串行执行gRPC 层不做状态判断。

View File

@ -769,7 +769,8 @@ CREATE TABLE IF NOT EXISTS bd_leader_profiles (
status VARCHAR(32) NOT NULL COMMENT '业务状态', status VARCHAR(32) NOT NULL COMMENT '业务状态',
created_by_user_id BIGINT NOT NULL COMMENT '创建人用户 ID', created_by_user_id BIGINT NOT NULL COMMENT '创建人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms', created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms' updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
KEY idx_bd_leader_profiles_created_by (app_code, created_by_user_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BD Leader资料表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BD Leader资料表';
CREATE TABLE IF NOT EXISTS manager_profiles ( CREATE TABLE IF NOT EXISTS manager_profiles (
@ -800,6 +801,7 @@ CREATE TABLE IF NOT EXISTS coin_seller_profiles (
status VARCHAR(32) NOT NULL COMMENT '业务状态', status VARCHAR(32) NOT NULL COMMENT '业务状态',
merchant_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN_SELLER_COIN' COMMENT '商户资产类型', merchant_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN_SELLER_COIN' COMMENT '商户资产类型',
contact_info VARCHAR(128) NOT NULL DEFAULT '' COMMENT '联系信息', contact_info VARCHAR(128) NOT NULL DEFAULT '' COMMENT '联系信息',
can_manage_sub_coin_sellers TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在币商中心管理直属子币商',
created_by_user_id BIGINT NOT NULL COMMENT '创建人用户 ID', created_by_user_id BIGINT NOT NULL COMMENT '创建人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms', created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
@ -807,6 +809,24 @@ CREATE TABLE IF NOT EXISTS coin_seller_profiles (
KEY idx_coin_seller_profiles_created_by (app_code, created_by_user_id, created_at_ms) KEY idx_coin_seller_profiles_created_by (app_code, created_by_user_id, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='金币商户资料表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='金币商户资料表';
CREATE TABLE IF NOT EXISTS coin_seller_relations (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
relation_id BIGINT NOT NULL PRIMARY KEY COMMENT '直属关系 ID',
parent_user_id BIGINT NOT NULL COMMENT '父币商用户 ID',
child_user_id BIGINT NOT NULL COMMENT '子币商用户 ID',
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '关系状态',
created_by_user_id BIGINT NOT NULL COMMENT '创建人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
active_child_user_id BIGINT GENERATED ALWAYS AS (
CASE WHEN status = 'active' THEN child_user_id ELSE NULL END
) STORED COMMENT '当前有效子币商用户 ID',
UNIQUE KEY uk_coin_seller_relation_active_child (app_code, active_child_user_id),
UNIQUE KEY uk_coin_seller_relation_parent_child_status (app_code, parent_user_id, child_user_id, status),
KEY idx_coin_seller_relation_parent (app_code, parent_user_id, status, created_at_ms),
KEY idx_coin_seller_relation_child (app_code, child_user_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='币商直属子币商关系表';
CREATE TABLE IF NOT EXISTS agencies ( CREATE TABLE IF NOT EXISTS agencies (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
agency_id BIGINT NOT NULL PRIMARY KEY COMMENT '公会 ID', agency_id BIGINT NOT NULL PRIMARY KEY COMMENT '公会 ID',

View File

@ -0,0 +1,4 @@
-- 经理中心团队统计按 created_by_user_id 反查经理名下 BD Leader
-- 该表此前只有主键,无经理维度索引,团队查询会退化为全表扫描。
ALTER TABLE bd_leader_profiles
ADD INDEX idx_bd_leader_profiles_created_by (app_code, created_by_user_id, status);

View File

@ -0,0 +1,49 @@
SET @add_coin_seller_contact_info := (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE coin_seller_profiles ADD COLUMN contact_info VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''联系信息'' AFTER merchant_asset_type',
'SELECT 1'
)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'coin_seller_profiles'
AND COLUMN_NAME = 'contact_info'
);
PREPARE stmt FROM @add_coin_seller_contact_info;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @add_coin_seller_sub_permission := (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE coin_seller_profiles ADD COLUMN can_manage_sub_coin_sellers TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否允许在币商中心管理直属子币商'' AFTER contact_info',
'SELECT 1'
)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'coin_seller_profiles'
AND COLUMN_NAME = 'can_manage_sub_coin_sellers'
);
PREPARE stmt FROM @add_coin_seller_sub_permission;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS coin_seller_relations (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
relation_id BIGINT NOT NULL PRIMARY KEY COMMENT '直属关系 ID',
parent_user_id BIGINT NOT NULL COMMENT '父币商用户 ID',
child_user_id BIGINT NOT NULL COMMENT '子币商用户 ID',
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '关系状态',
created_by_user_id BIGINT NOT NULL COMMENT '创建人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
active_child_user_id BIGINT GENERATED ALWAYS AS (
CASE WHEN status = 'active' THEN child_user_id ELSE NULL END
) STORED COMMENT '当前有效子币商用户 ID',
UNIQUE KEY uk_coin_seller_relation_active_child (app_code, active_child_user_id),
UNIQUE KEY uk_coin_seller_relation_parent_child_status (app_code, parent_user_id, child_user_id, status),
KEY idx_coin_seller_relation_parent (app_code, parent_user_id, status, created_at_ms),
KEY idx_coin_seller_relation_child (app_code, child_user_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='币商直属子币商关系表';

View File

@ -46,6 +46,10 @@ const (
CoinSellerStatusDisabled = "disabled" CoinSellerStatusDisabled = "disabled"
// CoinSellerMerchantAssetType 是币商专用金币资产,不和玩家普通 COIN 混账。 // CoinSellerMerchantAssetType 是币商专用金币资产,不和玩家普通 COIN 混账。
CoinSellerMerchantAssetType = "COIN_SELLER_COIN" CoinSellerMerchantAssetType = "COIN_SELLER_COIN"
// CoinSellerRelationStatusActive 表示父子币商直属关系有效H5 转账只能命中 active 关系。
CoinSellerRelationStatusActive = "active"
// CoinSellerRelationStatusDisabled 表示历史关系已停用,保留审计但不能继续转账。
CoinSellerRelationStatusDisabled = "disabled"
// MembershipTypeOwner 表示 Agency owner 自动拥有的 membership。 // MembershipTypeOwner 表示 Agency owner 自动拥有的 membership。
MembershipTypeOwner = "owner" MembershipTypeOwner = "owner"
@ -136,6 +140,8 @@ const (
CommandTypeCreateCoinSeller = "admin_create_coin_seller" CommandTypeCreateCoinSeller = "admin_create_coin_seller"
// CommandTypeSetCoinSellerStatus 记录后台启用或停用币商身份命令。 // CommandTypeSetCoinSellerStatus 记录后台启用或停用币商身份命令。
CommandTypeSetCoinSellerStatus = "admin_set_coin_seller_status" CommandTypeSetCoinSellerStatus = "admin_set_coin_seller_status"
// CommandTypeCreateSubCoinSeller 记录父币商在 H5 直开或绑定直属子币商的命令。
CommandTypeCreateSubCoinSeller = "coin_seller_create_sub_seller"
// ResultTypeApplication 表示命令结果是 agency_applications 行。 // ResultTypeApplication 表示命令结果是 agency_applications 行。
ResultTypeApplication = "agency_application" ResultTypeApplication = "agency_application"
@ -149,6 +155,8 @@ const (
ResultTypeAgency = "agency" ResultTypeAgency = "agency"
// ResultTypeCoinSellerProfile 表示命令结果是 coin_seller_profiles 行。 // ResultTypeCoinSellerProfile 表示命令结果是 coin_seller_profiles 行。
ResultTypeCoinSellerProfile = "coin_seller_profile" ResultTypeCoinSellerProfile = "coin_seller_profile"
// ResultTypeCoinSellerRelation 表示命令结果是 coin_seller_relations 行。
ResultTypeCoinSellerRelation = "coin_seller_relation"
) )
// HostProfile 是用户长期主播身份快照。 // HostProfile 是用户长期主播身份快照。
@ -184,6 +192,24 @@ type Agency struct {
UpdatedAtMs int64 UpdatedAtMs int64
} }
// ManagerTeamAgency 是团队统计专用的轻量归属投影;刻意不复用 Agency避免带出名称/成员数等重投影查询。
type ManagerTeamAgency struct {
AgencyID int64
OwnerUserID int64
ParentBDUserID int64
}
// ManagerTeamAgencies 是经理整棵团队树的 Agency 统计投影:
// 经理 → BD Leadercreated_by_user_id→ BDparent_leader_user_id→ Agencyparent_bd_user_id
// 经理中心团队工资统计据此圈定主播归属范围,不承载写语义。
type ManagerTeamAgencies struct {
Agencies []ManagerTeamAgency
TotalBDLeaders int
TotalBDs int
// Truncated 表示团队规模超出查询上限被截断,调用方必须把统计视为不完整口径而不是静默当成全量。
Truncated bool
}
// BDProfile 是 BD/BD Leader 角色事实。 // BDProfile 是 BD/BD Leader 角色事实。
type BDProfile struct { type BDProfile struct {
UserID int64 UserID int64
@ -220,32 +246,59 @@ type ManagerProfile struct {
// CoinSellerProfile 是币商角色事实,余额不保存在 user-service。 // CoinSellerProfile 是币商角色事实,余额不保存在 user-service。
type CoinSellerProfile struct { type CoinSellerProfile struct {
UserID int64 UserID int64
Status string Status string
MerchantAssetType string MerchantAssetType string
CreatedByUserID int64 CanManageSubCoinSellers bool
CreatedAtMs int64 CreatedByUserID int64
UpdatedAtMs int64 CreatedAtMs int64
UpdatedAtMs int64
} }
// CoinSellerListItem 是 App 充值入口需要的启用币商展示资料。 // CoinSellerListItem 是 App 充值入口需要的启用币商展示资料。
// 区域来自 users 当前事实,币商状态来自 coin_seller_profiles不在 gateway 拼装。 // 区域来自 users 当前事实,币商状态来自 coin_seller_profiles不在 gateway 拼装。
type CoinSellerListItem struct { type CoinSellerListItem struct {
UserID int64 UserID int64
DisplayUserID string DisplayUserID string
Username string Username string
Avatar string Avatar string
CountryID int64 CountryID int64
CountryCode string CountryCode string
CountryName string CountryName string
CountryDisplayName string CountryDisplayName string
RegionID int64 RegionID int64
RegionCode string RegionCode string
RegionName string RegionName string
Status string Status string
MerchantAssetType string MerchantAssetType string
ContactInfo string ContactInfo string
UpdatedAtMs int64 CanManageSubCoinSellers bool
UpdatedAtMs int64
}
// CoinSellerSubRelation 是父币商和直属子币商之间的状态事实。
type CoinSellerSubRelation struct {
RelationID int64
ParentUserID int64
ChildUserID int64
Status string
CreatedByUserID int64
CreatedAtMs int64
UpdatedAtMs int64
}
// CreateSubCoinSellerResult 聚合直开/绑定后 H5 立即展示所需的关系和子币商资料。
type CreateSubCoinSellerResult struct {
Relation CoinSellerSubRelation
Child CoinSellerListItem
}
// ListSubCoinSellersResult 是直属子币商分页列表。
type ListSubCoinSellersResult struct {
Items []CoinSellerListItem
Total int64
Page int
PageSize int
} }
// UserRoleSummary 是面向 App 入口显隐的聚合 read model。 // UserRoleSummary 是面向 App 入口显隐的聚合 read model。

View File

@ -146,6 +146,12 @@ type ListBDAgenciesCommand struct {
PageSize int PageSize int
} }
// ListManagerTeamAgenciesCommand 描述经理查询整棵团队树 Agency 的条件;只统计 active 链路,不支持按状态放宽。
type ListManagerTeamAgenciesCommand struct {
ManagerUserID int64
PageSize int
}
// ProcessRoleInvitationInput 是处理角色邀请的外部输入。 // ProcessRoleInvitationInput 是处理角色邀请的外部输入。
type ProcessRoleInvitationInput struct { type ProcessRoleInvitationInput struct {
CommandID string CommandID string
@ -238,46 +244,82 @@ type SetBDStatusCommand struct {
// CreateCoinSellerInput 是后台创建币商身份的外部输入。 // CreateCoinSellerInput 是后台创建币商身份的外部输入。
type CreateCoinSellerInput struct { type CreateCoinSellerInput struct {
CommandID string CommandID string
AdminUserID int64 AdminUserID int64
TargetUserID int64 TargetUserID int64
Reason string Reason string
RequestID string RequestID string
CanManageSubCoinSellers *bool
} }
// CreateCoinSellerCommand 是后台创建币商身份事务需要的完整命令。 // CreateCoinSellerCommand 是后台创建币商身份事务需要的完整命令。
type CreateCoinSellerCommand struct { type CreateCoinSellerCommand struct {
CommandID string CommandID string
AdminUserID int64 AdminUserID int64
TargetUserID int64 TargetUserID int64
Reason string Reason string
RequestID string RequestID string
EventID int64 CanManageSubCoinSellers *bool
NowMs int64 EventID int64
NowMs int64
} }
// SetCoinSellerStatusInput 是后台调整币商身份状态的外部输入。 // SetCoinSellerStatusInput 是后台调整币商身份状态的外部输入。
type SetCoinSellerStatusInput struct { type SetCoinSellerStatusInput struct {
CommandID string CommandID string
AdminUserID int64 AdminUserID int64
TargetUserID int64 TargetUserID int64
Status string Status string
Reason string Reason string
RequestID string RequestID string
CanManageSubCoinSellers *bool
} }
// SetCoinSellerStatusCommand 是后台调整币商身份状态事务需要的完整命令。 // SetCoinSellerStatusCommand 是后台调整币商身份状态事务需要的完整命令。
type SetCoinSellerStatusCommand struct { type SetCoinSellerStatusCommand struct {
CommandID string
AdminUserID int64
TargetUserID int64
Status string
Reason string
RequestID string
CanManageSubCoinSellers *bool
EventID int64
NowMs int64
}
// CreateSubCoinSellerInput 是父币商在 H5 通过短 ID 搜索后直开/绑定子币商的外部输入。
type CreateSubCoinSellerInput struct {
CommandID string CommandID string
AdminUserID int64 ParentUserID int64
TargetUserID int64 ChildUserID int64
Status string RequestID string
Reason string }
// CreateSubCoinSellerCommand 是父子币商关系事务需要的完整命令。
type CreateSubCoinSellerCommand struct {
CommandID string
RelationID int64
ParentUserID int64
ChildUserID int64
RequestID string RequestID string
EventID int64 EventID int64
NowMs int64 NowMs int64
} }
// ListSubCoinSellersCommand 描述父币商读取直属子币商列表的分页条件。
type ListSubCoinSellersCommand struct {
ParentUserID int64
Page int
PageSize int
}
// CheckCoinSellerSubRelationCommand 描述父币商对子币商发起转账前的关系校验条件。
type CheckCoinSellerSubRelationCommand struct {
ParentUserID int64
ChildUserID int64
}
// CreateAgencyInput 是后台直接创建 Agency 的外部输入ParentBDUserID 为 0 时表示独立 Agency。 // CreateAgencyInput 是后台直接创建 Agency 的外部输入ParentBDUserID 为 0 时表示独立 Agency。
type CreateAgencyInput struct { type CreateAgencyInput struct {
CommandID string CommandID string

View File

@ -26,12 +26,16 @@ type Repository interface {
ListBDLeaderBDs(ctx context.Context, command ListBDLeaderBDsCommand) ([]hostdomain.BDProfile, error) ListBDLeaderBDs(ctx context.Context, command ListBDLeaderBDsCommand) ([]hostdomain.BDProfile, error)
ListBDLeaderAgencies(ctx context.Context, command ListBDLeaderAgenciesCommand) ([]hostdomain.Agency, error) ListBDLeaderAgencies(ctx context.Context, command ListBDLeaderAgenciesCommand) ([]hostdomain.Agency, error)
ListBDAgencies(ctx context.Context, command ListBDAgenciesCommand) ([]hostdomain.Agency, error) ListBDAgencies(ctx context.Context, command ListBDAgenciesCommand) ([]hostdomain.Agency, error)
ListManagerTeamAgencies(ctx context.Context, command ListManagerTeamAgenciesCommand) (hostdomain.ManagerTeamAgencies, error)
ProcessRoleInvitation(ctx context.Context, command ProcessRoleInvitationCommand) (hostdomain.ProcessRoleInvitationResult, error) ProcessRoleInvitation(ctx context.Context, command ProcessRoleInvitationCommand) (hostdomain.ProcessRoleInvitationResult, error)
CreateBDLeader(ctx context.Context, command CreateBDLeaderCommand) (hostdomain.BDProfile, error) CreateBDLeader(ctx context.Context, command CreateBDLeaderCommand) (hostdomain.BDProfile, error)
CreateBD(ctx context.Context, command CreateBDCommand) (hostdomain.BDProfile, error) CreateBD(ctx context.Context, command CreateBDCommand) (hostdomain.BDProfile, error)
SetBDStatus(ctx context.Context, command SetBDStatusCommand) (hostdomain.BDProfile, error) SetBDStatus(ctx context.Context, command SetBDStatusCommand) (hostdomain.BDProfile, error)
CreateCoinSeller(ctx context.Context, command CreateCoinSellerCommand) (hostdomain.CoinSellerProfile, error) CreateCoinSeller(ctx context.Context, command CreateCoinSellerCommand) (hostdomain.CoinSellerProfile, error)
SetCoinSellerStatus(ctx context.Context, command SetCoinSellerStatusCommand) (hostdomain.CoinSellerProfile, error) SetCoinSellerStatus(ctx context.Context, command SetCoinSellerStatusCommand) (hostdomain.CoinSellerProfile, error)
CreateSubCoinSeller(ctx context.Context, command CreateSubCoinSellerCommand) (hostdomain.CreateSubCoinSellerResult, error)
ListSubCoinSellers(ctx context.Context, command ListSubCoinSellersCommand) (hostdomain.ListSubCoinSellersResult, error)
CheckCoinSellerSubRelation(ctx context.Context, command CheckCoinSellerSubRelationCommand) (hostdomain.CoinSellerListItem, error)
CreateAgency(ctx context.Context, command CreateAgencyCommand) (hostdomain.CreateAgencyResult, error) CreateAgency(ctx context.Context, command CreateAgencyCommand) (hostdomain.CreateAgencyResult, error)
CloseAgency(ctx context.Context, command CloseAgencyCommand) (hostdomain.Agency, error) CloseAgency(ctx context.Context, command CloseAgencyCommand) (hostdomain.Agency, error)
DeleteAgency(ctx context.Context, command DeleteAgencyCommand) (hostdomain.Agency, error) DeleteAgency(ctx context.Context, command DeleteAgencyCommand) (hostdomain.Agency, error)
@ -382,6 +386,28 @@ func (s *Service) ListBDAgencies(ctx context.Context, command ListBDAgenciesComm
}) })
} }
// ListManagerTeamAgencies 返回经理团队树内全部有效 Agency经理中心团队工资统计据此圈定主播归属。
func (s *Service) ListManagerTeamAgencies(ctx context.Context, command ListManagerTeamAgenciesCommand) (hostdomain.ManagerTeamAgencies, error) {
if s.repository == nil {
return hostdomain.ManagerTeamAgencies{}, xerr.New(xerr.Unavailable, "host repository is not configured")
}
if command.ManagerUserID <= 0 {
return hostdomain.ManagerTeamAgencies{}, xerr.New(xerr.InvalidArgument, "manager_user_id is required")
}
// 团队统计要求一次拿全 Agency 集合,防御上限比常规分页列表更高;超限用 Truncated 显式标记而不是静默丢数据。
pageSize := command.PageSize
if pageSize <= 0 {
pageSize = 2000
}
if pageSize > 5000 {
pageSize = 5000
}
return s.repository.ListManagerTeamAgencies(ctx, ListManagerTeamAgenciesCommand{
ManagerUserID: command.ManagerUserID,
PageSize: pageSize,
})
}
// ProcessRoleInvitation 接受、拒绝或取消角色邀请。 // ProcessRoleInvitation 接受、拒绝或取消角色邀请。
func (s *Service) ProcessRoleInvitation(ctx context.Context, command ProcessRoleInvitationInput) (hostdomain.ProcessRoleInvitationResult, error) { func (s *Service) ProcessRoleInvitation(ctx context.Context, command ProcessRoleInvitationInput) (hostdomain.ProcessRoleInvitationResult, error) {
if err := s.requireWriteDependencies(); err != nil { if err := s.requireWriteDependencies(); err != nil {
@ -431,7 +457,7 @@ func (s *Service) CreateBDLeader(ctx context.Context, command CreateBDLeaderInpu
TargetUserID: command.TargetUserID, TargetUserID: command.TargetUserID,
Reason: strings.TrimSpace(command.Reason), Reason: strings.TrimSpace(command.Reason),
RequestID: strings.TrimSpace(command.RequestID), RequestID: strings.TrimSpace(command.RequestID),
EventID: s.nextEventIDRange(2), EventID: s.idGenerator.NewInt64(),
NowMs: nowMs, NowMs: nowMs,
}) })
} }
@ -500,13 +526,14 @@ func (s *Service) CreateCoinSeller(ctx context.Context, command CreateCoinSeller
nowMs := s.now().UnixMilli() nowMs := s.now().UnixMilli()
return s.repository.CreateCoinSeller(ctx, CreateCoinSellerCommand{ return s.repository.CreateCoinSeller(ctx, CreateCoinSellerCommand{
CommandID: strings.TrimSpace(command.CommandID), CommandID: strings.TrimSpace(command.CommandID),
AdminUserID: command.AdminUserID, AdminUserID: command.AdminUserID,
TargetUserID: command.TargetUserID, TargetUserID: command.TargetUserID,
Reason: strings.TrimSpace(command.Reason), Reason: strings.TrimSpace(command.Reason),
RequestID: strings.TrimSpace(command.RequestID), RequestID: strings.TrimSpace(command.RequestID),
EventID: s.idGenerator.NewInt64(), CanManageSubCoinSellers: command.CanManageSubCoinSellers,
NowMs: nowMs, EventID: s.idGenerator.NewInt64(),
NowMs: nowMs,
}) })
} }
@ -525,17 +552,78 @@ func (s *Service) SetCoinSellerStatus(ctx context.Context, command SetCoinSeller
nowMs := s.now().UnixMilli() nowMs := s.now().UnixMilli()
return s.repository.SetCoinSellerStatus(ctx, SetCoinSellerStatusCommand{ return s.repository.SetCoinSellerStatus(ctx, SetCoinSellerStatusCommand{
CommandID: strings.TrimSpace(command.CommandID),
AdminUserID: command.AdminUserID,
TargetUserID: command.TargetUserID,
Status: status,
Reason: strings.TrimSpace(command.Reason),
RequestID: strings.TrimSpace(command.RequestID),
CanManageSubCoinSellers: command.CanManageSubCoinSellers,
EventID: s.idGenerator.NewInt64(),
NowMs: nowMs,
})
}
// CreateSubCoinSeller 在 H5 币商中心直开或绑定直属子币商。
func (s *Service) CreateSubCoinSeller(ctx context.Context, command CreateSubCoinSellerInput) (hostdomain.CreateSubCoinSellerResult, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.CreateSubCoinSellerResult{}, err
}
if strings.TrimSpace(command.CommandID) == "" || command.ParentUserID <= 0 || command.ChildUserID <= 0 {
return hostdomain.CreateSubCoinSellerResult{}, xerr.New(xerr.InvalidArgument, "command_id, parent_user_id and child_user_id are required")
}
if command.ParentUserID == command.ChildUserID {
return hostdomain.CreateSubCoinSellerResult{}, xerr.New(xerr.Conflict, "coin seller cannot add self as sub seller")
}
nowMs := s.now().UnixMilli()
return s.repository.CreateSubCoinSeller(ctx, CreateSubCoinSellerCommand{
CommandID: strings.TrimSpace(command.CommandID), CommandID: strings.TrimSpace(command.CommandID),
AdminUserID: command.AdminUserID, RelationID: s.idGenerator.NewInt64(),
TargetUserID: command.TargetUserID, ParentUserID: command.ParentUserID,
Status: status, ChildUserID: command.ChildUserID,
Reason: strings.TrimSpace(command.Reason),
RequestID: strings.TrimSpace(command.RequestID), RequestID: strings.TrimSpace(command.RequestID),
EventID: s.idGenerator.NewInt64(), EventID: s.nextEventIDRange(2),
NowMs: nowMs, NowMs: nowMs,
}) })
} }
// ListSubCoinSellers 返回父币商直属子币商列表;权限和 active 身份在存储层按当前事实校验。
func (s *Service) ListSubCoinSellers(ctx context.Context, command ListSubCoinSellersCommand) (hostdomain.ListSubCoinSellersResult, error) {
if s.repository == nil {
return hostdomain.ListSubCoinSellersResult{}, xerr.New(xerr.Unavailable, "host repository is not configured")
}
if command.ParentUserID <= 0 {
return hostdomain.ListSubCoinSellersResult{}, xerr.New(xerr.InvalidArgument, "parent_user_id is required")
}
page := command.Page
if page <= 0 {
page = 1
}
pageSize := command.PageSize
if pageSize <= 0 || pageSize > 100 {
pageSize = 20
}
return s.repository.ListSubCoinSellers(ctx, ListSubCoinSellersCommand{
ParentUserID: command.ParentUserID,
Page: page,
PageSize: pageSize,
})
}
// CheckCoinSellerSubRelation 是转账前的服务端授权边界,避免 H5 隐藏按钮后仍能绕过前端直调。
func (s *Service) CheckCoinSellerSubRelation(ctx context.Context, command CheckCoinSellerSubRelationCommand) (hostdomain.CoinSellerListItem, error) {
if s.repository == nil {
return hostdomain.CoinSellerListItem{}, xerr.New(xerr.Unavailable, "host repository is not configured")
}
if command.ParentUserID <= 0 || command.ChildUserID <= 0 {
return hostdomain.CoinSellerListItem{}, xerr.New(xerr.InvalidArgument, "parent_user_id and child_user_id are required")
}
if command.ParentUserID == command.ChildUserID {
return hostdomain.CoinSellerListItem{}, xerr.New(xerr.Conflict, "coin seller sub relation is invalid")
}
return s.repository.CheckCoinSellerSubRelation(ctx, command)
}
// CreateAgency 由后台直接创建 Agency父级 BD 可选,并原子创建或复用 owner 的 host_profile 和 owner membership。 // CreateAgency 由后台直接创建 Agency父级 BD 可选,并原子创建或复用 owner 的 host_profile 和 owner membership。
func (s *Service) CreateAgency(ctx context.Context, command CreateAgencyInput) (hostdomain.CreateAgencyResult, error) { func (s *Service) CreateAgency(ctx context.Context, command CreateAgencyInput) (hostdomain.CreateAgencyResult, error) {
if err := s.requireWriteDependencies(); err != nil { if err := s.requireWriteDependencies(); err != nil {

View File

@ -12,6 +12,12 @@ import (
const ( const (
BusinessSceneManagerResourceGrant = "manager_resource_grant" BusinessSceneManagerResourceGrant = "manager_resource_grant"
BusinessSceneManagerVIPGrant = "manager_vip_grant" BusinessSceneManagerVIPGrant = "manager_vip_grant"
BusinessSceneManagerUpdateLevel = "manager_update_user_level"
BusinessSceneManagerAddBDLeader = "manager_add_bd_leader"
BusinessSceneManagerAddAdmin = "manager_add_admin"
BusinessSceneManagerAddSuperadmin = "manager_add_superadmin"
BusinessSceneManagerBlockUser = "manager_block_user"
BusinessSceneManagerTransfer = "manager_transfer_user_country"
defaultBusinessLookupPageSize = 20 defaultBusinessLookupPageSize = 20
maxBusinessLookupPageSize = 20 maxBusinessLookupPageSize = 20
@ -21,7 +27,7 @@ const (
// service 层仍校验 scene 和 keyword避免未来入口把它误用成全局用户搜索。 // service 层仍校验 scene 和 keyword避免未来入口把它误用成全局用户搜索。
func (s *Service) BusinessUserLookup(ctx context.Context, scene string, keyword string, pageSize int32) ([]userdomain.BusinessUserLookupItem, error) { func (s *Service) BusinessUserLookup(ctx context.Context, scene string, keyword string, pageSize int32) ([]userdomain.BusinessUserLookupItem, error) {
scene = strings.ToLower(strings.TrimSpace(scene)) scene = strings.ToLower(strings.TrimSpace(scene))
if scene != BusinessSceneManagerResourceGrant && scene != BusinessSceneManagerVIPGrant { if !isBusinessUserLookupScene(scene) {
return nil, xerr.New(xerr.InvalidArgument, "business lookup scene is invalid") return nil, xerr.New(xerr.InvalidArgument, "business lookup scene is invalid")
} }
if s.userRepository == nil { if s.userRepository == nil {
@ -41,6 +47,22 @@ func (s *Service) BusinessUserLookup(ctx context.Context, scene string, keyword
return s.userRepository.BusinessUserLookup(ctx, keyword, numericKeyword, pageSize) return s.userRepository.BusinessUserLookup(ctx, keyword, numericKeyword, pageSize)
} }
func isBusinessUserLookupScene(scene string) bool {
switch scene {
case BusinessSceneManagerResourceGrant,
BusinessSceneManagerVIPGrant,
BusinessSceneManagerUpdateLevel,
BusinessSceneManagerAddBDLeader,
BusinessSceneManagerAddAdmin,
BusinessSceneManagerAddSuperadmin,
BusinessSceneManagerBlockUser,
BusinessSceneManagerTransfer:
return true
default:
return false
}
}
func isASCIIInteger(value string) bool { func isASCIIInteger(value string) bool {
if value == "" { if value == "" {
return false return false

View File

@ -212,12 +212,13 @@ func (r *Repository) CreateCoinSeller(ctx context.Context, command hostservice.C
} }
profile := hostdomain.CoinSellerProfile{ profile := hostdomain.CoinSellerProfile{
UserID: command.TargetUserID, UserID: command.TargetUserID,
Status: hostdomain.CoinSellerStatusActive, Status: hostdomain.CoinSellerStatusActive,
MerchantAssetType: hostdomain.CoinSellerMerchantAssetType, MerchantAssetType: hostdomain.CoinSellerMerchantAssetType,
CreatedByUserID: command.AdminUserID, CanManageSubCoinSellers: boolValueDefaultTrue(command.CanManageSubCoinSellers),
CreatedAtMs: command.NowMs, CreatedByUserID: command.AdminUserID,
UpdatedAtMs: command.NowMs, CreatedAtMs: command.NowMs,
UpdatedAtMs: command.NowMs,
} }
if err := insertCoinSellerProfile(ctx, tx, profile); err != nil { if err := insertCoinSellerProfile(ctx, tx, profile); err != nil {
return hostdomain.CoinSellerProfile{}, mapHostDuplicateError(err) return hostdomain.CoinSellerProfile{}, mapHostDuplicateError(err)
@ -257,6 +258,9 @@ func (r *Repository) SetCoinSellerStatus(ctx context.Context, command hostservic
} }
after := before after := before
after.Status = command.Status after.Status = command.Status
if command.CanManageSubCoinSellers != nil {
after.CanManageSubCoinSellers = *command.CanManageSubCoinSellers
}
after.UpdatedAtMs = command.NowMs after.UpdatedAtMs = command.NowMs
if err := updateCoinSellerProfileStatus(ctx, tx, after); err != nil { if err := updateCoinSellerProfileStatus(ctx, tx, after); err != nil {
return hostdomain.CoinSellerProfile{}, err return hostdomain.CoinSellerProfile{}, err

View File

@ -0,0 +1,406 @@
package host
import (
"context"
"database/sql"
"fmt"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
hostdomain "hyapp/services/user-service/internal/domain/host"
hostservice "hyapp/services/user-service/internal/service/host"
)
// CreateSubCoinSeller 直开或绑定直属子币商,并把“创建币商身份”和“创建直属关系”放进同一事务。
func (r *Repository) CreateSubCoinSeller(ctx context.Context, command hostservice.CreateSubCoinSellerCommand) (hostdomain.CreateSubCoinSellerResult, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return hostdomain.CreateSubCoinSellerResult{}, err
}
defer tx.Rollback()
if existing, ok, err := commandResult(ctx, tx, command.CommandID); err != nil {
return hostdomain.CreateSubCoinSellerResult{}, err
} else if ok {
if err := requireCommandResult(existing, hostdomain.CommandTypeCreateSubCoinSeller, hostdomain.ResultTypeCoinSellerRelation); err != nil {
return hostdomain.CreateSubCoinSellerResult{}, err
}
relation, err := queryCoinSellerRelation(ctx, tx, "WHERE relation_id = ?", existing.ResultID)
if err != nil {
return hostdomain.CreateSubCoinSellerResult{}, err
}
child, err := coinSellerListItemByUser(ctx, tx, relation.ChildUserID)
if err != nil {
return hostdomain.CreateSubCoinSellerResult{}, err
}
return hostdomain.CreateSubCoinSellerResult{Relation: relation, Child: child}, nil
}
parentRegionID, parentProfile, err := r.requireActiveCoinSellerSubManager(ctx, tx, command.ParentUserID, true)
if err != nil {
return hostdomain.CreateSubCoinSellerResult{}, err
}
childRegionID, err := r.userRegion(ctx, tx, command.ChildUserID, "FOR UPDATE")
if err != nil {
return hostdomain.CreateSubCoinSellerResult{}, err
}
if parentRegionID != childRegionID {
// 父子币商库存只允许在同区域内流转,避免跨区域库存转移绕过后台区域治理。
return hostdomain.CreateSubCoinSellerResult{}, xerr.New(xerr.RegionMismatch, "child region does not match parent region")
}
if err := r.ensureCoinSellerRelationHasNoCycle(ctx, tx, command.ParentUserID, command.ChildUserID); err != nil {
return hostdomain.CreateSubCoinSellerResult{}, err
}
if existingParent, ok, err := queryActiveCoinSellerParent(ctx, tx, command.ChildUserID, true); err != nil {
return hostdomain.CreateSubCoinSellerResult{}, err
} else if ok {
if existingParent.ParentUserID == command.ParentUserID {
return hostdomain.CreateSubCoinSellerResult{}, xerr.New(xerr.Conflict, "child coin seller relation already exists")
}
return hostdomain.CreateSubCoinSellerResult{}, xerr.New(xerr.Conflict, "child coin seller already has active parent")
}
childProfile, ok, err := queryCoinSellerProfileByUserMaybe(ctx, tx, command.ChildUserID, true)
if err != nil {
return hostdomain.CreateSubCoinSellerResult{}, err
}
profileCreated := false
if !ok {
// 搜索直开只补齐“从未是币商”的目标;被后台停用的币商不能被父币商从 H5 重新启用。
childProfile = hostdomain.CoinSellerProfile{
UserID: command.ChildUserID,
Status: hostdomain.CoinSellerStatusActive,
MerchantAssetType: hostdomain.CoinSellerMerchantAssetType,
CanManageSubCoinSellers: true,
CreatedByUserID: command.ParentUserID,
CreatedAtMs: command.NowMs,
UpdatedAtMs: command.NowMs,
}
if err := insertCoinSellerProfile(ctx, tx, childProfile); err != nil {
return hostdomain.CreateSubCoinSellerResult{}, mapHostDuplicateError(err)
}
profileCreated = true
} else if childProfile.Status != hostdomain.CoinSellerStatusActive || childProfile.MerchantAssetType != hostdomain.CoinSellerMerchantAssetType {
return hostdomain.CreateSubCoinSellerResult{}, xerr.New(xerr.PermissionDenied, "child coin seller is not active")
}
relation := hostdomain.CoinSellerSubRelation{
RelationID: command.RelationID,
ParentUserID: command.ParentUserID,
ChildUserID: command.ChildUserID,
Status: hostdomain.CoinSellerRelationStatusActive,
CreatedByUserID: parentProfile.UserID,
CreatedAtMs: command.NowMs,
UpdatedAtMs: command.NowMs,
}
if err := insertCoinSellerRelation(ctx, tx, relation); err != nil {
return hostdomain.CreateSubCoinSellerResult{}, mapHostDuplicateError(err)
}
if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{CommandID: command.CommandID, CommandType: hostdomain.CommandTypeCreateSubCoinSeller, ResultType: hostdomain.ResultTypeCoinSellerRelation, ResultID: relation.RelationID, CreatedAtMs: command.NowMs}); err != nil {
return hostdomain.CreateSubCoinSellerResult{}, mapHostDuplicateError(err)
}
if profileCreated {
if err := insertHostOutbox(ctx, tx, command.EventID, "CoinSellerCreated", "coin_seller_profile", childProfile.UserID, command.NowMs); err != nil {
return hostdomain.CreateSubCoinSellerResult{}, err
}
}
if err := insertHostOutbox(ctx, tx, command.EventID+1, "CoinSellerSubRelationCreated", "coin_seller_relation", relation.RelationID, command.NowMs); err != nil {
return hostdomain.CreateSubCoinSellerResult{}, err
}
if err := tx.Commit(); err != nil {
return hostdomain.CreateSubCoinSellerResult{}, err
}
child, err := coinSellerListItemByUser(ctx, r.db, relation.ChildUserID)
if err != nil {
return hostdomain.CreateSubCoinSellerResult{}, err
}
return hostdomain.CreateSubCoinSellerResult{Relation: relation, Child: child}, nil
}
// ListSubCoinSellers 返回直属子币商列表;读取前仍校验父币商当前权限,避免权限关闭后继续泄漏团队列表。
func (r *Repository) ListSubCoinSellers(ctx context.Context, command hostservice.ListSubCoinSellersCommand) (hostdomain.ListSubCoinSellersResult, error) {
if _, _, err := r.requireActiveCoinSellerSubManager(ctx, r.db, command.ParentUserID, false); err != nil {
return hostdomain.ListSubCoinSellersResult{}, err
}
page := command.Page
pageSize := command.PageSize
var total int64
if err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM coin_seller_relations relation
INNER JOIN coin_seller_profiles seller
ON seller.app_code = relation.app_code
AND seller.user_id = relation.child_user_id
AND seller.status = ?
AND seller.merchant_asset_type = ?
INNER JOIN users u
ON u.app_code = relation.app_code
AND u.user_id = relation.child_user_id
AND u.status = 'active'
WHERE relation.app_code = ?
AND relation.parent_user_id = ?
AND relation.status = ?
`, hostdomain.CoinSellerStatusActive, hostdomain.CoinSellerMerchantAssetType, appcode.FromContext(ctx), command.ParentUserID, hostdomain.CoinSellerRelationStatusActive).Scan(&total); err != nil {
return hostdomain.ListSubCoinSellersResult{}, err
}
rows, err := r.db.QueryContext(ctx, `
SELECT
u.user_id,
u.current_display_user_id,
COALESCE(u.username, ''),
COALESCE(u.avatar, ''),
COALESCE(country.country_id, 0),
COALESCE(u.country, ''),
COALESCE(country.country_name, ''),
COALESCE(country.country_display_name, ''),
u.region_id,
COALESCE(region.region_code, ''),
COALESCE(region.name, ''),
seller.status,
seller.merchant_asset_type,
COALESCE(NULLIF(u.contact_info, ''), seller.contact_info, ''),
seller.can_manage_sub_coin_sellers,
seller.updated_at_ms
FROM coin_seller_relations relation
INNER JOIN users u
ON u.app_code = relation.app_code
AND u.user_id = relation.child_user_id
AND u.status = 'active'
INNER JOIN coin_seller_profiles seller
ON seller.app_code = relation.app_code
AND seller.user_id = relation.child_user_id
AND seller.status = ?
AND seller.merchant_asset_type = ?
LEFT JOIN countries country
ON country.app_code = u.app_code AND country.country_code = u.country
LEFT JOIN regions region
ON region.app_code = u.app_code AND region.region_id = u.region_id
WHERE relation.app_code = ?
AND relation.parent_user_id = ?
AND relation.status = ?
ORDER BY relation.created_at_ms DESC, relation.relation_id DESC
LIMIT ? OFFSET ?
`, hostdomain.CoinSellerStatusActive, hostdomain.CoinSellerMerchantAssetType, appcode.FromContext(ctx), command.ParentUserID, hostdomain.CoinSellerRelationStatusActive, pageSize, coinSellerListOffset(page, pageSize))
if err != nil {
return hostdomain.ListSubCoinSellersResult{}, err
}
defer rows.Close()
items := make([]hostdomain.CoinSellerListItem, 0, pageSize)
for rows.Next() {
item, err := scanCoinSellerListItem(rows)
if err != nil {
return hostdomain.ListSubCoinSellersResult{}, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return hostdomain.ListSubCoinSellersResult{}, err
}
return hostdomain.ListSubCoinSellersResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
}
// CheckCoinSellerSubRelation 校验父子关系、父级权限、子币商状态和区域一致性,供 gateway 转账前使用。
func (r *Repository) CheckCoinSellerSubRelation(ctx context.Context, command hostservice.CheckCoinSellerSubRelationCommand) (hostdomain.CoinSellerListItem, error) {
parentRegionID, _, err := r.requireActiveCoinSellerSubManager(ctx, r.db, command.ParentUserID, false)
if err != nil {
return hostdomain.CoinSellerListItem{}, err
}
if _, ok, err := queryActiveCoinSellerRelation(ctx, r.db, command.ParentUserID, command.ChildUserID, false); err != nil {
return hostdomain.CoinSellerListItem{}, err
} else if !ok {
return hostdomain.CoinSellerListItem{}, xerr.New(xerr.PermissionDenied, "child coin seller relation is required")
}
child, err := coinSellerListItemByUser(ctx, r.db, command.ChildUserID)
if err != nil {
return hostdomain.CoinSellerListItem{}, err
}
if child.RegionID != parentRegionID {
// 用户区域变更后,即使历史关系仍在,也不能继续转移库存。
return hostdomain.CoinSellerListItem{}, xerr.New(xerr.RegionMismatch, "child region does not match parent region")
}
return child, nil
}
func (r *Repository) requireActiveCoinSellerSubManager(ctx context.Context, q sqlQueryer, userID int64, lock bool) (int64, hostdomain.CoinSellerProfile, error) {
lockClause := ""
if lock {
lockClause = "FOR UPDATE"
}
regionID, err := r.userRegion(ctx, q, userID, lockClause)
if err != nil {
return 0, hostdomain.CoinSellerProfile{}, err
}
if err := requireActiveRegion(ctx, q, regionID); err != nil {
return 0, hostdomain.CoinSellerProfile{}, err
}
profileClause := "WHERE user_id = ?"
if lock {
profileClause += " FOR UPDATE"
}
profile, err := queryCoinSellerProfile(ctx, q, profileClause, userID)
if err != nil {
return 0, hostdomain.CoinSellerProfile{}, err
}
if profile.Status != hostdomain.CoinSellerStatusActive || profile.MerchantAssetType != hostdomain.CoinSellerMerchantAssetType {
return 0, hostdomain.CoinSellerProfile{}, xerr.New(xerr.PermissionDenied, "active coin seller is required")
}
if !profile.CanManageSubCoinSellers {
return 0, hostdomain.CoinSellerProfile{}, xerr.New(xerr.PermissionDenied, "coin seller sub permission is required")
}
return regionID, profile, nil
}
func (r *Repository) ensureCoinSellerRelationHasNoCycle(ctx context.Context, q sqlQueryer, parentUserID int64, childUserID int64) error {
// 每个 active 子币商最多一个父级,所以从 parent 向上爬即可发现 child 是否已在祖先链路中。
seen := map[int64]bool{childUserID: true}
current := parentUserID
for current > 0 {
if seen[current] {
return xerr.New(xerr.Conflict, "coin seller relation cycle is not allowed")
}
seen[current] = true
relation, ok, err := queryActiveCoinSellerParent(ctx, q, current, false)
if err != nil {
return err
}
if !ok {
return nil
}
current = relation.ParentUserID
}
return nil
}
func coinSellerListItemByUser(ctx context.Context, q sqlQueryer, userID int64) (hostdomain.CoinSellerListItem, error) {
row := q.QueryRowContext(ctx, `
SELECT
u.user_id,
u.current_display_user_id,
COALESCE(u.username, ''),
COALESCE(u.avatar, ''),
COALESCE(country.country_id, 0),
COALESCE(u.country, ''),
COALESCE(country.country_name, ''),
COALESCE(country.country_display_name, ''),
u.region_id,
COALESCE(region.region_code, ''),
COALESCE(region.name, ''),
seller.status,
seller.merchant_asset_type,
COALESCE(NULLIF(u.contact_info, ''), seller.contact_info, ''),
seller.can_manage_sub_coin_sellers,
seller.updated_at_ms
FROM users u
INNER JOIN coin_seller_profiles seller
ON seller.app_code = u.app_code
AND seller.user_id = u.user_id
LEFT JOIN countries country
ON country.app_code = u.app_code AND country.country_code = u.country
LEFT JOIN regions region
ON region.app_code = u.app_code AND region.region_id = u.region_id
WHERE u.app_code = ?
AND u.user_id = ?
AND u.status = 'active'
AND seller.status = ?
AND seller.merchant_asset_type = ?
`, appcode.FromContext(ctx), userID, hostdomain.CoinSellerStatusActive, hostdomain.CoinSellerMerchantAssetType)
item, err := scanCoinSellerListItem(row)
if err == sql.ErrNoRows {
return hostdomain.CoinSellerListItem{}, xerr.New(xerr.NotFound, "coin seller not found")
}
return item, err
}
func scanCoinSellerListItem(scanner rowScanner) (hostdomain.CoinSellerListItem, error) {
var item hostdomain.CoinSellerListItem
err := scanner.Scan(
&item.UserID,
&item.DisplayUserID,
&item.Username,
&item.Avatar,
&item.CountryID,
&item.CountryCode,
&item.CountryName,
&item.CountryDisplayName,
&item.RegionID,
&item.RegionCode,
&item.RegionName,
&item.Status,
&item.MerchantAssetType,
&item.ContactInfo,
&item.CanManageSubCoinSellers,
&item.UpdatedAtMs,
)
return item, err
}
func queryCoinSellerRelation(ctx context.Context, q sqlQueryer, clause string, args ...any) (hostdomain.CoinSellerSubRelation, error) {
clause, args = appScopedClause(ctx, clause, args...)
relation, err := scanCoinSellerRelation(q.QueryRowContext(ctx, fmt.Sprintf(`
SELECT relation_id, parent_user_id, child_user_id, status, created_by_user_id, created_at_ms, updated_at_ms
FROM coin_seller_relations %s`, clause), args...))
if err == sql.ErrNoRows {
return hostdomain.CoinSellerSubRelation{}, xerr.New(xerr.NotFound, "coin seller relation not found")
}
return relation, err
}
func queryActiveCoinSellerParent(ctx context.Context, q sqlQueryer, childUserID int64, lock bool) (hostdomain.CoinSellerSubRelation, bool, error) {
clause := "WHERE child_user_id = ? AND status = ?"
if lock {
clause += " FOR UPDATE"
}
clause, args := appScopedClause(ctx, clause, childUserID, hostdomain.CoinSellerRelationStatusActive)
relation, err := scanCoinSellerRelation(q.QueryRowContext(ctx, fmt.Sprintf(`
SELECT relation_id, parent_user_id, child_user_id, status, created_by_user_id, created_at_ms, updated_at_ms
FROM coin_seller_relations %s`, clause), args...))
if err == sql.ErrNoRows {
return hostdomain.CoinSellerSubRelation{}, false, nil
}
if err != nil {
return hostdomain.CoinSellerSubRelation{}, false, err
}
return relation, true, nil
}
func queryActiveCoinSellerRelation(ctx context.Context, q sqlQueryer, parentUserID int64, childUserID int64, lock bool) (hostdomain.CoinSellerSubRelation, bool, error) {
clause := "WHERE parent_user_id = ? AND child_user_id = ? AND status = ?"
if lock {
clause += " FOR UPDATE"
}
clause, args := appScopedClause(ctx, clause, parentUserID, childUserID, hostdomain.CoinSellerRelationStatusActive)
relation, err := scanCoinSellerRelation(q.QueryRowContext(ctx, fmt.Sprintf(`
SELECT relation_id, parent_user_id, child_user_id, status, created_by_user_id, created_at_ms, updated_at_ms
FROM coin_seller_relations %s`, clause), args...))
if err == sql.ErrNoRows {
return hostdomain.CoinSellerSubRelation{}, false, nil
}
if err != nil {
return hostdomain.CoinSellerSubRelation{}, false, err
}
return relation, true, nil
}
func scanCoinSellerRelation(scanner rowScanner) (hostdomain.CoinSellerSubRelation, error) {
var relation hostdomain.CoinSellerSubRelation
err := scanner.Scan(
&relation.RelationID,
&relation.ParentUserID,
&relation.ChildUserID,
&relation.Status,
&relation.CreatedByUserID,
&relation.CreatedAtMs,
&relation.UpdatedAtMs,
)
return relation, err
}
func coinSellerListOffset(page int, pageSize int) int {
if page <= 1 {
return 0
}
return (page - 1) * pageSize
}

View File

@ -29,7 +29,7 @@ func New(db *sql.DB) *Repository {
const ( const (
coinSellerProfileColumns = ` coinSellerProfileColumns = `
user_id, status, merchant_asset_type, user_id, status, merchant_asset_type, can_manage_sub_coin_sellers,
created_by_user_id, created_at_ms, updated_at_ms` created_by_user_id, created_at_ms, updated_at_ms`
) )
@ -148,6 +148,10 @@ func firstNonBlank(values ...string) string {
return "" return ""
} }
func boolValueDefaultTrue(value *bool) bool {
return value == nil || *value
}
func currentUserRegion(ctx context.Context, q sqlQueryer, userID int64, lockClause string) (int64, error) { func currentUserRegion(ctx context.Context, q sqlQueryer, userID int64, lockClause string) (int64, error) {
// Host 领域不拥有 users 表,但所有角色流转都依赖用户当前有效区域。 // Host 领域不拥有 users 表,但所有角色流转都依赖用户当前有效区域。
// 调用方需要串行化用户身份变化时传入 FOR UPDATE 来锁住 users 行。 // 调用方需要串行化用户身份变化时传入 FOR UPDATE 来锁住 users 行。

View File

@ -184,6 +184,7 @@ func (r *Repository) ListActiveCoinSellersInMyRegion(ctx context.Context, userID
seller.status, seller.status,
seller.merchant_asset_type, seller.merchant_asset_type,
COALESCE(NULLIF(u.contact_info, ''), seller.contact_info, ''), COALESCE(NULLIF(u.contact_info, ''), seller.contact_info, ''),
seller.can_manage_sub_coin_sellers,
seller.updated_at_ms seller.updated_at_ms
FROM users u FROM users u
INNER JOIN coin_seller_profiles seller INNER JOIN coin_seller_profiles seller
@ -222,6 +223,7 @@ func (r *Repository) ListActiveCoinSellersInMyRegion(ctx context.Context, userID
&item.Status, &item.Status,
&item.MerchantAssetType, &item.MerchantAssetType,
&item.ContactInfo, &item.ContactInfo,
&item.CanManageSubCoinSellers,
&item.UpdatedAtMs, &item.UpdatedAtMs,
); err != nil { ); err != nil {
return nil, err return nil, err
@ -455,6 +457,131 @@ func (r *Repository) ListBDAgencies(ctx context.Context, command hostservice.Lis
return queryAgencies(ctx, r.db, clause, args...) return queryAgencies(ctx, r.db, clause, args...)
} }
// ListManagerTeamAgencies 读取经理整棵团队树内的有效 Agency 归属投影:
// 经理 → bd_leader_profiles.created_by_user_id → bd_profiles.parent_leader_user_id → agencies.parent_bd_user_id。
// 逐层先解析出上级 ID 列表再用单个 IN 查询 Agency避免 OR + 相关子查询让优化器放弃
// idx_agencies_parent_bd 索引退化成整租户扫描;投影只取归属三列,不带成员数/区域等每行相关子查询。
func (r *Repository) ListManagerTeamAgencies(ctx context.Context, command hostservice.ListManagerTeamAgenciesCommand) (hostdomain.ManagerTeamAgencies, error) {
// 经理身份停用后不能继续读取团队数据,与 BD Leader 列表接口的权限语义保持一致。
active, err := r.HasActiveManagerProfile(ctx, command.ManagerUserID)
if err != nil {
return hostdomain.ManagerTeamAgencies{}, err
}
if !active {
return hostdomain.ManagerTeamAgencies{}, xerr.New(xerr.PermissionDenied, "manager is not active")
}
app := appcode.FromContext(ctx)
result := hostdomain.ManagerTeamAgencies{Agencies: []hostdomain.ManagerTeamAgency{}}
// 上级链路每层都带截断上限:正常团队远小于该值,超限用 Truncated 显式标记而不是静默丢数据。
leaderIDs, leadersTruncated, err := r.listManagerTeamUserIDs(ctx, `
SELECT user_id
FROM bd_leader_profiles
WHERE app_code = ? AND created_by_user_id = ? AND status = ?
ORDER BY user_id ASC
LIMIT ?`, command.PageSize, app, command.ManagerUserID, hostdomain.BDStatusActive)
if err != nil {
return hostdomain.ManagerTeamAgencies{}, err
}
result.TotalBDLeaders = len(leaderIDs)
result.Truncated = leadersTruncated
if len(leaderIDs) == 0 {
// 没有任何 BD Leader 时团队为空,直接返回零值统计。
return result, nil
}
bdArgs := make([]any, 0, len(leaderIDs)+3)
bdArgs = append(bdArgs, app, hostdomain.BDRoleBD, hostdomain.BDStatusActive)
for _, id := range leaderIDs {
bdArgs = append(bdArgs, id)
}
bdIDs, bdsTruncated, err := r.listManagerTeamUserIDs(ctx, `
SELECT user_id
FROM bd_profiles
WHERE app_code = ? AND role = ? AND status = ?
AND parent_leader_user_id IN (`+placeholderList(len(leaderIDs))+`)
ORDER BY user_id ASC
LIMIT ?`, command.PageSize, bdArgs...)
if err != nil {
return hostdomain.ManagerTeamAgencies{}, err
}
result.TotalBDs = len(bdIDs)
result.Truncated = result.Truncated || bdsTruncated
// Agency 既可能直接挂 BD Leader 也可能挂下属 BD合并成一个父级集合后单索引路径查询。
parentIDs := append(append(make([]int64, 0, len(leaderIDs)+len(bdIDs)), leaderIDs...), bdIDs...)
agencyArgs := make([]any, 0, len(parentIDs)+3)
agencyArgs = append(agencyArgs, app, hostdomain.AgencyStatusActive)
for _, id := range parentIDs {
agencyArgs = append(agencyArgs, id)
}
agencyArgs = append(agencyArgs, command.PageSize+1)
rows, err := r.db.QueryContext(ctx, `
SELECT agency_id, owner_user_id, COALESCE(parent_bd_user_id, 0)
FROM agencies
WHERE app_code = ? AND status = ?
AND parent_bd_user_id IN (`+placeholderList(len(parentIDs))+`)
ORDER BY agency_id ASC
LIMIT ?`, agencyArgs...)
if err != nil {
return hostdomain.ManagerTeamAgencies{}, err
}
defer rows.Close()
for rows.Next() {
var agency hostdomain.ManagerTeamAgency
if err := rows.Scan(&agency.AgencyID, &agency.OwnerUserID, &agency.ParentBDUserID); err != nil {
return hostdomain.ManagerTeamAgencies{}, err
}
result.Agencies = append(result.Agencies, agency)
}
if err := rows.Err(); err != nil {
return hostdomain.ManagerTeamAgencies{}, err
}
if len(result.Agencies) > command.PageSize {
result.Truncated = true
result.Agencies = result.Agencies[:command.PageSize]
}
return result, nil
}
// listManagerTeamUserIDs 执行“取上限 limit+1 行识别截断”的 ID 列表查询;
// query 末尾必须带 LIMIT ? 占位,本函数负责追加 limit+1 参数并返回截断后的 ID 集合和截断标记。
func (r *Repository) listManagerTeamUserIDs(ctx context.Context, query string, limit int, args ...any) ([]int64, bool, error) {
rows, err := r.db.QueryContext(ctx, query, append(args, limit+1)...)
if err != nil {
return nil, false, err
}
defer rows.Close()
ids := make([]int64, 0, limit)
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, false, err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, false, err
}
if len(ids) > limit {
return ids[:limit], true, nil
}
return ids, false, nil
}
// placeholderList 生成 IN 子句占位符;调用方保证 count > 0。
func placeholderList(count int) string {
if count <= 0 {
return "NULL"
}
out := make([]byte, 0, count*2-1)
for i := 0; i < count; i++ {
if i > 0 {
out = append(out, ',')
}
out = append(out, '?')
}
return string(out)
}
// ListRoleInvitations 读取目标用户收到的角色邀请消息;默认由业务层传入 pending 状态用于消息中心。 // ListRoleInvitations 读取目标用户收到的角色邀请消息;默认由业务层传入 pending 状态用于消息中心。
func (r *Repository) ListRoleInvitations(ctx context.Context, command hostservice.ListRoleInvitationsCommand) ([]hostdomain.RoleInvitation, error) { func (r *Repository) ListRoleInvitations(ctx context.Context, command hostservice.ListRoleInvitationsCommand) ([]hostdomain.RoleInvitation, error) {
query := fmt.Sprintf(`SELECT %s FROM role_invitations WHERE app_code = ? AND target_user_id = ?`, roleInvitationColumns) query := fmt.Sprintf(`SELECT %s FROM role_invitations WHERE app_code = ? AND target_user_id = ?`, roleInvitationColumns)
@ -809,6 +936,7 @@ func scanCoinSellerProfile(scanner rowScanner) (hostdomain.CoinSellerProfile, er
&profile.UserID, &profile.UserID,
&profile.Status, &profile.Status,
&profile.MerchantAssetType, &profile.MerchantAssetType,
&profile.CanManageSubCoinSellers,
&profile.CreatedByUserID, &profile.CreatedByUserID,
&profile.CreatedAtMs, &profile.CreatedAtMs,
&profile.UpdatedAtMs, &profile.UpdatedAtMs,

View File

@ -345,10 +345,10 @@ func insertCoinSellerProfile(ctx context.Context, tx *sql.Tx, profile hostdomain
// 币商身份是独立角色事实;账户余额属于 wallet-service不在此表冗余。 // 币商身份是独立角色事实;账户余额属于 wallet-service不在此表冗余。
_, err := tx.ExecContext(ctx, ` _, err := tx.ExecContext(ctx, `
INSERT INTO coin_seller_profiles ( INSERT INTO coin_seller_profiles (
app_code, user_id, status, merchant_asset_type, app_code, user_id, status, merchant_asset_type, can_manage_sub_coin_sellers,
created_by_user_id, created_at_ms, updated_at_ms created_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), profile.UserID, profile.Status, profile.MerchantAssetType, profile.CreatedByUserID, profile.CreatedAtMs, profile.UpdatedAtMs) `, appcode.FromContext(ctx), profile.UserID, profile.Status, profile.MerchantAssetType, profile.CanManageSubCoinSellers, profile.CreatedByUserID, profile.CreatedAtMs, profile.UpdatedAtMs)
return err return err
} }
@ -356,9 +356,20 @@ func updateCoinSellerProfileStatus(ctx context.Context, tx *sql.Tx, profile host
// 启停币商身份只改状态merchant_asset_type 是账户边界,创建后不能静默迁移。 // 启停币商身份只改状态merchant_asset_type 是账户边界,创建后不能静默迁移。
_, err := tx.ExecContext(ctx, ` _, err := tx.ExecContext(ctx, `
UPDATE coin_seller_profiles UPDATE coin_seller_profiles
SET status = ?, updated_at_ms = ? SET status = ?, can_manage_sub_coin_sellers = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? WHERE app_code = ? AND user_id = ?
`, profile.Status, profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID) `, profile.Status, profile.CanManageSubCoinSellers, profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID)
return err
}
func insertCoinSellerRelation(ctx context.Context, tx *sql.Tx, relation hostdomain.CoinSellerSubRelation) error {
// 直属关系是 H5 子币商转账的授权边界;唯一键保证一个子币商同一时间只有一个有效父币商。
_, err := tx.ExecContext(ctx, `
INSERT INTO coin_seller_relations (
app_code, relation_id, parent_user_id, child_user_id, status,
created_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), relation.RelationID, relation.ParentUserID, relation.ChildUserID, relation.Status, relation.CreatedByUserID, relation.CreatedAtMs, relation.UpdatedAtMs)
return err return err
} }

View File

@ -0,0 +1,29 @@
package grpc
import (
"testing"
hostservice "hyapp/services/user-service/internal/service/host"
userservice "hyapp/services/user-service/internal/service/user"
)
func TestCapabilityForBusinessLookupSceneUsesExactManagerCapability(t *testing.T) {
for _, item := range []struct {
scene string
capability string
}{
{scene: userservice.BusinessSceneManagerResourceGrant, capability: hostservice.CapabilityManagerResourceGrant},
{scene: userservice.BusinessSceneManagerVIPGrant, capability: hostservice.CapabilityManagerGrantVIP},
{scene: userservice.BusinessSceneManagerUpdateLevel, capability: hostservice.CapabilityManagerUpdateUserLevel},
{scene: userservice.BusinessSceneManagerAddBDLeader, capability: hostservice.CapabilityManagerAddBDLeader},
{scene: userservice.BusinessSceneManagerAddAdmin, capability: hostservice.CapabilityManagerAddAdmin},
{scene: userservice.BusinessSceneManagerAddSuperadmin, capability: hostservice.CapabilityManagerAddSuperadmin},
{scene: userservice.BusinessSceneManagerBlockUser, capability: hostservice.CapabilityManagerBlockUser},
{scene: userservice.BusinessSceneManagerTransfer, capability: hostservice.CapabilityManagerTransferCountry},
} {
capability, ok := capabilityForBusinessLookupScene(item.scene)
if !ok || capability != item.capability {
t.Fatalf("scene %q mapped to capability=%q ok=%v, want %q", item.scene, capability, ok, item.capability)
}
}
}

View File

@ -463,12 +463,13 @@ func toProtoCoinSellerProfile(profile hostdomain.CoinSellerProfile) *userv1.Coin
return nil return nil
} }
return &userv1.CoinSellerProfile{ return &userv1.CoinSellerProfile{
UserId: profile.UserID, UserId: profile.UserID,
Status: profile.Status, Status: profile.Status,
MerchantAssetType: profile.MerchantAssetType, MerchantAssetType: profile.MerchantAssetType,
CreatedByUserId: profile.CreatedByUserID, CanManageSubCoinSellers: profile.CanManageSubCoinSellers,
CreatedAtMs: profile.CreatedAtMs, CreatedByUserId: profile.CreatedByUserID,
UpdatedAtMs: profile.UpdatedAtMs, CreatedAtMs: profile.CreatedAtMs,
UpdatedAtMs: profile.UpdatedAtMs,
} }
} }
@ -478,21 +479,38 @@ func toProtoCoinSellerListItem(item hostdomain.CoinSellerListItem) *userv1.CoinS
return nil return nil
} }
return &userv1.CoinSellerListItem{ return &userv1.CoinSellerListItem{
UserId: item.UserID, UserId: item.UserID,
DisplayUserId: item.DisplayUserID, DisplayUserId: item.DisplayUserID,
Username: item.Username, Username: item.Username,
Avatar: item.Avatar, Avatar: item.Avatar,
CountryId: item.CountryID, CountryId: item.CountryID,
CountryCode: item.CountryCode, CountryCode: item.CountryCode,
CountryName: item.CountryName, CountryName: item.CountryName,
CountryDisplayName: item.CountryDisplayName, CountryDisplayName: item.CountryDisplayName,
RegionId: item.RegionID, RegionId: item.RegionID,
RegionCode: item.RegionCode, RegionCode: item.RegionCode,
RegionName: item.RegionName, RegionName: item.RegionName,
Status: item.Status, Status: item.Status,
MerchantAssetType: item.MerchantAssetType, MerchantAssetType: item.MerchantAssetType,
ContactInfo: item.ContactInfo, ContactInfo: item.ContactInfo,
UpdatedAtMs: item.UpdatedAtMs, CanManageSubCoinSellers: item.CanManageSubCoinSellers,
UpdatedAtMs: item.UpdatedAtMs,
}
}
// toProtoCoinSellerSubRelation 把父子币商关系事实转换为内部 gRPC 投影。
func toProtoCoinSellerSubRelation(relation hostdomain.CoinSellerSubRelation) *userv1.CoinSellerSubRelation {
if relation.RelationID == 0 {
return nil
}
return &userv1.CoinSellerSubRelation{
RelationId: relation.RelationID,
ParentUserId: relation.ParentUserID,
ChildUserId: relation.ChildUserID,
Status: relation.Status,
CreatedByUserId: relation.CreatedByUserID,
CreatedAtMs: relation.CreatedAtMs,
UpdatedAtMs: relation.UpdatedAtMs,
} }
} }

View File

@ -222,6 +222,36 @@ func (s *Server) ListBDAgencies(ctx context.Context, req *userv1.ListBDAgenciesR
return resp, nil return resp, nil
} }
// ListManagerTeamAgencies 返回经理团队树内全部有效 Agency经理停用时由业务层返回 PermissionDenied。
func (s *Server) ListManagerTeamAgencies(ctx context.Context, req *userv1.ListManagerTeamAgenciesRequest) (*userv1.ListManagerTeamAgenciesResponse, error) {
if s.hostSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))
}
ctx = contextWithApp(ctx, req.GetMeta())
result, err := s.hostSvc.ListManagerTeamAgencies(ctx, hostservice.ListManagerTeamAgenciesCommand{
ManagerUserID: req.GetManagerUserId(),
PageSize: int(req.GetPageSize()),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &userv1.ListManagerTeamAgenciesResponse{
Agencies: make([]*userv1.ManagerTeamAgency, 0, len(result.Agencies)),
TotalBdLeaders: int32(result.TotalBDLeaders),
TotalBds: int32(result.TotalBDs),
Truncated: result.Truncated,
}
for _, agency := range result.Agencies {
resp.Agencies = append(resp.Agencies, &userv1.ManagerTeamAgency{
AgencyId: agency.AgencyID,
OwnerUserId: agency.OwnerUserID,
ParentBdUserId: agency.ParentBDUserID,
})
}
return resp, nil
}
// ListRoleInvitations 返回目标用户收到的角色邀请消息。 // ListRoleInvitations 返回目标用户收到的角色邀请消息。
func (s *Server) ListRoleInvitations(ctx context.Context, req *userv1.ListRoleInvitationsRequest) (*userv1.ListRoleInvitationsResponse, error) { func (s *Server) ListRoleInvitations(ctx context.Context, req *userv1.ListRoleInvitationsRequest) (*userv1.ListRoleInvitationsResponse, error) {
if s.hostSvc == nil { if s.hostSvc == nil {
@ -365,6 +395,69 @@ func (s *Server) ListActiveCoinSellersInMyRegion(ctx context.Context, req *userv
return resp, nil return resp, nil
} }
// CreateSubCoinSeller 处理父币商在 H5 币商中心搜索直开直属子币商。
func (s *Server) CreateSubCoinSeller(ctx context.Context, req *userv1.CreateSubCoinSellerRequest) (*userv1.CreateSubCoinSellerResponse, error) {
if s.hostSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))
}
ctx = contextWithApp(ctx, req.GetMeta())
result, err := s.hostSvc.CreateSubCoinSeller(ctx, hostservice.CreateSubCoinSellerInput{
CommandID: req.GetCommandId(),
ParentUserID: req.GetParentUserId(),
ChildUserID: req.GetChildUserId(),
RequestID: req.GetMeta().GetRequestId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.CreateSubCoinSellerResponse{
Relation: toProtoCoinSellerSubRelation(result.Relation),
Child: toProtoCoinSellerListItem(result.Child),
}, nil
}
// ListSubCoinSellers 返回父币商直属子币商分页列表。
func (s *Server) ListSubCoinSellers(ctx context.Context, req *userv1.ListSubCoinSellersRequest) (*userv1.ListSubCoinSellersResponse, error) {
if s.hostSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))
}
ctx = contextWithApp(ctx, req.GetMeta())
result, err := s.hostSvc.ListSubCoinSellers(ctx, hostservice.ListSubCoinSellersCommand{
ParentUserID: req.GetParentUserId(),
Page: int(req.GetPage()),
PageSize: int(req.GetPageSize()),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &userv1.ListSubCoinSellersResponse{
Children: make([]*userv1.CoinSellerListItem, 0, len(result.Items)),
Total: result.Total,
Page: int32(result.Page),
PageSize: int32(result.PageSize),
}
for _, item := range result.Items {
resp.Children = append(resp.Children, toProtoCoinSellerListItem(item))
}
return resp, nil
}
// CheckCoinSellerSubRelation 校验父子币商关系和父级权限,供 gateway 子币商库存转账前调用。
func (s *Server) CheckCoinSellerSubRelation(ctx context.Context, req *userv1.CheckCoinSellerSubRelationRequest) (*userv1.CheckCoinSellerSubRelationResponse, error) {
if s.hostSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))
}
ctx = contextWithApp(ctx, req.GetMeta())
child, err := s.hostSvc.CheckCoinSellerSubRelation(ctx, hostservice.CheckCoinSellerSubRelationCommand{
ParentUserID: req.GetParentUserId(),
ChildUserID: req.GetChildUserId(),
})
if err != nil {
return &userv1.CheckCoinSellerSubRelationResponse{Allowed: false, Reason: string(xerr.CodeOf(err))}, xerr.ToGRPCError(err)
}
return &userv1.CheckCoinSellerSubRelationResponse{Allowed: true, Child: toProtoCoinSellerListItem(child)}, nil
}
// GetUserRoleSummary 返回 App 入口显隐所需的角色摘要,替代 gateway 多角色 fanout。 // GetUserRoleSummary 返回 App 入口显隐所需的角色摘要,替代 gateway 多角色 fanout。
func (s *Server) GetUserRoleSummary(ctx context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error) { func (s *Server) GetUserRoleSummary(ctx context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error) {
if s.hostSvc == nil { if s.hostSvc == nil {

View File

@ -76,11 +76,12 @@ func (s *Server) CreateCoinSeller(ctx context.Context, req *userv1.CreateCoinSel
} }
ctx = contextWithApp(ctx, req.GetMeta()) ctx = contextWithApp(ctx, req.GetMeta())
profile, err := s.hostSvc.CreateCoinSeller(ctx, hostservice.CreateCoinSellerInput{ profile, err := s.hostSvc.CreateCoinSeller(ctx, hostservice.CreateCoinSellerInput{
CommandID: req.GetCommandId(), CommandID: req.GetCommandId(),
AdminUserID: req.GetAdminUserId(), AdminUserID: req.GetAdminUserId(),
TargetUserID: req.GetTargetUserId(), TargetUserID: req.GetTargetUserId(),
Reason: req.GetReason(), Reason: req.GetReason(),
RequestID: req.GetMeta().GetRequestId(), RequestID: req.GetMeta().GetRequestId(),
CanManageSubCoinSellers: req.CanManageSubCoinSellers,
}) })
if err != nil { if err != nil {
return nil, xerr.ToGRPCError(err) return nil, xerr.ToGRPCError(err)
@ -95,12 +96,13 @@ func (s *Server) SetCoinSellerStatus(ctx context.Context, req *userv1.SetCoinSel
} }
ctx = contextWithApp(ctx, req.GetMeta()) ctx = contextWithApp(ctx, req.GetMeta())
profile, err := s.hostSvc.SetCoinSellerStatus(ctx, hostservice.SetCoinSellerStatusInput{ profile, err := s.hostSvc.SetCoinSellerStatus(ctx, hostservice.SetCoinSellerStatusInput{
CommandID: req.GetCommandId(), CommandID: req.GetCommandId(),
AdminUserID: req.GetAdminUserId(), AdminUserID: req.GetAdminUserId(),
TargetUserID: req.GetTargetUserId(), TargetUserID: req.GetTargetUserId(),
Status: req.GetStatus(), Status: req.GetStatus(),
Reason: req.GetReason(), Reason: req.GetReason(),
RequestID: req.GetMeta().GetRequestId(), RequestID: req.GetMeta().GetRequestId(),
CanManageSubCoinSellers: req.CanManageSubCoinSellers,
}) })
if err != nil { if err != nil {
return nil, xerr.ToGRPCError(err) return nil, xerr.ToGRPCError(err)

View File

@ -410,6 +410,18 @@ func capabilityForBusinessLookupScene(scene string) (string, bool) {
return hostservice.CapabilityManagerResourceGrant, true return hostservice.CapabilityManagerResourceGrant, true
case userservice.BusinessSceneManagerVIPGrant: case userservice.BusinessSceneManagerVIPGrant:
return hostservice.CapabilityManagerGrantVIP, true return hostservice.CapabilityManagerGrantVIP, true
case userservice.BusinessSceneManagerUpdateLevel:
return hostservice.CapabilityManagerUpdateUserLevel, true
case userservice.BusinessSceneManagerAddBDLeader:
return hostservice.CapabilityManagerAddBDLeader, true
case userservice.BusinessSceneManagerAddAdmin:
return hostservice.CapabilityManagerAddAdmin, true
case userservice.BusinessSceneManagerAddSuperadmin:
return hostservice.CapabilityManagerAddSuperadmin, true
case userservice.BusinessSceneManagerBlockUser:
return hostservice.CapabilityManagerBlockUser, true
case userservice.BusinessSceneManagerTransfer:
return hostservice.CapabilityManagerTransferCountry, true
default: default:
return "", false return "", false
} }

View File

@ -159,9 +159,21 @@ CREATE TABLE IF NOT EXISTS host_period_diamond_accounts (
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, user_id, cycle_key), PRIMARY KEY (app_code, user_id, cycle_key),
KEY idx_host_period_diamond_region_cycle (app_code, region_id, cycle_key), KEY idx_host_period_diamond_region_cycle (app_code, region_id, cycle_key),
KEY idx_host_period_diamond_cycle_total (app_code, cycle_key, total_diamonds) KEY idx_host_period_diamond_cycle_total (app_code, cycle_key, total_diamonds),
KEY idx_host_period_diamond_cycle_owner (app_code, cycle_key, agency_owner_user_id, total_diamonds)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播工资周期钻石账户表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播工资周期钻石账户表';
-- 经理中心团队工资统计按 (cycle_key, agency_owner_user_id) 圈定团队主播;
-- 存量环境缺该索引时聚合会退化为整周期扫描,这里幂等补建。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_period_diamond_accounts' AND INDEX_NAME = 'idx_host_period_diamond_cycle_owner') = 0,
'ALTER TABLE host_period_diamond_accounts ADD INDEX idx_host_period_diamond_cycle_owner (app_code, cycle_key, agency_owner_user_id, total_diamonds)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 主播工资周期钻石流水:每笔成功送礼只在 target 为 active host 时写入,用于审计和补偿重建周期账户。 -- 主播工资周期钻石流水:每笔成功送礼只在 target 为 active host 时写入,用于审计和补偿重建周期账户。
CREATE TABLE IF NOT EXISTS host_period_diamond_entries ( CREATE TABLE IF NOT EXISTS host_period_diamond_entries (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',

View File

@ -32,6 +32,28 @@ type CoinSellerTransferReceipt struct {
RechargePolicyUSDMinorUnit int64 RechargePolicyUSDMinorUnit int64
} }
// CoinSellerSubTransferCommand 是父币商向直属子币商转移 COIN_SELLER_COIN 库存的账务命令。
type CoinSellerSubTransferCommand struct {
AppCode string
CommandID string
ParentUserID int64
ChildUserID int64
RegionID int64
Amount int64
Reason string
}
// CoinSellerSubTransferReceipt 返回父子币商库存转账后的双边余额。
type CoinSellerSubTransferReceipt struct {
TransactionID string
ParentUserID int64
ChildUserID int64
ParentBalanceAfter int64
ChildBalanceAfter int64
Amount int64
CreatedAtMS int64
}
// CoinSellerSalaryExchangeRateTier 是工资转给币商时按区域和美元金额匹配的金币兑换比例。 // CoinSellerSalaryExchangeRateTier 是工资转给币商时按区域和美元金额匹配的金币兑换比例。
type CoinSellerSalaryExchangeRateTier struct { type CoinSellerSalaryExchangeRateTier struct {
RegionID int64 RegionID int64

View File

@ -62,3 +62,25 @@ type HostSalaryProgress struct {
GiftDiamondTotal int64 GiftDiamondTotal int64
UpdatedAtMS int64 UpdatedAtMS int64
} }
// TeamHostSalaryStatsQuery 按 Agency 收款人集合聚合主播工资统计的查询条件。
// 收款人集合由调用方gateway从 user-service 组织树解析wallet 只按周期账户上的 agency_owner_user_id 快照聚合。
type TeamHostSalaryStatsQuery struct {
AgencyOwnerUserIDs []int64
CycleKeys []string
// NowMs 用于解析各区域当前生效的工资政策做档位估算。
NowMs int64
}
// TeamHostSalaryCycleStat 是一组 Agency 收款人名下主播在单个工资周期的工资统计。
// Estimated 是“预收入”口径:按当前累计钻石命中的档位累计工资 + 超出档位的剩余钻石按月底费率折美元,
// 与已结算累计取大;已关闭周期读出来即真实发放值,进行中周期是“立刻月结能拿多少”的估算。
type TeamHostSalaryCycleStat struct {
CycleKey string
EstimatedHostSalaryUSDMinor int64
// SettledHostSalaryUSDMinor 含月底剩余钻石折美元部分,等于该周期实际入账主播工资钱包的美元累计。
SettledHostSalaryUSDMinor int64
// ActiveHostCount 统计该周期内累计钻石大于 0 的主播数。
ActiveHostCount int
TotalDiamonds int64
}

View File

@ -84,6 +84,33 @@ func (s *Service) TransferCoinFromSeller(ctx context.Context, command ledger.Coi
return s.repository.TransferCoinFromSeller(ctx, command) return s.repository.TransferCoinFromSeller(ctx, command)
} }
// TransferCoinSellerStockToChild 执行父子币商专用金币库存转账;父子关系和权限由 gateway 在调用前校验。
func (s *Service) TransferCoinSellerStockToChild(ctx context.Context, command ledger.CoinSellerSubTransferCommand) (ledger.CoinSellerSubTransferReceipt, error) {
if command.CommandID == "" || command.ParentUserID <= 0 || command.ChildUserID <= 0 || command.Amount <= 0 {
return ledger.CoinSellerSubTransferReceipt{}, xerr.New(xerr.InvalidArgument, "coin seller sub transfer command is incomplete")
}
if command.ParentUserID == command.ChildUserID {
return ledger.CoinSellerSubTransferReceipt{}, xerr.New(xerr.InvalidArgument, "parent and child must be different")
}
if command.RegionID <= 0 {
return ledger.CoinSellerSubTransferReceipt{}, xerr.New(xerr.InvalidArgument, "region_id is required")
}
command.Reason = strings.TrimSpace(command.Reason)
if command.Reason == "" {
command.Reason = "coin seller sub transfer"
}
if len(command.CommandID) > 128 || len(command.Reason) > 512 {
return ledger.CoinSellerSubTransferReceipt{}, xerr.New(xerr.InvalidArgument, "coin seller sub transfer text fields are too long")
}
if s.repository == nil {
return ledger.CoinSellerSubTransferReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.TransferCoinSellerStockToChild(ctx, command)
}
// ListCoinSellerSalaryExchangeRateTiers 返回指定区域的工资转币商比例区间gateway 用它给 H5 展示预计到账金币。 // ListCoinSellerSalaryExchangeRateTiers 返回指定区域的工资转币商比例区间gateway 用它给 H5 展示预计到账金币。
func (s *Service) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, appCode string, regionID int64, includeDisabled bool) ([]ledger.CoinSellerSalaryExchangeRateTier, error) { func (s *Service) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, appCode string, regionID int64, includeDisabled bool) ([]ledger.CoinSellerSalaryExchangeRateTier, error) {
if regionID <= 0 { if regionID <= 0 {

View File

@ -69,6 +69,26 @@ func (s *Service) GetActiveHostSalaryPolicy(ctx context.Context, appCode string,
return s.repository.GetActiveHostSalaryPolicy(ctx, regionID, settlementMode, triggerMode, nowMs) return s.repository.GetActiveHostSalaryPolicy(ctx, regionID, settlementMode, triggerMode, nowMs)
} }
// GetTeamHostSalaryStats 聚合一组 Agency 收款人名下主播的预收入工资,经理中心团队工资卡片使用。
// 收款人集合为空时按空团队返回零值统计,不视为错误;周期数和收款人数设防御上限避免误用打出超大 IN 查询。
func (s *Service) GetTeamHostSalaryStats(ctx context.Context, appCode string, query ledger.TeamHostSalaryStatsQuery) ([]ledger.TeamHostSalaryCycleStat, error) {
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
if len(query.CycleKeys) == 0 || len(query.CycleKeys) > 12 {
return nil, xerr.New(xerr.InvalidArgument, "cycle_keys must contain 1 to 12 entries")
}
if len(query.AgencyOwnerUserIDs) > 10000 {
return nil, xerr.New(xerr.InvalidArgument, "agency_owner_user_ids is too large")
}
if query.NowMs <= 0 {
query.NowMs = s.now().UTC().UnixMilli()
}
appCode = appcode.Normalize(appCode)
ctx = appcode.WithContext(ctx, appCode)
return s.repository.GetTeamHostSalaryStats(ctx, query)
}
// GetHostSalaryProgress 返回主播当前工资周期的钻石累计;没有收礼账户时返回 0避免 H5 用其他等级系统兜底导致进度口径错误。 // GetHostSalaryProgress 返回主播当前工资周期的钻石累计;没有收礼账户时返回 0避免 H5 用其他等级系统兜底导致进度口径错误。
func (s *Service) GetHostSalaryProgress(ctx context.Context, appCode string, userID int64, cycleKey string, nowMs int64) (ledger.HostSalaryProgress, error) { func (s *Service) GetHostSalaryProgress(ctx context.Context, appCode string, userID int64, cycleKey string, nowMs int64) (ledger.HostSalaryProgress, error) {
if userID <= 0 { if userID <= 0 {

View File

@ -54,6 +54,7 @@ type BalanceStore interface {
type HostSalaryStore interface { type HostSalaryStore interface {
GetActiveHostSalaryPolicy(ctx context.Context, regionID int64, settlementMode string, triggerMode string, nowMs int64) (ledger.HostSalaryPolicy, bool, 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) GetHostSalaryProgress(ctx context.Context, userID int64, cycleKey string) (ledger.HostSalaryProgress, error)
GetTeamHostSalaryStats(ctx context.Context, query ledger.TeamHostSalaryStatsQuery) ([]ledger.TeamHostSalaryCycleStat, error)
ProcessHostSalarySettlementBatch(ctx context.Context, command ledger.HostSalarySettlementBatchCommand) (ledger.HostSalarySettlementBatchResult, error) ProcessHostSalarySettlementBatch(ctx context.Context, command ledger.HostSalarySettlementBatchCommand) (ledger.HostSalarySettlementBatchResult, error)
} }
@ -61,6 +62,7 @@ type HostSalaryStore interface {
type CoinSellerStore interface { type CoinSellerStore interface {
AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error) AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error)
TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error) TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error)
TransferCoinSellerStockToChild(ctx context.Context, command ledger.CoinSellerSubTransferCommand) (ledger.CoinSellerSubTransferReceipt, error)
ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, appCode string, regionID int64, includeDisabled bool) ([]ledger.CoinSellerSalaryExchangeRateTier, error) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, appCode string, regionID int64, includeDisabled bool) ([]ledger.CoinSellerSalaryExchangeRateTier, error)
ExchangeSalaryToCoin(ctx context.Context, command ledger.SalaryExchangeCommand) (ledger.SalaryExchangeReceipt, error) ExchangeSalaryToCoin(ctx context.Context, command ledger.SalaryExchangeCommand) (ledger.SalaryExchangeReceipt, error)
TransferSalaryToCoinSeller(ctx context.Context, command ledger.SalaryTransferToCoinSellerCommand) (ledger.SalaryTransferToCoinSellerReceipt, error) TransferSalaryToCoinSeller(ctx context.Context, command ledger.SalaryTransferToCoinSellerCommand) (ledger.SalaryTransferToCoinSellerReceipt, error)

View File

@ -240,6 +240,109 @@ func (r *Repository) TransferCoinFromSeller(ctx context.Context, command ledger.
return receiptFromCoinSellerTransferMetadata(transactionID, metadata), nil return receiptFromCoinSellerTransferMetadata(transactionID, metadata), nil
} }
// TransferCoinSellerStockToChild 在父子币商 COIN_SELLER_COIN 账户之间转移库存;它不是玩家充值,不写充值统计表。
func (r *Repository) TransferCoinSellerStockToChild(ctx context.Context, command ledger.CoinSellerSubTransferCommand) (ledger.CoinSellerSubTransferReceipt, error) {
if r == nil || r.db == nil {
return ledger.CoinSellerSubTransferReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
ctx = contextWithCommandApp(ctx, command.AppCode)
command.AppCode = appcode.FromContext(ctx)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return ledger.CoinSellerSubTransferReceipt{}, err
}
defer func() { _ = tx.Rollback() }()
requestHash := coinSellerSubTransferRequestHash(command)
if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizTypeCoinSellerSubTransfer, xerr.IdempotencyConflict); err != nil || exists {
if err != nil || !exists {
return ledger.CoinSellerSubTransferReceipt{}, err
}
return r.receiptForCoinSellerSubTransferTransaction(ctx, tx, txRow.TransactionID)
}
nowMs := time.Now().UnixMilli()
// 父子账户使用同一资产,按统一顺序加锁,避免未来出现双向划拨时产生反向锁等待。
states, err := r.lockAccountsOrdered(ctx, tx, []walletAccountLockRequest{
{UserID: command.ParentUserID, AssetType: ledger.AssetCoinSellerCoin, CreateIfMissing: false},
{UserID: command.ChildUserID, AssetType: ledger.AssetCoinSellerCoin, CreateIfMissing: true},
}, nowMs)
if err != nil {
return ledger.CoinSellerSubTransferReceipt{}, err
}
parent := states[walletAccountLockKey(command.ParentUserID, ledger.AssetCoinSellerCoin)].account
if parent.AvailableAmount < command.Amount {
return ledger.CoinSellerSubTransferReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
}
child := states[walletAccountLockKey(command.ChildUserID, ledger.AssetCoinSellerCoin)].account
parentAfter := parent.AvailableAmount - command.Amount
childAfter, err := checkedAdd(child.AvailableAmount, command.Amount)
if err != nil {
return ledger.CoinSellerSubTransferReceipt{}, err
}
transactionID := transactionID(command.AppCode, command.CommandID)
metadata := coinSellerSubTransferMetadata{
AppCode: command.AppCode,
ParentUserID: command.ParentUserID,
ChildUserID: command.ChildUserID,
RegionID: command.RegionID,
Amount: command.Amount,
Reason: command.Reason,
AssetType: ledger.AssetCoinSellerCoin,
ParentBalanceAfter: parentAfter,
ChildBalanceAfter: childAfter,
CreatedAtMS: nowMs,
}
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeCoinSellerSubTransfer, requestHash, fmt.Sprintf("coin_seller_sub:%d:%d", command.ParentUserID, command.ChildUserID), metadata, nowMs); err != nil {
return ledger.CoinSellerSubTransferReceipt{}, err
}
if err := r.applyAccountDelta(ctx, tx, parent, -command.Amount, 0, nowMs); err != nil {
return ledger.CoinSellerSubTransferReceipt{}, err
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.ParentUserID,
AssetType: ledger.AssetCoinSellerCoin,
AvailableDelta: -command.Amount,
FrozenDelta: 0,
AvailableAfter: parentAfter,
FrozenAfter: parent.FrozenAmount,
CounterpartyUserID: command.ChildUserID,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.CoinSellerSubTransferReceipt{}, err
}
if err := r.applyAccountDelta(ctx, tx, child, command.Amount, 0, nowMs); err != nil {
return ledger.CoinSellerSubTransferReceipt{}, err
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.ChildUserID,
AssetType: ledger.AssetCoinSellerCoin,
AvailableDelta: command.Amount,
FrozenDelta: 0,
AvailableAfter: childAfter,
FrozenAfter: child.FrozenAmount,
CounterpartyUserID: command.ParentUserID,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.CoinSellerSubTransferReceipt{}, err
}
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
balanceChangedEvent(transactionID, command.CommandID, command.ParentUserID, ledger.AssetCoinSellerCoin, -command.Amount, 0, parentAfter, parent.FrozenAmount, parent.Version+1, metadata, nowMs, bizTypeCoinSellerSubTransfer),
balanceChangedEvent(transactionID, command.CommandID, command.ChildUserID, ledger.AssetCoinSellerCoin, command.Amount, 0, childAfter, child.FrozenAmount, child.Version+1, metadata, nowMs, bizTypeCoinSellerSubTransfer),
coinSellerSubTransferredEvent(transactionID, command.CommandID, metadata, nowMs),
}); err != nil {
return ledger.CoinSellerSubTransferReceipt{}, err
}
if err := tx.Commit(); err != nil {
return ledger.CoinSellerSubTransferReceipt{}, err
}
return receiptFromCoinSellerSubTransferMetadata(transactionID, metadata), nil
}
// ListCoinSellerSalaryExchangeRateTiers 返回指定区域工资转币商的兑换区间;默认只返回 active 区间。 // ListCoinSellerSalaryExchangeRateTiers 返回指定区域工资转币商的兑换区间;默认只返回 active 区间。
func (r *Repository) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, appCode string, regionID int64, includeDisabled bool) ([]ledger.CoinSellerSalaryExchangeRateTier, error) { func (r *Repository) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, appCode string, regionID int64, includeDisabled bool) ([]ledger.CoinSellerSalaryExchangeRateTier, error) {
if r == nil || r.db == nil { if r == nil || r.db == nil {
@ -649,6 +752,17 @@ func coinSellerTransferRequestHash(command ledger.CoinSellerTransferCommand) str
)) ))
} }
func coinSellerSubTransferRequestHash(command ledger.CoinSellerSubTransferCommand) string {
return stableHash(fmt.Sprintf("coin_seller_sub_transfer|%s|%d|%d|%d|%d|%s",
appcode.Normalize(command.AppCode),
command.ParentUserID,
command.ChildUserID,
command.RegionID,
command.Amount,
strings.TrimSpace(command.Reason),
))
}
func salaryExchangeRequestHash(command ledger.SalaryExchangeCommand) string { func salaryExchangeRequestHash(command ledger.SalaryExchangeCommand) string {
return stableHash(fmt.Sprintf("salary_exchange|%s|%d|%s|%d|%s", return stableHash(fmt.Sprintf("salary_exchange|%s|%d|%s|%d|%s",
appcode.Normalize(command.AppCode), appcode.Normalize(command.AppCode),

View File

@ -13,6 +13,7 @@ const (
bizTypeInviteActivityReward = "invite_activity_reward" bizTypeInviteActivityReward = "invite_activity_reward"
bizTypeAgencyOpeningReward = "agency_opening_reward" bizTypeAgencyOpeningReward = "agency_opening_reward"
bizTypeCoinSellerTransfer = "coin_seller_transfer" bizTypeCoinSellerTransfer = "coin_seller_transfer"
bizTypeCoinSellerSubTransfer = "coin_seller_sub_transfer"
bizTypeCoinSellerStockPurchase = "coin_seller_stock_purchase" bizTypeCoinSellerStockPurchase = "coin_seller_stock_purchase"
bizTypeCoinSellerStockDeduction = "coin_seller_stock_deduction" bizTypeCoinSellerStockDeduction = "coin_seller_stock_deduction"
bizTypeCoinSellerCoinCompensation = "coin_seller_coin_compensation" bizTypeCoinSellerCoinCompensation = "coin_seller_coin_compensation"

View File

@ -0,0 +1,157 @@
package mysql
import (
"context"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
)
// GetTeamHostSalaryStats 按 Agency 收款人集合聚合主播工资统计,经理中心团队工资卡片使用。
// 预收入口径与结算保持同一套档位数学:当前累计钻石命中的档位累计工资 + 超出档位的剩余钻石按月底费率折美元;
// 再与已结算累计(含已入账剩余折算)取大,保证已 month_end 关闭的周期回读即真实发放值,进行中周期是即时估算。
// 这是只读聚合,不加锁不推进任何结算进度。
func (r *Repository) GetTeamHostSalaryStats(ctx context.Context, query ledger.TeamHostSalaryStatsQuery) ([]ledger.TeamHostSalaryCycleStat, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
owners := uniquePositiveInt64(query.AgencyOwnerUserIDs)
stats := make([]ledger.TeamHostSalaryCycleStat, 0, len(query.CycleKeys))
// 政策按区域跨周期复用同一份解析结果;估算统一用“当前生效政策”,不回放历史政策版本。
policyByRegion := map[int64]*ledger.HostSalaryPolicy{}
for _, cycleKey := range query.CycleKeys {
cycleKey = strings.TrimSpace(cycleKey)
if cycleKey == "" {
return nil, xerr.New(xerr.InvalidArgument, "cycle_key is required")
}
stat := ledger.TeamHostSalaryCycleStat{CycleKey: cycleKey}
if len(owners) == 0 {
// 经理团队还没有任何 Agency 时返回零值统计,调用方不需要为空团队做特殊分支。
stats = append(stats, stat)
continue
}
if err := r.aggregateTeamHostSalaryCycle(ctx, owners, cycleKey, query.NowMs, policyByRegion, &stat); err != nil {
return nil, err
}
stats = append(stats, stat)
}
return stats, nil
}
// teamHostSalaryRow 是单主播周期账户与结算进度的联合投影,先整批读出再做档位估算。
type teamHostSalaryRow struct {
RegionID int64
TotalDiamonds int64
SettledHostUSDMinor int64
SettledResidualUSDMinor int64
MonthEndClearedAtMS int64
}
func (r *Repository) aggregateTeamHostSalaryCycle(ctx context.Context, owners []int64, cycleKey string, nowMs int64, policyByRegion map[int64]*ledger.HostSalaryPolicy, stat *ledger.TeamHostSalaryCycleStat) error {
// 先把整个结果集读完再解析政策:政策查询和主查询共用连接池,游标未关时嵌套发查询
// 在池被打满的场景下会互相等待造成饥饿;行结构很小,整批驻留内存成本可忽略。
hostRows, err := r.listTeamHostSalaryRows(ctx, owners, cycleKey)
if err != nil {
return err
}
for _, row := range hostRows {
// 已结算累计包含月底剩余钻石折美元,两者都入了主播工资钱包,属于同一“主播工资”口径。
settledTotal, err := checkedAdd(row.SettledHostUSDMinor, row.SettledResidualUSDMinor)
if err != nil {
return err
}
estimated := settledTotal
// month_end 已清算的周期以真实发放为准,不再用“当前政策”重估:
// 政策在周期关闭后上调时,重估会显示从未发放过的金额,误导经理对账。
if row.MonthEndClearedAtMS == 0 {
policy, err := r.resolveTeamSalaryPolicy(ctx, row.RegionID, nowMs, policyByRegion)
if err != nil {
return err
}
if policy != nil {
// 与结算同一套档位数学:最高已达档位累计工资 + 档位之上的剩余钻石按月底费率折美元。
level := highestHostSalaryLevel(policy.Levels, row.TotalDiamonds)
residualEstimate, err := usdMinorFromDiamondRate(residualDiamondRemainder(row.TotalDiamonds, level), policy.ResidualDiamondToUSDRate)
if err != nil {
return err
}
levelTotal, err := checkedAdd(level.HostSalaryUSDMinor, residualEstimate)
if err != nil {
return err
}
// 取大兜底政策回调:进行中周期的预收入永远不小于已经真实入账的工资。
estimated = maxInt64(settledTotal, levelTotal)
}
}
stat.ActiveHostCount++
stat.TotalDiamonds, err = checkedAdd(stat.TotalDiamonds, row.TotalDiamonds)
if err != nil {
return err
}
stat.SettledHostSalaryUSDMinor, err = checkedAdd(stat.SettledHostSalaryUSDMinor, settledTotal)
if err != nil {
return err
}
stat.EstimatedHostSalaryUSDMinor, err = checkedAdd(stat.EstimatedHostSalaryUSDMinor, estimated)
if err != nil {
return err
}
}
return nil
}
func (r *Repository) listTeamHostSalaryRows(ctx context.Context, owners []int64, cycleKey string) ([]teamHostSalaryRow, error) {
args := make([]any, 0, len(owners)+2)
args = append(args, appcode.FromContext(ctx), cycleKey)
for _, owner := range owners {
args = append(args, owner)
}
// 周期账户是收礼实时累计的事实表LEFT JOIN 进度表拿到已结算累计和周期关闭标记,未结算过的主播按 0 处理。
rows, err := r.db.QueryContext(ctx, `
SELECT a.region_id, a.total_diamonds,
COALESCE(p.settled_host_salary_usd_minor, 0),
COALESCE(p.residual_usd_minor, 0),
COALESCE(p.month_end_cleared_at_ms, 0)
FROM host_period_diamond_accounts a
LEFT JOIN host_salary_settlement_progress p
ON p.app_code = a.app_code AND p.user_id = a.user_id AND p.cycle_key = a.cycle_key
WHERE a.app_code = ?
AND a.cycle_key = ?
AND a.total_diamonds > 0
AND a.agency_owner_user_id IN (`+placeholders(len(owners))+`)`,
args...)
if err != nil {
return nil, err
}
defer rows.Close()
hostRows := make([]teamHostSalaryRow, 0, 256)
for rows.Next() {
var row teamHostSalaryRow
if err := rows.Scan(&row.RegionID, &row.TotalDiamonds, &row.SettledHostUSDMinor, &row.SettledResidualUSDMinor, &row.MonthEndClearedAtMS); err != nil {
return nil, err
}
hostRows = append(hostRows, row)
}
return hostRows, rows.Err()
}
func (r *Repository) resolveTeamSalaryPolicy(ctx context.Context, regionID int64, nowMs int64, policyByRegion map[int64]*ledger.HostSalaryPolicy) (*ledger.HostSalaryPolicy, error) {
if cached, ok := policyByRegion[regionID]; ok {
return cached, nil
}
// 展示估算不区分结算模式和触发方式,取区域当前最新生效政策,与 H5 平台政策展示口径一致。
policy, found, err := r.queryActiveHostSalaryPolicy(ctx, r.db, regionID, "", "", nowMs)
if err != nil {
return nil, err
}
if !found {
// 区域没有生效政策时记 nil主播预收入退化为已结算累计不做档位估算。
policyByRegion[regionID] = nil
return nil, nil
}
policyByRegion[regionID] = &policy
return &policy, nil
}

View File

@ -228,6 +228,19 @@ type coinSellerTransferMetadata struct {
RechargeType string `json:"recharge_type,omitempty"` RechargeType string `json:"recharge_type,omitempty"`
} }
type coinSellerSubTransferMetadata struct {
AppCode string `json:"app_code"`
ParentUserID int64 `json:"parent_user_id"`
ChildUserID int64 `json:"child_user_id"`
RegionID int64 `json:"region_id"`
Amount int64 `json:"amount"`
Reason string `json:"reason"`
AssetType string `json:"asset_type"`
ParentBalanceAfter int64 `json:"parent_balance_after"`
ChildBalanceAfter int64 `json:"child_balance_after"`
CreatedAtMS int64 `json:"created_at_ms"`
}
type salaryExchangeMetadata struct { type salaryExchangeMetadata struct {
AppCode string `json:"app_code"` AppCode string `json:"app_code"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`

View File

@ -392,6 +392,32 @@ func coinSellerTransferredEvent(transactionID string, commandID string, sellerUs
} }
} }
func coinSellerSubTransferredEvent(transactionID string, commandID string, metadata coinSellerSubTransferMetadata, nowMs int64) walletOutboxEvent {
return walletOutboxEvent{
EventID: eventID(transactionID, "WalletCoinSellerSubTransferred", metadata.ParentUserID, ledger.AssetCoinSellerCoin),
EventType: "WalletCoinSellerSubTransferred",
TransactionID: transactionID,
CommandID: commandID,
UserID: metadata.ParentUserID,
AssetType: ledger.AssetCoinSellerCoin,
AvailableDelta: -metadata.Amount,
FrozenDelta: 0,
Payload: map[string]any{
"transaction_id": transactionID,
"command_id": commandID,
"parent_user_id": metadata.ParentUserID,
"child_user_id": metadata.ChildUserID,
"region_id": metadata.RegionID,
"amount": metadata.Amount,
"asset_type": metadata.AssetType,
"parent_balance_after": metadata.ParentBalanceAfter,
"child_balance_after": metadata.ChildBalanceAfter,
"created_at_ms": nowMs,
},
CreatedAtMS: nowMs,
}
}
func rechargeRecordedEvent(transactionID string, commandID string, targetUserID int64, availableDelta int64, payload any, nowMs int64) walletOutboxEvent { func rechargeRecordedEvent(transactionID string, commandID string, targetUserID int64, availableDelta int64, payload any, nowMs int64) walletOutboxEvent {
assetType := ledger.AssetCoin assetType := ledger.AssetCoin
if metadata, ok := payload.(coinSellerTransferMetadata); ok { if metadata, ok := payload.(coinSellerTransferMetadata); ok {

View File

@ -380,6 +380,36 @@ func receiptFromCoinSellerTransferMetadata(transactionID string, metadata coinSe
} }
} }
func (r *Repository) receiptForCoinSellerSubTransferTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.CoinSellerSubTransferReceipt, error) {
var metadataJSON string
if err := tx.QueryRowContext(ctx,
`SELECT COALESCE(CAST(metadata_json AS CHAR), '{}')
FROM wallet_transactions
WHERE app_code = ? AND transaction_id = ?`,
appcode.FromContext(ctx),
transactionID,
).Scan(&metadataJSON); err != nil {
return ledger.CoinSellerSubTransferReceipt{}, err
}
var metadata coinSellerSubTransferMetadata
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
return ledger.CoinSellerSubTransferReceipt{}, err
}
return receiptFromCoinSellerSubTransferMetadata(transactionID, metadata), nil
}
func receiptFromCoinSellerSubTransferMetadata(transactionID string, metadata coinSellerSubTransferMetadata) ledger.CoinSellerSubTransferReceipt {
return ledger.CoinSellerSubTransferReceipt{
TransactionID: transactionID,
ParentUserID: metadata.ParentUserID,
ChildUserID: metadata.ChildUserID,
ParentBalanceAfter: metadata.ParentBalanceAfter,
ChildBalanceAfter: metadata.ChildBalanceAfter,
Amount: metadata.Amount,
CreatedAtMS: metadata.CreatedAtMS,
}
}
func (r *Repository) receiptForSalaryExchangeTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.SalaryExchangeReceipt, error) { func (r *Repository) receiptForSalaryExchangeTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.SalaryExchangeReceipt, error) {
var metadataJSON string var metadataJSON string
if err := tx.QueryRowContext(ctx, if err := tx.QueryRowContext(ctx,

View File

@ -76,6 +76,31 @@ func (s *Server) TransferCoinFromSeller(ctx context.Context, req *walletv1.Trans
}, nil }, nil
} }
// TransferCoinSellerStockToChild 处理父币商转直属子币商专用金币库存,调用方必须先完成父子关系校验。
func (s *Server) TransferCoinSellerStockToChild(ctx context.Context, req *walletv1.TransferCoinSellerStockToChildRequest) (*walletv1.TransferCoinSellerStockToChildResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
receipt, err := s.svc.TransferCoinSellerStockToChild(ctx, ledger.CoinSellerSubTransferCommand{
AppCode: req.GetAppCode(),
CommandID: req.GetCommandId(),
ParentUserID: req.GetParentUserId(),
ChildUserID: req.GetChildUserId(),
RegionID: req.GetRegionId(),
Amount: req.GetAmount(),
Reason: req.GetReason(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &walletv1.TransferCoinSellerStockToChildResponse{
TransactionId: receipt.TransactionID,
ParentBalanceAfter: receipt.ParentBalanceAfter,
ChildBalanceAfter: receipt.ChildBalanceAfter,
Amount: receipt.Amount,
CreatedAtMs: receipt.CreatedAtMS,
}, nil
}
// ListCoinSellerSalaryExchangeRateTiers 返回工资转币商使用的区域比例区间,调用方用它做预计到账展示。 // ListCoinSellerSalaryExchangeRateTiers 返回工资转币商使用的区域比例区间,调用方用它做预计到账展示。
func (s *Server) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, req *walletv1.ListCoinSellerSalaryExchangeRateTiersRequest) (*walletv1.ListCoinSellerSalaryExchangeRateTiersResponse, error) { func (s *Server) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, req *walletv1.ListCoinSellerSalaryExchangeRateTiersRequest) (*walletv1.ListCoinSellerSalaryExchangeRateTiersResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode()) ctx = appcode.WithContext(ctx, req.GetAppCode())

View File

@ -2,6 +2,7 @@ package grpc
import ( import (
"context" "context"
"time"
walletv1 "hyapp.local/api/proto/wallet/v1" walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
@ -33,6 +34,33 @@ func (s *Server) GetHostSalaryProgress(ctx context.Context, req *walletv1.GetHos
return &walletv1.GetHostSalaryProgressResponse{Progress: hostSalaryProgressToProto(progress)}, nil return &walletv1.GetHostSalaryProgressResponse{Progress: hostSalaryProgressToProto(progress)}, nil
} }
// GetTeamHostSalaryStats 按 Agency 收款人集合聚合主播预收入工资gateway 经理中心概览调用。
func (s *Server) GetTeamHostSalaryStats(ctx context.Context, req *walletv1.GetTeamHostSalaryStatsRequest) (*walletv1.GetTeamHostSalaryStatsResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
stats, err := s.svc.GetTeamHostSalaryStats(ctx, req.GetAppCode(), ledger.TeamHostSalaryStatsQuery{
AgencyOwnerUserIDs: req.GetAgencyOwnerUserIds(),
CycleKeys: req.GetCycleKeys(),
NowMs: req.GetNowMs(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
response := &walletv1.GetTeamHostSalaryStatsResponse{
Stats: make([]*walletv1.TeamHostSalaryCycleStat, 0, len(stats)),
ServerTimeMs: time.Now().UTC().UnixMilli(),
}
for _, stat := range stats {
response.Stats = append(response.Stats, &walletv1.TeamHostSalaryCycleStat{
CycleKey: stat.CycleKey,
EstimatedHostSalaryUsdMinor: stat.EstimatedHostSalaryUSDMinor,
SettledHostSalaryUsdMinor: stat.SettledHostSalaryUSDMinor,
ActiveHostCount: int32(stat.ActiveHostCount),
TotalDiamonds: stat.TotalDiamonds,
})
}
return response, nil
}
func hostSalaryProgressToProto(progress ledger.HostSalaryProgress) *walletv1.HostSalaryProgress { func hostSalaryProgressToProto(progress ledger.HostSalaryProgress) *walletv1.HostSalaryProgress {
return &walletv1.HostSalaryProgress{ return &walletv1.HostSalaryProgress{
HostUserId: progress.HostUserID, HostUserId: progress.HostUserID,