子币商和经理中心改版
This commit is contained in:
parent
ba35148c94
commit
a13fcd61c7
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||||
|
|||||||
@ -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
@ -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);
|
||||||
|
|||||||
@ -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
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
57
docs/经理中心团队工资统计.md
Normal file
57
docs/经理中心团队工资统计.md
Normal 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 注入)才显示设计稿假数据。
|
||||||
@ -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),
|
||||||
|
|||||||
@ -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"))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
404
server/admin/internal/modules/countryregion/country_rename.go
Normal file
404
server/admin/internal/modules/countryregion/country_rename.go
Normal 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 ""
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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 负责唯一性和格式校验。
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 数据库。
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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" {
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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{
|
||||||
|
|||||||
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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 发现页,
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -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 层不做状态判断。
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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);
|
||||||
@ -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='币商直属子币商关系表';
|
||||||
@ -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 Leader(created_by_user_id)→ BD(parent_leader_user_id)→ Agency(parent_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。
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -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 行。
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 '应用编码,用于多租户隔离',
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -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"`
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user