子币商以及充值流水
This commit is contained in:
parent
dd20dbcd4d
commit
ccc2a8b108
File diff suppressed because it is too large
Load Diff
@ -93,6 +93,21 @@ message CoinSellerSubRelation {
|
|||||||
int64 updated_at_ms = 7;
|
int64 updated_at_ms = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CoinSellerSubApplication 是父币商邀请子币商后的后台审核事实。
|
||||||
|
message CoinSellerSubApplication {
|
||||||
|
int64 application_id = 1;
|
||||||
|
string command_id = 2;
|
||||||
|
int64 parent_user_id = 3;
|
||||||
|
int64 target_user_id = 4;
|
||||||
|
string status = 5;
|
||||||
|
int64 relation_id = 6;
|
||||||
|
int64 reviewed_by_admin_id = 7;
|
||||||
|
string review_reason = 8;
|
||||||
|
int64 reviewed_at_ms = 9;
|
||||||
|
int64 created_at_ms = 10;
|
||||||
|
int64 updated_at_ms = 11;
|
||||||
|
}
|
||||||
|
|
||||||
// UserRoleSummary 是 App 我的页和入口显隐使用的轻量角色摘要。
|
// UserRoleSummary 是 App 我的页和入口显隐使用的轻量角色摘要。
|
||||||
// 它把 host、Agency、manager、BD 和币商身份收敛到一次 user-service RPC,避免 gateway 随角色扩张继续 fanout。
|
// 它把 host、Agency、manager、BD 和币商身份收敛到一次 user-service RPC,避免 gateway 随角色扩张继续 fanout。
|
||||||
message UserRoleSummary {
|
message UserRoleSummary {
|
||||||
@ -399,6 +414,17 @@ message CreateSubCoinSellerResponse {
|
|||||||
CoinSellerListItem child = 2;
|
CoinSellerListItem child = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message SubmitSubCoinSellerApplicationRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
string command_id = 2;
|
||||||
|
int64 parent_user_id = 3;
|
||||||
|
int64 target_user_id = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SubmitSubCoinSellerApplicationResponse {
|
||||||
|
CoinSellerSubApplication application = 1;
|
||||||
|
}
|
||||||
|
|
||||||
message ListSubCoinSellersRequest {
|
message ListSubCoinSellersRequest {
|
||||||
RequestMeta meta = 1;
|
RequestMeta meta = 1;
|
||||||
int64 parent_user_id = 2;
|
int64 parent_user_id = 2;
|
||||||
@ -540,6 +566,21 @@ message SetCoinSellerStatusResponse {
|
|||||||
CoinSellerProfile coin_seller_profile = 1;
|
CoinSellerProfile coin_seller_profile = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message ReviewSubCoinSellerApplicationRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
string command_id = 2;
|
||||||
|
int64 admin_user_id = 3;
|
||||||
|
int64 application_id = 4;
|
||||||
|
string decision = 5;
|
||||||
|
string reason = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ReviewSubCoinSellerApplicationResponse {
|
||||||
|
CoinSellerSubApplication application = 1;
|
||||||
|
CoinSellerSubRelation relation = 2;
|
||||||
|
CoinSellerListItem child = 3;
|
||||||
|
}
|
||||||
|
|
||||||
message CreateAgencyRequest {
|
message CreateAgencyRequest {
|
||||||
RequestMeta meta = 1;
|
RequestMeta meta = 1;
|
||||||
string command_id = 2;
|
string command_id = 2;
|
||||||
@ -660,6 +701,7 @@ service UserHostService {
|
|||||||
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 CreateSubCoinSeller(CreateSubCoinSellerRequest) returns (CreateSubCoinSellerResponse);
|
||||||
|
rpc SubmitSubCoinSellerApplication(SubmitSubCoinSellerApplicationRequest) returns (SubmitSubCoinSellerApplicationResponse);
|
||||||
rpc ListSubCoinSellers(ListSubCoinSellersRequest) returns (ListSubCoinSellersResponse);
|
rpc ListSubCoinSellers(ListSubCoinSellersRequest) returns (ListSubCoinSellersResponse);
|
||||||
rpc CheckCoinSellerSubRelation(CheckCoinSellerSubRelationRequest) returns (CheckCoinSellerSubRelationResponse);
|
rpc CheckCoinSellerSubRelation(CheckCoinSellerSubRelationRequest) returns (CheckCoinSellerSubRelationResponse);
|
||||||
rpc GetUserRoleSummary(GetUserRoleSummaryRequest) returns (GetUserRoleSummaryResponse);
|
rpc GetUserRoleSummary(GetUserRoleSummaryRequest) returns (GetUserRoleSummaryResponse);
|
||||||
@ -677,6 +719,7 @@ service UserHostAdminService {
|
|||||||
rpc SetBDStatus(SetBDStatusRequest) returns (SetBDStatusResponse);
|
rpc SetBDStatus(SetBDStatusRequest) returns (SetBDStatusResponse);
|
||||||
rpc CreateCoinSeller(CreateCoinSellerRequest) returns (CreateCoinSellerResponse);
|
rpc CreateCoinSeller(CreateCoinSellerRequest) returns (CreateCoinSellerResponse);
|
||||||
rpc SetCoinSellerStatus(SetCoinSellerStatusRequest) returns (SetCoinSellerStatusResponse);
|
rpc SetCoinSellerStatus(SetCoinSellerStatusRequest) returns (SetCoinSellerStatusResponse);
|
||||||
|
rpc ReviewSubCoinSellerApplication(ReviewSubCoinSellerApplicationRequest) returns (ReviewSubCoinSellerApplicationResponse);
|
||||||
rpc CreateAgency(CreateAgencyRequest) returns (CreateAgencyResponse);
|
rpc CreateAgency(CreateAgencyRequest) returns (CreateAgencyResponse);
|
||||||
rpc AdminAddAgencyHost(AdminAddAgencyHostRequest) returns (AdminAddAgencyHostResponse);
|
rpc AdminAddAgencyHost(AdminAddAgencyHostRequest) returns (AdminAddAgencyHostResponse);
|
||||||
rpc CloseAgency(CloseAgencyRequest) returns (CloseAgencyResponse);
|
rpc CloseAgency(CloseAgencyRequest) returns (CloseAgencyResponse);
|
||||||
|
|||||||
@ -39,6 +39,7 @@ const (
|
|||||||
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_CreateSubCoinSeller_FullMethodName = "/hyapp.user.v1.UserHostService/CreateSubCoinSeller"
|
||||||
|
UserHostService_SubmitSubCoinSellerApplication_FullMethodName = "/hyapp.user.v1.UserHostService/SubmitSubCoinSellerApplication"
|
||||||
UserHostService_ListSubCoinSellers_FullMethodName = "/hyapp.user.v1.UserHostService/ListSubCoinSellers"
|
UserHostService_ListSubCoinSellers_FullMethodName = "/hyapp.user.v1.UserHostService/ListSubCoinSellers"
|
||||||
UserHostService_CheckCoinSellerSubRelation_FullMethodName = "/hyapp.user.v1.UserHostService/CheckCoinSellerSubRelation"
|
UserHostService_CheckCoinSellerSubRelation_FullMethodName = "/hyapp.user.v1.UserHostService/CheckCoinSellerSubRelation"
|
||||||
UserHostService_GetUserRoleSummary_FullMethodName = "/hyapp.user.v1.UserHostService/GetUserRoleSummary"
|
UserHostService_GetUserRoleSummary_FullMethodName = "/hyapp.user.v1.UserHostService/GetUserRoleSummary"
|
||||||
@ -76,6 +77,7 @@ type UserHostServiceClient interface {
|
|||||||
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)
|
CreateSubCoinSeller(ctx context.Context, in *CreateSubCoinSellerRequest, opts ...grpc.CallOption) (*CreateSubCoinSellerResponse, error)
|
||||||
|
SubmitSubCoinSellerApplication(ctx context.Context, in *SubmitSubCoinSellerApplicationRequest, opts ...grpc.CallOption) (*SubmitSubCoinSellerApplicationResponse, error)
|
||||||
ListSubCoinSellers(ctx context.Context, in *ListSubCoinSellersRequest, opts ...grpc.CallOption) (*ListSubCoinSellersResponse, error)
|
ListSubCoinSellers(ctx context.Context, in *ListSubCoinSellersRequest, opts ...grpc.CallOption) (*ListSubCoinSellersResponse, error)
|
||||||
CheckCoinSellerSubRelation(ctx context.Context, in *CheckCoinSellerSubRelationRequest, opts ...grpc.CallOption) (*CheckCoinSellerSubRelationResponse, 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)
|
||||||
@ -294,6 +296,16 @@ func (c *userHostServiceClient) CreateSubCoinSeller(ctx context.Context, in *Cre
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *userHostServiceClient) SubmitSubCoinSellerApplication(ctx context.Context, in *SubmitSubCoinSellerApplicationRequest, opts ...grpc.CallOption) (*SubmitSubCoinSellerApplicationResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(SubmitSubCoinSellerApplicationResponse)
|
||||||
|
err := c.cc.Invoke(ctx, UserHostService_SubmitSubCoinSellerApplication_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) {
|
func (c *userHostServiceClient) ListSubCoinSellers(ctx context.Context, in *ListSubCoinSellersRequest, opts ...grpc.CallOption) (*ListSubCoinSellersResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(ListSubCoinSellersResponse)
|
out := new(ListSubCoinSellersResponse)
|
||||||
@ -401,6 +413,7 @@ type UserHostServiceServer interface {
|
|||||||
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)
|
CreateSubCoinSeller(context.Context, *CreateSubCoinSellerRequest) (*CreateSubCoinSellerResponse, error)
|
||||||
|
SubmitSubCoinSellerApplication(context.Context, *SubmitSubCoinSellerApplicationRequest) (*SubmitSubCoinSellerApplicationResponse, error)
|
||||||
ListSubCoinSellers(context.Context, *ListSubCoinSellersRequest) (*ListSubCoinSellersResponse, error)
|
ListSubCoinSellers(context.Context, *ListSubCoinSellersRequest) (*ListSubCoinSellersResponse, error)
|
||||||
CheckCoinSellerSubRelation(context.Context, *CheckCoinSellerSubRelationRequest) (*CheckCoinSellerSubRelationResponse, error)
|
CheckCoinSellerSubRelation(context.Context, *CheckCoinSellerSubRelationRequest) (*CheckCoinSellerSubRelationResponse, error)
|
||||||
GetUserRoleSummary(context.Context, *GetUserRoleSummaryRequest) (*GetUserRoleSummaryResponse, error)
|
GetUserRoleSummary(context.Context, *GetUserRoleSummaryRequest) (*GetUserRoleSummaryResponse, error)
|
||||||
@ -479,6 +492,9 @@ func (UnimplementedUserHostServiceServer) ListActiveCoinSellersInMyRegion(contex
|
|||||||
func (UnimplementedUserHostServiceServer) CreateSubCoinSeller(context.Context, *CreateSubCoinSellerRequest) (*CreateSubCoinSellerResponse, error) {
|
func (UnimplementedUserHostServiceServer) CreateSubCoinSeller(context.Context, *CreateSubCoinSellerRequest) (*CreateSubCoinSellerResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method CreateSubCoinSeller not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method CreateSubCoinSeller not implemented")
|
||||||
}
|
}
|
||||||
|
func (UnimplementedUserHostServiceServer) SubmitSubCoinSellerApplication(context.Context, *SubmitSubCoinSellerApplicationRequest) (*SubmitSubCoinSellerApplicationResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method SubmitSubCoinSellerApplication not implemented")
|
||||||
|
}
|
||||||
func (UnimplementedUserHostServiceServer) ListSubCoinSellers(context.Context, *ListSubCoinSellersRequest) (*ListSubCoinSellersResponse, error) {
|
func (UnimplementedUserHostServiceServer) ListSubCoinSellers(context.Context, *ListSubCoinSellersRequest) (*ListSubCoinSellersResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method ListSubCoinSellers not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method ListSubCoinSellers not implemented")
|
||||||
}
|
}
|
||||||
@ -884,6 +900,24 @@ func _UserHostService_CreateSubCoinSeller_Handler(srv interface{}, ctx context.C
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _UserHostService_SubmitSubCoinSellerApplication_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(SubmitSubCoinSellerApplicationRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(UserHostServiceServer).SubmitSubCoinSellerApplication(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: UserHostService_SubmitSubCoinSellerApplication_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(UserHostServiceServer).SubmitSubCoinSellerApplication(ctx, req.(*SubmitSubCoinSellerApplicationRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
func _UserHostService_ListSubCoinSellers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
func _UserHostService_ListSubCoinSellers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(ListSubCoinSellersRequest)
|
in := new(ListSubCoinSellersRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@ -1115,6 +1149,10 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "CreateSubCoinSeller",
|
MethodName: "CreateSubCoinSeller",
|
||||||
Handler: _UserHostService_CreateSubCoinSeller_Handler,
|
Handler: _UserHostService_CreateSubCoinSeller_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "SubmitSubCoinSellerApplication",
|
||||||
|
Handler: _UserHostService_SubmitSubCoinSellerApplication_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "ListSubCoinSellers",
|
MethodName: "ListSubCoinSellers",
|
||||||
Handler: _UserHostService_ListSubCoinSellers_Handler,
|
Handler: _UserHostService_ListSubCoinSellers_Handler,
|
||||||
@ -1153,17 +1191,18 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
UserHostAdminService_CreateBDLeader_FullMethodName = "/hyapp.user.v1.UserHostAdminService/CreateBDLeader"
|
UserHostAdminService_CreateBDLeader_FullMethodName = "/hyapp.user.v1.UserHostAdminService/CreateBDLeader"
|
||||||
UserHostAdminService_CreateBD_FullMethodName = "/hyapp.user.v1.UserHostAdminService/CreateBD"
|
UserHostAdminService_CreateBD_FullMethodName = "/hyapp.user.v1.UserHostAdminService/CreateBD"
|
||||||
UserHostAdminService_SetBDStatus_FullMethodName = "/hyapp.user.v1.UserHostAdminService/SetBDStatus"
|
UserHostAdminService_SetBDStatus_FullMethodName = "/hyapp.user.v1.UserHostAdminService/SetBDStatus"
|
||||||
UserHostAdminService_CreateCoinSeller_FullMethodName = "/hyapp.user.v1.UserHostAdminService/CreateCoinSeller"
|
UserHostAdminService_CreateCoinSeller_FullMethodName = "/hyapp.user.v1.UserHostAdminService/CreateCoinSeller"
|
||||||
UserHostAdminService_SetCoinSellerStatus_FullMethodName = "/hyapp.user.v1.UserHostAdminService/SetCoinSellerStatus"
|
UserHostAdminService_SetCoinSellerStatus_FullMethodName = "/hyapp.user.v1.UserHostAdminService/SetCoinSellerStatus"
|
||||||
UserHostAdminService_CreateAgency_FullMethodName = "/hyapp.user.v1.UserHostAdminService/CreateAgency"
|
UserHostAdminService_ReviewSubCoinSellerApplication_FullMethodName = "/hyapp.user.v1.UserHostAdminService/ReviewSubCoinSellerApplication"
|
||||||
UserHostAdminService_AdminAddAgencyHost_FullMethodName = "/hyapp.user.v1.UserHostAdminService/AdminAddAgencyHost"
|
UserHostAdminService_CreateAgency_FullMethodName = "/hyapp.user.v1.UserHostAdminService/CreateAgency"
|
||||||
UserHostAdminService_CloseAgency_FullMethodName = "/hyapp.user.v1.UserHostAdminService/CloseAgency"
|
UserHostAdminService_AdminAddAgencyHost_FullMethodName = "/hyapp.user.v1.UserHostAdminService/AdminAddAgencyHost"
|
||||||
UserHostAdminService_DeleteAgency_FullMethodName = "/hyapp.user.v1.UserHostAdminService/DeleteAgency"
|
UserHostAdminService_CloseAgency_FullMethodName = "/hyapp.user.v1.UserHostAdminService/CloseAgency"
|
||||||
UserHostAdminService_SetAgencyStatus_FullMethodName = "/hyapp.user.v1.UserHostAdminService/SetAgencyStatus"
|
UserHostAdminService_DeleteAgency_FullMethodName = "/hyapp.user.v1.UserHostAdminService/DeleteAgency"
|
||||||
UserHostAdminService_SetAgencyJoinEnabled_FullMethodName = "/hyapp.user.v1.UserHostAdminService/SetAgencyJoinEnabled"
|
UserHostAdminService_SetAgencyStatus_FullMethodName = "/hyapp.user.v1.UserHostAdminService/SetAgencyStatus"
|
||||||
|
UserHostAdminService_SetAgencyJoinEnabled_FullMethodName = "/hyapp.user.v1.UserHostAdminService/SetAgencyJoinEnabled"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserHostAdminServiceClient is the client API for UserHostAdminService service.
|
// UserHostAdminServiceClient is the client API for UserHostAdminService service.
|
||||||
@ -1177,6 +1216,7 @@ type UserHostAdminServiceClient interface {
|
|||||||
SetBDStatus(ctx context.Context, in *SetBDStatusRequest, opts ...grpc.CallOption) (*SetBDStatusResponse, error)
|
SetBDStatus(ctx context.Context, in *SetBDStatusRequest, opts ...grpc.CallOption) (*SetBDStatusResponse, error)
|
||||||
CreateCoinSeller(ctx context.Context, in *CreateCoinSellerRequest, opts ...grpc.CallOption) (*CreateCoinSellerResponse, error)
|
CreateCoinSeller(ctx context.Context, in *CreateCoinSellerRequest, opts ...grpc.CallOption) (*CreateCoinSellerResponse, error)
|
||||||
SetCoinSellerStatus(ctx context.Context, in *SetCoinSellerStatusRequest, opts ...grpc.CallOption) (*SetCoinSellerStatusResponse, error)
|
SetCoinSellerStatus(ctx context.Context, in *SetCoinSellerStatusRequest, opts ...grpc.CallOption) (*SetCoinSellerStatusResponse, error)
|
||||||
|
ReviewSubCoinSellerApplication(ctx context.Context, in *ReviewSubCoinSellerApplicationRequest, opts ...grpc.CallOption) (*ReviewSubCoinSellerApplicationResponse, error)
|
||||||
CreateAgency(ctx context.Context, in *CreateAgencyRequest, opts ...grpc.CallOption) (*CreateAgencyResponse, error)
|
CreateAgency(ctx context.Context, in *CreateAgencyRequest, opts ...grpc.CallOption) (*CreateAgencyResponse, error)
|
||||||
AdminAddAgencyHost(ctx context.Context, in *AdminAddAgencyHostRequest, opts ...grpc.CallOption) (*AdminAddAgencyHostResponse, error)
|
AdminAddAgencyHost(ctx context.Context, in *AdminAddAgencyHostRequest, opts ...grpc.CallOption) (*AdminAddAgencyHostResponse, error)
|
||||||
CloseAgency(ctx context.Context, in *CloseAgencyRequest, opts ...grpc.CallOption) (*CloseAgencyResponse, error)
|
CloseAgency(ctx context.Context, in *CloseAgencyRequest, opts ...grpc.CallOption) (*CloseAgencyResponse, error)
|
||||||
@ -1243,6 +1283,16 @@ func (c *userHostAdminServiceClient) SetCoinSellerStatus(ctx context.Context, in
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *userHostAdminServiceClient) ReviewSubCoinSellerApplication(ctx context.Context, in *ReviewSubCoinSellerApplicationRequest, opts ...grpc.CallOption) (*ReviewSubCoinSellerApplicationResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(ReviewSubCoinSellerApplicationResponse)
|
||||||
|
err := c.cc.Invoke(ctx, UserHostAdminService_ReviewSubCoinSellerApplication_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *userHostAdminServiceClient) CreateAgency(ctx context.Context, in *CreateAgencyRequest, opts ...grpc.CallOption) (*CreateAgencyResponse, error) {
|
func (c *userHostAdminServiceClient) CreateAgency(ctx context.Context, in *CreateAgencyRequest, opts ...grpc.CallOption) (*CreateAgencyResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(CreateAgencyResponse)
|
out := new(CreateAgencyResponse)
|
||||||
@ -1314,6 +1364,7 @@ type UserHostAdminServiceServer interface {
|
|||||||
SetBDStatus(context.Context, *SetBDStatusRequest) (*SetBDStatusResponse, error)
|
SetBDStatus(context.Context, *SetBDStatusRequest) (*SetBDStatusResponse, error)
|
||||||
CreateCoinSeller(context.Context, *CreateCoinSellerRequest) (*CreateCoinSellerResponse, error)
|
CreateCoinSeller(context.Context, *CreateCoinSellerRequest) (*CreateCoinSellerResponse, error)
|
||||||
SetCoinSellerStatus(context.Context, *SetCoinSellerStatusRequest) (*SetCoinSellerStatusResponse, error)
|
SetCoinSellerStatus(context.Context, *SetCoinSellerStatusRequest) (*SetCoinSellerStatusResponse, error)
|
||||||
|
ReviewSubCoinSellerApplication(context.Context, *ReviewSubCoinSellerApplicationRequest) (*ReviewSubCoinSellerApplicationResponse, error)
|
||||||
CreateAgency(context.Context, *CreateAgencyRequest) (*CreateAgencyResponse, error)
|
CreateAgency(context.Context, *CreateAgencyRequest) (*CreateAgencyResponse, error)
|
||||||
AdminAddAgencyHost(context.Context, *AdminAddAgencyHostRequest) (*AdminAddAgencyHostResponse, error)
|
AdminAddAgencyHost(context.Context, *AdminAddAgencyHostRequest) (*AdminAddAgencyHostResponse, error)
|
||||||
CloseAgency(context.Context, *CloseAgencyRequest) (*CloseAgencyResponse, error)
|
CloseAgency(context.Context, *CloseAgencyRequest) (*CloseAgencyResponse, error)
|
||||||
@ -1345,6 +1396,9 @@ func (UnimplementedUserHostAdminServiceServer) CreateCoinSeller(context.Context,
|
|||||||
func (UnimplementedUserHostAdminServiceServer) SetCoinSellerStatus(context.Context, *SetCoinSellerStatusRequest) (*SetCoinSellerStatusResponse, error) {
|
func (UnimplementedUserHostAdminServiceServer) SetCoinSellerStatus(context.Context, *SetCoinSellerStatusRequest) (*SetCoinSellerStatusResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method SetCoinSellerStatus not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method SetCoinSellerStatus not implemented")
|
||||||
}
|
}
|
||||||
|
func (UnimplementedUserHostAdminServiceServer) ReviewSubCoinSellerApplication(context.Context, *ReviewSubCoinSellerApplicationRequest) (*ReviewSubCoinSellerApplicationResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method ReviewSubCoinSellerApplication not implemented")
|
||||||
|
}
|
||||||
func (UnimplementedUserHostAdminServiceServer) CreateAgency(context.Context, *CreateAgencyRequest) (*CreateAgencyResponse, error) {
|
func (UnimplementedUserHostAdminServiceServer) CreateAgency(context.Context, *CreateAgencyRequest) (*CreateAgencyResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method CreateAgency not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method CreateAgency not implemented")
|
||||||
}
|
}
|
||||||
@ -1474,6 +1528,24 @@ func _UserHostAdminService_SetCoinSellerStatus_Handler(srv interface{}, ctx cont
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _UserHostAdminService_ReviewSubCoinSellerApplication_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(ReviewSubCoinSellerApplicationRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(UserHostAdminServiceServer).ReviewSubCoinSellerApplication(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: UserHostAdminService_ReviewSubCoinSellerApplication_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(UserHostAdminServiceServer).ReviewSubCoinSellerApplication(ctx, req.(*ReviewSubCoinSellerApplicationRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
func _UserHostAdminService_CreateAgency_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
func _UserHostAdminService_CreateAgency_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(CreateAgencyRequest)
|
in := new(CreateAgencyRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@ -1609,6 +1681,10 @@ var UserHostAdminService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "SetCoinSellerStatus",
|
MethodName: "SetCoinSellerStatus",
|
||||||
Handler: _UserHostAdminService_SetCoinSellerStatus_Handler,
|
Handler: _UserHostAdminService_SetCoinSellerStatus_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ReviewSubCoinSellerApplication",
|
||||||
|
Handler: _UserHostAdminService_ReviewSubCoinSellerApplication_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "CreateAgency",
|
MethodName: "CreateAgency",
|
||||||
Handler: _UserHostAdminService_CreateAgency_Handler,
|
Handler: _UserHostAdminService_CreateAgency_Handler,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1780,6 +1780,7 @@ message VerifyCoinSellerRechargeReceiptRequest {
|
|||||||
string provider_country_code = 8;
|
string provider_country_code = 8;
|
||||||
string provider_currency_code = 9;
|
string provider_currency_code = 9;
|
||||||
string provider_pay_type = 10;
|
string provider_pay_type = 10;
|
||||||
|
int64 provider_amount_minor = 11;
|
||||||
}
|
}
|
||||||
|
|
||||||
message VerifyCoinSellerRechargeReceiptResponse {
|
message VerifyCoinSellerRechargeReceiptResponse {
|
||||||
|
|||||||
@ -349,7 +349,7 @@ func main() {
|
|||||||
Databi: databimodule.New(databiService, store, auditHandler),
|
Databi: databimodule.New(databiService, store, auditHandler),
|
||||||
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||||
FinanceApplication: financeapplicationmodule.New(store, auditHandler, financeApplicationOptions...),
|
FinanceApplication: financeapplicationmodule.New(store, auditHandler, financeApplicationOptions...),
|
||||||
FinanceOrder: financeordermodule.New(store, walletclient.NewGRPC(walletConn), userclient.NewGRPC(userConn), auditHandler, financeordermodule.WithLegacyCoinSellerRechargeWriters(legacyCoinSellerRechargeWriters...)),
|
FinanceOrder: financeordermodule.New(store, walletclient.NewGRPC(walletConn), userclient.NewGRPC(userConn), auditHandler, financeordermodule.WithFinanceCoinSellerRechargeStatsRecorder(dashboardService), financeordermodule.WithLegacyCoinSellerRechargeWriters(legacyCoinSellerRechargeWriters...)),
|
||||||
FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler),
|
FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler),
|
||||||
FullServerNotice: fullservernoticemodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
FullServerNotice: fullservernoticemodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||||
Game: gamemanagementmodule.New(
|
Game: gamemanagementmodule.New(
|
||||||
|
|||||||
@ -38,6 +38,7 @@ type Client interface {
|
|||||||
SetBDStatus(ctx context.Context, req SetBDStatusRequest) (*BDProfile, error)
|
SetBDStatus(ctx context.Context, req SetBDStatusRequest) (*BDProfile, error)
|
||||||
CreateCoinSeller(ctx context.Context, req CreateCoinSellerRequest) (*CoinSellerProfile, error)
|
CreateCoinSeller(ctx context.Context, req CreateCoinSellerRequest) (*CoinSellerProfile, error)
|
||||||
SetCoinSellerStatus(ctx context.Context, req SetCoinSellerStatusRequest) (*CoinSellerProfile, error)
|
SetCoinSellerStatus(ctx context.Context, req SetCoinSellerStatusRequest) (*CoinSellerProfile, error)
|
||||||
|
ReviewCoinSellerSubApplication(ctx context.Context, req ReviewCoinSellerSubApplicationRequest) (*CoinSellerSubApplicationReviewResult, error)
|
||||||
GetCoinSellerProfile(ctx context.Context, req GetCoinSellerProfileRequest) (*CoinSellerProfile, error)
|
GetCoinSellerProfile(ctx context.Context, req GetCoinSellerProfileRequest) (*CoinSellerProfile, error)
|
||||||
GetUserRoleSummary(ctx context.Context, req GetUserRoleSummaryRequest) (*UserRoleSummary, error)
|
GetUserRoleSummary(ctx context.Context, req GetUserRoleSummaryRequest) (*UserRoleSummary, error)
|
||||||
CreateAgency(ctx context.Context, req CreateAgencyRequest) (*CreateAgencyResult, error)
|
CreateAgency(ctx context.Context, req CreateAgencyRequest) (*CreateAgencyResult, error)
|
||||||
@ -532,6 +533,16 @@ type SetCoinSellerStatusRequest struct {
|
|||||||
CanManageSubCoinSellers *bool
|
CanManageSubCoinSellers *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReviewCoinSellerSubApplicationRequest struct {
|
||||||
|
RequestID string
|
||||||
|
Caller string
|
||||||
|
CommandID string
|
||||||
|
AdminUserID int64
|
||||||
|
ApplicationID int64
|
||||||
|
Decision string
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
type GetCoinSellerProfileRequest struct {
|
type GetCoinSellerProfileRequest struct {
|
||||||
RequestID string
|
RequestID string
|
||||||
Caller string
|
Caller string
|
||||||
@ -698,6 +709,50 @@ type CoinSellerProfile struct {
|
|||||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CoinSellerListItem struct {
|
||||||
|
UserID int64 `json:"userId,string"`
|
||||||
|
DisplayUserID string `json:"displayUserId"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
RegionID int64 `json:"regionId"`
|
||||||
|
RegionName string `json:"regionName"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
MerchantAssetType string `json:"merchantAssetType"`
|
||||||
|
ContactInfo string `json:"contactInfo"`
|
||||||
|
CanManageSubCoinSellers bool `json:"canManageSubCoinSellers"`
|
||||||
|
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CoinSellerSubApplication struct {
|
||||||
|
ApplicationID int64 `json:"applicationId,string"`
|
||||||
|
CommandID string `json:"commandId"`
|
||||||
|
ParentUserID int64 `json:"parentUserId,string"`
|
||||||
|
TargetUserID int64 `json:"targetUserId,string"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
RelationID int64 `json:"relationId,string"`
|
||||||
|
ReviewedByAdminID int64 `json:"reviewedByAdminId,string"`
|
||||||
|
ReviewReason string `json:"reviewReason"`
|
||||||
|
ReviewedAtMs int64 `json:"reviewedAtMs"`
|
||||||
|
CreatedAtMs int64 `json:"createdAtMs"`
|
||||||
|
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CoinSellerSubRelation struct {
|
||||||
|
RelationID int64 `json:"relationId,string"`
|
||||||
|
ParentUserID int64 `json:"parentUserId,string"`
|
||||||
|
ChildUserID int64 `json:"childUserId,string"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedByUserID int64 `json:"createdByUserId,string"`
|
||||||
|
CreatedAtMs int64 `json:"createdAtMs"`
|
||||||
|
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CoinSellerSubApplicationReviewResult struct {
|
||||||
|
Application *CoinSellerSubApplication `json:"application"`
|
||||||
|
Relation *CoinSellerSubRelation `json:"relation,omitempty"`
|
||||||
|
Child *CoinSellerListItem `json:"child,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type UserRoleSummary struct {
|
type UserRoleSummary struct {
|
||||||
UserID int64 `json:"userId,string"`
|
UserID int64 `json:"userId,string"`
|
||||||
IsHost bool `json:"isHost"`
|
IsHost bool `json:"isHost"`
|
||||||
@ -819,6 +874,25 @@ func (c *GRPCClient) SetCoinSellerStatus(ctx context.Context, req SetCoinSellerS
|
|||||||
return fromProtoCoinSellerProfile(resp.GetCoinSellerProfile()), nil
|
return fromProtoCoinSellerProfile(resp.GetCoinSellerProfile()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) ReviewCoinSellerSubApplication(ctx context.Context, req ReviewCoinSellerSubApplicationRequest) (*CoinSellerSubApplicationReviewResult, error) {
|
||||||
|
resp, err := c.hostAdminClient.ReviewSubCoinSellerApplication(ctx, &userv1.ReviewSubCoinSellerApplicationRequest{
|
||||||
|
Meta: requestMeta(ctx, req.RequestID, req.Caller),
|
||||||
|
CommandId: req.CommandID,
|
||||||
|
AdminUserId: req.AdminUserID,
|
||||||
|
ApplicationId: req.ApplicationID,
|
||||||
|
Decision: req.Decision,
|
||||||
|
Reason: req.Reason,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &CoinSellerSubApplicationReviewResult{
|
||||||
|
Application: fromProtoCoinSellerSubApplication(resp.GetApplication()),
|
||||||
|
Relation: fromProtoCoinSellerSubRelation(resp.GetRelation()),
|
||||||
|
Child: fromProtoCoinSellerListItem(resp.GetChild()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *GRPCClient) GetCoinSellerProfile(ctx context.Context, req GetCoinSellerProfileRequest) (*CoinSellerProfile, error) {
|
func (c *GRPCClient) GetCoinSellerProfile(ctx context.Context, req GetCoinSellerProfileRequest) (*CoinSellerProfile, error) {
|
||||||
resp, err := c.hostClient.GetCoinSellerProfile(ctx, &userv1.GetCoinSellerProfileRequest{
|
resp, err := c.hostClient.GetCoinSellerProfile(ctx, &userv1.GetCoinSellerProfileRequest{
|
||||||
Meta: requestMeta(ctx, req.RequestID, req.Caller),
|
Meta: requestMeta(ctx, req.RequestID, req.Caller),
|
||||||
@ -1017,6 +1091,59 @@ func fromProtoCoinSellerProfile(profile *userv1.CoinSellerProfile) *CoinSellerPr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fromProtoCoinSellerSubApplication(application *userv1.CoinSellerSubApplication) *CoinSellerSubApplication {
|
||||||
|
if application == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &CoinSellerSubApplication{
|
||||||
|
ApplicationID: application.GetApplicationId(),
|
||||||
|
CommandID: application.GetCommandId(),
|
||||||
|
ParentUserID: application.GetParentUserId(),
|
||||||
|
TargetUserID: application.GetTargetUserId(),
|
||||||
|
Status: application.GetStatus(),
|
||||||
|
RelationID: application.GetRelationId(),
|
||||||
|
ReviewedByAdminID: application.GetReviewedByAdminId(),
|
||||||
|
ReviewReason: application.GetReviewReason(),
|
||||||
|
ReviewedAtMs: application.GetReviewedAtMs(),
|
||||||
|
CreatedAtMs: application.GetCreatedAtMs(),
|
||||||
|
UpdatedAtMs: application.GetUpdatedAtMs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromProtoCoinSellerSubRelation(relation *userv1.CoinSellerSubRelation) *CoinSellerSubRelation {
|
||||||
|
if relation == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &CoinSellerSubRelation{
|
||||||
|
RelationID: relation.GetRelationId(),
|
||||||
|
ParentUserID: relation.GetParentUserId(),
|
||||||
|
ChildUserID: relation.GetChildUserId(),
|
||||||
|
Status: relation.GetStatus(),
|
||||||
|
CreatedByUserID: relation.GetCreatedByUserId(),
|
||||||
|
CreatedAtMs: relation.GetCreatedAtMs(),
|
||||||
|
UpdatedAtMs: relation.GetUpdatedAtMs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromProtoCoinSellerListItem(item *userv1.CoinSellerListItem) *CoinSellerListItem {
|
||||||
|
if item == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &CoinSellerListItem{
|
||||||
|
UserID: item.GetUserId(),
|
||||||
|
DisplayUserID: item.GetDisplayUserId(),
|
||||||
|
Username: item.GetUsername(),
|
||||||
|
Avatar: item.GetAvatar(),
|
||||||
|
RegionID: item.GetRegionId(),
|
||||||
|
RegionName: item.GetRegionName(),
|
||||||
|
Status: item.GetStatus(),
|
||||||
|
MerchantAssetType: item.GetMerchantAssetType(),
|
||||||
|
ContactInfo: item.GetContactInfo(),
|
||||||
|
CanManageSubCoinSellers: item.GetCanManageSubCoinSellers(),
|
||||||
|
UpdatedAtMs: item.GetUpdatedAtMs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func fromProtoUserRoleSummary(summary *userv1.UserRoleSummary) *UserRoleSummary {
|
func fromProtoUserRoleSummary(summary *userv1.UserRoleSummary) *UserRoleSummary {
|
||||||
if summary == nil {
|
if summary == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -23,6 +23,7 @@ const (
|
|||||||
coinSellerAssetType = "COIN_SELLER_COIN"
|
coinSellerAssetType = "COIN_SELLER_COIN"
|
||||||
coinManualCreditBizType = "manual_credit"
|
coinManualCreditBizType = "manual_credit"
|
||||||
coinSellerTransferBizType = "coin_seller_transfer"
|
coinSellerTransferBizType = "coin_seller_transfer"
|
||||||
|
coinSellerSubTransferBizType = "coin_seller_sub_transfer"
|
||||||
coinSellerRechargeBizType = "coin_seller_recharge"
|
coinSellerRechargeBizType = "coin_seller_recharge"
|
||||||
coinSellerStockPurchaseBizType = "coin_seller_stock_purchase"
|
coinSellerStockPurchaseBizType = "coin_seller_stock_purchase"
|
||||||
coinSellerStockDeductionBizType = "coin_seller_stock_deduction"
|
coinSellerStockDeductionBizType = "coin_seller_stock_deduction"
|
||||||
@ -30,6 +31,7 @@ const (
|
|||||||
salaryTransferToCoinSellerBizType = "salary_transfer_to_coin_seller"
|
salaryTransferToCoinSellerBizType = "salary_transfer_to_coin_seller"
|
||||||
coinSellerLedgerTypeAdminStockCredit = "admin_stock_credit"
|
coinSellerLedgerTypeAdminStockCredit = "admin_stock_credit"
|
||||||
coinSellerLedgerTypeSellerTransfer = "seller_transfer"
|
coinSellerLedgerTypeSellerTransfer = "seller_transfer"
|
||||||
|
coinSellerLedgerTypeSubSellerTransfer = "sub_seller_transfer"
|
||||||
coinSellerLedgerTypeSalaryTransferIncome = "salary_transfer_received"
|
coinSellerLedgerTypeSalaryTransferIncome = "salary_transfer_received"
|
||||||
directionIn = "income"
|
directionIn = "income"
|
||||||
directionOut = "expense"
|
directionOut = "expense"
|
||||||
@ -845,6 +847,7 @@ func coinSellerLedgerBizTypes(ledgerType string) ([]string, error) {
|
|||||||
coinSellerRechargeBizType,
|
coinSellerRechargeBizType,
|
||||||
coinSellerCoinCompensationBizType,
|
coinSellerCoinCompensationBizType,
|
||||||
coinSellerTransferBizType,
|
coinSellerTransferBizType,
|
||||||
|
coinSellerSubTransferBizType,
|
||||||
salaryTransferToCoinSellerBizType,
|
salaryTransferToCoinSellerBizType,
|
||||||
coinManualCreditBizType,
|
coinManualCreditBizType,
|
||||||
}, nil
|
}, nil
|
||||||
@ -854,6 +857,9 @@ func coinSellerLedgerBizTypes(ledgerType string) ([]string, error) {
|
|||||||
case coinSellerLedgerTypeSellerTransfer:
|
case coinSellerLedgerTypeSellerTransfer:
|
||||||
// 币商转用户只展示币商侧出账分录,收款用户资料在展示投影里补齐。
|
// 币商转用户只展示币商侧出账分录,收款用户资料在展示投影里补齐。
|
||||||
return []string{coinSellerTransferBizType}, nil
|
return []string{coinSellerTransferBizType}, nil
|
||||||
|
case coinSellerLedgerTypeSubSellerTransfer:
|
||||||
|
// 父子币商库存划拨按 wallet_entries 展示双边余额变动,不能混入普通玩家充值流水。
|
||||||
|
return []string{coinSellerSubTransferBizType}, nil
|
||||||
case coinSellerLedgerTypeSalaryTransferIncome:
|
case coinSellerLedgerTypeSalaryTransferIncome:
|
||||||
// 工资转币商是用户工资资产换入币商库存,币商侧表现为 COIN_SELLER_COIN 入账。
|
// 工资转币商是用户工资资产换入币商库存,币商侧表现为 COIN_SELLER_COIN 入账。
|
||||||
return []string{salaryTransferToCoinSellerBizType}, nil
|
return []string{salaryTransferToCoinSellerBizType}, nil
|
||||||
@ -870,6 +876,8 @@ func coinSellerLedgerTypeForBizType(bizType string) string {
|
|||||||
return coinSellerLedgerTypeAdminStockCredit
|
return coinSellerLedgerTypeAdminStockCredit
|
||||||
case coinSellerTransferBizType:
|
case coinSellerTransferBizType:
|
||||||
return coinSellerLedgerTypeSellerTransfer
|
return coinSellerLedgerTypeSellerTransfer
|
||||||
|
case coinSellerSubTransferBizType:
|
||||||
|
return coinSellerLedgerTypeSubSellerTransfer
|
||||||
case salaryTransferToCoinSellerBizType:
|
case salaryTransferToCoinSellerBizType:
|
||||||
return coinSellerLedgerTypeSalaryTransferIncome
|
return coinSellerLedgerTypeSalaryTransferIncome
|
||||||
default:
|
default:
|
||||||
@ -894,6 +902,8 @@ func coinSellerLedgerLabel(item coinSellerLedgerDTO) string {
|
|||||||
return "金币增加"
|
return "金币增加"
|
||||||
case coinSellerTransferBizType:
|
case coinSellerTransferBizType:
|
||||||
return "币商转用户"
|
return "币商转用户"
|
||||||
|
case coinSellerSubTransferBizType:
|
||||||
|
return "向子币商转账"
|
||||||
case salaryTransferToCoinSellerBizType:
|
case salaryTransferToCoinSellerBizType:
|
||||||
return "工资转币商"
|
return "工资转币商"
|
||||||
}
|
}
|
||||||
@ -902,6 +912,8 @@ func coinSellerLedgerLabel(item coinSellerLedgerDTO) string {
|
|||||||
return "后台操作"
|
return "后台操作"
|
||||||
case coinSellerLedgerTypeSellerTransfer:
|
case coinSellerLedgerTypeSellerTransfer:
|
||||||
return "币商转用户"
|
return "币商转用户"
|
||||||
|
case coinSellerLedgerTypeSubSellerTransfer:
|
||||||
|
return "向子币商转账"
|
||||||
case coinSellerLedgerTypeSalaryTransferIncome:
|
case coinSellerLedgerTypeSalaryTransferIncome:
|
||||||
return "工资转币商"
|
return "工资转币商"
|
||||||
default:
|
default:
|
||||||
@ -950,6 +962,14 @@ func coinSellerLedgerReceiverUserID(bizType string, sellerUserID int64, counterp
|
|||||||
return counterpartyUserID
|
return counterpartyUserID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if bizType == coinSellerSubTransferBizType {
|
||||||
|
// 父子币商划拨会落父出账和子入账两条库存分录;两条记录的实际收款人都是 child_user_id。
|
||||||
|
// 子币商入账侧的 counterparty_user_id 是父币商,不能拿来展示为收款人。
|
||||||
|
if childUserID := metadataInt64(metadata, "child_user_id", "childUserId"); childUserID > 0 {
|
||||||
|
return childUserID
|
||||||
|
}
|
||||||
|
return sellerUserID
|
||||||
|
}
|
||||||
// 后台入账和工资转币商都展示币商本人为收款人,这是页面“实际收款人”的产品口径。
|
// 后台入账和工资转币商都展示币商本人为收款人,这是页面“实际收款人”的产品口径。
|
||||||
return sellerUserID
|
return sellerUserID
|
||||||
}
|
}
|
||||||
|
|||||||
@ -168,6 +168,20 @@ func TestCoinSellerLedgerWhereUsesExactSellerAndType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCoinSellerLedgerWhereUsesSubSellerTransferType(t *testing.T) {
|
||||||
|
query := coinSellerLedgerQuery{SellerUserID: 3001, LedgerType: coinSellerLedgerTypeSubSellerTransfer, StartAtMS: 100, EndAtMS: 200}
|
||||||
|
where, args, err := coinSellerLedgerWhere("lalu", query, []int64{3001})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("coin seller ledger where failed: %v", err)
|
||||||
|
}
|
||||||
|
if want := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type = ? AND e.created_at_ms >= ? AND e.created_at_ms < ? AND e.user_id IN (?)"; where != want {
|
||||||
|
t.Fatalf("where mismatch:\nwant %s\n got %s", want, where)
|
||||||
|
}
|
||||||
|
if len(args) != 6 || args[0] != "lalu" || args[1] != coinSellerAssetType || args[2] != coinSellerSubTransferBizType || args[3] != int64(100) || args[4] != int64(200) || args[5] != int64(3001) {
|
||||||
|
t.Fatalf("args mismatch: %#v", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCoinSellerLedgerWhereMapsAdminStockCredit(t *testing.T) {
|
func TestCoinSellerLedgerWhereMapsAdminStockCredit(t *testing.T) {
|
||||||
query := coinSellerLedgerQuery{LedgerType: coinSellerLedgerTypeAdminStockCredit}
|
query := coinSellerLedgerQuery{LedgerType: coinSellerLedgerTypeAdminStockCredit}
|
||||||
where, args, err := coinSellerLedgerWhere("lalu", query, []int64{3001, 3002})
|
where, args, err := coinSellerLedgerWhere("lalu", query, []int64{3001, 3002})
|
||||||
@ -187,10 +201,10 @@ func TestCoinSellerLedgerWhereEmptyTypeUsesAllPublicTypes(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("coin seller ledger where failed: %v", err)
|
t.Fatalf("coin seller ledger where failed: %v", err)
|
||||||
}
|
}
|
||||||
if want := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type IN (?,?,?,?,?,?,?)"; where != want {
|
if want := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type IN (?,?,?,?,?,?,?,?)"; where != want {
|
||||||
t.Fatalf("where mismatch:\nwant %s\n got %s", want, where)
|
t.Fatalf("where mismatch:\nwant %s\n got %s", want, where)
|
||||||
}
|
}
|
||||||
if len(args) != 9 || args[0] != "lalu" || args[1] != coinSellerAssetType || args[2] != coinSellerStockPurchaseBizType || args[3] != coinSellerStockDeductionBizType || args[4] != coinSellerRechargeBizType || args[5] != coinSellerCoinCompensationBizType || args[6] != coinSellerTransferBizType || args[7] != salaryTransferToCoinSellerBizType || args[8] != coinManualCreditBizType {
|
if len(args) != 10 || args[0] != "lalu" || args[1] != coinSellerAssetType || args[2] != coinSellerStockPurchaseBizType || args[3] != coinSellerStockDeductionBizType || args[4] != coinSellerRechargeBizType || args[5] != coinSellerCoinCompensationBizType || args[6] != coinSellerTransferBizType || args[7] != coinSellerSubTransferBizType || args[8] != salaryTransferToCoinSellerBizType || args[9] != coinManualCreditBizType {
|
||||||
t.Fatalf("args mismatch: %#v", args)
|
t.Fatalf("args mismatch: %#v", args)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -207,6 +221,13 @@ func TestCoinSellerLedgerReceiverUserIDUsesActualReceiver(t *testing.T) {
|
|||||||
if got := coinSellerLedgerReceiverUserID(coinSellerTransferBizType, 3001, 0, metadata); got != 4001 {
|
if got := coinSellerLedgerReceiverUserID(coinSellerTransferBizType, 3001, 0, metadata); got != 4001 {
|
||||||
t.Fatalf("seller transfer receiver mismatch: %d", got)
|
t.Fatalf("seller transfer receiver mismatch: %d", got)
|
||||||
}
|
}
|
||||||
|
subTransferMetadata := map[string]any{"child_user_id": float64(5001)}
|
||||||
|
if got := coinSellerLedgerReceiverUserID(coinSellerSubTransferBizType, 3001, 4001, subTransferMetadata); got != 5001 {
|
||||||
|
t.Fatalf("sub seller transfer receiver mismatch: %d", got)
|
||||||
|
}
|
||||||
|
if got := coinSellerLedgerReceiverUserID(coinSellerSubTransferBizType, 5001, 3001, subTransferMetadata); got != 5001 {
|
||||||
|
t.Fatalf("sub seller income receiver mismatch: %d", got)
|
||||||
|
}
|
||||||
if got := coinSellerLedgerReceiverUserID(coinSellerStockPurchaseBizType, 3001, 4001, metadata); got != 3001 {
|
if got := coinSellerLedgerReceiverUserID(coinSellerStockPurchaseBizType, 3001, 4001, metadata); got != 3001 {
|
||||||
t.Fatalf("admin stock receiver mismatch: %d", got)
|
t.Fatalf("admin stock receiver mismatch: %d", got)
|
||||||
}
|
}
|
||||||
@ -245,6 +266,7 @@ func TestCoinSellerLedgerLabelUsesConcreteStockAndDebitType(t *testing.T) {
|
|||||||
{name: "compensation", item: coinSellerLedgerDTO{BizType: coinSellerCoinCompensationBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit}, want: "金币补偿"},
|
{name: "compensation", item: coinSellerLedgerDTO{BizType: coinSellerCoinCompensationBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit}, want: "金币补偿"},
|
||||||
{name: "debit", item: coinSellerLedgerDTO{BizType: coinManualCreditBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit, AvailableDelta: -100}, want: "金币扣除"},
|
{name: "debit", item: coinSellerLedgerDTO{BizType: coinManualCreditBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit, AvailableDelta: -100}, want: "金币扣除"},
|
||||||
{name: "manual increase", item: coinSellerLedgerDTO{BizType: coinManualCreditBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit, AvailableDelta: 100}, want: "金币增加"},
|
{name: "manual increase", item: coinSellerLedgerDTO{BizType: coinManualCreditBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit, AvailableDelta: 100}, want: "金币增加"},
|
||||||
|
{name: "sub seller transfer", item: coinSellerLedgerDTO{BizType: coinSellerSubTransferBizType, LedgerType: coinSellerLedgerTypeSubSellerTransfer}, want: "向子币商转账"},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
if got := coinSellerLedgerLabel(tc.item); got != tc.want {
|
if got := coinSellerLedgerLabel(tc.item); got != tc.want {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package dashboard
|
package dashboard
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@ -11,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"hyapp-admin-server/internal/config"
|
"hyapp-admin-server/internal/config"
|
||||||
"hyapp-admin-server/internal/integration/userclient"
|
"hyapp-admin-server/internal/integration/userclient"
|
||||||
|
"hyapp-admin-server/internal/model"
|
||||||
"hyapp-admin-server/internal/repository"
|
"hyapp-admin-server/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -262,6 +264,26 @@ func (s *DashboardService) SocialRequirements(ctx context.Context, query SocialR
|
|||||||
return s.statisticsGET(ctx, "/internal/v1/statistics/social/requirements", values)
|
return s.statisticsGET(ctx, "/internal/v1/statistics/social/requirements", values)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *DashboardService) RecordFinanceCoinSellerRechargeOrder(ctx context.Context, order model.CoinSellerRechargeOrder) error {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// statistics-service 只接收已校验订单快照;admin 是财务工单事实 owner,不能让统计服务回查 admin 表。
|
||||||
|
payload := map[string]any{
|
||||||
|
"app_code": order.AppCode,
|
||||||
|
"event_id": fmt.Sprintf("finance_coin_seller_recharge_order:%d", order.ID),
|
||||||
|
"order_id": order.ID,
|
||||||
|
"verify_status": order.VerifyStatus,
|
||||||
|
"target_user_id": order.TargetUserID,
|
||||||
|
"target_country_id": order.TargetCountryID,
|
||||||
|
"target_region_id": order.TargetRegionID,
|
||||||
|
"usd_minor": order.USDMinorAmount,
|
||||||
|
"coin_amount": order.CoinAmount,
|
||||||
|
"created_at_ms": order.CreatedAtMS,
|
||||||
|
}
|
||||||
|
return s.statisticsPOST(ctx, "/internal/v1/statistics/social/coin-seller-recharge-orders", payload)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *DashboardService) PlatformGrantUsers(ctx context.Context, query PlatformGrantStatisticsQuery) (map[string]any, error) {
|
func (s *DashboardService) PlatformGrantUsers(ctx context.Context, query PlatformGrantStatisticsQuery) (map[string]any, error) {
|
||||||
page, err := s.statisticsGET(ctx, "/internal/v1/statistics/platform-grants/users", platformGrantValues(query, false))
|
page, err := s.statisticsGET(ctx, "/internal/v1/statistics/platform-grants/users", platformGrantValues(query, false))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -307,6 +329,29 @@ func (s *DashboardService) statisticsGET(ctx context.Context, path string, value
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *DashboardService) statisticsPOST(ctx context.Context, path string, payload any) error {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.StatisticsService.BaseURL+path, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := s.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
var out map[string]any
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||||
|
return fmt.Errorf("statistics service returned status %d: %v", resp.StatusCode, out["error"])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func platformGrantValues(query PlatformGrantStatisticsQuery, includeUser bool) url.Values {
|
func platformGrantValues(query PlatformGrantStatisticsQuery, includeUser bool) url.Values {
|
||||||
values := url.Values{}
|
values := url.Values{}
|
||||||
values.Set("app_code", query.AppCode)
|
values.Set("app_code", query.AppCode)
|
||||||
|
|||||||
@ -20,12 +20,18 @@ type createCoinSellerRechargeOrderRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type verifyCoinSellerRechargeReceiptRequest struct {
|
type verifyCoinSellerRechargeReceiptRequest struct {
|
||||||
AppCode string `json:"appCode"`
|
AppCode string `json:"appCode"`
|
||||||
USDAmount float64 `json:"usdAmount"`
|
USDAmount float64 `json:"usdAmount"`
|
||||||
USDMinorAmount int64 `json:"usdMinorAmount"`
|
USDMinorAmount int64 `json:"usdMinorAmount"`
|
||||||
ProviderCode string `json:"providerCode"`
|
ProviderCode string `json:"providerCode"`
|
||||||
Chain string `json:"chain"`
|
Chain string `json:"chain"`
|
||||||
ExternalOrderNo string `json:"externalOrderNo"`
|
ExternalOrderNo string `json:"externalOrderNo"`
|
||||||
|
ProviderCountryCode string `json:"providerCountryCode"`
|
||||||
|
ProviderCurrencyCode string `json:"providerCurrencyCode"`
|
||||||
|
ProviderAmountMinor int64 `json:"providerAmountMinor"`
|
||||||
|
ProviderPayType string `json:"providerPayType"`
|
||||||
|
OrderDate string `json:"orderDate"`
|
||||||
|
OrderDateMS int64 `json:"orderDateMs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type coinSellerRechargeReceiptVerificationDTO struct {
|
type coinSellerRechargeReceiptVerificationDTO struct {
|
||||||
|
|||||||
174
server/admin/internal/modules/financeorder/legacy_writer_test.go
Normal file
174
server/admin/internal/modules/financeorder/legacy_writer_test.go
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
package financeorder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"hyapp-admin-server/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLegacyCoinSellerRechargeWriterGrantUpdatesBalanceAndRunningWater(t *testing.T) {
|
||||||
|
walletDB, walletMock, cleanup := newLegacyWriterSQLMock(t)
|
||||||
|
defer cleanup()
|
||||||
|
writer := NewMySQLLegacyCoinSellerRechargeWriter(config.LegacyCoinSellerRechargeSourceConfig{
|
||||||
|
AppCode: "aslan",
|
||||||
|
SysOrigin: "ATYOU",
|
||||||
|
RequestTimeout: 5 * time.Second,
|
||||||
|
}, nil, walletDB)
|
||||||
|
grantedAt := time.Date(2026, 7, 9, 9, 30, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
walletMock.ExpectBegin()
|
||||||
|
expectLegacyBalanceLock(walletMock, "ATYOU", 10001).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "earn_points", "consumption_points"}).AddRow(int64(77), "5000", "1000"))
|
||||||
|
walletMock.ExpectQuery(`(?s)SELECT id\s+FROM user_freight_balance_running_water\s+WHERE sys_origin = \? AND remark = \?\s+LIMIT 1`).
|
||||||
|
WithArgs("ATYOU", "MIFA-001").
|
||||||
|
WillReturnError(sql.ErrNoRows)
|
||||||
|
walletMock.ExpectExec(`(?s)UPDATE user_freight_balance\s+SET earn_points = COALESCE\(earn_points, 0\) \+ \?, update_user = \?, update_time = \?\s+WHERE id = \?`).
|
||||||
|
WithArgs(int64(1200), int64(10001), grantedAt, int64(77)).
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
walletMock.ExpectExec(`(?s)INSERT INTO user_freight_balance_running_water`).
|
||||||
|
WithArgs(sqlmock.AnyArg(), "ATYOU", int64(10001), int64(10001), nil, int64(1200), int64(5200), "12.34", "MIFAPAY-进货", "MIFA-001", int64(10001), int64(10001), grantedAt, grantedAt).
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
walletMock.ExpectCommit()
|
||||||
|
|
||||||
|
result, err := writer.GrantCoinSellerRecharge(context.Background(), legacyRechargeGrantInput{
|
||||||
|
UserID: 10001,
|
||||||
|
ExternalOrderNo: "MIFA-001",
|
||||||
|
ProviderCode: "mifapay",
|
||||||
|
USDAmount: "12.34",
|
||||||
|
CoinAmount: 1200,
|
||||||
|
GrantedAt: grantedAt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("legacy grant failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(result.TransactionID, "legacy-freight-purchase-") || result.BalanceAfter != 5200 {
|
||||||
|
t.Fatalf("legacy grant result mismatch: %+v", result)
|
||||||
|
}
|
||||||
|
if err := walletMock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("wallet sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLegacyCoinSellerRechargeWriterGrantRejectsDuplicateOrder(t *testing.T) {
|
||||||
|
walletDB, walletMock, cleanup := newLegacyWriterSQLMock(t)
|
||||||
|
defer cleanup()
|
||||||
|
writer := NewMySQLLegacyCoinSellerRechargeWriter(config.LegacyCoinSellerRechargeSourceConfig{
|
||||||
|
AppCode: "yumi",
|
||||||
|
SysOrigin: "LIKEI",
|
||||||
|
RequestTimeout: 5 * time.Second,
|
||||||
|
}, nil, walletDB)
|
||||||
|
|
||||||
|
walletMock.ExpectBegin()
|
||||||
|
expectLegacyBalanceLock(walletMock, "LIKEI", 20002).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "earn_points", "consumption_points"}).AddRow(int64(88), "7000", "0"))
|
||||||
|
walletMock.ExpectQuery(`(?s)SELECT id\s+FROM user_freight_balance_running_water\s+WHERE sys_origin = \? AND remark = \?\s+LIMIT 1`).
|
||||||
|
WithArgs("LIKEI", "V5-001").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(9001)))
|
||||||
|
walletMock.ExpectRollback()
|
||||||
|
|
||||||
|
_, err := writer.GrantCoinSellerRecharge(context.Background(), legacyRechargeGrantInput{
|
||||||
|
UserID: 20002,
|
||||||
|
ExternalOrderNo: "V5-001",
|
||||||
|
ProviderCode: "v5pay",
|
||||||
|
USDAmount: "10.00",
|
||||||
|
CoinAmount: 1000,
|
||||||
|
GrantedAt: time.Date(2026, 7, 9, 10, 0, 0, 0, time.UTC),
|
||||||
|
})
|
||||||
|
if !errors.Is(err, errLegacyCoinSellerOrderDuplicate) {
|
||||||
|
t.Fatalf("expected duplicate order error, got %v", err)
|
||||||
|
}
|
||||||
|
if err := walletMock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("wallet sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLegacyCoinSellerRechargeWriterResolveCoinSellerByAccountRequiresDealer(t *testing.T) {
|
||||||
|
appDB, appMock, cleanupApp := newLegacyWriterSQLMock(t)
|
||||||
|
defer cleanupApp()
|
||||||
|
walletDB, walletMock, cleanupWallet := newLegacyWriterSQLMock(t)
|
||||||
|
defer cleanupWallet()
|
||||||
|
writer := NewMySQLLegacyCoinSellerRechargeWriter(config.LegacyCoinSellerRechargeSourceConfig{
|
||||||
|
AppCode: "aslan",
|
||||||
|
SysOrigin: "ATYOU",
|
||||||
|
RequestTimeout: 5 * time.Second,
|
||||||
|
}, appDB, walletDB)
|
||||||
|
|
||||||
|
expectLegacyUserLookup(appMock, "ATYOU", "agent001").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "account", "country_id"}).AddRow(int64(10001), "agent001", int64(63)))
|
||||||
|
expectLegacyCoinSellerCheck(walletMock, "ATYOU", 10001).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(77)))
|
||||||
|
|
||||||
|
target, err := writer.ResolveCoinSeller(context.Background(), " agent001 ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve legacy coin seller failed: %v", err)
|
||||||
|
}
|
||||||
|
if target.UserID != 10001 || target.DisplayUserID != "agent001" || target.CountryID != 63 {
|
||||||
|
t.Fatalf("legacy coin seller target mismatch: %+v", target)
|
||||||
|
}
|
||||||
|
if err := appMock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("app sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
if err := walletMock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("wallet sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLegacyCoinSellerRechargeWriterResolveCoinSellerRejectsNonDealer(t *testing.T) {
|
||||||
|
appDB, appMock, cleanupApp := newLegacyWriterSQLMock(t)
|
||||||
|
defer cleanupApp()
|
||||||
|
walletDB, walletMock, cleanupWallet := newLegacyWriterSQLMock(t)
|
||||||
|
defer cleanupWallet()
|
||||||
|
writer := NewMySQLLegacyCoinSellerRechargeWriter(config.LegacyCoinSellerRechargeSourceConfig{
|
||||||
|
AppCode: "yumi",
|
||||||
|
SysOrigin: "LIKEI",
|
||||||
|
RequestTimeout: 5 * time.Second,
|
||||||
|
}, appDB, walletDB)
|
||||||
|
|
||||||
|
expectLegacyUserLookup(appMock, "LIKEI", "visitor001").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "account", "country_id"}).AddRow(int64(20002), "visitor001", int64(86)))
|
||||||
|
expectLegacyCoinSellerCheck(walletMock, "LIKEI", 20002).
|
||||||
|
WillReturnError(sql.ErrNoRows)
|
||||||
|
|
||||||
|
_, err := writer.ResolveCoinSeller(context.Background(), "visitor001")
|
||||||
|
if !errors.Is(err, errLegacyCoinSellerNotFound) {
|
||||||
|
t.Fatalf("expected non-dealer legacy user to be rejected, got %v", err)
|
||||||
|
}
|
||||||
|
if err := appMock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("app sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
if err := walletMock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("wallet sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectLegacyBalanceLock(mock sqlmock.Sqlmock, sysOrigin string, userID int64) *sqlmock.ExpectedQuery {
|
||||||
|
return mock.ExpectQuery(`(?s)SELECT id, CAST\(COALESCE\(earn_points, 0\) AS CHAR\), CAST\(COALESCE\(consumption_points, 0\) AS CHAR\)\s+FROM user_freight_balance\s+WHERE sys_origin = \? AND user_id = \?.*FOR UPDATE`).
|
||||||
|
WithArgs(sysOrigin, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectLegacyUserLookup(mock sqlmock.Sqlmock, sysOrigin string, keyword string) *sqlmock.ExpectedQuery {
|
||||||
|
return mock.ExpectQuery(`(?s)SELECT id, account, COALESCE\(country_id, 0\)\s+FROM user_base_info\s+WHERE origin_sys = \? AND \(account = \? OR CAST\(id AS CHAR\) = \?\).*LIMIT 1`).
|
||||||
|
WithArgs(sysOrigin, keyword, keyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectLegacyCoinSellerCheck(mock sqlmock.Sqlmock, sysOrigin string, userID int64) *sqlmock.ExpectedQuery {
|
||||||
|
return mock.ExpectQuery(`(?s)SELECT id\s+FROM user_freight_balance\s+WHERE sys_origin = \? AND user_id = \? AND COALESCE\(is_close, 0\) = 0 AND \(COALESCE\(dealer, 0\) = 1 OR COALESCE\(super_dealer, 0\) = 1\)\s+LIMIT 1`).
|
||||||
|
WithArgs(sysOrigin, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLegacyWriterSQLMock(t *testing.T) (*sql.DB, sqlmock.Sqlmock, func()) {
|
||||||
|
t.Helper()
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
return db, mock, func() {
|
||||||
|
_ = db.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@ -45,11 +46,22 @@ type Service struct {
|
|||||||
wallet walletclient.Client
|
wallet walletclient.Client
|
||||||
user userclient.Client
|
user userclient.Client
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
statsRecorder financeCoinSellerRechargeStatsRecorder
|
||||||
legacyWriters map[string]LegacyCoinSellerRechargeWriter
|
legacyWriters map[string]LegacyCoinSellerRechargeWriter
|
||||||
}
|
}
|
||||||
|
|
||||||
type Option func(*Service)
|
type Option func(*Service)
|
||||||
|
|
||||||
|
type financeCoinSellerRechargeStatsRecorder interface {
|
||||||
|
RecordFinanceCoinSellerRechargeOrder(ctx context.Context, order model.CoinSellerRechargeOrder) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithFinanceCoinSellerRechargeStatsRecorder(recorder financeCoinSellerRechargeStatsRecorder) Option {
|
||||||
|
return func(service *Service) {
|
||||||
|
service.statsRecorder = recorder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func WithLegacyCoinSellerRechargeWriters(writers ...LegacyCoinSellerRechargeWriter) Option {
|
func WithLegacyCoinSellerRechargeWriters(writers ...LegacyCoinSellerRechargeWriter) Option {
|
||||||
return func(service *Service) {
|
return func(service *Service) {
|
||||||
if service.legacyWriters == nil {
|
if service.legacyWriters == nil {
|
||||||
@ -160,6 +172,7 @@ func (s *Service) CreateOrder(ctx context.Context, actor shared.Actor, req creat
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
s.recordVerifiedCoinSellerRechargeOrder(ctx, *item)
|
||||||
dto := coinSellerRechargeOrderDTOFromModel(*item)
|
dto := coinSellerRechargeOrderDTOFromModel(*item)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
@ -235,6 +248,7 @@ func (s *Service) VerifyOrder(ctx context.Context, actor shared.Actor, id uint,
|
|||||||
OrderDateMs: order.OrderDateMS,
|
OrderDateMs: order.OrderDateMS,
|
||||||
ProviderCountryCode: order.ProviderCountryCode,
|
ProviderCountryCode: order.ProviderCountryCode,
|
||||||
ProviderCurrencyCode: order.ProviderCurrencyCode,
|
ProviderCurrencyCode: order.ProviderCurrencyCode,
|
||||||
|
ProviderAmountMinor: order.ProviderAmountMinor,
|
||||||
ProviderPayType: order.ProviderPayType,
|
ProviderPayType: order.ProviderPayType,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -263,6 +277,9 @@ func (s *Service) VerifyOrder(ctx context.Context, actor shared.Actor, id uint,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if updated != nil && updated.VerifyStatus == model.CoinSellerRechargeVerifyStatusVerified {
|
||||||
|
s.recordVerifiedCoinSellerRechargeOrder(ctx, *updated)
|
||||||
|
}
|
||||||
dto := coinSellerRechargeOrderDTOFromModel(*updated)
|
dto := coinSellerRechargeOrderDTOFromModel(*updated)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
@ -314,6 +331,16 @@ func (s *Service) GrantOrder(ctx context.Context, actor shared.Actor, id uint, r
|
|||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) recordVerifiedCoinSellerRechargeOrder(ctx context.Context, order model.CoinSellerRechargeOrder) {
|
||||||
|
if s == nil || s.statsRecorder == nil || order.ID == 0 || order.VerifyStatus != model.CoinSellerRechargeVerifyStatusVerified {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 统计投影是幂等的派生读模型;创建/校验主链路不能因为 statistics-service 短暂不可用而回滚已校验财务工单。
|
||||||
|
if err := s.statsRecorder.RecordFinanceCoinSellerRechargeOrder(ctx, order); err != nil {
|
||||||
|
slog.WarnContext(ctx, "finance_coin_seller_recharge_stats_record_failed", "order_id", order.ID, "app_code", order.AppCode, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type normalizedCreateOrderInput struct {
|
type normalizedCreateOrderInput struct {
|
||||||
AppCode string
|
AppCode string
|
||||||
TargetUserID string
|
TargetUserID string
|
||||||
@ -332,20 +359,30 @@ type normalizedCreateOrderInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type normalizedReceiptVerificationInput struct {
|
type normalizedReceiptVerificationInput struct {
|
||||||
AppCode string
|
AppCode string
|
||||||
USDMinorAmount int64
|
USDMinorAmount int64
|
||||||
ProviderCode string
|
ProviderCode string
|
||||||
Chain string
|
Chain string
|
||||||
ExternalOrderNo string
|
ExternalOrderNo string
|
||||||
|
ProviderCountryCode string
|
||||||
|
ProviderCurrencyCode string
|
||||||
|
ProviderAmountMinor int64
|
||||||
|
ProviderPayType string
|
||||||
|
OrderDateMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (input normalizedCreateOrderInput) receiptVerificationInput() normalizedReceiptVerificationInput {
|
func (input normalizedCreateOrderInput) receiptVerificationInput() normalizedReceiptVerificationInput {
|
||||||
return normalizedReceiptVerificationInput{
|
return normalizedReceiptVerificationInput{
|
||||||
AppCode: input.AppCode,
|
AppCode: input.AppCode,
|
||||||
USDMinorAmount: input.USDMinorAmount,
|
USDMinorAmount: input.USDMinorAmount,
|
||||||
ProviderCode: input.ProviderCode,
|
ProviderCode: input.ProviderCode,
|
||||||
Chain: input.Chain,
|
Chain: input.Chain,
|
||||||
ExternalOrderNo: input.ExternalOrderNo,
|
ExternalOrderNo: input.ExternalOrderNo,
|
||||||
|
ProviderCountryCode: input.ProviderCountryCode,
|
||||||
|
ProviderCurrencyCode: input.ProviderCurrencyCode,
|
||||||
|
ProviderAmountMinor: input.ProviderAmountMinor,
|
||||||
|
ProviderPayType: input.ProviderPayType,
|
||||||
|
OrderDateMS: input.OrderDateMS,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -386,6 +423,14 @@ func normalizeCreateOrderRequest(req createCoinSellerRechargeOrderRequest) (norm
|
|||||||
if chain == "" {
|
if chain == "" {
|
||||||
return normalizedCreateOrderInput{}, errors.New("USDT 充值必须选择链")
|
return normalizedCreateOrderInput{}, errors.New("USDT 充值必须选择链")
|
||||||
}
|
}
|
||||||
|
case "mifapay", "v5pay":
|
||||||
|
// 三方实付币种和金额是人工订单绑定的校验条件;缺失时会退化成只按订单号查,容易把错误 APP/币种的流水绑定进币商台账。
|
||||||
|
if providerCurrencyCode == "" {
|
||||||
|
return normalizedCreateOrderInput{}, errors.New("三方币种不能为空")
|
||||||
|
}
|
||||||
|
if req.ProviderAmountMinor <= 0 {
|
||||||
|
return normalizedCreateOrderInput{}, errors.New("三方实付金额不正确")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return normalizedCreateOrderInput{
|
return normalizedCreateOrderInput{
|
||||||
AppCode: appCode,
|
AppCode: appCode,
|
||||||
@ -429,6 +474,16 @@ func normalizeReceiptVerificationRequest(req verifyCoinSellerRechargeReceiptRequ
|
|||||||
if usdMinorAmount < 0 {
|
if usdMinorAmount < 0 {
|
||||||
return normalizedReceiptVerificationInput{}, errors.New("充值美金数量不正确")
|
return normalizedReceiptVerificationInput{}, errors.New("充值美金数量不正确")
|
||||||
}
|
}
|
||||||
|
if req.ProviderAmountMinor < 0 {
|
||||||
|
return normalizedReceiptVerificationInput{}, errors.New("三方实付金额不正确")
|
||||||
|
}
|
||||||
|
providerCountryCode := strings.ToUpper(strings.TrimSpace(req.ProviderCountryCode))
|
||||||
|
providerCurrencyCode := strings.ToUpper(strings.TrimSpace(req.ProviderCurrencyCode))
|
||||||
|
providerPayType := strings.TrimSpace(req.ProviderPayType)
|
||||||
|
orderDateMS, err := normalizeOrderDateMS(req.OrderDate, req.OrderDateMS)
|
||||||
|
if err != nil {
|
||||||
|
return normalizedReceiptVerificationInput{}, err
|
||||||
|
}
|
||||||
if providerCode == "usdt" {
|
if providerCode == "usdt" {
|
||||||
if chain == "" {
|
if chain == "" {
|
||||||
return normalizedReceiptVerificationInput{}, errors.New("USDT 充值必须选择链")
|
return normalizedReceiptVerificationInput{}, errors.New("USDT 充值必须选择链")
|
||||||
@ -436,13 +491,26 @@ func normalizeReceiptVerificationRequest(req verifyCoinSellerRechargeReceiptRequ
|
|||||||
if usdMinorAmount <= 0 {
|
if usdMinorAmount <= 0 {
|
||||||
return normalizedReceiptVerificationInput{}, errors.New("USDT 校验必须填写 USD 金额")
|
return normalizedReceiptVerificationInput{}, errors.New("USDT 校验必须填写 USD 金额")
|
||||||
}
|
}
|
||||||
|
} else if requiresProviderCurrencyAmount(providerCode) {
|
||||||
|
// 校验按钮是创建订单前的资金事实预检;MiFaPay/V5Pay 必须带运营填写的币种和实付金额,才能和 provider 回包做强比对。
|
||||||
|
if providerCurrencyCode == "" {
|
||||||
|
return normalizedReceiptVerificationInput{}, errors.New("三方币种不能为空")
|
||||||
|
}
|
||||||
|
if req.ProviderAmountMinor <= 0 {
|
||||||
|
return normalizedReceiptVerificationInput{}, errors.New("三方实付金额不正确")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return normalizedReceiptVerificationInput{
|
return normalizedReceiptVerificationInput{
|
||||||
AppCode: appCode,
|
AppCode: appCode,
|
||||||
USDMinorAmount: usdMinorAmount,
|
USDMinorAmount: usdMinorAmount,
|
||||||
ProviderCode: providerCode,
|
ProviderCode: providerCode,
|
||||||
Chain: chain,
|
Chain: chain,
|
||||||
ExternalOrderNo: externalOrderNo,
|
ExternalOrderNo: externalOrderNo,
|
||||||
|
ProviderCountryCode: providerCountryCode,
|
||||||
|
ProviderCurrencyCode: providerCurrencyCode,
|
||||||
|
ProviderAmountMinor: req.ProviderAmountMinor,
|
||||||
|
ProviderPayType: providerPayType,
|
||||||
|
OrderDateMS: orderDateMS,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -452,12 +520,17 @@ func (s *Service) verifyReceiptInput(ctx context.Context, input normalizedReceip
|
|||||||
}
|
}
|
||||||
// admin-server 不直连三方支付和链节点;wallet-service 统一持有 provider 配置、收款地址和查单适配器。
|
// admin-server 不直连三方支付和链节点;wallet-service 统一持有 provider 配置、收款地址和查单适配器。
|
||||||
resp, err := s.wallet.VerifyCoinSellerRechargeReceipt(appctx.WithContext(ctx, input.AppCode), &walletv1.VerifyCoinSellerRechargeReceiptRequest{
|
resp, err := s.wallet.VerifyCoinSellerRechargeReceipt(appctx.WithContext(ctx, input.AppCode), &walletv1.VerifyCoinSellerRechargeReceiptRequest{
|
||||||
RequestId: strings.TrimSpace(requestID),
|
RequestId: strings.TrimSpace(requestID),
|
||||||
AppCode: input.AppCode,
|
AppCode: input.AppCode,
|
||||||
ProviderCode: receiptVerificationProviderCode(input),
|
ProviderCode: receiptVerificationProviderCode(input),
|
||||||
ExternalOrderNo: input.ExternalOrderNo,
|
ExternalOrderNo: input.ExternalOrderNo,
|
||||||
Chain: receiptVerificationChain(input),
|
Chain: receiptVerificationChain(input),
|
||||||
UsdMinorAmount: input.USDMinorAmount,
|
UsdMinorAmount: input.USDMinorAmount,
|
||||||
|
OrderDateMs: input.OrderDateMS,
|
||||||
|
ProviderCountryCode: input.ProviderCountryCode,
|
||||||
|
ProviderCurrencyCode: input.ProviderCurrencyCode,
|
||||||
|
ProviderAmountMinor: input.ProviderAmountMinor,
|
||||||
|
ProviderPayType: input.ProviderPayType,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coinSellerRechargeReceiptVerificationDTO{}, err
|
return coinSellerRechargeReceiptVerificationDTO{}, err
|
||||||
@ -688,7 +761,7 @@ func verifyReceiptMatchesInput(input normalizedCreateOrderInput, receipt coinSel
|
|||||||
if !strings.EqualFold(receipt.ExternalOrderNo, input.ExternalOrderNo) {
|
if !strings.EqualFold(receipt.ExternalOrderNo, input.ExternalOrderNo) {
|
||||||
return errors.New("校验返回订单号不匹配")
|
return errors.New("校验返回订单号不匹配")
|
||||||
}
|
}
|
||||||
// 新流程不要求运营录入本地币种实付金额;非 USD 金额只作为 provider 快照留痕,不阻断创建。
|
// USD/USDT 继续按后台美元金额核验;MiFaPay/V5Pay 的本地币种实付金额由表单强制填写,创建前再次和 provider 回包比对。
|
||||||
currency := strings.ToUpper(strings.TrimSpace(firstNonEmpty(receipt.CurrencyCode, input.ProviderCurrencyCode)))
|
currency := strings.ToUpper(strings.TrimSpace(firstNonEmpty(receipt.CurrencyCode, input.ProviderCurrencyCode)))
|
||||||
if input.ProviderCurrencyCode != "" && currency != "" && !strings.EqualFold(currency, input.ProviderCurrencyCode) {
|
if input.ProviderCurrencyCode != "" && currency != "" && !strings.EqualFold(currency, input.ProviderCurrencyCode) {
|
||||||
return errors.New("校验返回币种不匹配")
|
return errors.New("校验返回币种不匹配")
|
||||||
@ -803,6 +876,15 @@ func receiptVerificationProviderCode(input normalizedReceiptVerificationInput) s
|
|||||||
return receiptProviderCode(model.CoinSellerRechargeOrder{ProviderCode: input.ProviderCode, Chain: input.Chain})
|
return receiptProviderCode(model.CoinSellerRechargeOrder{ProviderCode: input.ProviderCode, Chain: input.Chain})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func requiresProviderCurrencyAmount(providerCode string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(providerCode)) {
|
||||||
|
case "mifapay", "v5pay":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func receiptChain(order model.CoinSellerRechargeOrder) string {
|
func receiptChain(order model.CoinSellerRechargeOrder) string {
|
||||||
switch strings.ToUpper(strings.TrimSpace(order.Chain)) {
|
switch strings.ToUpper(strings.TrimSpace(order.Chain)) {
|
||||||
case "TRON":
|
case "TRON":
|
||||||
|
|||||||
@ -40,9 +40,9 @@ func TestNormalizeCreateOrderRequiresExplicitUSDTChain(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeCreateOrderDoesNotRequireProviderHints(t *testing.T) {
|
func TestNormalizeCreateOrderRequiresProviderCurrencyAmount(t *testing.T) {
|
||||||
for _, providerCode := range []string{"mifapay", "v5pay"} {
|
for _, providerCode := range []string{"mifapay", "v5pay"} {
|
||||||
input, err := normalizeCreateOrderRequest(createCoinSellerRechargeOrderRequest{
|
_, err := normalizeCreateOrderRequest(createCoinSellerRechargeOrderRequest{
|
||||||
AppCode: "lalu",
|
AppCode: "lalu",
|
||||||
TargetUserID: "10001",
|
TargetUserID: "10001",
|
||||||
USDAmount: 12.34,
|
USDAmount: 12.34,
|
||||||
@ -50,24 +50,73 @@ func TestNormalizeCreateOrderDoesNotRequireProviderHints(t *testing.T) {
|
|||||||
ProviderCode: providerCode,
|
ProviderCode: providerCode,
|
||||||
ExternalOrderNo: providerCode + "-order",
|
ExternalOrderNo: providerCode + "-order",
|
||||||
})
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "三方币种") {
|
||||||
|
t.Fatalf("expected %s missing currency error, got %v", providerCode, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
input, err := normalizeCreateOrderRequest(createCoinSellerRechargeOrderRequest{
|
||||||
|
AppCode: "lalu",
|
||||||
|
TargetUserID: "10001",
|
||||||
|
USDAmount: 12.34,
|
||||||
|
CoinAmount: 1200,
|
||||||
|
ProviderCode: providerCode,
|
||||||
|
ExternalOrderNo: providerCode + "-order",
|
||||||
|
ProviderCurrencyCode: " php ",
|
||||||
|
ProviderAmountMinor: 123400,
|
||||||
|
ProviderPayType: "GCASH",
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("normalize %s without provider hints failed: %v", providerCode, err)
|
t.Fatalf("normalize %s with provider currency amount failed: %v", providerCode, err)
|
||||||
}
|
}
|
||||||
if input.ProviderCurrencyCode != "" || input.ProviderCountryCode != "" || input.OrderDateMS != 0 {
|
if input.ProviderCurrencyCode != "PHP" || input.ProviderAmountMinor != 123400 || input.ProviderPayType != "GCASH" {
|
||||||
t.Fatalf("provider hints should remain optional for %s: %+v", providerCode, input)
|
t.Fatalf("provider currency amount should be normalized for %s: %+v", providerCode, input)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeReceiptVerificationCarriesProviderCurrencyAmount(t *testing.T) {
|
||||||
|
input, err := normalizeReceiptVerificationRequest(verifyCoinSellerRechargeReceiptRequest{
|
||||||
|
AppCode: "lalu",
|
||||||
|
USDAmount: 10,
|
||||||
|
ProviderCode: "v5pay",
|
||||||
|
ExternalOrderNo: "V5-001",
|
||||||
|
ProviderCountryCode: " ph ",
|
||||||
|
ProviderCurrencyCode: " php ",
|
||||||
|
ProviderAmountMinor: 560000,
|
||||||
|
ProviderPayType: "GCASH",
|
||||||
|
OrderDate: "2026-07-09",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize receipt verification failed: %v", err)
|
||||||
|
}
|
||||||
|
if input.USDMinorAmount != 1000 || input.ProviderCountryCode != "PH" || input.ProviderCurrencyCode != "PHP" || input.ProviderAmountMinor != 560000 || input.ProviderPayType != "GCASH" {
|
||||||
|
t.Fatalf("provider verification input mismatch: %+v", input)
|
||||||
|
}
|
||||||
|
if input.OrderDateMS <= 0 {
|
||||||
|
t.Fatalf("order date should be normalized into millis: %+v", input)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = normalizeReceiptVerificationRequest(verifyCoinSellerRechargeReceiptRequest{
|
||||||
|
AppCode: "lalu",
|
||||||
|
ProviderCode: "mifapay",
|
||||||
|
ExternalOrderNo: "MIFA-001",
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "三方币种") {
|
||||||
|
t.Fatalf("expected missing provider currency error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeCreateOrderAllowsZeroCoinAmount(t *testing.T) {
|
func TestNormalizeCreateOrderAllowsZeroCoinAmount(t *testing.T) {
|
||||||
input, err := normalizeCreateOrderRequest(createCoinSellerRechargeOrderRequest{
|
input, err := normalizeCreateOrderRequest(createCoinSellerRechargeOrderRequest{
|
||||||
AppCode: "lalu",
|
AppCode: "lalu",
|
||||||
TargetUserID: "10001",
|
TargetUserID: "10001",
|
||||||
USDAmount: 12.34,
|
USDAmount: 12.34,
|
||||||
CoinAmount: 0,
|
CoinAmount: 0,
|
||||||
ProviderCode: "mifapay",
|
ProviderCode: "mifapay",
|
||||||
ExternalOrderNo: "MIFA-001",
|
ExternalOrderNo: "MIFA-001",
|
||||||
Remark: " 补单,不发金币 ",
|
ProviderCurrencyCode: "PHP",
|
||||||
|
ProviderAmountMinor: 1234,
|
||||||
|
Remark: " 补单,不发金币 ",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("zero coin makeup order should be accepted: %v", err)
|
t.Fatalf("zero coin makeup order should be accepted: %v", err)
|
||||||
@ -93,6 +142,70 @@ func TestExecuteGrantSkipsWalletAndLegacyForZeroCoinMakeupOrder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteGrantUsesLegacyWriterForAslan(t *testing.T) {
|
||||||
|
writer := &fakeLegacyCoinSellerRechargeWriter{
|
||||||
|
appCode: "aslan",
|
||||||
|
grantResult: legacyRechargeGrantResult{
|
||||||
|
TransactionID: "legacy-tx-1",
|
||||||
|
BalanceAfter: 6200,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
service := NewService(nil, nil, nil, WithLegacyCoinSellerRechargeWriters(writer))
|
||||||
|
result, err := service.executeGrant(context.Background(), shared.Actor{UserID: 7}, model.CoinSellerRechargeOrder{
|
||||||
|
ID: 99,
|
||||||
|
AppCode: "aslan",
|
||||||
|
TargetUserID: 10001,
|
||||||
|
ExternalOrderNo: "V5-001",
|
||||||
|
ProviderCode: "v5pay",
|
||||||
|
USDAmount: "10.00",
|
||||||
|
CoinAmount: 1200,
|
||||||
|
}, "req-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("aslan legacy grant failed: %v", err)
|
||||||
|
}
|
||||||
|
if writer.grantCalls != 1 || writer.grantInput.UserID != 10001 || writer.grantInput.ExternalOrderNo != "V5-001" || writer.grantInput.OperatorUserID != 7 {
|
||||||
|
t.Fatalf("legacy writer grant input mismatch: calls=%d input=%+v", writer.grantCalls, writer.grantInput)
|
||||||
|
}
|
||||||
|
if result.CommandID != "admin-coin-seller-recharge:99" || result.TransactionID != "legacy-tx-1" || result.AmountDelta != 1200 || result.BalanceAfter != 6200 {
|
||||||
|
t.Fatalf("legacy grant result mismatch: %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteGrantRequiresLegacyWriterForYumi(t *testing.T) {
|
||||||
|
service := NewService(nil, nil, nil)
|
||||||
|
_, err := service.executeGrant(context.Background(), shared.Actor{UserID: 7}, model.CoinSellerRechargeOrder{
|
||||||
|
ID: 100,
|
||||||
|
AppCode: "yumi",
|
||||||
|
TargetUserID: 20002,
|
||||||
|
ExternalOrderNo: "MIFA-001",
|
||||||
|
ProviderCode: "mifapay",
|
||||||
|
USDAmount: "12.34",
|
||||||
|
CoinAmount: 1000,
|
||||||
|
}, "req-1")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "legacy coin seller recharge writer is not configured") {
|
||||||
|
t.Fatalf("expected yumi legacy writer configuration error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCoinSellerTargetUsesLegacyWriterForAslan(t *testing.T) {
|
||||||
|
writer := &fakeLegacyCoinSellerRechargeWriter{
|
||||||
|
appCode: "aslan",
|
||||||
|
resolveResult: coinSellerTarget{
|
||||||
|
UserID: 10001,
|
||||||
|
DisplayUserID: "agent001",
|
||||||
|
CountryID: 63,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
service := NewService(nil, nil, nil, WithLegacyCoinSellerRechargeWriters(writer))
|
||||||
|
target, err := service.resolveCoinSellerTarget(context.Background(), "req-1", "aslan", "agent001")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve aslan legacy target failed: %v", err)
|
||||||
|
}
|
||||||
|
if writer.resolveKeyword != "agent001" || target.UserID != 10001 || target.DisplayUserID != "agent001" || target.CountryID != 63 {
|
||||||
|
t.Fatalf("legacy resolve mismatch: keyword=%q target=%+v", writer.resolveKeyword, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVerifyReceiptMatchesOrderAmounts(t *testing.T) {
|
func TestVerifyReceiptMatchesOrderAmounts(t *testing.T) {
|
||||||
order := model.CoinSellerRechargeOrder{
|
order := model.CoinSellerRechargeOrder{
|
||||||
ExternalOrderNo: "merchant-1",
|
ExternalOrderNo: "merchant-1",
|
||||||
@ -209,3 +322,33 @@ func TestReceiptProviderCodeForUSDTChains(t *testing.T) {
|
|||||||
t.Fatalf("bsc provider code mismatch: %s", got)
|
t.Fatalf("bsc provider code mismatch: %s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeLegacyCoinSellerRechargeWriter struct {
|
||||||
|
appCode string
|
||||||
|
resolveKeyword string
|
||||||
|
resolveResult coinSellerTarget
|
||||||
|
resolveErr error
|
||||||
|
grantCalls int
|
||||||
|
grantInput legacyRechargeGrantInput
|
||||||
|
grantResult legacyRechargeGrantResult
|
||||||
|
grantErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeLegacyCoinSellerRechargeWriter) AppCode() string {
|
||||||
|
return f.appCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeLegacyCoinSellerRechargeWriter) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeLegacyCoinSellerRechargeWriter) ResolveCoinSeller(_ context.Context, keyword string) (coinSellerTarget, error) {
|
||||||
|
f.resolveKeyword = keyword
|
||||||
|
return f.resolveResult, f.resolveErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeLegacyCoinSellerRechargeWriter) GrantCoinSellerRecharge(_ context.Context, input legacyRechargeGrantInput) (legacyRechargeGrantResult, error) {
|
||||||
|
f.grantCalls++
|
||||||
|
f.grantInput = input
|
||||||
|
return f.grantResult, f.grantErr
|
||||||
|
}
|
||||||
|
|||||||
@ -142,6 +142,64 @@ func (h *Handler) ListCoinSellers(c *gin.Context) {
|
|||||||
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
|
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListCoinSellerSubApplications(c *gin.Context) {
|
||||||
|
query, ok := parseListQuery(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, total, err := h.service.ListCoinSellerSubApplications(c.Request.Context(), query)
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "获取子币商申请列表失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListCoinSellerSubSellers(c *gin.Context) {
|
||||||
|
parentUserID, ok := parseInt64ID(c, "user_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query, ok := parseListQuery(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, total, err := h.service.ListCoinSellerSubSellers(c.Request.Context(), parentUserID, query)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ApproveCoinSellerSubApplication(c *gin.Context) {
|
||||||
|
h.reviewCoinSellerSubApplication(c, "approved")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RejectCoinSellerSubApplication(c *gin.Context) {
|
||||||
|
h.reviewCoinSellerSubApplication(c, "rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) reviewCoinSellerSubApplication(c *gin.Context, decision string) {
|
||||||
|
applicationID, ok := parseInt64ID(c, "application_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req coinSellerSubApplicationReviewRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "子币商申请审核参数不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.service.ReviewCoinSellerSubApplication(c.Request.Context(), adminActorID(c), applicationID, middleware.CurrentRequestID(c), decision, req)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeHostOrgAuditLog(c, h.audit, "review-coin-seller-sub-application", "coin_seller_sub_applications", applicationID,
|
||||||
|
fmt.Sprintf("command_id=%s application_id=%d decision=%s reason=%q", strings.TrimSpace(req.CommandID), applicationID, decision, strings.TrimSpace(req.Reason)))
|
||||||
|
response.OK(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) ListSalaryWalletHistory(c *gin.Context) {
|
func (h *Handler) ListSalaryWalletHistory(c *gin.Context) {
|
||||||
query, ok := parseSalaryWalletHistoryQuery(c)
|
query, ok := parseSalaryWalletHistoryQuery(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@ -540,6 +598,12 @@ func parseListQuery(c *gin.Context) (listQuery, bool) {
|
|||||||
if query.ParentLeaderUserID, ok = parseOptionalInt64Query(c, "parentLeaderUserId", "parent_leader_user_id"); !ok {
|
if query.ParentLeaderUserID, ok = parseOptionalInt64Query(c, "parentLeaderUserId", "parent_leader_user_id"); !ok {
|
||||||
return listQuery{}, false
|
return listQuery{}, false
|
||||||
}
|
}
|
||||||
|
if query.ParentUserID, ok = parseOptionalInt64Query(c, "parentUserId", "parent_user_id"); !ok {
|
||||||
|
return listQuery{}, false
|
||||||
|
}
|
||||||
|
if query.TargetUserID, ok = parseOptionalInt64Query(c, "targetUserId", "target_user_id"); !ok {
|
||||||
|
return listQuery{}, false
|
||||||
|
}
|
||||||
return normalizeListQuery(query), true
|
return normalizeListQuery(query), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -33,6 +33,7 @@ type CoinSellerListItem struct {
|
|||||||
TotalRechargeUSDTMicro int64 `json:"totalRechargeUsdtMicro"`
|
TotalRechargeUSDTMicro int64 `json:"totalRechargeUsdtMicro"`
|
||||||
Contact string `json:"contact"`
|
Contact string `json:"contact"`
|
||||||
CanManageSubCoinSellers bool `json:"canManageSubCoinSellers"`
|
CanManageSubCoinSellers bool `json:"canManageSubCoinSellers"`
|
||||||
|
SubCoinSellerCount int64 `json:"subCoinSellerCount"`
|
||||||
DisplayUserID string `json:"displayUserId"`
|
DisplayUserID string `json:"displayUserId"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
@ -43,6 +44,29 @@ type CoinSellerListItem struct {
|
|||||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CoinSellerSubApplicationListItem struct {
|
||||||
|
ApplicationID int64 `json:"applicationId,string"`
|
||||||
|
CommandID string `json:"commandId"`
|
||||||
|
Parent UserIdentity `json:"parent"`
|
||||||
|
Target UserIdentity `json:"target"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
RelationID int64 `json:"relationId,string"`
|
||||||
|
ReviewedByAdminID int64 `json:"reviewedByAdminId,string"`
|
||||||
|
ReviewReason string `json:"reviewReason"`
|
||||||
|
ReviewedAtMs int64 `json:"reviewedAtMs"`
|
||||||
|
CreatedAtMs int64 `json:"createdAtMs"`
|
||||||
|
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserIdentity struct {
|
||||||
|
UserID int64 `json:"userId,string"`
|
||||||
|
DisplayUserID string `json:"displayUserId"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
RegionID int64 `json:"regionId"`
|
||||||
|
RegionName string `json:"regionName"`
|
||||||
|
}
|
||||||
|
|
||||||
type ManagerListItem struct {
|
type ManagerListItem struct {
|
||||||
UserID int64 `json:"userId,string"`
|
UserID int64 `json:"userId,string"`
|
||||||
DisplayUserID string `json:"displayUserId"`
|
DisplayUserID string `json:"displayUserId"`
|
||||||
@ -1101,6 +1125,22 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
|
|||||||
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.can_manage_sub_coin_sellers,
|
||||||
|
COALESCE((
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM coin_seller_relations csr
|
||||||
|
INNER JOIN coin_seller_profiles child_csp
|
||||||
|
ON child_csp.app_code = csr.app_code
|
||||||
|
AND child_csp.user_id = csr.child_user_id
|
||||||
|
AND child_csp.status = 'active'
|
||||||
|
AND child_csp.merchant_asset_type = ?
|
||||||
|
INNER JOIN users child_u
|
||||||
|
ON child_u.app_code = csr.app_code
|
||||||
|
AND child_u.user_id = csr.child_user_id
|
||||||
|
AND child_u.status = 'active'
|
||||||
|
WHERE csr.app_code = csp.app_code
|
||||||
|
AND csr.parent_user_id = csp.user_id
|
||||||
|
AND csr.status = 'active'
|
||||||
|
), 0),
|
||||||
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),
|
||||||
@ -1108,7 +1148,7 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
|
|||||||
%s
|
%s
|
||||||
ORDER BY csp.created_at_ms DESC, csp.user_id DESC
|
ORDER BY csp.created_at_ms DESC, csp.user_id DESC
|
||||||
%s
|
%s
|
||||||
`, whereSQL, limitSQL), queryArgs...)
|
`, whereSQL, limitSQL), append([]any{coinSellerMerchantAssetType}, queryArgs...)...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
@ -1124,6 +1164,7 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
|
|||||||
&item.MerchantAssetType,
|
&item.MerchantAssetType,
|
||||||
&item.Contact,
|
&item.Contact,
|
||||||
&item.CanManageSubCoinSellers,
|
&item.CanManageSubCoinSellers,
|
||||||
|
&item.SubCoinSellerCount,
|
||||||
&item.CreatedByUserID,
|
&item.CreatedByUserID,
|
||||||
&item.CreatedAtMs,
|
&item.CreatedAtMs,
|
||||||
&item.UpdatedAtMs,
|
&item.UpdatedAtMs,
|
||||||
@ -1160,6 +1201,201 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
|
|||||||
return items, total, nil
|
return items, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Reader) ListCoinSellerSubApplications(ctx context.Context, query listQuery) ([]*CoinSellerSubApplicationListItem, int64, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, 0, errUserDBNotConfigured()
|
||||||
|
}
|
||||||
|
appCode := appctx.FromContext(ctx)
|
||||||
|
whereSQL := `
|
||||||
|
FROM coin_seller_sub_applications app
|
||||||
|
LEFT JOIN users parent_u ON parent_u.app_code = app.app_code AND parent_u.user_id = app.parent_user_id
|
||||||
|
LEFT JOIN regions parent_r ON parent_r.app_code = parent_u.app_code AND parent_r.region_id = parent_u.region_id
|
||||||
|
LEFT JOIN users target_u ON target_u.app_code = app.app_code AND target_u.user_id = app.target_user_id
|
||||||
|
LEFT JOIN regions target_r ON target_r.app_code = target_u.app_code AND target_r.region_id = target_u.region_id
|
||||||
|
WHERE app.app_code = ?`
|
||||||
|
args := []any{appCode}
|
||||||
|
if query.Status != "" {
|
||||||
|
whereSQL += " AND app.status = ?"
|
||||||
|
args = append(args, query.Status)
|
||||||
|
}
|
||||||
|
if query.ParentUserID > 0 {
|
||||||
|
whereSQL += " AND app.parent_user_id = ?"
|
||||||
|
args = append(args, query.ParentUserID)
|
||||||
|
}
|
||||||
|
if query.TargetUserID > 0 {
|
||||||
|
whereSQL += " AND app.target_user_id = ?"
|
||||||
|
args = append(args, query.TargetUserID)
|
||||||
|
}
|
||||||
|
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||||||
|
parentSQL, parentArgs := shared.UserIdentityKeywordSQL("parent_u", "app.parent_user_id", keyword, time.Now().UnixMilli())
|
||||||
|
targetSQL, targetArgs := shared.UserIdentityKeywordSQL("target_u", "app.target_user_id", keyword, time.Now().UnixMilli())
|
||||||
|
whereSQL += " AND (" + parentSQL + " OR " + targetSQL + " OR app.command_id LIKE ? OR app.review_reason LIKE ?)"
|
||||||
|
args = append(args, parentArgs...)
|
||||||
|
args = append(args, targetArgs...)
|
||||||
|
like := "%" + keyword + "%"
|
||||||
|
args = append(args, like, like)
|
||||||
|
}
|
||||||
|
total, err := countRows(ctx, r.db, whereSQL, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
|
||||||
|
SELECT app.application_id, app.command_id, app.status, app.relation_id,
|
||||||
|
app.reviewed_by_admin_id, app.review_reason, app.reviewed_at_ms,
|
||||||
|
app.created_at_ms, app.updated_at_ms,
|
||||||
|
app.parent_user_id, COALESCE(parent_u.current_display_user_id, ''),
|
||||||
|
COALESCE(parent_u.username, ''), COALESCE(parent_u.avatar, ''),
|
||||||
|
COALESCE(parent_u.region_id, 0), COALESCE(parent_r.name, ''),
|
||||||
|
app.target_user_id, COALESCE(target_u.current_display_user_id, ''),
|
||||||
|
COALESCE(target_u.username, ''), COALESCE(target_u.avatar, ''),
|
||||||
|
COALESCE(target_u.region_id, 0), COALESCE(target_r.name, '')
|
||||||
|
%s
|
||||||
|
ORDER BY app.created_at_ms DESC, app.application_id DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := make([]*CoinSellerSubApplicationListItem, 0, query.PageSize)
|
||||||
|
for rows.Next() {
|
||||||
|
item := &CoinSellerSubApplicationListItem{}
|
||||||
|
if err := rows.Scan(
|
||||||
|
&item.ApplicationID,
|
||||||
|
&item.CommandID,
|
||||||
|
&item.Status,
|
||||||
|
&item.RelationID,
|
||||||
|
&item.ReviewedByAdminID,
|
||||||
|
&item.ReviewReason,
|
||||||
|
&item.ReviewedAtMs,
|
||||||
|
&item.CreatedAtMs,
|
||||||
|
&item.UpdatedAtMs,
|
||||||
|
&item.Parent.UserID,
|
||||||
|
&item.Parent.DisplayUserID,
|
||||||
|
&item.Parent.Username,
|
||||||
|
&item.Parent.Avatar,
|
||||||
|
&item.Parent.RegionID,
|
||||||
|
&item.Parent.RegionName,
|
||||||
|
&item.Target.UserID,
|
||||||
|
&item.Target.DisplayUserID,
|
||||||
|
&item.Target.Username,
|
||||||
|
&item.Target.Avatar,
|
||||||
|
&item.Target.RegionID,
|
||||||
|
&item.Target.RegionName,
|
||||||
|
); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reader) ListCoinSellerSubSellers(ctx context.Context, parentUserID int64, query listQuery) ([]*CoinSellerListItem, int64, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, 0, errUserDBNotConfigured()
|
||||||
|
}
|
||||||
|
if parentUserID <= 0 {
|
||||||
|
return nil, 0, fmt.Errorf("parent_user_id is required")
|
||||||
|
}
|
||||||
|
appCode := appctx.FromContext(ctx)
|
||||||
|
whereSQL := `
|
||||||
|
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 csp
|
||||||
|
ON csp.app_code = relation.app_code
|
||||||
|
AND csp.user_id = relation.child_user_id
|
||||||
|
AND csp.status = 'active'
|
||||||
|
AND csp.merchant_asset_type = ?
|
||||||
|
LEFT JOIN regions r ON r.app_code = u.app_code AND r.region_id = u.region_id
|
||||||
|
WHERE relation.app_code = ?
|
||||||
|
AND relation.parent_user_id = ?
|
||||||
|
AND relation.status = 'active'`
|
||||||
|
args := []any{coinSellerMerchantAssetType, appCode, parentUserID}
|
||||||
|
total, err := countRows(ctx, r.db, whereSQL, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
|
||||||
|
SELECT csp.user_id, csp.status, csp.merchant_asset_type,
|
||||||
|
COALESCE(NULLIF(u.contact_info, ''), csp.contact_info, ''),
|
||||||
|
csp.can_manage_sub_coin_sellers,
|
||||||
|
COALESCE((
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM coin_seller_relations csr
|
||||||
|
INNER JOIN coin_seller_profiles child_csp
|
||||||
|
ON child_csp.app_code = csr.app_code
|
||||||
|
AND child_csp.user_id = csr.child_user_id
|
||||||
|
AND child_csp.status = 'active'
|
||||||
|
AND child_csp.merchant_asset_type = ?
|
||||||
|
INNER JOIN users child_u
|
||||||
|
ON child_u.app_code = csr.app_code
|
||||||
|
AND child_u.user_id = csr.child_user_id
|
||||||
|
AND child_u.status = 'active'
|
||||||
|
WHERE csr.app_code = csp.app_code
|
||||||
|
AND csr.parent_user_id = csp.user_id
|
||||||
|
AND csr.status = 'active'
|
||||||
|
), 0),
|
||||||
|
csp.created_by_user_id, csp.created_at_ms, csp.updated_at_ms,
|
||||||
|
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
|
||||||
|
COALESCE(u.avatar, ''), COALESCE(u.region_id, 0),
|
||||||
|
COALESCE(r.name, '')
|
||||||
|
%s
|
||||||
|
ORDER BY relation.created_at_ms DESC, relation.relation_id DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`, whereSQL), append([]any{coinSellerMerchantAssetType}, append(args, query.PageSize, offset(query.Page, query.PageSize))...)...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := make([]*CoinSellerListItem, 0, query.PageSize)
|
||||||
|
userIDs := make([]int64, 0, query.PageSize)
|
||||||
|
for rows.Next() {
|
||||||
|
item := &CoinSellerListItem{}
|
||||||
|
if err := rows.Scan(
|
||||||
|
&item.UserID,
|
||||||
|
&item.Status,
|
||||||
|
&item.MerchantAssetType,
|
||||||
|
&item.Contact,
|
||||||
|
&item.CanManageSubCoinSellers,
|
||||||
|
&item.SubCoinSellerCount,
|
||||||
|
&item.CreatedByUserID,
|
||||||
|
&item.CreatedAtMs,
|
||||||
|
&item.UpdatedAtMs,
|
||||||
|
&item.DisplayUserID,
|
||||||
|
&item.Username,
|
||||||
|
&item.Avatar,
|
||||||
|
&item.RegionID,
|
||||||
|
&item.RegionName,
|
||||||
|
); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
userIDs = append(userIDs, item.UserID)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
balances, err := r.coinSellerBalances(ctx, appCode, userIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
rechargeTotals, err := r.coinSellerRechargeTotals(ctx, appCode, userIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
item.MerchantBalance = balances[item.UserID]
|
||||||
|
item.TotalRechargeUSDTMicro = rechargeTotals[item.UserID]
|
||||||
|
}
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Reader) UpdateCoinSellerContact(ctx context.Context, userID int64, contact string) error {
|
func (r *Reader) UpdateCoinSellerContact(ctx context.Context, userID int64, contact string) error {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return errUserDBNotConfigured()
|
return errUserDBNotConfigured()
|
||||||
|
|||||||
@ -55,6 +55,8 @@ type listQuery struct {
|
|||||||
AgencyID int64
|
AgencyID int64
|
||||||
ParentBDUserID int64
|
ParentBDUserID int64
|
||||||
ParentLeaderUserID int64
|
ParentLeaderUserID int64
|
||||||
|
ParentUserID int64
|
||||||
|
TargetUserID int64
|
||||||
SortBy string
|
SortBy string
|
||||||
SortDirection string
|
SortDirection string
|
||||||
}
|
}
|
||||||
@ -201,6 +203,11 @@ type coinSellerStatusRequest struct {
|
|||||||
CanManageSubCoinSellers *bool `json:"canManageSubCoinSellers"`
|
CanManageSubCoinSellers *bool `json:"canManageSubCoinSellers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type coinSellerSubApplicationReviewRequest struct {
|
||||||
|
CommandID string `json:"commandId" binding:"required"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
type coinSellerStockCreditRequest struct {
|
type coinSellerStockCreditRequest struct {
|
||||||
CommandID string `json:"commandId" binding:"required"`
|
CommandID string `json:"commandId" binding:"required"`
|
||||||
Type string `json:"type" binding:"required"`
|
Type string `json:"type" binding:"required"`
|
||||||
|
|||||||
@ -31,6 +31,10 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
|||||||
protected.GET("/admin/coin-sellers", middleware.RequirePermission("coin-seller:view"), h.ListCoinSellers)
|
protected.GET("/admin/coin-sellers", middleware.RequirePermission("coin-seller:view"), h.ListCoinSellers)
|
||||||
protected.POST("/admin/coin-sellers", middleware.RequirePermission("coin-seller:create"), h.CreateCoinSeller)
|
protected.POST("/admin/coin-sellers", middleware.RequirePermission("coin-seller:create"), h.CreateCoinSeller)
|
||||||
protected.PATCH("/admin/coin-sellers/:user_id/status", middleware.RequirePermission("coin-seller:update"), h.SetCoinSellerStatus)
|
protected.PATCH("/admin/coin-sellers/:user_id/status", middleware.RequirePermission("coin-seller:update"), h.SetCoinSellerStatus)
|
||||||
|
protected.GET("/admin/coin-sellers/:user_id/sub-sellers", middleware.RequirePermission("coin-seller:view"), h.ListCoinSellerSubSellers)
|
||||||
|
protected.GET("/admin/coin-seller-sub-applications", middleware.RequirePermission("coin-seller-sub-application:view"), h.ListCoinSellerSubApplications)
|
||||||
|
protected.POST("/admin/coin-seller-sub-applications/:application_id/approve", middleware.RequirePermission("coin-seller-sub-application:audit"), h.ApproveCoinSellerSubApplication)
|
||||||
|
protected.POST("/admin/coin-seller-sub-applications/:application_id/reject", middleware.RequirePermission("coin-seller-sub-application:audit"), h.RejectCoinSellerSubApplication)
|
||||||
protected.POST("/admin/coin-sellers/:user_id/stock-credits", middleware.RequirePermission("coin-seller:stock-credit"), h.CreditCoinSellerStock)
|
protected.POST("/admin/coin-sellers/:user_id/stock-credits", middleware.RequirePermission("coin-seller:stock-credit"), h.CreditCoinSellerStock)
|
||||||
protected.POST("/admin/coin-sellers/:user_id/stock-debits", middleware.RequirePermission("coin-seller:stock-credit"), h.DebitCoinSellerStock)
|
protected.POST("/admin/coin-sellers/:user_id/stock-debits", middleware.RequirePermission("coin-seller:stock-credit"), h.DebitCoinSellerStock)
|
||||||
protected.GET("/admin/coin-seller-salary-rates/:region_id", middleware.RequirePermission("coin-seller:exchange-rate"), h.GetCoinSellerSalaryRates)
|
protected.GET("/admin/coin-seller-salary-rates/:region_id", middleware.RequirePermission("coin-seller:exchange-rate"), h.GetCoinSellerSalaryRates)
|
||||||
|
|||||||
@ -84,6 +84,28 @@ func (s *Service) ListCoinSellers(ctx context.Context, query listQuery) ([]*Coin
|
|||||||
return s.reader.ListCoinSellers(ctx, query)
|
return s.reader.ListCoinSellers(ctx, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListCoinSellerSubApplications(ctx context.Context, query listQuery) ([]*CoinSellerSubApplicationListItem, int64, error) {
|
||||||
|
query = normalizeListQuery(query)
|
||||||
|
return s.reader.ListCoinSellerSubApplications(ctx, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListCoinSellerSubSellers(ctx context.Context, parentUserID int64, query listQuery) ([]*CoinSellerListItem, int64, error) {
|
||||||
|
query = normalizeListQuery(query)
|
||||||
|
return s.reader.ListCoinSellerSubSellers(ctx, parentUserID, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReviewCoinSellerSubApplication(ctx context.Context, actorID int64, applicationID int64, requestID string, decision string, req coinSellerSubApplicationReviewRequest) (*userclient.CoinSellerSubApplicationReviewResult, error) {
|
||||||
|
return s.userClient.ReviewCoinSellerSubApplication(ctx, userclient.ReviewCoinSellerSubApplicationRequest{
|
||||||
|
RequestID: requestID,
|
||||||
|
Caller: "hyapp-admin-server",
|
||||||
|
CommandID: strings.TrimSpace(req.CommandID),
|
||||||
|
AdminUserID: actorID,
|
||||||
|
ApplicationID: applicationID,
|
||||||
|
Decision: strings.TrimSpace(decision),
|
||||||
|
Reason: strings.TrimSpace(req.Reason),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) GetCoinSellerSalaryRates(ctx context.Context, regionID int64) ([]CoinSellerSalaryRateTier, error) {
|
func (s *Service) GetCoinSellerSalaryRates(ctx context.Context, regionID int64) ([]CoinSellerSalaryRateTier, error) {
|
||||||
return s.reader.ListCoinSellerSalaryRates(ctx, regionID)
|
return s.reader.ListCoinSellerSalaryRates(ctx, regionID)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
package payment
|
package payment
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"hyapp-admin-server/internal/appctx"
|
"hyapp-admin-server/internal/appctx"
|
||||||
"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"
|
||||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
|
|
||||||
@ -83,6 +86,18 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
|
|||||||
response.ServerError(c, "获取财务概览失败")
|
response.ServerError(c, "获取财务概览失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := h.applyFinanceThirdPartyOverview(c.Request.Context(), appCode, financeThirdPartyRechargeQuery{
|
||||||
|
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
|
||||||
|
Status: options.Status,
|
||||||
|
Keyword: options.Keyword,
|
||||||
|
RegionID: query.RegionID,
|
||||||
|
StartAtMS: startAtMS,
|
||||||
|
EndAtMS: endAtMS,
|
||||||
|
TzOffsetMinutes: tzOffsetMinutes,
|
||||||
|
}, &overview); err != nil {
|
||||||
|
response.ServerError(c, "获取财务概览失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
h.fillApprovedWithdrawalStats(&overview, appCode, startAtMS, endAtMS)
|
h.fillApprovedWithdrawalStats(&overview, appCode, startAtMS, endAtMS)
|
||||||
response.OK(c, overview)
|
response.OK(c, overview)
|
||||||
return
|
return
|
||||||
@ -152,10 +167,89 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
|
|||||||
overview.Withdrawal.TransferCount = transfer.GetTransferCount()
|
overview.Withdrawal.TransferCount = transfer.GetTransferCount()
|
||||||
overview.Withdrawal.TransferUsdMinor = transfer.GetTransferUsdMinor()
|
overview.Withdrawal.TransferUsdMinor = transfer.GetTransferUsdMinor()
|
||||||
}
|
}
|
||||||
|
if err := h.applyFinanceThirdPartyOverview(c.Request.Context(), appCode, financeThirdPartyRechargeQuery{
|
||||||
|
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
|
||||||
|
Status: options.Status,
|
||||||
|
Keyword: options.Keyword,
|
||||||
|
RegionID: queryInt64(c, "region_id", "regionId"),
|
||||||
|
StartAtMS: startAtMS,
|
||||||
|
EndAtMS: endAtMS,
|
||||||
|
TzOffsetMinutes: tzOffsetMinutes,
|
||||||
|
}, &overview); err != nil {
|
||||||
|
response.ServerError(c, "获取财务概览失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
h.fillApprovedWithdrawalStats(&overview, appCode, startAtMS, endAtMS)
|
h.fillApprovedWithdrawalStats(&overview, appCode, startAtMS, endAtMS)
|
||||||
response.OK(c, overview)
|
response.OK(c, overview)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) applyFinanceThirdPartyOverview(ctx context.Context, appCode string, query financeThirdPartyRechargeQuery, overview *rechargeBillOverviewDTO) error {
|
||||||
|
if overview == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
stats, err := h.financeThirdPartyRechargeStats(ctx, appCode, query)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rechargeType := normalizedRechargeType(query.RechargeType)
|
||||||
|
byDate := map[string]int{}
|
||||||
|
for i := range overview.Daily {
|
||||||
|
byDate[overview.Daily[i].Date] = i
|
||||||
|
if rechargeType == "" || rechargeType == "third_party" {
|
||||||
|
// 覆写而不是累加:ordinary 三方支付仍可存在于钱包账单,但不能进入 finance thirdParty 统计。
|
||||||
|
overview.Daily[i].ThirdPartyUsdMinor = 0
|
||||||
|
overview.Daily[i].ThirdPartyCoinAmount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rechargeType == "" || rechargeType == "third_party" {
|
||||||
|
for date, bucket := range stats.VerifiedDaily {
|
||||||
|
if date == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
index, ok := byDate[date]
|
||||||
|
if !ok {
|
||||||
|
overview.Daily = append(overview.Daily, rechargeBillDailyBucketDTO{Date: date})
|
||||||
|
index = len(overview.Daily) - 1
|
||||||
|
byDate[date] = index
|
||||||
|
}
|
||||||
|
overview.Daily[index].ThirdPartyUsdMinor = bucket.USDMinorAmount
|
||||||
|
overview.Daily[index].ThirdPartyCoinAmount = bucket.CoinAmount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(overview.Daily, func(i, j int) bool {
|
||||||
|
return overview.Daily[i].Date < overview.Daily[j].Date
|
||||||
|
})
|
||||||
|
if rechargeType == "third_party" {
|
||||||
|
// thirdParty 区域分布来自工单 target_region_id/target_country_id 快照,不能复用普通支付账单区域。
|
||||||
|
overview.Regions = financeThirdPartyRegionBuckets(stats.VerifiedRegions)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func financeThirdPartyRegionBuckets(rows []repository.CoinSellerRechargeOrderRegionStatsBucket) []rechargeBillRegionBucketDTO {
|
||||||
|
byRegion := map[int64]*rechargeBillRegionBucketDTO{}
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.RegionID <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bucket, ok := byRegion[row.RegionID]
|
||||||
|
if !ok {
|
||||||
|
bucket = &rechargeBillRegionBucketDTO{RegionID: row.RegionID}
|
||||||
|
byRegion[row.RegionID] = bucket
|
||||||
|
}
|
||||||
|
bucket.BillCount += row.Count
|
||||||
|
bucket.UsdMinorAmount += row.USDMinor
|
||||||
|
}
|
||||||
|
out := make([]rechargeBillRegionBucketDTO, 0, len(byRegion))
|
||||||
|
for _, bucket := range byRegion {
|
||||||
|
out = append(out, *bucket)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
return out[i].UsdMinorAmount > out[j].UsdMinorAmount
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) requireLegacyCoinSellerDayRange(c *gin.Context, query legacyRechargeBillQuery, tzOffsetMinutes int32) bool {
|
func (h *Handler) requireLegacyCoinSellerDayRange(c *gin.Context, query legacyRechargeBillQuery, tzOffsetMinutes int32) bool {
|
||||||
if !legacyCoinSellerAggregateRequested(query) || !legacyCoinSellerAggregateFilterSupported(query) {
|
if !legacyCoinSellerAggregateRequested(query) || !legacyCoinSellerAggregateFilterSupported(query) {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@ -49,6 +49,26 @@ type googleRechargePaidDTO struct {
|
|||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type financeThirdPartyRechargeStats struct {
|
||||||
|
Verified rechargeBillSummaryBucketDTO
|
||||||
|
GrantedOverlap rechargeBillSummaryBucketDTO
|
||||||
|
VerifiedDaily map[string]rechargeBillSummaryBucketDTO
|
||||||
|
GrantedDaily map[string]rechargeBillSummaryBucketDTO
|
||||||
|
VerifiedRegions []repository.CoinSellerRechargeOrderRegionStatsBucket
|
||||||
|
}
|
||||||
|
|
||||||
|
type financeThirdPartyRechargeQuery struct {
|
||||||
|
RechargeType string
|
||||||
|
Status string
|
||||||
|
Keyword string
|
||||||
|
RegionID int64
|
||||||
|
StartAtMS int64
|
||||||
|
EndAtMS int64
|
||||||
|
TzOffsetMinutes int32
|
||||||
|
TargetUserID int64
|
||||||
|
HasUserOnlyFilter bool
|
||||||
|
}
|
||||||
|
|
||||||
// listLegacyRechargeBills 处理 legacy App 的充值明细;分页与展示口径对齐 wallet-service 账单列表。
|
// listLegacyRechargeBills 处理 legacy App 的充值明细;分页与展示口径对齐 wallet-service 账单列表。
|
||||||
func (h *Handler) listLegacyRechargeBills(c *gin.Context, source RechargeBillSource) {
|
func (h *Handler) listLegacyRechargeBills(c *gin.Context, source RechargeBillSource) {
|
||||||
options := shared.ListOptions(c)
|
options := shared.ListOptions(c)
|
||||||
@ -102,6 +122,18 @@ func (h *Handler) GetRechargeBillSummary(c *gin.Context) {
|
|||||||
response.ServerError(c, "获取充值汇总失败")
|
response.ServerError(c, "获取充值汇总失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := h.applyFinanceThirdPartySummary(c.Request.Context(), appCode, financeThirdPartyRechargeQuery{
|
||||||
|
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
|
||||||
|
Status: options.Status,
|
||||||
|
Keyword: options.Keyword,
|
||||||
|
RegionID: query.RegionID,
|
||||||
|
StartAtMS: query.StartAtMS,
|
||||||
|
EndAtMS: query.EndAtMS,
|
||||||
|
TzOffsetMinutes: tzOffsetMinutes,
|
||||||
|
}, &summary); err != nil {
|
||||||
|
response.ServerError(c, "获取充值汇总失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
response.OK(c, summary)
|
response.OK(c, summary)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -135,12 +167,27 @@ func (h *Handler) GetRechargeBillSummary(c *gin.Context) {
|
|||||||
writeWalletError(c, err, "获取充值汇总失败")
|
writeWalletError(c, err, "获取充值汇总失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, rechargeBillSummaryDTO{
|
summary := rechargeBillSummaryDTO{
|
||||||
Total: rechargeBillSummaryBucketFromProto(resp.GetTotal()),
|
Total: rechargeBillSummaryBucketFromProto(resp.GetTotal()),
|
||||||
GooglePlay: rechargeBillSummaryBucketFromProto(resp.GetGooglePlay()),
|
GooglePlay: rechargeBillSummaryBucketFromProto(resp.GetGooglePlay()),
|
||||||
ThirdParty: rechargeBillSummaryBucketFromProto(resp.GetThirdParty()),
|
ThirdParty: rechargeBillSummaryBucketFromProto(resp.GetThirdParty()),
|
||||||
CoinSeller: rechargeBillSummaryBucketFromProto(resp.GetCoinSeller()),
|
CoinSeller: rechargeBillSummaryBucketFromProto(resp.GetCoinSeller()),
|
||||||
})
|
}
|
||||||
|
if err := h.applyFinanceThirdPartySummary(c.Request.Context(), appCode, financeThirdPartyRechargeQuery{
|
||||||
|
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
|
||||||
|
Status: options.Status,
|
||||||
|
Keyword: options.Keyword,
|
||||||
|
RegionID: queryInt64(c, "region_id", "regionId"),
|
||||||
|
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
|
||||||
|
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
|
||||||
|
TzOffsetMinutes: tzOffsetMinutes,
|
||||||
|
TargetUserID: sellerUserID,
|
||||||
|
HasUserOnlyFilter: userID > 0 && sellerUserID == 0,
|
||||||
|
}, &summary); err != nil {
|
||||||
|
response.ServerError(c, "获取充值汇总失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
func rechargeBillSummaryBucketFromProto(bucket *walletv1.RechargeBillSummaryBucket) rechargeBillSummaryBucketDTO {
|
func rechargeBillSummaryBucketFromProto(bucket *walletv1.RechargeBillSummaryBucket) rechargeBillSummaryBucketDTO {
|
||||||
@ -154,6 +201,100 @@ func rechargeBillSummaryBucketFromProto(bucket *walletv1.RechargeBillSummaryBuck
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) applyFinanceThirdPartySummary(ctx context.Context, appCode string, query financeThirdPartyRechargeQuery, summary *rechargeBillSummaryDTO) error {
|
||||||
|
if summary == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
stats, err := h.financeThirdPartyRechargeStats(ctx, appCode, query)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rechargeType := normalizedRechargeType(query.RechargeType)
|
||||||
|
if rechargeType == "" || rechargeType == "third_party" {
|
||||||
|
// 财务页 thirdParty 只表示后台工单校验通过的币商充值外部订单;普通 MifaPay/V5Pay/USDT H5 支付不能进入该桶。
|
||||||
|
summary.ThirdParty = stats.Verified
|
||||||
|
} else {
|
||||||
|
summary.ThirdParty = rechargeBillSummaryBucketDTO{}
|
||||||
|
}
|
||||||
|
if rechargeType != "" && rechargeType != "coin_seller" {
|
||||||
|
summary.CoinSeller = rechargeBillSummaryBucketDTO{}
|
||||||
|
}
|
||||||
|
if rechargeType != "" && rechargeType != "google_play_recharge" {
|
||||||
|
summary.GooglePlay = rechargeBillSummaryBucketDTO{}
|
||||||
|
}
|
||||||
|
if rechargeType == "" {
|
||||||
|
// coinSeller 桶保持全量展示;total 扣掉已落账重叠部分,避免同一财务工单同时经 thirdParty 与 coinSeller 重复计入总额。
|
||||||
|
summary.Total = nonNegativeRechargeBillBucket(rechargeBillSummaryBucketDTO{
|
||||||
|
BillCount: summary.GooglePlay.BillCount + summary.CoinSeller.BillCount + summary.ThirdParty.BillCount - stats.GrantedOverlap.BillCount,
|
||||||
|
CoinAmount: summary.GooglePlay.CoinAmount + summary.CoinSeller.CoinAmount + summary.ThirdParty.CoinAmount - stats.GrantedOverlap.CoinAmount,
|
||||||
|
USDMinorAmount: summary.GooglePlay.USDMinorAmount + summary.CoinSeller.USDMinorAmount + summary.ThirdParty.USDMinorAmount - stats.GrantedOverlap.USDMinorAmount,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
legacyRecomputeSummaryTotal(summary)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) financeThirdPartyRechargeStats(ctx context.Context, appCode string, query financeThirdPartyRechargeQuery) (financeThirdPartyRechargeStats, error) {
|
||||||
|
stats := financeThirdPartyRechargeStats{
|
||||||
|
VerifiedDaily: map[string]rechargeBillSummaryBucketDTO{},
|
||||||
|
GrantedDaily: map[string]rechargeBillSummaryBucketDTO{},
|
||||||
|
}
|
||||||
|
rechargeType := normalizedRechargeType(query.RechargeType)
|
||||||
|
if rechargeType != "" && rechargeType != "third_party" && rechargeType != "coin_seller" {
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
if h == nil || h.store == nil || query.HasUserOnlyFilter {
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
repoStats, err := h.store.CoinSellerRechargeOrderStats(repository.CoinSellerRechargeOrderStatsOptions{
|
||||||
|
AppCode: appCode,
|
||||||
|
Status: query.Status,
|
||||||
|
RegionID: query.RegionID,
|
||||||
|
TargetUserID: query.TargetUserID,
|
||||||
|
Keyword: query.Keyword,
|
||||||
|
CreatedFromMS: query.StartAtMS,
|
||||||
|
CreatedToMS: query.EndAtMS,
|
||||||
|
TzOffsetMinutes: query.TzOffsetMinutes,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
stats.Verified = rechargeBillSummaryBucketDTO{BillCount: repoStats.VerifiedCount, CoinAmount: repoStats.VerifiedCoinAmount, USDMinorAmount: repoStats.VerifiedUSDMinor}
|
||||||
|
stats.GrantedOverlap = rechargeBillSummaryBucketDTO{BillCount: repoStats.GrantedCount, CoinAmount: repoStats.GrantedCoinAmount, USDMinorAmount: repoStats.GrantedUSDMinor}
|
||||||
|
stats.VerifiedDaily = financeThirdPartyDailyStats(repoStats.VerifiedDaily)
|
||||||
|
stats.GrantedDaily = financeThirdPartyDailyStats(repoStats.GrantedDaily)
|
||||||
|
stats.VerifiedRegions = repoStats.VerifiedRegionBuckets
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func financeThirdPartyDailyStats(in map[string]repository.CoinSellerRechargeOrderStatsBucket) map[string]rechargeBillSummaryBucketDTO {
|
||||||
|
out := make(map[string]rechargeBillSummaryBucketDTO, len(in))
|
||||||
|
for date, bucket := range in {
|
||||||
|
out[date] = rechargeBillSummaryBucketDTO{BillCount: bucket.Count, CoinAmount: bucket.CoinAmount, USDMinorAmount: bucket.USDMinor}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedRechargeType(value string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func nonNegativeRechargeBillBucket(bucket rechargeBillSummaryBucketDTO) rechargeBillSummaryBucketDTO {
|
||||||
|
return rechargeBillSummaryBucketDTO{
|
||||||
|
BillCount: nonNegativeInt64(bucket.BillCount),
|
||||||
|
CoinAmount: nonNegativeInt64(bucket.CoinAmount),
|
||||||
|
USDMinorAmount: nonNegativeInt64(bucket.USDMinorAmount),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nonNegativeInt64(value int64) int64 {
|
||||||
|
if value < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
// RefreshGoogleRechargePaidDetails 对指定账单触发 Google Orders API 实付明细同步,供财务补齐历史订单实付。
|
// RefreshGoogleRechargePaidDetails 对指定账单触发 Google Orders API 实付明细同步,供财务补齐历史订单实付。
|
||||||
func (h *Handler) RefreshGoogleRechargePaidDetails(c *gin.Context) {
|
func (h *Handler) RefreshGoogleRechargePaidDetails(c *gin.Context) {
|
||||||
var request googleRechargePaidRefreshRequest
|
var request googleRechargePaidRefreshRequest
|
||||||
|
|||||||
@ -31,6 +31,45 @@ type CoinSellerRechargeOrderListOptions struct {
|
|||||||
CreatedToMS int64
|
CreatedToMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CoinSellerRechargeOrderStatsOptions struct {
|
||||||
|
AppCode string
|
||||||
|
Status string
|
||||||
|
Keyword string
|
||||||
|
TargetUserID int64
|
||||||
|
RegionID int64
|
||||||
|
CountryID int64
|
||||||
|
CreatedFromMS int64
|
||||||
|
CreatedToMS int64
|
||||||
|
TzOffsetMinutes int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type CoinSellerRechargeOrderStats struct {
|
||||||
|
VerifiedCount int64
|
||||||
|
VerifiedUSDMinor int64
|
||||||
|
VerifiedCoinAmount int64
|
||||||
|
GrantedCount int64
|
||||||
|
GrantedUSDMinor int64
|
||||||
|
GrantedCoinAmount int64
|
||||||
|
VerifiedDaily map[string]CoinSellerRechargeOrderStatsBucket
|
||||||
|
GrantedDaily map[string]CoinSellerRechargeOrderStatsBucket
|
||||||
|
VerifiedRegionBuckets []CoinSellerRechargeOrderRegionStatsBucket
|
||||||
|
GrantedRegionBuckets []CoinSellerRechargeOrderRegionStatsBucket
|
||||||
|
}
|
||||||
|
|
||||||
|
type CoinSellerRechargeOrderStatsBucket struct {
|
||||||
|
Count int64
|
||||||
|
USDMinor int64
|
||||||
|
CoinAmount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type CoinSellerRechargeOrderRegionStatsBucket struct {
|
||||||
|
RegionID int64
|
||||||
|
CountryID int64
|
||||||
|
Count int64
|
||||||
|
USDMinor int64
|
||||||
|
CoinAmount int64
|
||||||
|
}
|
||||||
|
|
||||||
type CoinSellerRechargeOrderVerifyInput struct {
|
type CoinSellerRechargeOrderVerifyInput struct {
|
||||||
VerifiedByUserID uint
|
VerifiedByUserID uint
|
||||||
VerifiedByName string
|
VerifiedByName string
|
||||||
@ -87,6 +126,57 @@ func (s *Store) ListCoinSellerRechargeOrders(options CoinSellerRechargeOrderList
|
|||||||
return orders, total, err
|
return orders, total, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) CoinSellerRechargeOrderStats(options CoinSellerRechargeOrderStatsOptions) (CoinSellerRechargeOrderStats, error) {
|
||||||
|
stats := CoinSellerRechargeOrderStats{
|
||||||
|
VerifiedDaily: map[string]CoinSellerRechargeOrderStatsBucket{},
|
||||||
|
GrantedDaily: map[string]CoinSellerRechargeOrderStatsBucket{},
|
||||||
|
}
|
||||||
|
var totalRow struct {
|
||||||
|
VerifiedUSDMinor int64 `gorm:"column:verified_usd_minor"`
|
||||||
|
VerifiedCoinAmount int64 `gorm:"column:verified_coin_amount"`
|
||||||
|
VerifiedCount int64 `gorm:"column:verified_count"`
|
||||||
|
GrantedUSDMinor int64 `gorm:"column:granted_usd_minor"`
|
||||||
|
GrantedCoinAmount int64 `gorm:"column:granted_coin_amount"`
|
||||||
|
GrantedCount int64 `gorm:"column:granted_count"`
|
||||||
|
}
|
||||||
|
if err := applyCoinSellerRechargeOrderStatsFilters(s.db.Model(&model.CoinSellerRechargeOrder{}), options).
|
||||||
|
Select(`
|
||||||
|
COALESCE(SUM(usd_minor_amount), 0) AS verified_usd_minor,
|
||||||
|
COALESCE(SUM(coin_amount), 0) AS verified_coin_amount,
|
||||||
|
COUNT(*) AS verified_count,
|
||||||
|
COALESCE(SUM(CASE WHEN grant_status = ? AND (wallet_transaction_id <> '' OR wallet_amount_delta <> 0) THEN usd_minor_amount ELSE 0 END), 0) AS granted_usd_minor,
|
||||||
|
COALESCE(SUM(CASE WHEN grant_status = ? AND (wallet_transaction_id <> '' OR wallet_amount_delta <> 0) THEN coin_amount ELSE 0 END), 0) AS granted_coin_amount,
|
||||||
|
COALESCE(SUM(CASE WHEN grant_status = ? AND (wallet_transaction_id <> '' OR wallet_amount_delta <> 0) THEN 1 ELSE 0 END), 0) AS granted_count`,
|
||||||
|
model.CoinSellerRechargeGrantStatusGranted,
|
||||||
|
model.CoinSellerRechargeGrantStatusGranted,
|
||||||
|
model.CoinSellerRechargeGrantStatusGranted,
|
||||||
|
).Scan(&totalRow).Error; err != nil {
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
stats.VerifiedUSDMinor = totalRow.VerifiedUSDMinor
|
||||||
|
stats.VerifiedCoinAmount = totalRow.VerifiedCoinAmount
|
||||||
|
stats.VerifiedCount = totalRow.VerifiedCount
|
||||||
|
stats.GrantedUSDMinor = totalRow.GrantedUSDMinor
|
||||||
|
stats.GrantedCoinAmount = totalRow.GrantedCoinAmount
|
||||||
|
stats.GrantedCount = totalRow.GrantedCount
|
||||||
|
if err := s.scanCoinSellerRechargeOrderDailyStats(options, true, stats.VerifiedDaily); err != nil {
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
if err := s.scanCoinSellerRechargeOrderDailyStats(options, false, stats.GrantedDaily); err != nil {
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
stats.VerifiedRegionBuckets, err = s.scanCoinSellerRechargeOrderRegionStats(options, true)
|
||||||
|
if err != nil {
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
stats.GrantedRegionBuckets, err = s.scanCoinSellerRechargeOrderRegionStats(options, false)
|
||||||
|
if err != nil {
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) MarkCoinSellerRechargeOrderVerified(id uint, input CoinSellerRechargeOrderVerifyInput) (*model.CoinSellerRechargeOrder, error) {
|
func (s *Store) MarkCoinSellerRechargeOrderVerified(id uint, input CoinSellerRechargeOrderVerifyInput) (*model.CoinSellerRechargeOrder, error) {
|
||||||
return s.updateCoinSellerRechargeVerifyState(id, input, true)
|
return s.updateCoinSellerRechargeVerifyState(id, input, true)
|
||||||
}
|
}
|
||||||
@ -275,3 +365,89 @@ func applyCoinSellerRechargeOrderFilters(query *gorm.DB, options CoinSellerRecha
|
|||||||
}
|
}
|
||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyCoinSellerRechargeOrderStatsFilters(query *gorm.DB, options CoinSellerRechargeOrderStatsOptions) *gorm.DB {
|
||||||
|
// 财务三方统计只承接“创建并校验通过”的币商充值订单;发放状态只影响去重扣减,不决定是否入统计。
|
||||||
|
query = query.Where("verify_status = ?", model.CoinSellerRechargeVerifyStatusVerified)
|
||||||
|
if appCode := strings.TrimSpace(options.AppCode); appCode != "" && appCode != "all" {
|
||||||
|
query = query.Where("app_code = ?", appCode)
|
||||||
|
}
|
||||||
|
if status := strings.TrimSpace(options.Status); status != "" && status != "all" {
|
||||||
|
query = query.Where("status = ?", status)
|
||||||
|
}
|
||||||
|
if options.TargetUserID > 0 {
|
||||||
|
query = query.Where("target_user_id = ?", options.TargetUserID)
|
||||||
|
}
|
||||||
|
if options.RegionID > 0 {
|
||||||
|
query = query.Where("target_region_id = ?", options.RegionID)
|
||||||
|
}
|
||||||
|
if options.CountryID > 0 {
|
||||||
|
query = query.Where("target_country_id = ?", options.CountryID)
|
||||||
|
}
|
||||||
|
if options.CreatedFromMS > 0 {
|
||||||
|
query = query.Where("created_at_ms >= ?", options.CreatedFromMS)
|
||||||
|
}
|
||||||
|
if options.CreatedToMS > 0 {
|
||||||
|
query = query.Where("created_at_ms < ?", options.CreatedToMS)
|
||||||
|
}
|
||||||
|
if keyword := strings.TrimSpace(options.Keyword); keyword != "" {
|
||||||
|
like := "%" + keyword + "%"
|
||||||
|
query = query.Where("external_order_no LIKE ? OR provider_order_id LIKE ? OR target_display_user_id LIKE ? OR wallet_transaction_id LIKE ? OR operator_name LIKE ? OR verified_by_name LIKE ? OR remark LIKE ? OR CAST(target_user_id AS CHAR) LIKE ? OR CAST(id AS CHAR) LIKE ?", like, like, like, like, like, like, like, like, like)
|
||||||
|
}
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) scanCoinSellerRechargeOrderDailyStats(options CoinSellerRechargeOrderStatsOptions, includeAllVerified bool, out map[string]CoinSellerRechargeOrderStatsBucket) error {
|
||||||
|
if out == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
query := applyCoinSellerRechargeOrderStatsFilters(s.db.Model(&model.CoinSellerRechargeOrder{}), options)
|
||||||
|
if !includeAllVerified {
|
||||||
|
query = query.Where("grant_status = ? AND (wallet_transaction_id <> '' OR wallet_amount_delta <> 0)", model.CoinSellerRechargeGrantStatusGranted)
|
||||||
|
}
|
||||||
|
type row struct {
|
||||||
|
Date string
|
||||||
|
Count int64
|
||||||
|
USDMinor int64
|
||||||
|
CoinAmount int64
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
tzOffsetMS := int64(options.TzOffsetMinutes) * 60_000
|
||||||
|
// 财务筛选按 created_at_ms 取数;日趋势按前端传入的统计时区偏移切日,避免北京时间财务页跨日错桶。
|
||||||
|
if err := query.Select(`
|
||||||
|
DATE_FORMAT(FROM_UNIXTIME((created_at_ms + ?) / 1000), '%Y-%m-%d') AS date,
|
||||||
|
COUNT(*) AS count,
|
||||||
|
COALESCE(SUM(usd_minor_amount), 0) AS usd_minor,
|
||||||
|
COALESCE(SUM(coin_amount), 0) AS coin_amount`, tzOffsetMS).
|
||||||
|
Group("date").
|
||||||
|
Order("date ASC").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.Date == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[row.Date] = CoinSellerRechargeOrderStatsBucket{Count: row.Count, USDMinor: row.USDMinor, CoinAmount: row.CoinAmount}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) scanCoinSellerRechargeOrderRegionStats(options CoinSellerRechargeOrderStatsOptions, includeAllVerified bool) ([]CoinSellerRechargeOrderRegionStatsBucket, error) {
|
||||||
|
query := applyCoinSellerRechargeOrderStatsFilters(s.db.Model(&model.CoinSellerRechargeOrder{}), options)
|
||||||
|
if !includeAllVerified {
|
||||||
|
query = query.Where("grant_status = ? AND (wallet_transaction_id <> '' OR wallet_amount_delta <> 0)", model.CoinSellerRechargeGrantStatusGranted)
|
||||||
|
}
|
||||||
|
var rows []CoinSellerRechargeOrderRegionStatsBucket
|
||||||
|
// target_region_id/target_country_id 是后台工单创建时的币商归属快照;财务区域统计必须使用这组字段。
|
||||||
|
err := query.Select(`
|
||||||
|
target_region_id AS region_id,
|
||||||
|
target_country_id AS country_id,
|
||||||
|
COUNT(*) AS count,
|
||||||
|
COALESCE(SUM(usd_minor_amount), 0) AS usd_minor,
|
||||||
|
COALESCE(SUM(coin_amount), 0) AS coin_amount`).
|
||||||
|
Group("target_region_id, target_country_id").
|
||||||
|
Order("usd_minor DESC").
|
||||||
|
Scan(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1,62 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCoinSellerRechargeOrderStatsAggregatesVerifiedAndGrantedOverlap(t *testing.T) {
|
||||||
|
store, mock, closeStore := newRepositorySQLMock(t)
|
||||||
|
defer closeStore()
|
||||||
|
|
||||||
|
mock.ExpectQuery("verified_usd_minor").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{
|
||||||
|
"verified_usd_minor",
|
||||||
|
"verified_coin_amount",
|
||||||
|
"verified_count",
|
||||||
|
"granted_usd_minor",
|
||||||
|
"granted_coin_amount",
|
||||||
|
"granted_count",
|
||||||
|
}).AddRow(int64(3_000), int64(30_000), int64(3), int64(2_000), int64(20_000), int64(2)))
|
||||||
|
mock.ExpectQuery("DATE_FORMAT").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"date", "count", "usd_minor", "coin_amount"}).
|
||||||
|
AddRow("2026-07-08", int64(1), int64(1_000), int64(10_000)).
|
||||||
|
AddRow("2026-07-09", int64(2), int64(2_000), int64(20_000)))
|
||||||
|
mock.ExpectQuery("DATE_FORMAT").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"date", "count", "usd_minor", "coin_amount"}).
|
||||||
|
AddRow("2026-07-09", int64(2), int64(2_000), int64(20_000)))
|
||||||
|
mock.ExpectQuery("target_region_id AS region_id").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"region_id", "country_id", "count", "usd_minor", "coin_amount"}).
|
||||||
|
AddRow(int64(101), int64(91), int64(3), int64(3_000), int64(30_000)))
|
||||||
|
mock.ExpectQuery("target_region_id AS region_id").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"region_id", "country_id", "count", "usd_minor", "coin_amount"}).
|
||||||
|
AddRow(int64(101), int64(91), int64(2), int64(2_000), int64(20_000)))
|
||||||
|
|
||||||
|
stats, err := store.CoinSellerRechargeOrderStats(CoinSellerRechargeOrderStatsOptions{
|
||||||
|
AppCode: "lalu",
|
||||||
|
RegionID: 101,
|
||||||
|
TargetUserID: 9001,
|
||||||
|
Keyword: "merchant-1",
|
||||||
|
CreatedFromMS: 1_783_468_800_000,
|
||||||
|
CreatedToMS: 1_783_555_200_000,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CoinSellerRechargeOrderStats failed: %v", err)
|
||||||
|
}
|
||||||
|
if stats.VerifiedCount != 3 || stats.VerifiedUSDMinor != 3_000 || stats.VerifiedCoinAmount != 30_000 {
|
||||||
|
t.Fatalf("verified totals mismatch: %+v", stats)
|
||||||
|
}
|
||||||
|
if stats.GrantedCount != 2 || stats.GrantedUSDMinor != 2_000 || stats.GrantedCoinAmount != 20_000 {
|
||||||
|
t.Fatalf("granted totals mismatch: %+v", stats)
|
||||||
|
}
|
||||||
|
if stats.VerifiedDaily["2026-07-08"].USDMinor != 1_000 || stats.GrantedDaily["2026-07-09"].CoinAmount != 20_000 {
|
||||||
|
t.Fatalf("daily stats mismatch: %+v", stats)
|
||||||
|
}
|
||||||
|
if len(stats.VerifiedRegionBuckets) != 1 || stats.VerifiedRegionBuckets[0].RegionID != 101 || stats.VerifiedRegionBuckets[0].CountryID != 91 {
|
||||||
|
t.Fatalf("region stats mismatch: %+v", stats.VerifiedRegionBuckets)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("sql expectations mismatch: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -103,6 +103,8 @@ var defaultPermissions = []model.Permission{
|
|||||||
{Name: "币商更新", Code: "coin-seller:update", Kind: "button"},
|
{Name: "币商更新", Code: "coin-seller:update", Kind: "button"},
|
||||||
{Name: "币商进货", Code: "coin-seller:stock-credit", Kind: "button"},
|
{Name: "币商进货", Code: "coin-seller:stock-credit", Kind: "button"},
|
||||||
{Name: "币商工资兑换比例", Code: "coin-seller:exchange-rate", Kind: "button"},
|
{Name: "币商工资兑换比例", Code: "coin-seller:exchange-rate", Kind: "button"},
|
||||||
|
{Name: "子币商申请查看", Code: "coin-seller-sub-application:view", Kind: "menu"},
|
||||||
|
{Name: "子币商申请审核", Code: "coin-seller-sub-application:audit", Kind: "button"},
|
||||||
{Name: "金币流水查看", Code: "coin-ledger:view", Kind: "menu"},
|
{Name: "金币流水查看", Code: "coin-ledger:view", Kind: "menu"},
|
||||||
{Name: "币商流水查看", Code: "coin-seller-ledger:view", Kind: "menu"},
|
{Name: "币商流水查看", Code: "coin-seller-ledger:view", Kind: "menu"},
|
||||||
{Name: "主播提现查看", Code: "host-withdrawal:view", Kind: "menu"},
|
{Name: "主播提现查看", Code: "host-withdrawal:view", Kind: "menu"},
|
||||||
@ -355,7 +357,8 @@ func (s *Store) seedMenus() error {
|
|||||||
{ParentID: &hostOrgID, Title: "工资政策", Code: "host-agency-policy", Path: "/host/salary-policies", Icon: "wallet", PermissionCode: "host-agency-policy:view", Sort: 85, Visible: true},
|
{ParentID: &hostOrgID, Title: "工资政策", Code: "host-agency-policy", Path: "/host/salary-policies", Icon: "wallet", PermissionCode: "host-agency-policy:view", Sort: 85, Visible: true},
|
||||||
{ParentID: &hostOrgID, Title: "工资结算", Code: "host-salary-settlement", Path: "/host/salary-settlements", Icon: "receipt", PermissionCode: "host-salary-settlement:view", Sort: 86, Visible: true},
|
{ParentID: &hostOrgID, Title: "工资结算", Code: "host-salary-settlement", Path: "/host/salary-settlements", Icon: "receipt", PermissionCode: "host-salary-settlement:view", Sort: 86, Visible: true},
|
||||||
{ParentID: &hostOrgID, Title: "Coin Saller列表", Code: "host-org-coin-sellers", Path: "/host/coin-sellers", Icon: "wallet", PermissionCode: "coin-seller:view", Sort: 87, Visible: true},
|
{ParentID: &hostOrgID, Title: "Coin Saller列表", Code: "host-org-coin-sellers", Path: "/host/coin-sellers", Icon: "wallet", PermissionCode: "coin-seller:view", Sort: 87, Visible: true},
|
||||||
{ParentID: &hostOrgID, Title: "主播提现", Code: "host-withdrawals", Path: "/host/withdrawals", Icon: "receipt", PermissionCode: "host-withdrawal:view", Sort: 88, Visible: true},
|
{ParentID: &hostOrgID, Title: "Sub Coin Seller 申请列表", Code: "host-org-coin-seller-sub-applications", Path: "/host/coin-seller-sub-applications", Icon: "receipt", PermissionCode: "coin-seller-sub-application:view", Sort: 88, Visible: true},
|
||||||
|
{ParentID: &hostOrgID, Title: "主播提现", Code: "host-withdrawals", Path: "/host/withdrawals", Icon: "receipt", PermissionCode: "host-withdrawal:view", Sort: 89, Visible: true},
|
||||||
}
|
}
|
||||||
for _, menu := range children {
|
for _, menu := range children {
|
||||||
if _, err := s.syncDefaultMenu(s.db, menu); err != nil {
|
if _, err := s.syncDefaultMenu(s.db, menu); err != nil {
|
||||||
@ -655,7 +658,7 @@ func defaultRolePermissionCodes(code string) []string {
|
|||||||
"host:view", "host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle",
|
"host:view", "host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle",
|
||||||
"agency:view", "agency:create", "agency:status", "agency:delete",
|
"agency:view", "agency:create", "agency:status", "agency:delete",
|
||||||
"bd:view", "bd:create", "bd:update",
|
"bd:view", "bd:create", "bd:update",
|
||||||
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate",
|
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate", "coin-seller-sub-application:view", "coin-seller-sub-application:audit",
|
||||||
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "policy-template:view", "policy-template:create", "policy-instance:create", "policy-instance:update", "policy-instance:publish", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-order:coin-seller-recharge:view", "finance-order:coin-seller-recharge:create", "finance-order:coin-seller-recharge:verify", "finance-order:coin-seller-recharge:grant", "finance-withdrawal:view", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "policy-template:view", "policy-template:create", "policy-instance:create", "policy-instance:update", "policy-instance:publish", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-order:coin-seller-recharge:view", "finance-order:coin-seller-recharge:create", "finance-order:coin-seller-recharge:verify", "finance-order:coin-seller-recharge:grant", "finance-withdrawal:view", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
||||||
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
|
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
|
||||||
"game:view", "game:create", "game:update", "game:status", "game:delete",
|
"game:view", "game:create", "game:update", "game:status", "game:delete",
|
||||||
@ -673,7 +676,7 @@ func defaultRolePermissionCodes(code string) []string {
|
|||||||
"upload:create",
|
"upload:create",
|
||||||
}
|
}
|
||||||
case "auditor":
|
case "auditor":
|
||||||
return []string{"overview:view", "log:view", "user:view", "team:view", "app-user:view", "level-config:view", "pretty-id:view", "risk-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "host-withdrawal:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "policy-template:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "finance-order:coin-seller-recharge:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "weekly-star:view", "agency-opening:view", "role:view", "permission:view", "job:view"}
|
return []string{"overview:view", "log:view", "user:view", "team:view", "app-user:view", "level-config:view", "pretty-id:view", "risk-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "host-withdrawal:view", "coin-seller-sub-application:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "policy-template:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "finance-order:coin-seller-recharge:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "weekly-star:view", "agency-opening:view", "role:view", "permission:view", "job:view"}
|
||||||
case "readonly":
|
case "readonly":
|
||||||
return []string{
|
return []string{
|
||||||
"overview:view",
|
"overview:view",
|
||||||
@ -707,6 +710,7 @@ func defaultRolePermissionCodes(code string) []string {
|
|||||||
"agency:view",
|
"agency:view",
|
||||||
"bd:view",
|
"bd:view",
|
||||||
"coin-seller:view",
|
"coin-seller:view",
|
||||||
|
"coin-seller-sub-application:view",
|
||||||
"coin-ledger:view",
|
"coin-ledger:view",
|
||||||
"coin-seller-ledger:view",
|
"coin-seller-ledger:view",
|
||||||
"coin-adjustment:view",
|
"coin-adjustment:view",
|
||||||
@ -766,7 +770,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
|
|||||||
"country:view", "country:create", "country:update", "country:status",
|
"country:view", "country:create", "country:update", "country:status",
|
||||||
"region:view", "region:create", "region:update", "region:status",
|
"region:view", "region:create", "region:update", "region:status",
|
||||||
"host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle",
|
"host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle",
|
||||||
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate",
|
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate", "coin-seller-sub-application:view", "coin-seller-sub-application:audit",
|
||||||
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "policy-template:view", "policy-template:create", "policy-instance:create", "policy-instance:update", "policy-instance:publish", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-order:coin-seller-recharge:view", "finance-order:coin-seller-recharge:create", "finance-order:coin-seller-recharge:verify", "finance-order:coin-seller-recharge:grant", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "policy-template:view", "policy-template:create", "policy-instance:create", "policy-instance:update", "policy-instance:publish", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-order:coin-seller-recharge:view", "finance-order:coin-seller-recharge:create", "finance-order:coin-seller-recharge:verify", "finance-order:coin-seller-recharge:grant", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
||||||
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
|
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
|
||||||
"game:view", "game:create", "game:update", "game:status", "game:delete",
|
"game:view", "game:create", "game:update", "game:status", "game:delete",
|
||||||
@ -781,7 +785,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
|
|||||||
"agency-opening:view", "agency-opening:create", "agency-opening:update",
|
"agency-opening:view", "agency-opening:create", "agency-opening:update",
|
||||||
}
|
}
|
||||||
case "auditor", "readonly":
|
case "auditor", "readonly":
|
||||||
return []string{"team:view", "level-config:view", "pretty-id:view", "risk-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "host-withdrawal:view", "coin-seller:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "policy-template:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "finance-order:coin-seller-recharge:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "weekly-star:view", "agency-opening:view"}
|
return []string{"team:view", "level-config:view", "pretty-id:view", "risk-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "host-withdrawal:view", "coin-seller:view", "coin-seller-sub-application:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "policy-template:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "finance-order:coin-seller-recharge:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "weekly-star:view", "agency-opening:view"}
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
51
server/admin/migrations/084_coin_seller_sub_applications.sql
Normal file
51
server/admin/migrations/084_coin_seller_sub_applications.sql
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
SET @now_ms := UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000;
|
||||||
|
|
||||||
|
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
|
||||||
|
('子币商申请查看', 'coin-seller-sub-application:view', 'menu', '允许查看子币商邀请申请列表', @now_ms, @now_ms),
|
||||||
|
('子币商申请审核', 'coin-seller-sub-application:audit', 'button', '允许通过或拒绝子币商邀请申请', @now_ms, @now_ms)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
kind = VALUES(kind),
|
||||||
|
description = VALUES(description),
|
||||||
|
updated_at_ms = @now_ms;
|
||||||
|
|
||||||
|
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
|
||||||
|
SELECT parent.id, 'Sub Coin Seller 申请列表', 'host-org-coin-seller-sub-applications', '/host/coin-seller-sub-applications', 'receipt', 'coin-seller-sub-application:view', 88, TRUE, @now_ms, @now_ms
|
||||||
|
FROM admin_menus parent
|
||||||
|
WHERE parent.code = 'host-org'
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
parent_id = VALUES(parent_id),
|
||||||
|
title = VALUES(title),
|
||||||
|
path = VALUES(path),
|
||||||
|
icon = VALUES(icon),
|
||||||
|
permission_code = VALUES(permission_code),
|
||||||
|
sort = VALUES(sort),
|
||||||
|
visible = VALUES(visible),
|
||||||
|
updated_at_ms = @now_ms;
|
||||||
|
|
||||||
|
UPDATE admin_menus
|
||||||
|
SET sort = 89, updated_at_ms = @now_ms
|
||||||
|
WHERE code = 'host-withdrawals' AND sort < 89;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||||
|
SELECT admin_role.id, admin_permission.id
|
||||||
|
FROM admin_roles admin_role
|
||||||
|
JOIN admin_permissions admin_permission
|
||||||
|
WHERE admin_role.code IN ('platform-admin', 'ops-admin')
|
||||||
|
AND admin_permission.code IN ('coin-seller-sub-application:view', 'coin-seller-sub-application:audit');
|
||||||
|
|
||||||
|
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||||
|
SELECT DISTINCT existing.role_id, sub_view.id
|
||||||
|
FROM admin_role_permissions existing
|
||||||
|
JOIN admin_permissions coin_view
|
||||||
|
ON coin_view.id = existing.permission_id
|
||||||
|
AND coin_view.code = 'coin-seller:view'
|
||||||
|
JOIN admin_permissions sub_view
|
||||||
|
ON sub_view.code = 'coin-seller-sub-application:view';
|
||||||
|
|
||||||
|
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||||
|
SELECT admin_role.id, admin_permission.id
|
||||||
|
FROM admin_roles admin_role
|
||||||
|
JOIN admin_permissions admin_permission
|
||||||
|
WHERE admin_role.code IN ('auditor', 'readonly')
|
||||||
|
AND admin_permission.code = 'coin-seller-sub-application:view';
|
||||||
@ -122,6 +122,7 @@ type UserHostClient interface {
|
|||||||
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)
|
CreateSubCoinSeller(ctx context.Context, req *userv1.CreateSubCoinSellerRequest) (*userv1.CreateSubCoinSellerResponse, error)
|
||||||
|
SubmitSubCoinSellerApplication(ctx context.Context, req *userv1.SubmitSubCoinSellerApplicationRequest) (*userv1.SubmitSubCoinSellerApplicationResponse, error)
|
||||||
ListSubCoinSellers(ctx context.Context, req *userv1.ListSubCoinSellersRequest) (*userv1.ListSubCoinSellersResponse, error)
|
ListSubCoinSellers(ctx context.Context, req *userv1.ListSubCoinSellersRequest) (*userv1.ListSubCoinSellersResponse, error)
|
||||||
CheckCoinSellerSubRelation(ctx context.Context, req *userv1.CheckCoinSellerSubRelationRequest) (*userv1.CheckCoinSellerSubRelationResponse, 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)
|
||||||
@ -580,6 +581,10 @@ func (c *grpcUserHostClient) CreateSubCoinSeller(ctx context.Context, req *userv
|
|||||||
return c.client.CreateSubCoinSeller(ctx, req)
|
return c.client.CreateSubCoinSeller(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *grpcUserHostClient) SubmitSubCoinSellerApplication(ctx context.Context, req *userv1.SubmitSubCoinSellerApplicationRequest) (*userv1.SubmitSubCoinSellerApplicationResponse, error) {
|
||||||
|
return c.client.SubmitSubCoinSellerApplication(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *grpcUserHostClient) ListSubCoinSellers(ctx context.Context, req *userv1.ListSubCoinSellersRequest) (*userv1.ListSubCoinSellersResponse, error) {
|
func (c *grpcUserHostClient) ListSubCoinSellers(ctx context.Context, req *userv1.ListSubCoinSellersRequest) (*userv1.ListSubCoinSellersResponse, error) {
|
||||||
return c.client.ListSubCoinSellers(ctx, req)
|
return c.client.ListSubCoinSellers(ctx, req)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -506,6 +506,9 @@ type fakeUserHostClient struct {
|
|||||||
lastCreateSubSeller *userv1.CreateSubCoinSellerRequest
|
lastCreateSubSeller *userv1.CreateSubCoinSellerRequest
|
||||||
createSubSellerResp *userv1.CreateSubCoinSellerResponse
|
createSubSellerResp *userv1.CreateSubCoinSellerResponse
|
||||||
createSubSellerErr error
|
createSubSellerErr error
|
||||||
|
lastSubmitSubApp *userv1.SubmitSubCoinSellerApplicationRequest
|
||||||
|
submitSubAppResp *userv1.SubmitSubCoinSellerApplicationResponse
|
||||||
|
submitSubAppErr error
|
||||||
lastListSubSellers *userv1.ListSubCoinSellersRequest
|
lastListSubSellers *userv1.ListSubCoinSellersRequest
|
||||||
listSubSellersResp *userv1.ListSubCoinSellersResponse
|
listSubSellersResp *userv1.ListSubCoinSellersResponse
|
||||||
listSubSellersErr error
|
listSubSellersErr error
|
||||||
@ -1846,6 +1849,17 @@ func (f *fakeUserHostClient) CreateSubCoinSeller(_ context.Context, req *userv1.
|
|||||||
return &userv1.CreateSubCoinSellerResponse{}, nil
|
return &userv1.CreateSubCoinSellerResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeUserHostClient) SubmitSubCoinSellerApplication(_ context.Context, req *userv1.SubmitSubCoinSellerApplicationRequest) (*userv1.SubmitSubCoinSellerApplicationResponse, error) {
|
||||||
|
f.lastSubmitSubApp = req
|
||||||
|
if f.submitSubAppErr != nil {
|
||||||
|
return nil, f.submitSubAppErr
|
||||||
|
}
|
||||||
|
if f.submitSubAppResp != nil {
|
||||||
|
return f.submitSubAppResp, nil
|
||||||
|
}
|
||||||
|
return &userv1.SubmitSubCoinSellerApplicationResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeUserHostClient) ListSubCoinSellers(_ context.Context, req *userv1.ListSubCoinSellersRequest) (*userv1.ListSubCoinSellersResponse, error) {
|
func (f *fakeUserHostClient) ListSubCoinSellers(_ context.Context, req *userv1.ListSubCoinSellersRequest) (*userv1.ListSubCoinSellersResponse, error) {
|
||||||
f.lastListSubSellers = req
|
f.lastListSubSellers = req
|
||||||
if f.listSubSellersErr != nil {
|
if f.listSubSellersErr != nil {
|
||||||
|
|||||||
@ -93,7 +93,7 @@ func (h *Handler) getCoinSellerOverview(writer http.ResponseWriter, request *htt
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// createSubCoinSeller 通过短 ID 直开或绑定直属子币商;目标解析在 gateway,关系事实在 user-service 事务内落库。
|
// createSubCoinSeller 通过短 ID 提交子币商邀请申请;目标解析在 gateway,审核事实在 user-service 事务内落库。
|
||||||
func (h *Handler) createSubCoinSeller(writer http.ResponseWriter, request *http.Request) {
|
func (h *Handler) createSubCoinSeller(writer http.ResponseWriter, request *http.Request) {
|
||||||
if h.userHostClient == nil || h.userIdentityClient == nil {
|
if h.userHostClient == nil || h.userIdentityClient == nil {
|
||||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||||
@ -113,19 +113,19 @@ func (h *Handler) createSubCoinSeller(writer http.ResponseWriter, request *http.
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp, err := h.userHostClient.CreateSubCoinSeller(request.Context(), &userv1.CreateSubCoinSellerRequest{
|
resp, err := h.userHostClient.SubmitSubCoinSellerApplication(request.Context(), &userv1.SubmitSubCoinSellerApplicationRequest{
|
||||||
Meta: httpkit.UserMeta(request, ""),
|
Meta: httpkit.UserMeta(request, ""),
|
||||||
CommandId: commandID,
|
CommandId: commandID,
|
||||||
ParentUserId: auth.UserIDFromContext(request.Context()),
|
ParentUserId: auth.UserIDFromContext(request.Context()),
|
||||||
ChildUserId: targetUserID,
|
TargetUserId: targetUserID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpkit.WriteRPCError(writer, request, err)
|
httpkit.WriteRPCError(writer, request, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
httpkit.WriteOK(writer, request, map[string]any{
|
httpkit.WriteOK(writer, request, map[string]any{
|
||||||
"relation": coinSellerSubRelationFromProto(resp.GetRelation()),
|
"application": coinSellerSubApplicationFromProto(resp.GetApplication()),
|
||||||
"child": coinSellerFromProto(resp.GetChild()),
|
"message": "official will review your invitation within 24 hours",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -267,6 +267,35 @@ func coinSellerSubRelationFromProto(relation *userv1.CoinSellerSubRelation) map[
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func coinSellerSubApplicationFromProto(application *userv1.CoinSellerSubApplication) map[string]any {
|
||||||
|
if application == nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"application_id": strconv.FormatInt(application.GetApplicationId(), 10),
|
||||||
|
"applicationId": strconv.FormatInt(application.GetApplicationId(), 10),
|
||||||
|
"command_id": application.GetCommandId(),
|
||||||
|
"commandId": application.GetCommandId(),
|
||||||
|
"parent_user_id": strconv.FormatInt(application.GetParentUserId(), 10),
|
||||||
|
"parentUserId": strconv.FormatInt(application.GetParentUserId(), 10),
|
||||||
|
"target_user_id": strconv.FormatInt(application.GetTargetUserId(), 10),
|
||||||
|
"targetUserId": strconv.FormatInt(application.GetTargetUserId(), 10),
|
||||||
|
"status": application.GetStatus(),
|
||||||
|
"relation_id": strconv.FormatInt(application.GetRelationId(), 10),
|
||||||
|
"relationId": strconv.FormatInt(application.GetRelationId(), 10),
|
||||||
|
"reviewed_by_admin_id": strconv.FormatInt(application.GetReviewedByAdminId(), 10),
|
||||||
|
"reviewedByAdminId": strconv.FormatInt(application.GetReviewedByAdminId(), 10),
|
||||||
|
"review_reason": application.GetReviewReason(),
|
||||||
|
"reviewReason": application.GetReviewReason(),
|
||||||
|
"reviewed_at_ms": application.GetReviewedAtMs(),
|
||||||
|
"reviewedAtMs": application.GetReviewedAtMs(),
|
||||||
|
"created_at_ms": application.GetCreatedAtMs(),
|
||||||
|
"createdAtMs": application.GetCreatedAtMs(),
|
||||||
|
"updated_at_ms": application.GetUpdatedAtMs(),
|
||||||
|
"updatedAtMs": application.GetUpdatedAtMs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func firstTrimmed(values ...string) string {
|
func firstTrimmed(values ...string) string {
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||||
|
|||||||
@ -200,10 +200,10 @@ func coinSellerSubSellerPermission(allowed bool) coinSellerSubSellerPermissionDa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// inviteCoinSellerSubSeller 通过短 ID 或内部 user_id 创建直属子币商关系。
|
// inviteCoinSellerSubSeller 通过短 ID 或内部 user_id 提交子币商邀请申请。
|
||||||
// user-service 在事务内校验父级权限、同区域、无环和子级唯一 active 父级,gateway 不复制这些规则。
|
// user-service 在事务内校验父级权限、同区域、无环和子级唯一 active 父级,gateway 不复制这些规则。
|
||||||
func (h *Handler) inviteCoinSellerSubSeller(writer http.ResponseWriter, request *http.Request) {
|
func (h *Handler) inviteCoinSellerSubSeller(writer http.ResponseWriter, request *http.Request) {
|
||||||
if h.userHostClient == nil || h.userIdentityClient == nil || h.walletClient == nil {
|
if h.userHostClient == nil || h.userIdentityClient == nil {
|
||||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -220,24 +220,19 @@ func (h *Handler) inviteCoinSellerSubSeller(writer http.ResponseWriter, request
|
|||||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp, err := h.userHostClient.CreateSubCoinSeller(request.Context(), &userv1.CreateSubCoinSellerRequest{
|
resp, err := h.userHostClient.SubmitSubCoinSellerApplication(request.Context(), &userv1.SubmitSubCoinSellerApplicationRequest{
|
||||||
Meta: httpkit.UserMeta(request, ""),
|
Meta: httpkit.UserMeta(request, ""),
|
||||||
CommandId: commandID,
|
CommandId: commandID,
|
||||||
ParentUserId: auth.UserIDFromContext(request.Context()),
|
ParentUserId: auth.UserIDFromContext(request.Context()),
|
||||||
ChildUserId: childUserID,
|
TargetUserId: childUserID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpkit.WriteRPCError(writer, request, err)
|
httpkit.WriteRPCError(writer, request, err)
|
||||||
return
|
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{
|
httpkit.WriteOK(writer, request, map[string]any{
|
||||||
"relation": resp.GetRelation(),
|
"application": coinSellerSubApplicationFromProto(resp.GetApplication()),
|
||||||
"child": coinSellerSubSellerFromProto(resp.GetChild(), balance),
|
"message": "official will review your invitation within 24 hours",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -37,6 +37,7 @@ func newQueryHTTPServer(addr string, repo *mysqlstorage.Repository) (*queryHTTPS
|
|||||||
mux.HandleFunc("/internal/v1/statistics/app/events", s.appEvents)
|
mux.HandleFunc("/internal/v1/statistics/app/events", s.appEvents)
|
||||||
mux.HandleFunc("/internal/v1/statistics/app/funnel", s.appFunnel)
|
mux.HandleFunc("/internal/v1/statistics/app/funnel", s.appFunnel)
|
||||||
mux.HandleFunc("/internal/v1/statistics/social/requirements", s.socialRequirements)
|
mux.HandleFunc("/internal/v1/statistics/social/requirements", s.socialRequirements)
|
||||||
|
mux.HandleFunc("/internal/v1/statistics/social/coin-seller-recharge-orders", s.socialCoinSellerRechargeOrder)
|
||||||
mux.HandleFunc("/internal/v1/statistics/self-game/events", s.selfGameEvents)
|
mux.HandleFunc("/internal/v1/statistics/self-game/events", s.selfGameEvents)
|
||||||
mux.HandleFunc("/internal/v1/statistics/self-games/overview", s.selfGameOverview)
|
mux.HandleFunc("/internal/v1/statistics/self-games/overview", s.selfGameOverview)
|
||||||
s.server = &http.Server{Handler: mux, ReadHeaderTimeout: 2 * time.Second}
|
s.server = &http.Server{Handler: mux, ReadHeaderTimeout: 2 * time.Second}
|
||||||
@ -227,6 +228,21 @@ type appEventsRequest struct {
|
|||||||
Events []appTrackingEventRequest `json:"events"`
|
Events []appTrackingEventRequest `json:"events"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type socialCoinSellerRechargeOrderRequest struct {
|
||||||
|
AppCode string `json:"app_code"`
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
OrderID int64 `json:"order_id"`
|
||||||
|
VerifyStatus string `json:"verify_status"`
|
||||||
|
TargetUserID int64 `json:"target_user_id"`
|
||||||
|
USDMinor int64 `json:"usd_minor"`
|
||||||
|
USDMinorAlt int64 `json:"usd_minor_amount"`
|
||||||
|
CoinAmount int64 `json:"coin_amount"`
|
||||||
|
CountryID int64 `json:"target_country_id"`
|
||||||
|
RegionID int64 `json:"target_region_id"`
|
||||||
|
CreatedAtMS int64 `json:"created_at_ms"`
|
||||||
|
OccurredAtMS int64 `json:"occurred_at_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
type appTrackingEventRequest struct {
|
type appTrackingEventRequest struct {
|
||||||
AppCode string `json:"app_code"`
|
AppCode string `json:"app_code"`
|
||||||
EventID string `json:"event_id"`
|
EventID string `json:"event_id"`
|
||||||
@ -251,6 +267,53 @@ type appTrackingEventRequest struct {
|
|||||||
OccurredAtMS int64 `json:"occurred_at_ms"`
|
OccurredAtMS int64 `json:"occurred_at_ms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *queryHTTPServer) socialCoinSellerRechargeOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "method not allowed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
var body socialCoinSellerRechargeOrderRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app := appcode.Normalize(body.AppCode)
|
||||||
|
if app == "" {
|
||||||
|
app = "lalu"
|
||||||
|
}
|
||||||
|
eventID := strings.TrimSpace(body.EventID)
|
||||||
|
if eventID == "" && body.OrderID > 0 {
|
||||||
|
eventID = "finance_coin_seller_recharge_order:" + strconv.FormatInt(body.OrderID, 10)
|
||||||
|
}
|
||||||
|
usdMinor := body.USDMinor
|
||||||
|
if usdMinor <= 0 {
|
||||||
|
usdMinor = body.USDMinorAlt
|
||||||
|
}
|
||||||
|
if eventID == "" || body.TargetUserID <= 0 || usdMinor <= 0 || body.CreatedAtMS <= 0 || !strings.EqualFold(strings.TrimSpace(body.VerifyStatus), "verified") {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "verified event_id/order_id, target_user_id, usd_minor and created_at_ms are required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 这个入口只承接 admin finance 已经校验完成的订单快照;statistics-service 不回查 admin 表,避免统计链路拥有财务状态。
|
||||||
|
err := s.repo.ConsumeFinanceCoinSellerRechargeOrder(appcode.WithContext(r.Context(), app), mysqlstorage.FinanceCoinSellerRechargeOrderEvent{
|
||||||
|
AppCode: app,
|
||||||
|
EventID: eventID,
|
||||||
|
VerifyStatus: body.VerifyStatus,
|
||||||
|
TargetUserID: body.TargetUserID,
|
||||||
|
USDMinor: usdMinor,
|
||||||
|
CoinAmount: body.CoinAmount,
|
||||||
|
CountryID: body.CountryID,
|
||||||
|
RegionID: body.RegionID,
|
||||||
|
CreatedAtMS: body.CreatedAtMS,
|
||||||
|
OccurredAtMS: body.OccurredAtMS,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *queryHTTPServer) appEvents(w http.ResponseWriter, r *http.Request) {
|
func (s *queryHTTPServer) appEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "method not allowed"})
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "method not allowed"})
|
||||||
|
|||||||
@ -311,6 +311,23 @@ func TestQuerySocialRequirementsAggregatesP0ToP5(t *testing.T) {
|
|||||||
t.Fatalf("consume recharge %s: %v", event.EventID, err)
|
t.Fatalf("consume recharge %s: %v", event.EventID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
financeOrder := FinanceCoinSellerRechargeOrderEvent{
|
||||||
|
AppCode: "lalu", EventID: "social:finance:coin-seller:verified", VerifyStatus: "verified", TargetUserID: user2,
|
||||||
|
USDMinor: 900, CoinAmount: 90_000, CountryID: 86, RegionID: 210, CreatedAtMS: start.Add(12*time.Minute + 30*time.Second).UnixMilli(),
|
||||||
|
}
|
||||||
|
if err := repository.ConsumeFinanceCoinSellerRechargeOrder(ctx, financeOrder); err != nil {
|
||||||
|
t.Fatalf("consume finance coin-seller order: %v", err)
|
||||||
|
}
|
||||||
|
financeOrder.USDMinor = 1200
|
||||||
|
if err := repository.ConsumeFinanceCoinSellerRechargeOrder(ctx, financeOrder); err != nil {
|
||||||
|
t.Fatalf("consume duplicate finance coin-seller order: %v", err)
|
||||||
|
}
|
||||||
|
if err := repository.ConsumeFinanceCoinSellerRechargeOrder(ctx, FinanceCoinSellerRechargeOrderEvent{
|
||||||
|
AppCode: "lalu", EventID: "social:finance:coin-seller:pending", VerifyStatus: "pending", TargetUserID: user2,
|
||||||
|
USDMinor: 300, CoinAmount: 30_000, CountryID: 86, RegionID: 210, CreatedAtMS: start.Add(12*time.Minute + 45*time.Second).UnixMilli(),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("consume pending finance coin-seller order: %v", err)
|
||||||
|
}
|
||||||
for _, event := range []RoomGiftEvent{
|
for _, event := range []RoomGiftEvent{
|
||||||
{AppCode: "lalu", EventID: "social:gift:normal", SenderUserID: user1, CountryID: 86, VisibleRegionID: 210, GiftValue: 100, CoinSpent: 100, GiftTypeCode: "normal", OccurredAtMS: start.Add(13 * time.Minute).UnixMilli()},
|
{AppCode: "lalu", EventID: "social:gift:normal", SenderUserID: user1, CountryID: 86, VisibleRegionID: 210, GiftValue: 100, CoinSpent: 100, GiftTypeCode: "normal", OccurredAtMS: start.Add(13 * time.Minute).UnixMilli()},
|
||||||
{AppCode: "lalu", EventID: "social:gift:lucky", SenderUserID: user1, CountryID: 86, VisibleRegionID: 210, GiftValue: 200, CoinSpent: 200, PoolID: "lucky", GiftTypeCode: "lucky", OccurredAtMS: start.Add(14 * time.Minute).UnixMilli()},
|
{AppCode: "lalu", EventID: "social:gift:lucky", SenderUserID: user1, CountryID: 86, VisibleRegionID: 210, GiftValue: 200, CoinSpent: 200, PoolID: "lucky", GiftTypeCode: "lucky", OccurredAtMS: start.Add(14 * time.Minute).UnixMilli()},
|
||||||
@ -353,19 +370,30 @@ func TestQuerySocialRequirementsAggregatesP0ToP5(t *testing.T) {
|
|||||||
t.Fatalf("P0 metrics mismatch: %+v", newUsers.Total.Metrics)
|
t.Fatalf("P0 metrics mismatch: %+v", newUsers.Total.Metrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
revenue, err := repository.QuerySocialRequirements(ctx, SocialRequirementsQuery{AppCode: "lalu", StartMS: query.StartMS, EndMS: query.EndMS, Section: SocialSectionRevenue, PayerType: "paid"})
|
revenue := socialTestSection(t, result, SocialSectionRevenue)
|
||||||
|
if socialMetricInt(t, revenue.Total, "recharge_usd_minor") != 2100 ||
|
||||||
|
socialMetricInt(t, revenue.Total, "google_recharge_usd_minor") != 500 ||
|
||||||
|
socialMetricInt(t, revenue.Total, "third_party_recharge_usd_minor") != 900 ||
|
||||||
|
socialMetricInt(t, revenue.Total, "coin_seller_recharge_usd_minor") != 900 ||
|
||||||
|
socialMetricInt(t, revenue.Total, "recharge_count") != 3 ||
|
||||||
|
socialMetricInt(t, revenue.Total, "recharge_users") != 1 ||
|
||||||
|
math.Abs(socialMetricFloat(t, revenue.Total, "paid_rate")-0.5) > 0.0001 ||
|
||||||
|
socialMetricInt(t, revenue.Total, "arppu_usd_minor") != 1200 {
|
||||||
|
t.Fatalf("P1 default metrics mismatch: %+v", revenue.Total.Metrics)
|
||||||
|
}
|
||||||
|
paidRevenue, err := repository.QuerySocialRequirements(ctx, SocialRequirementsQuery{AppCode: "lalu", StartMS: query.StartMS, EndMS: query.EndMS, Section: SocialSectionRevenue, PayerType: "paid"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("query revenue requirements: %v", err)
|
t.Fatalf("query revenue requirements: %v", err)
|
||||||
}
|
}
|
||||||
revenueSection := socialTestSection(t, revenue, SocialSectionRevenue)
|
paidRevenueSection := socialTestSection(t, paidRevenue, SocialSectionRevenue)
|
||||||
if socialMetricInt(t, revenueSection.Total, "recharge_usd_minor") != 1200 ||
|
if socialMetricInt(t, paidRevenueSection.Total, "recharge_usd_minor") != 1200 ||
|
||||||
socialMetricInt(t, revenueSection.Total, "google_recharge_usd_minor") != 500 ||
|
socialMetricInt(t, paidRevenueSection.Total, "google_recharge_usd_minor") != 500 ||
|
||||||
socialMetricInt(t, revenueSection.Total, "third_party_recharge_usd_minor") != 700 ||
|
socialMetricInt(t, paidRevenueSection.Total, "third_party_recharge_usd_minor") != 0 ||
|
||||||
socialMetricInt(t, revenueSection.Total, "recharge_count") != 2 ||
|
socialMetricInt(t, paidRevenueSection.Total, "recharge_count") != 2 ||
|
||||||
socialMetricInt(t, revenueSection.Total, "first_recharge_users") != 1 ||
|
socialMetricInt(t, paidRevenueSection.Total, "first_recharge_users") != 1 ||
|
||||||
socialMetricInt(t, revenueSection.Total, "repeat_recharge_users") != 1 ||
|
socialMetricInt(t, paidRevenueSection.Total, "repeat_recharge_users") != 1 ||
|
||||||
math.Abs(socialMetricFloat(t, revenueSection.Total, "paid_rate")-1) > 0.0001 {
|
math.Abs(socialMetricFloat(t, paidRevenueSection.Total, "paid_rate")-1) > 0.0001 {
|
||||||
t.Fatalf("P1 metrics mismatch: %+v", revenueSection.Total.Metrics)
|
t.Fatalf("P1 paid metrics mismatch: %+v", paidRevenueSection.Total.Metrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
retention := socialTestSection(t, result, SocialSectionRetention)
|
retention := socialTestSection(t, result, SocialSectionRetention)
|
||||||
|
|||||||
@ -15,11 +15,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SourceUser = "user"
|
SourceUser = "user"
|
||||||
SourceWallet = "wallet"
|
SourceWallet = "wallet"
|
||||||
SourceRoom = "room"
|
SourceRoom = "room"
|
||||||
SourceGame = "game"
|
SourceGame = "game"
|
||||||
SourceH5 = "h5"
|
SourceH5 = "h5"
|
||||||
SourceFinance = "finance"
|
SourceFinance = "finance"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -615,16 +615,16 @@ type CoinSellerStockEvent struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FinanceCoinSellerRechargeOrderEvent struct {
|
type FinanceCoinSellerRechargeOrderEvent struct {
|
||||||
AppCode string
|
AppCode string
|
||||||
EventID string
|
EventID string
|
||||||
VerifyStatus string
|
VerifyStatus string
|
||||||
TargetUserID int64
|
TargetUserID int64
|
||||||
USDMinor int64
|
USDMinor int64
|
||||||
CoinAmount int64
|
CoinAmount int64
|
||||||
CountryID int64
|
CountryID int64
|
||||||
RegionID int64
|
RegionID int64
|
||||||
CreatedAtMS int64
|
CreatedAtMS int64
|
||||||
OccurredAtMS int64
|
OccurredAtMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type RoomGiftEvent struct {
|
type RoomGiftEvent struct {
|
||||||
@ -1004,6 +1004,7 @@ func (r *Repository) ConsumeFinanceCoinSellerRechargeOrder(ctx context.Context,
|
|||||||
RechargeCount: 1,
|
RechargeCount: 1,
|
||||||
RechargeUSDMinor: event.USDMinor,
|
RechargeUSDMinor: event.USDMinor,
|
||||||
ThirdPartyRechargeUSDMinor: event.USDMinor,
|
ThirdPartyRechargeUSDMinor: event.USDMinor,
|
||||||
|
CoinSellerRechargeUSDMinor: event.USDMinor,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -523,6 +523,7 @@ func socialSectionMetrics(section string, agg socialRequirementAggregate, retent
|
|||||||
switch section {
|
switch section {
|
||||||
case SocialSectionRevenue:
|
case SocialSectionRevenue:
|
||||||
totalConsume := agg.GameBetCoin + agg.NormalGiftCoin + agg.LuckyGiftCoin + agg.SuperGiftCoin + agg.OtherConsumeCoin
|
totalConsume := agg.GameBetCoin + agg.NormalGiftCoin + agg.LuckyGiftCoin + agg.SuperGiftCoin + agg.OtherConsumeCoin
|
||||||
|
ordinaryRechargeUSDMinor := agg.FirstRechargeUSDMinor + agg.RepeatRechargeUSDMinor
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"recharge_usd_minor": agg.RechargeUSDMinor,
|
"recharge_usd_minor": agg.RechargeUSDMinor,
|
||||||
"google_recharge_usd_minor": agg.GoogleRechargeUSDMinor,
|
"google_recharge_usd_minor": agg.GoogleRechargeUSDMinor,
|
||||||
@ -537,7 +538,7 @@ func socialSectionMetrics(section string, agg socialRequirementAggregate, retent
|
|||||||
"repurchase_rate": ratio(agg.RepeatRecharge, agg.Recharge),
|
"repurchase_rate": ratio(agg.RepeatRecharge, agg.Recharge),
|
||||||
"paid_rate": ratio(agg.Recharge, agg.Active),
|
"paid_rate": ratio(agg.Recharge, agg.Active),
|
||||||
"arpu_usd_minor": div(agg.RechargeUSDMinor, agg.Active),
|
"arpu_usd_minor": div(agg.RechargeUSDMinor, agg.Active),
|
||||||
"arppu_usd_minor": div(agg.RechargeUSDMinor, agg.Recharge),
|
"arppu_usd_minor": div(ordinaryRechargeUSDMinor, agg.Recharge),
|
||||||
"consumption_penetration_rate": ratio(agg.Gift+agg.GameBet, agg.Active),
|
"consumption_penetration_rate": ratio(agg.Gift+agg.GameBet, agg.Active),
|
||||||
"game_consumption_ratio": ratio(agg.GameBetCoin, totalConsume),
|
"game_consumption_ratio": ratio(agg.GameBetCoin, totalConsume),
|
||||||
"lucky_gift_consumption_ratio": ratio(agg.LuckyGiftCoin+agg.SuperGiftCoin, totalConsume),
|
"lucky_gift_consumption_ratio": ratio(agg.LuckyGiftCoin+agg.SuperGiftCoin, totalConsume),
|
||||||
@ -740,11 +741,12 @@ func socialRequirementMetricDefinitions(section string) []MetricDefinition {
|
|||||||
switch section {
|
switch section {
|
||||||
case SocialSectionRevenue:
|
case SocialSectionRevenue:
|
||||||
return []MetricDefinition{
|
return []MetricDefinition{
|
||||||
{Metric: "recharge_usd_minor", Definition: "服务端 WalletRechargeRecorded 充值事实汇总,单位 USD minor。"},
|
{Metric: "recharge_usd_minor", Definition: "普通 WalletRechargeRecorded 用户充值和 finance verified 币商充值订单汇总,单位 USD minor。"},
|
||||||
|
{Metric: "third_party_recharge_usd_minor", Definition: "finance 工作台币商充值订单 verify_status=verified 后按 created_at_ms 计入;普通 MifaPay/V5Pay/USDT 用户充值不进入此列。"},
|
||||||
{Metric: "repurchase_rate", Definition: "复购充值用户 / 当日充值用户。"},
|
{Metric: "repurchase_rate", Definition: "复购充值用户 / 当日充值用户。"},
|
||||||
{Metric: "paid_rate", Definition: "充值用户 / DAU,支持用户身份和当日付费身份筛选。"},
|
{Metric: "paid_rate", Definition: "充值用户 / DAU,支持用户身份和当日付费身份筛选。"},
|
||||||
{Metric: "arpu_usd_minor", Definition: "充值金额 / DAU。"},
|
{Metric: "arpu_usd_minor", Definition: "充值金额 / DAU。"},
|
||||||
{Metric: "arppu_usd_minor", Definition: "充值金额 / 充值用户。"},
|
{Metric: "arppu_usd_minor", Definition: "普通充值金额 / 普通充值用户;finance 币商订单只进入收入金额和订单次数,不污染用户付费均值。"},
|
||||||
}
|
}
|
||||||
case SocialSectionRetention:
|
case SocialSectionRetention:
|
||||||
return []MetricDefinition{
|
return []MetricDefinition{
|
||||||
|
|||||||
@ -827,6 +827,29 @@ CREATE TABLE IF NOT EXISTS coin_seller_relations (
|
|||||||
KEY idx_coin_seller_relation_child (app_code, child_user_id, status)
|
KEY idx_coin_seller_relation_child (app_code, child_user_id, status)
|
||||||
) 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_sub_applications (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||||
|
application_id BIGINT NOT NULL PRIMARY KEY COMMENT '子币商申请 ID',
|
||||||
|
command_id VARCHAR(128) NOT NULL COMMENT '提交申请幂等命令 ID',
|
||||||
|
parent_user_id BIGINT NOT NULL COMMENT '父币商用户 ID',
|
||||||
|
target_user_id BIGINT NOT NULL COMMENT '目标子币商用户 ID',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '审核状态 pending/approved/rejected',
|
||||||
|
relation_id BIGINT NOT NULL DEFAULT 0 COMMENT '审核通过后创建的直属关系 ID',
|
||||||
|
reviewed_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '审核管理员 ID',
|
||||||
|
review_reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '审核备注',
|
||||||
|
reviewed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '审核时间,UTC epoch ms',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
pending_target_user_id BIGINT GENERATED ALWAYS AS (
|
||||||
|
CASE WHEN status = 'pending' THEN target_user_id ELSE NULL END
|
||||||
|
) STORED COMMENT '当前待审目标用户 ID',
|
||||||
|
UNIQUE KEY uk_coin_seller_sub_application_command (app_code, command_id),
|
||||||
|
UNIQUE KEY uk_coin_seller_sub_application_pending_target (app_code, pending_target_user_id),
|
||||||
|
KEY idx_coin_seller_sub_application_parent (app_code, parent_user_id, status, created_at_ms),
|
||||||
|
KEY idx_coin_seller_sub_application_target (app_code, target_user_id, status, created_at_ms),
|
||||||
|
KEY idx_coin_seller_sub_application_status (app_code, status, created_at_ms)
|
||||||
|
) 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,22 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS coin_seller_sub_applications (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||||
|
application_id BIGINT NOT NULL PRIMARY KEY COMMENT '子币商申请 ID',
|
||||||
|
command_id VARCHAR(128) NOT NULL COMMENT '提交申请幂等命令 ID',
|
||||||
|
parent_user_id BIGINT NOT NULL COMMENT '父币商用户 ID',
|
||||||
|
target_user_id BIGINT NOT NULL COMMENT '目标子币商用户 ID',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '审核状态 pending/approved/rejected',
|
||||||
|
relation_id BIGINT NOT NULL DEFAULT 0 COMMENT '审核通过后创建的直属关系 ID',
|
||||||
|
reviewed_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '审核管理员 ID',
|
||||||
|
review_reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '审核备注',
|
||||||
|
reviewed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '审核时间,UTC epoch ms',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
pending_target_user_id BIGINT GENERATED ALWAYS AS (
|
||||||
|
CASE WHEN status = 'pending' THEN target_user_id ELSE NULL END
|
||||||
|
) STORED COMMENT '当前待审目标用户 ID',
|
||||||
|
UNIQUE KEY uk_coin_seller_sub_application_command (app_code, command_id),
|
||||||
|
UNIQUE KEY uk_coin_seller_sub_application_pending_target (app_code, pending_target_user_id),
|
||||||
|
KEY idx_coin_seller_sub_application_parent (app_code, parent_user_id, status, created_at_ms),
|
||||||
|
KEY idx_coin_seller_sub_application_target (app_code, target_user_id, status, created_at_ms),
|
||||||
|
KEY idx_coin_seller_sub_application_status (app_code, status, created_at_ms)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='币商子币商邀请审核申请表';
|
||||||
@ -140,8 +140,12 @@ 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 是历史直开子币商命令;审批制上线后不再允许新请求使用。
|
||||||
CommandTypeCreateSubCoinSeller = "coin_seller_create_sub_seller"
|
CommandTypeCreateSubCoinSeller = "coin_seller_create_sub_seller"
|
||||||
|
// CommandTypeSubmitSubCoinSellerApplication 记录父币商提交子币商审核申请的命令。
|
||||||
|
CommandTypeSubmitSubCoinSellerApplication = "coin_seller_submit_sub_application"
|
||||||
|
// CommandTypeReviewSubCoinSellerApplication 记录后台审核子币商申请的命令。
|
||||||
|
CommandTypeReviewSubCoinSellerApplication = "admin_review_coin_seller_sub_application"
|
||||||
|
|
||||||
// ResultTypeApplication 表示命令结果是 agency_applications 行。
|
// ResultTypeApplication 表示命令结果是 agency_applications 行。
|
||||||
ResultTypeApplication = "agency_application"
|
ResultTypeApplication = "agency_application"
|
||||||
@ -157,6 +161,8 @@ const (
|
|||||||
ResultTypeCoinSellerProfile = "coin_seller_profile"
|
ResultTypeCoinSellerProfile = "coin_seller_profile"
|
||||||
// ResultTypeCoinSellerRelation 表示命令结果是 coin_seller_relations 行。
|
// ResultTypeCoinSellerRelation 表示命令结果是 coin_seller_relations 行。
|
||||||
ResultTypeCoinSellerRelation = "coin_seller_relation"
|
ResultTypeCoinSellerRelation = "coin_seller_relation"
|
||||||
|
// ResultTypeCoinSellerSubApplication 表示命令结果是 coin_seller_sub_applications 行。
|
||||||
|
ResultTypeCoinSellerSubApplication = "coin_seller_sub_application"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HostProfile 是用户长期主播身份快照。
|
// HostProfile 是用户长期主播身份快照。
|
||||||
@ -287,12 +293,45 @@ type CoinSellerSubRelation struct {
|
|||||||
UpdatedAtMs int64
|
UpdatedAtMs int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateSubCoinSellerResult 聚合直开/绑定后 H5 立即展示所需的关系和子币商资料。
|
const (
|
||||||
|
CoinSellerSubApplicationStatusPending = "pending"
|
||||||
|
CoinSellerSubApplicationStatusApproved = "approved"
|
||||||
|
CoinSellerSubApplicationStatusRejected = "rejected"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CoinSellerSubApplication 是子币商邀请审核事实;pending 不代表父子关系已经生效。
|
||||||
|
type CoinSellerSubApplication struct {
|
||||||
|
ApplicationID int64
|
||||||
|
CommandID string
|
||||||
|
ParentUserID int64
|
||||||
|
TargetUserID int64
|
||||||
|
Status string
|
||||||
|
RelationID int64
|
||||||
|
ReviewedByAdminID int64
|
||||||
|
ReviewReason string
|
||||||
|
ReviewedAtMs int64
|
||||||
|
CreatedAtMs int64
|
||||||
|
UpdatedAtMs int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSubCoinSellerResult 是历史直开 RPC 的兼容返回;新流程只应返回待审核申请。
|
||||||
type CreateSubCoinSellerResult struct {
|
type CreateSubCoinSellerResult struct {
|
||||||
Relation CoinSellerSubRelation
|
Relation CoinSellerSubRelation
|
||||||
Child CoinSellerListItem
|
Child CoinSellerListItem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SubmitSubCoinSellerApplicationResult 是 H5 提交子币商申请后的审核状态回执。
|
||||||
|
type SubmitSubCoinSellerApplicationResult struct {
|
||||||
|
Application CoinSellerSubApplication
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReviewSubCoinSellerApplicationResult 是后台审核后的申请和生效关系投影。
|
||||||
|
type ReviewSubCoinSellerApplicationResult struct {
|
||||||
|
Application CoinSellerSubApplication
|
||||||
|
Relation CoinSellerSubRelation
|
||||||
|
Child CoinSellerListItem
|
||||||
|
}
|
||||||
|
|
||||||
// ListSubCoinSellersResult 是直属子币商分页列表。
|
// ListSubCoinSellersResult 是直属子币商分页列表。
|
||||||
type ListSubCoinSellersResult struct {
|
type ListSubCoinSellersResult struct {
|
||||||
Items []CoinSellerListItem
|
Items []CoinSellerListItem
|
||||||
|
|||||||
@ -296,6 +296,14 @@ type CreateSubCoinSellerInput struct {
|
|||||||
RequestID string
|
RequestID string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SubmitSubCoinSellerApplicationInput 是 H5 父币商邀请子币商时提交后台审核的输入。
|
||||||
|
type SubmitSubCoinSellerApplicationInput struct {
|
||||||
|
CommandID string
|
||||||
|
ParentUserID int64
|
||||||
|
TargetUserID int64
|
||||||
|
RequestID string
|
||||||
|
}
|
||||||
|
|
||||||
// CreateSubCoinSellerCommand 是父子币商关系事务需要的完整命令。
|
// CreateSubCoinSellerCommand 是父子币商关系事务需要的完整命令。
|
||||||
type CreateSubCoinSellerCommand struct {
|
type CreateSubCoinSellerCommand struct {
|
||||||
CommandID string
|
CommandID string
|
||||||
@ -307,6 +315,39 @@ type CreateSubCoinSellerCommand struct {
|
|||||||
NowMs int64
|
NowMs int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SubmitSubCoinSellerApplicationCommand 是申请落库事务需要的完整命令。
|
||||||
|
type SubmitSubCoinSellerApplicationCommand struct {
|
||||||
|
CommandID string
|
||||||
|
ApplicationID int64
|
||||||
|
ParentUserID int64
|
||||||
|
TargetUserID int64
|
||||||
|
RequestID string
|
||||||
|
NowMs int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReviewSubCoinSellerApplicationInput 是后台审核子币商申请的外部输入。
|
||||||
|
type ReviewSubCoinSellerApplicationInput struct {
|
||||||
|
CommandID string
|
||||||
|
AdminUserID int64
|
||||||
|
ApplicationID int64
|
||||||
|
Decision string
|
||||||
|
Reason string
|
||||||
|
RequestID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReviewSubCoinSellerApplicationCommand 是审核状态机事务需要的完整命令。
|
||||||
|
type ReviewSubCoinSellerApplicationCommand struct {
|
||||||
|
CommandID string
|
||||||
|
AdminUserID int64
|
||||||
|
ApplicationID int64
|
||||||
|
Decision string
|
||||||
|
Reason string
|
||||||
|
RequestID string
|
||||||
|
RelationID int64
|
||||||
|
EventID int64
|
||||||
|
NowMs int64
|
||||||
|
}
|
||||||
|
|
||||||
// ListSubCoinSellersCommand 描述父币商读取直属子币商列表的分页条件。
|
// ListSubCoinSellersCommand 描述父币商读取直属子币商列表的分页条件。
|
||||||
type ListSubCoinSellersCommand struct {
|
type ListSubCoinSellersCommand struct {
|
||||||
ParentUserID int64
|
ParentUserID int64
|
||||||
|
|||||||
@ -34,6 +34,8 @@ type Repository interface {
|
|||||||
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)
|
CreateSubCoinSeller(ctx context.Context, command CreateSubCoinSellerCommand) (hostdomain.CreateSubCoinSellerResult, error)
|
||||||
|
SubmitSubCoinSellerApplication(ctx context.Context, command SubmitSubCoinSellerApplicationCommand) (hostdomain.SubmitSubCoinSellerApplicationResult, error)
|
||||||
|
ReviewSubCoinSellerApplication(ctx context.Context, command ReviewSubCoinSellerApplicationCommand) (hostdomain.ReviewSubCoinSellerApplicationResult, error)
|
||||||
ListSubCoinSellers(ctx context.Context, command ListSubCoinSellersCommand) (hostdomain.ListSubCoinSellersResult, error)
|
ListSubCoinSellers(ctx context.Context, command ListSubCoinSellersCommand) (hostdomain.ListSubCoinSellersResult, error)
|
||||||
CheckCoinSellerSubRelation(ctx context.Context, command CheckCoinSellerSubRelationCommand) (hostdomain.CoinSellerListItem, 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)
|
||||||
@ -564,26 +566,62 @@ func (s *Service) SetCoinSellerStatus(ctx context.Context, command SetCoinSeller
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateSubCoinSeller 在 H5 币商中心直开或绑定直属子币商。
|
// CreateSubCoinSeller 是历史直开 RPC。审批制上线后保留 proto 兼容,但服务端拒绝新直开请求,避免旧调用方绕过后台审核。
|
||||||
func (s *Service) CreateSubCoinSeller(ctx context.Context, command CreateSubCoinSellerInput) (hostdomain.CreateSubCoinSellerResult, error) {
|
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 {
|
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")
|
return hostdomain.CreateSubCoinSellerResult{}, xerr.New(xerr.InvalidArgument, "command_id, parent_user_id and child_user_id are required")
|
||||||
}
|
}
|
||||||
if command.ParentUserID == command.ChildUserID {
|
if command.ParentUserID == command.ChildUserID {
|
||||||
return hostdomain.CreateSubCoinSellerResult{}, xerr.New(xerr.Conflict, "coin seller cannot add self as sub seller")
|
return hostdomain.CreateSubCoinSellerResult{}, xerr.New(xerr.Conflict, "coin seller cannot add self as sub seller")
|
||||||
}
|
}
|
||||||
|
return hostdomain.CreateSubCoinSellerResult{}, xerr.New(xerr.PermissionDenied, "sub coin seller invitation requires admin review")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitSubCoinSellerApplication 只创建待审申请;active 直属关系必须等待后台审核通过后再落库。
|
||||||
|
func (s *Service) SubmitSubCoinSellerApplication(ctx context.Context, command SubmitSubCoinSellerApplicationInput) (hostdomain.SubmitSubCoinSellerApplicationResult, error) {
|
||||||
|
if err := s.requireWriteDependencies(); err != nil {
|
||||||
|
return hostdomain.SubmitSubCoinSellerApplicationResult{}, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(command.CommandID) == "" || command.ParentUserID <= 0 || command.TargetUserID <= 0 {
|
||||||
|
return hostdomain.SubmitSubCoinSellerApplicationResult{}, xerr.New(xerr.InvalidArgument, "command_id, parent_user_id and target_user_id are required")
|
||||||
|
}
|
||||||
|
if command.ParentUserID == command.TargetUserID {
|
||||||
|
return hostdomain.SubmitSubCoinSellerApplicationResult{}, xerr.New(xerr.Conflict, "coin seller cannot add self as sub seller")
|
||||||
|
}
|
||||||
nowMs := s.now().UnixMilli()
|
nowMs := s.now().UnixMilli()
|
||||||
return s.repository.CreateSubCoinSeller(ctx, CreateSubCoinSellerCommand{
|
return s.repository.SubmitSubCoinSellerApplication(ctx, SubmitSubCoinSellerApplicationCommand{
|
||||||
CommandID: strings.TrimSpace(command.CommandID),
|
CommandID: strings.TrimSpace(command.CommandID),
|
||||||
RelationID: s.idGenerator.NewInt64(),
|
ApplicationID: s.idGenerator.NewInt64(),
|
||||||
ParentUserID: command.ParentUserID,
|
ParentUserID: command.ParentUserID,
|
||||||
ChildUserID: command.ChildUserID,
|
TargetUserID: command.TargetUserID,
|
||||||
RequestID: strings.TrimSpace(command.RequestID),
|
RequestID: strings.TrimSpace(command.RequestID),
|
||||||
EventID: s.nextEventIDRange(2),
|
NowMs: nowMs,
|
||||||
NowMs: nowMs,
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReviewSubCoinSellerApplication 是后台审批边界;通过时才创建子币商身份与直属关系。
|
||||||
|
func (s *Service) ReviewSubCoinSellerApplication(ctx context.Context, command ReviewSubCoinSellerApplicationInput) (hostdomain.ReviewSubCoinSellerApplicationResult, error) {
|
||||||
|
if err := s.requireWriteDependencies(); err != nil {
|
||||||
|
return hostdomain.ReviewSubCoinSellerApplicationResult{}, err
|
||||||
|
}
|
||||||
|
decision := strings.TrimSpace(command.Decision)
|
||||||
|
if strings.TrimSpace(command.CommandID) == "" || command.AdminUserID <= 0 || command.ApplicationID <= 0 || decision == "" {
|
||||||
|
return hostdomain.ReviewSubCoinSellerApplicationResult{}, xerr.New(xerr.InvalidArgument, "command_id, admin_user_id, application_id and decision are required")
|
||||||
|
}
|
||||||
|
if decision != hostdomain.CoinSellerSubApplicationStatusApproved && decision != hostdomain.CoinSellerSubApplicationStatusRejected {
|
||||||
|
return hostdomain.ReviewSubCoinSellerApplicationResult{}, xerr.New(xerr.InvalidArgument, "decision must be approved or rejected")
|
||||||
|
}
|
||||||
|
nowMs := s.now().UnixMilli()
|
||||||
|
return s.repository.ReviewSubCoinSellerApplication(ctx, ReviewSubCoinSellerApplicationCommand{
|
||||||
|
CommandID: strings.TrimSpace(command.CommandID),
|
||||||
|
AdminUserID: command.AdminUserID,
|
||||||
|
ApplicationID: command.ApplicationID,
|
||||||
|
Decision: decision,
|
||||||
|
Reason: strings.TrimSpace(command.Reason),
|
||||||
|
RequestID: strings.TrimSpace(command.RequestID),
|
||||||
|
RelationID: s.idGenerator.NewInt64(),
|
||||||
|
EventID: s.nextEventIDRange(2),
|
||||||
|
NowMs: nowMs,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1690,6 +1690,171 @@ func TestListActiveCoinSellersInMyRegionFiltersByCurrentUserRegion(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateSubCoinSellerRPCRequiresAdminReview(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
seedActiveUser(t, repository, 950, 80)
|
||||||
|
seedActiveUser(t, repository, 951, 80)
|
||||||
|
svc := newHostService(repository, 10000, 11000)
|
||||||
|
|
||||||
|
_, err := svc.CreateSubCoinSeller(ctx, hostservice.CreateSubCoinSellerInput{
|
||||||
|
CommandID: "legacy-create-sub-seller",
|
||||||
|
ParentUserID: 950,
|
||||||
|
ChildUserID: 951,
|
||||||
|
})
|
||||||
|
if got := xerr.CodeOf(err); got != xerr.PermissionDenied {
|
||||||
|
t.Fatalf("legacy CreateSubCoinSeller must be blocked by review flow: got %s err=%v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubCoinSellerApplicationReviewCreatesActiveRelation(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
repository.PutRegion(userdomain.Region{
|
||||||
|
RegionID: 80,
|
||||||
|
RegionCode: "r80",
|
||||||
|
Name: "Region 80",
|
||||||
|
Status: userdomain.RegionStatusActive,
|
||||||
|
Countries: []string{"CN"},
|
||||||
|
})
|
||||||
|
for _, userID := range []int64{950, 951, 952, 953} {
|
||||||
|
seedActiveUser(t, repository, userID, 80)
|
||||||
|
}
|
||||||
|
svc := newHostService(repository, 10000, 11000)
|
||||||
|
for _, userID := range []int64{950, 952} {
|
||||||
|
if _, err := svc.CreateCoinSeller(ctx, hostservice.CreateCoinSellerInput{
|
||||||
|
CommandID: fmt.Sprintf("admin-create-parent-seller-%d", userID),
|
||||||
|
AdminUserID: 1,
|
||||||
|
TargetUserID: userID,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("CreateCoinSeller parent %d failed: %v", userID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
submitted, err := svc.SubmitSubCoinSellerApplication(ctx, hostservice.SubmitSubCoinSellerApplicationInput{
|
||||||
|
CommandID: "submit-sub-seller-951",
|
||||||
|
ParentUserID: 950,
|
||||||
|
TargetUserID: 951,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SubmitSubCoinSellerApplication failed: %v", err)
|
||||||
|
}
|
||||||
|
if submitted.Application.Status != hostdomain.CoinSellerSubApplicationStatusPending ||
|
||||||
|
submitted.Application.ParentUserID != 950 ||
|
||||||
|
submitted.Application.TargetUserID != 951 {
|
||||||
|
t.Fatalf("pending application mismatch: %+v", submitted.Application)
|
||||||
|
}
|
||||||
|
|
||||||
|
retriedSubmit, err := svc.SubmitSubCoinSellerApplication(ctx, hostservice.SubmitSubCoinSellerApplicationInput{
|
||||||
|
CommandID: "submit-sub-seller-951",
|
||||||
|
ParentUserID: 950,
|
||||||
|
TargetUserID: 951,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("retry SubmitSubCoinSellerApplication failed: %v", err)
|
||||||
|
}
|
||||||
|
if retriedSubmit.Application.ApplicationID != submitted.Application.ApplicationID {
|
||||||
|
t.Fatalf("submit retry must replay same application: got %d want %d", retriedSubmit.Application.ApplicationID, submitted.Application.ApplicationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicatePending, err := svc.SubmitSubCoinSellerApplication(ctx, hostservice.SubmitSubCoinSellerApplicationInput{
|
||||||
|
CommandID: "submit-sub-seller-951-again",
|
||||||
|
ParentUserID: 950,
|
||||||
|
TargetUserID: 951,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("same parent pending submit should replay existing pending: %v", err)
|
||||||
|
}
|
||||||
|
if duplicatePending.Application.ApplicationID != submitted.Application.ApplicationID {
|
||||||
|
t.Fatalf("same parent pending submit must return existing application: got %d want %d", duplicatePending.Application.ApplicationID, submitted.Application.ApplicationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = svc.SubmitSubCoinSellerApplication(ctx, hostservice.SubmitSubCoinSellerApplicationInput{
|
||||||
|
CommandID: "submit-sub-seller-951-other-parent",
|
||||||
|
ParentUserID: 952,
|
||||||
|
TargetUserID: 951,
|
||||||
|
})
|
||||||
|
if got := xerr.CodeOf(err); got != xerr.Conflict {
|
||||||
|
t.Fatalf("different parent must not steal pending target: got %s err=%v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeApprove, err := svc.ListSubCoinSellers(ctx, hostservice.ListSubCoinSellersCommand{
|
||||||
|
ParentUserID: 950,
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 20,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListSubCoinSellers before approve failed: %v", err)
|
||||||
|
}
|
||||||
|
if beforeApprove.Total != 0 || len(beforeApprove.Items) != 0 {
|
||||||
|
t.Fatalf("pending application must not appear as active child: %+v", beforeApprove)
|
||||||
|
}
|
||||||
|
|
||||||
|
approved, err := svc.ReviewSubCoinSellerApplication(ctx, hostservice.ReviewSubCoinSellerApplicationInput{
|
||||||
|
CommandID: "approve-sub-seller-951",
|
||||||
|
AdminUserID: 7,
|
||||||
|
ApplicationID: submitted.Application.ApplicationID,
|
||||||
|
Decision: hostdomain.CoinSellerSubApplicationStatusApproved,
|
||||||
|
Reason: "same region",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("approve sub seller application failed: %v", err)
|
||||||
|
}
|
||||||
|
if approved.Application.Status != hostdomain.CoinSellerSubApplicationStatusApproved ||
|
||||||
|
approved.Relation.ParentUserID != 950 ||
|
||||||
|
approved.Relation.ChildUserID != 951 ||
|
||||||
|
approved.Child.UserID != 951 {
|
||||||
|
t.Fatalf("approved facts mismatch: %+v", approved)
|
||||||
|
}
|
||||||
|
|
||||||
|
retriedApprove, err := svc.ReviewSubCoinSellerApplication(ctx, hostservice.ReviewSubCoinSellerApplicationInput{
|
||||||
|
CommandID: "approve-sub-seller-951",
|
||||||
|
AdminUserID: 7,
|
||||||
|
ApplicationID: submitted.Application.ApplicationID,
|
||||||
|
Decision: hostdomain.CoinSellerSubApplicationStatusApproved,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("retry approve sub seller application failed: %v", err)
|
||||||
|
}
|
||||||
|
if retriedApprove.Relation.RelationID != approved.Relation.RelationID {
|
||||||
|
t.Fatalf("approve retry must replay same relation: got %d want %d", retriedApprove.Relation.RelationID, approved.Relation.RelationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
afterApprove, err := svc.ListSubCoinSellers(ctx, hostservice.ListSubCoinSellersCommand{
|
||||||
|
ParentUserID: 950,
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 20,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListSubCoinSellers after approve failed: %v", err)
|
||||||
|
}
|
||||||
|
if afterApprove.Total != 1 || len(afterApprove.Items) != 1 || afterApprove.Items[0].UserID != 951 {
|
||||||
|
t.Fatalf("approved child must appear in active list: %+v", afterApprove)
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectedSubmit, err := svc.SubmitSubCoinSellerApplication(ctx, hostservice.SubmitSubCoinSellerApplicationInput{
|
||||||
|
CommandID: "submit-sub-seller-953",
|
||||||
|
ParentUserID: 950,
|
||||||
|
TargetUserID: 953,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SubmitSubCoinSellerApplication for rejected case failed: %v", err)
|
||||||
|
}
|
||||||
|
rejected, err := svc.ReviewSubCoinSellerApplication(ctx, hostservice.ReviewSubCoinSellerApplicationInput{
|
||||||
|
CommandID: "reject-sub-seller-953",
|
||||||
|
AdminUserID: 7,
|
||||||
|
ApplicationID: rejectedSubmit.Application.ApplicationID,
|
||||||
|
Decision: hostdomain.CoinSellerSubApplicationStatusRejected,
|
||||||
|
Reason: "manual reject",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reject sub seller application failed: %v", err)
|
||||||
|
}
|
||||||
|
if rejected.Application.Status != hostdomain.CoinSellerSubApplicationStatusRejected || rejected.Relation.RelationID != 0 || rejected.Child.UserID != 0 {
|
||||||
|
t.Fatalf("rejected application must not create relation or child: %+v", rejected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetUserRoleSummaryAggregatesRolesAndPendingInvitations(t *testing.T) {
|
func TestGetUserRoleSummaryAggregatesRolesAndPendingInvitations(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
|
|||||||
@ -11,111 +11,164 @@ import (
|
|||||||
hostservice "hyapp/services/user-service/internal/service/host"
|
hostservice "hyapp/services/user-service/internal/service/host"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateSubCoinSeller 直开或绑定直属子币商,并把“创建币商身份”和“创建直属关系”放进同一事务。
|
// CreateSubCoinSeller 是历史直开入口;保留方法只为接口兼容,新关系只能由 ReviewSubCoinSellerApplication 审批通过后创建。
|
||||||
func (r *Repository) CreateSubCoinSeller(ctx context.Context, command hostservice.CreateSubCoinSellerCommand) (hostdomain.CreateSubCoinSellerResult, error) {
|
func (r *Repository) CreateSubCoinSeller(ctx context.Context, command hostservice.CreateSubCoinSellerCommand) (hostdomain.CreateSubCoinSellerResult, error) {
|
||||||
|
return hostdomain.CreateSubCoinSellerResult{}, xerr.New(xerr.PermissionDenied, "sub coin seller invitation requires admin review")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitSubCoinSellerApplication 把父币商邀请写成待审申请;这里不创建 relation,避免 H5 绕过后台审核。
|
||||||
|
func (r *Repository) SubmitSubCoinSellerApplication(ctx context.Context, command hostservice.SubmitSubCoinSellerApplicationCommand) (hostdomain.SubmitSubCoinSellerApplicationResult, error) {
|
||||||
tx, err := r.db.BeginTx(ctx, nil)
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return hostdomain.CreateSubCoinSellerResult{}, err
|
return hostdomain.SubmitSubCoinSellerApplicationResult{}, err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
if existing, ok, err := commandResult(ctx, tx, command.CommandID); err != nil {
|
if existing, ok, err := commandResult(ctx, tx, command.CommandID); err != nil {
|
||||||
return hostdomain.CreateSubCoinSellerResult{}, err
|
return hostdomain.SubmitSubCoinSellerApplicationResult{}, err
|
||||||
} else if ok {
|
} else if ok {
|
||||||
if err := requireCommandResult(existing, hostdomain.CommandTypeCreateSubCoinSeller, hostdomain.ResultTypeCoinSellerRelation); err != nil {
|
if err := requireCommandResult(existing, hostdomain.CommandTypeSubmitSubCoinSellerApplication, hostdomain.ResultTypeCoinSellerSubApplication); err != nil {
|
||||||
return hostdomain.CreateSubCoinSellerResult{}, err
|
return hostdomain.SubmitSubCoinSellerApplicationResult{}, err
|
||||||
}
|
}
|
||||||
relation, err := queryCoinSellerRelation(ctx, tx, "WHERE relation_id = ?", existing.ResultID)
|
application, err := queryCoinSellerSubApplication(ctx, tx, "WHERE application_id = ?", existing.ResultID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return hostdomain.CreateSubCoinSellerResult{}, err
|
return hostdomain.SubmitSubCoinSellerApplicationResult{}, err
|
||||||
}
|
}
|
||||||
child, err := coinSellerListItemByUser(ctx, tx, relation.ChildUserID)
|
return hostdomain.SubmitSubCoinSellerApplicationResult{Application: application}, nil
|
||||||
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)
|
parentRegionID, _, err := r.requireActiveCoinSellerSubManager(ctx, tx, command.ParentUserID, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return hostdomain.CreateSubCoinSellerResult{}, err
|
return hostdomain.SubmitSubCoinSellerApplicationResult{}, err
|
||||||
}
|
}
|
||||||
childRegionID, err := r.userRegion(ctx, tx, command.ChildUserID, "FOR UPDATE")
|
targetRegionID, err := r.userRegion(ctx, tx, command.TargetUserID, "FOR UPDATE")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return hostdomain.CreateSubCoinSellerResult{}, err
|
return hostdomain.SubmitSubCoinSellerApplicationResult{}, err
|
||||||
}
|
}
|
||||||
if parentRegionID != childRegionID {
|
if parentRegionID != targetRegionID {
|
||||||
// 父子币商库存只允许在同区域内流转,避免跨区域库存转移绕过后台区域治理。
|
// 申请阶段就拦截跨区域目标,避免后台队列里出现必然无法转移库存的待办。
|
||||||
return hostdomain.CreateSubCoinSellerResult{}, xerr.New(xerr.RegionMismatch, "child region does not match parent region")
|
return hostdomain.SubmitSubCoinSellerApplicationResult{}, xerr.New(xerr.RegionMismatch, "target region does not match parent region")
|
||||||
}
|
}
|
||||||
if err := r.ensureCoinSellerRelationHasNoCycle(ctx, tx, command.ParentUserID, command.ChildUserID); err != nil {
|
if err := r.ensureCoinSellerRelationHasNoCycle(ctx, tx, command.ParentUserID, command.TargetUserID); err != nil {
|
||||||
return hostdomain.CreateSubCoinSellerResult{}, err
|
return hostdomain.SubmitSubCoinSellerApplicationResult{}, err
|
||||||
}
|
}
|
||||||
if existingParent, ok, err := queryActiveCoinSellerParent(ctx, tx, command.ChildUserID, true); err != nil {
|
if existingParent, ok, err := queryActiveCoinSellerParent(ctx, tx, command.TargetUserID, true); err != nil {
|
||||||
return hostdomain.CreateSubCoinSellerResult{}, err
|
return hostdomain.SubmitSubCoinSellerApplicationResult{}, err
|
||||||
} else if ok {
|
} else if ok {
|
||||||
if existingParent.ParentUserID == command.ParentUserID {
|
if existingParent.ParentUserID == command.ParentUserID {
|
||||||
return hostdomain.CreateSubCoinSellerResult{}, xerr.New(xerr.Conflict, "child coin seller relation already exists")
|
return hostdomain.SubmitSubCoinSellerApplicationResult{}, xerr.New(xerr.Conflict, "child coin seller relation already exists")
|
||||||
}
|
}
|
||||||
return hostdomain.CreateSubCoinSellerResult{}, xerr.New(xerr.Conflict, "child coin seller already has active parent")
|
return hostdomain.SubmitSubCoinSellerApplicationResult{}, xerr.New(xerr.Conflict, "target coin seller already has active parent")
|
||||||
|
}
|
||||||
|
if pending, ok, err := queryPendingCoinSellerSubApplicationByTarget(ctx, tx, command.TargetUserID, true); err != nil {
|
||||||
|
return hostdomain.SubmitSubCoinSellerApplicationResult{}, err
|
||||||
|
} else if ok {
|
||||||
|
if pending.ParentUserID != command.ParentUserID {
|
||||||
|
return hostdomain.SubmitSubCoinSellerApplicationResult{}, xerr.New(xerr.Conflict, "target coin seller already has pending application")
|
||||||
|
}
|
||||||
|
// 同一父子目标的重复提交归并到已有 pending,并为新的 command_id 写结果,移动端重试可稳定拿到同一申请。
|
||||||
|
if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{CommandID: command.CommandID, CommandType: hostdomain.CommandTypeSubmitSubCoinSellerApplication, ResultType: hostdomain.ResultTypeCoinSellerSubApplication, ResultID: pending.ApplicationID, CreatedAtMs: command.NowMs}); err != nil {
|
||||||
|
return hostdomain.SubmitSubCoinSellerApplicationResult{}, mapHostDuplicateError(err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return hostdomain.SubmitSubCoinSellerApplicationResult{}, err
|
||||||
|
}
|
||||||
|
return hostdomain.SubmitSubCoinSellerApplicationResult{Application: pending}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
childProfile, ok, err := queryCoinSellerProfileByUserMaybe(ctx, tx, command.ChildUserID, true)
|
application := hostdomain.CoinSellerSubApplication{
|
||||||
if err != nil {
|
ApplicationID: command.ApplicationID,
|
||||||
return hostdomain.CreateSubCoinSellerResult{}, err
|
CommandID: command.CommandID,
|
||||||
|
ParentUserID: command.ParentUserID,
|
||||||
|
TargetUserID: command.TargetUserID,
|
||||||
|
Status: hostdomain.CoinSellerSubApplicationStatusPending,
|
||||||
|
CreatedAtMs: command.NowMs,
|
||||||
|
UpdatedAtMs: command.NowMs,
|
||||||
}
|
}
|
||||||
profileCreated := false
|
if err := insertCoinSellerSubApplication(ctx, tx, application); err != nil {
|
||||||
if !ok {
|
return hostdomain.SubmitSubCoinSellerApplicationResult{}, mapHostDuplicateError(err)
|
||||||
// 搜索直开只补齐“从未是币商”的目标;被后台停用的币商不能被父币商从 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")
|
|
||||||
}
|
}
|
||||||
|
if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{CommandID: command.CommandID, CommandType: hostdomain.CommandTypeSubmitSubCoinSellerApplication, ResultType: hostdomain.ResultTypeCoinSellerSubApplication, ResultID: application.ApplicationID, CreatedAtMs: command.NowMs}); err != nil {
|
||||||
relation := hostdomain.CoinSellerSubRelation{
|
return hostdomain.SubmitSubCoinSellerApplicationResult{}, mapHostDuplicateError(err)
|
||||||
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 {
|
if err := tx.Commit(); err != nil {
|
||||||
return hostdomain.CreateSubCoinSellerResult{}, err
|
return hostdomain.SubmitSubCoinSellerApplicationResult{}, err
|
||||||
|
}
|
||||||
|
return hostdomain.SubmitSubCoinSellerApplicationResult{Application: application}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReviewSubCoinSellerApplication 审核 pending 申请;通过时在同一事务创建真实父子关系。
|
||||||
|
func (r *Repository) ReviewSubCoinSellerApplication(ctx context.Context, command hostservice.ReviewSubCoinSellerApplicationCommand) (hostdomain.ReviewSubCoinSellerApplicationResult, error) {
|
||||||
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return hostdomain.ReviewSubCoinSellerApplicationResult{}, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if existing, ok, err := commandResult(ctx, tx, command.CommandID); err != nil {
|
||||||
|
return hostdomain.ReviewSubCoinSellerApplicationResult{}, err
|
||||||
|
} else if ok {
|
||||||
|
if err := requireCommandResult(existing, hostdomain.CommandTypeReviewSubCoinSellerApplication, hostdomain.ResultTypeCoinSellerSubApplication); err != nil {
|
||||||
|
return hostdomain.ReviewSubCoinSellerApplicationResult{}, err
|
||||||
|
}
|
||||||
|
return r.coinSellerSubApplicationReviewResult(ctx, tx, existing.ResultID)
|
||||||
}
|
}
|
||||||
|
|
||||||
child, err := coinSellerListItemByUser(ctx, r.db, relation.ChildUserID)
|
application, err := queryCoinSellerSubApplication(ctx, tx, "WHERE application_id = ? FOR UPDATE", command.ApplicationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return hostdomain.CreateSubCoinSellerResult{}, err
|
return hostdomain.ReviewSubCoinSellerApplicationResult{}, err
|
||||||
}
|
}
|
||||||
return hostdomain.CreateSubCoinSellerResult{Relation: relation, Child: child}, nil
|
if application.Status != hostdomain.CoinSellerSubApplicationStatusPending {
|
||||||
|
return hostdomain.ReviewSubCoinSellerApplicationResult{}, xerr.New(xerr.Conflict, "coin seller sub application is already reviewed")
|
||||||
|
}
|
||||||
|
|
||||||
|
var relation hostdomain.CoinSellerSubRelation
|
||||||
|
var child hostdomain.CoinSellerListItem
|
||||||
|
var childProfile hostdomain.CoinSellerProfile
|
||||||
|
profileCreated := false
|
||||||
|
if command.Decision == hostdomain.CoinSellerSubApplicationStatusApproved {
|
||||||
|
relation, childProfile, profileCreated, err = r.createActiveSubCoinSellerRelation(ctx, tx, createActiveSubCoinSellerRelationCommand{
|
||||||
|
RelationID: command.RelationID,
|
||||||
|
ParentUserID: application.ParentUserID,
|
||||||
|
ChildUserID: application.TargetUserID,
|
||||||
|
NowMs: command.NowMs,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return hostdomain.ReviewSubCoinSellerApplicationResult{}, err
|
||||||
|
}
|
||||||
|
application.RelationID = relation.RelationID
|
||||||
|
}
|
||||||
|
application.Status = command.Decision
|
||||||
|
application.ReviewedByAdminID = command.AdminUserID
|
||||||
|
application.ReviewReason = command.Reason
|
||||||
|
application.ReviewedAtMs = command.NowMs
|
||||||
|
application.UpdatedAtMs = command.NowMs
|
||||||
|
if err := updateCoinSellerSubApplicationReview(ctx, tx, application); err != nil {
|
||||||
|
return hostdomain.ReviewSubCoinSellerApplicationResult{}, err
|
||||||
|
}
|
||||||
|
if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{CommandID: command.CommandID, CommandType: hostdomain.CommandTypeReviewSubCoinSellerApplication, ResultType: hostdomain.ResultTypeCoinSellerSubApplication, ResultID: application.ApplicationID, CreatedAtMs: command.NowMs}); err != nil {
|
||||||
|
return hostdomain.ReviewSubCoinSellerApplicationResult{}, mapHostDuplicateError(err)
|
||||||
|
}
|
||||||
|
if command.Decision == hostdomain.CoinSellerSubApplicationStatusApproved {
|
||||||
|
if profileCreated {
|
||||||
|
if err := insertHostOutbox(ctx, tx, command.EventID, "CoinSellerCreated", "coin_seller_profile", childProfile.UserID, command.NowMs); err != nil {
|
||||||
|
return hostdomain.ReviewSubCoinSellerApplicationResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := insertHostOutbox(ctx, tx, command.EventID+1, "CoinSellerSubRelationCreated", "coin_seller_relation", relation.RelationID, command.NowMs); err != nil {
|
||||||
|
return hostdomain.ReviewSubCoinSellerApplicationResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return hostdomain.ReviewSubCoinSellerApplicationResult{}, err
|
||||||
|
}
|
||||||
|
if relation.RelationID > 0 {
|
||||||
|
child, err = coinSellerListItemByUser(ctx, r.db, relation.ChildUserID)
|
||||||
|
if err != nil {
|
||||||
|
return hostdomain.ReviewSubCoinSellerApplicationResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hostdomain.ReviewSubCoinSellerApplicationResult{Application: application, Relation: relation, Child: child}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListSubCoinSellers 返回直属子币商列表;读取前仍校验父币商当前权限,避免权限关闭后继续泄漏团队列表。
|
// ListSubCoinSellers 返回直属子币商列表;读取前仍校验父币商当前权限,避免权限关闭后继续泄漏团队列表。
|
||||||
@ -224,6 +277,77 @@ func (r *Repository) CheckCoinSellerSubRelation(ctx context.Context, command hos
|
|||||||
return child, nil
|
return child, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type createActiveSubCoinSellerRelationCommand struct {
|
||||||
|
RelationID int64
|
||||||
|
ParentUserID int64
|
||||||
|
ChildUserID int64
|
||||||
|
NowMs int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) createActiveSubCoinSellerRelation(ctx context.Context, tx *sql.Tx, command createActiveSubCoinSellerRelationCommand) (hostdomain.CoinSellerSubRelation, hostdomain.CoinSellerProfile, bool, error) {
|
||||||
|
parentRegionID, parentProfile, err := r.requireActiveCoinSellerSubManager(ctx, tx, command.ParentUserID, true)
|
||||||
|
if err != nil {
|
||||||
|
return hostdomain.CoinSellerSubRelation{}, hostdomain.CoinSellerProfile{}, false, err
|
||||||
|
}
|
||||||
|
childRegionID, err := r.userRegion(ctx, tx, command.ChildUserID, "FOR UPDATE")
|
||||||
|
if err != nil {
|
||||||
|
return hostdomain.CoinSellerSubRelation{}, hostdomain.CoinSellerProfile{}, false, err
|
||||||
|
}
|
||||||
|
if parentRegionID != childRegionID {
|
||||||
|
// 父子币商库存只允许在同区域内流转,审批时也必须按最新用户区域重新校验。
|
||||||
|
return hostdomain.CoinSellerSubRelation{}, hostdomain.CoinSellerProfile{}, false, 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.CoinSellerSubRelation{}, hostdomain.CoinSellerProfile{}, false, err
|
||||||
|
}
|
||||||
|
if existingParent, ok, err := queryActiveCoinSellerParent(ctx, tx, command.ChildUserID, true); err != nil {
|
||||||
|
return hostdomain.CoinSellerSubRelation{}, hostdomain.CoinSellerProfile{}, false, err
|
||||||
|
} else if ok {
|
||||||
|
if existingParent.ParentUserID == command.ParentUserID {
|
||||||
|
return hostdomain.CoinSellerSubRelation{}, hostdomain.CoinSellerProfile{}, false, xerr.New(xerr.Conflict, "child coin seller relation already exists")
|
||||||
|
}
|
||||||
|
return hostdomain.CoinSellerSubRelation{}, hostdomain.CoinSellerProfile{}, false, 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.CoinSellerSubRelation{}, hostdomain.CoinSellerProfile{}, false, err
|
||||||
|
}
|
||||||
|
profileCreated := false
|
||||||
|
if !ok {
|
||||||
|
// 审批通过只补齐“从未是币商”的目标;被后台停用的币商不能被申请流程重新启用。
|
||||||
|
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.CoinSellerSubRelation{}, hostdomain.CoinSellerProfile{}, false, mapHostDuplicateError(err)
|
||||||
|
}
|
||||||
|
profileCreated = true
|
||||||
|
} else if childProfile.Status != hostdomain.CoinSellerStatusActive || childProfile.MerchantAssetType != hostdomain.CoinSellerMerchantAssetType {
|
||||||
|
return hostdomain.CoinSellerSubRelation{}, hostdomain.CoinSellerProfile{}, false, 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.CoinSellerSubRelation{}, hostdomain.CoinSellerProfile{}, false, mapHostDuplicateError(err)
|
||||||
|
}
|
||||||
|
return relation, childProfile, profileCreated, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) requireActiveCoinSellerSubManager(ctx context.Context, q sqlQueryer, userID int64, lock bool) (int64, hostdomain.CoinSellerProfile, error) {
|
func (r *Repository) requireActiveCoinSellerSubManager(ctx context.Context, q sqlQueryer, userID int64, lock bool) (int64, hostdomain.CoinSellerProfile, error) {
|
||||||
lockClause := ""
|
lockClause := ""
|
||||||
if lock {
|
if lock {
|
||||||
@ -337,6 +461,107 @@ func scanCoinSellerListItem(scanner rowScanner) (hostdomain.CoinSellerListItem,
|
|||||||
return item, err
|
return item, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func insertCoinSellerSubApplication(ctx context.Context, tx *sql.Tx, application hostdomain.CoinSellerSubApplication) error {
|
||||||
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO coin_seller_sub_applications (
|
||||||
|
app_code, application_id, command_id, parent_user_id, target_user_id,
|
||||||
|
status, relation_id, reviewed_by_admin_id, review_reason, reviewed_at_ms,
|
||||||
|
created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, appcode.FromContext(ctx), application.ApplicationID, application.CommandID, application.ParentUserID, application.TargetUserID,
|
||||||
|
application.Status, application.RelationID, application.ReviewedByAdminID, application.ReviewReason, application.ReviewedAtMs,
|
||||||
|
application.CreatedAtMs, application.UpdatedAtMs)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateCoinSellerSubApplicationReview(ctx context.Context, tx *sql.Tx, application hostdomain.CoinSellerSubApplication) error {
|
||||||
|
result, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE coin_seller_sub_applications
|
||||||
|
SET status = ?, relation_id = ?, reviewed_by_admin_id = ?, review_reason = ?,
|
||||||
|
reviewed_at_ms = ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND application_id = ? AND status = ?
|
||||||
|
`, application.Status, application.RelationID, application.ReviewedByAdminID, application.ReviewReason,
|
||||||
|
application.ReviewedAtMs, application.UpdatedAtMs, appcode.FromContext(ctx), application.ApplicationID,
|
||||||
|
hostdomain.CoinSellerSubApplicationStatusPending)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||||
|
return xerr.New(xerr.Conflict, "coin seller sub application is already reviewed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryCoinSellerSubApplication(ctx context.Context, q sqlQueryer, clause string, args ...any) (hostdomain.CoinSellerSubApplication, error) {
|
||||||
|
clause, args = appScopedClause(ctx, clause, args...)
|
||||||
|
application, err := scanCoinSellerSubApplication(q.QueryRowContext(ctx, fmt.Sprintf(`
|
||||||
|
SELECT application_id, command_id, parent_user_id, target_user_id, status,
|
||||||
|
relation_id, reviewed_by_admin_id, review_reason, reviewed_at_ms,
|
||||||
|
created_at_ms, updated_at_ms
|
||||||
|
FROM coin_seller_sub_applications %s`, clause), args...))
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return hostdomain.CoinSellerSubApplication{}, xerr.New(xerr.NotFound, "coin seller sub application not found")
|
||||||
|
}
|
||||||
|
return application, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryPendingCoinSellerSubApplicationByTarget(ctx context.Context, q sqlQueryer, targetUserID int64, lock bool) (hostdomain.CoinSellerSubApplication, bool, error) {
|
||||||
|
clause := "WHERE target_user_id = ? AND status = ?"
|
||||||
|
if lock {
|
||||||
|
clause += " FOR UPDATE"
|
||||||
|
}
|
||||||
|
clause, args := appScopedClause(ctx, clause, targetUserID, hostdomain.CoinSellerSubApplicationStatusPending)
|
||||||
|
application, err := scanCoinSellerSubApplication(q.QueryRowContext(ctx, fmt.Sprintf(`
|
||||||
|
SELECT application_id, command_id, parent_user_id, target_user_id, status,
|
||||||
|
relation_id, reviewed_by_admin_id, review_reason, reviewed_at_ms,
|
||||||
|
created_at_ms, updated_at_ms
|
||||||
|
FROM coin_seller_sub_applications %s`, clause), args...))
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return hostdomain.CoinSellerSubApplication{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return hostdomain.CoinSellerSubApplication{}, false, err
|
||||||
|
}
|
||||||
|
return application, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanCoinSellerSubApplication(scanner rowScanner) (hostdomain.CoinSellerSubApplication, error) {
|
||||||
|
var application hostdomain.CoinSellerSubApplication
|
||||||
|
err := scanner.Scan(
|
||||||
|
&application.ApplicationID,
|
||||||
|
&application.CommandID,
|
||||||
|
&application.ParentUserID,
|
||||||
|
&application.TargetUserID,
|
||||||
|
&application.Status,
|
||||||
|
&application.RelationID,
|
||||||
|
&application.ReviewedByAdminID,
|
||||||
|
&application.ReviewReason,
|
||||||
|
&application.ReviewedAtMs,
|
||||||
|
&application.CreatedAtMs,
|
||||||
|
&application.UpdatedAtMs,
|
||||||
|
)
|
||||||
|
return application, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) coinSellerSubApplicationReviewResult(ctx context.Context, q sqlQueryer, applicationID int64) (hostdomain.ReviewSubCoinSellerApplicationResult, error) {
|
||||||
|
application, err := queryCoinSellerSubApplication(ctx, q, "WHERE application_id = ?", applicationID)
|
||||||
|
if err != nil {
|
||||||
|
return hostdomain.ReviewSubCoinSellerApplicationResult{}, err
|
||||||
|
}
|
||||||
|
if application.RelationID <= 0 {
|
||||||
|
return hostdomain.ReviewSubCoinSellerApplicationResult{Application: application}, nil
|
||||||
|
}
|
||||||
|
relation, err := queryCoinSellerRelation(ctx, q, "WHERE relation_id = ?", application.RelationID)
|
||||||
|
if err != nil {
|
||||||
|
return hostdomain.ReviewSubCoinSellerApplicationResult{}, err
|
||||||
|
}
|
||||||
|
child, err := coinSellerListItemByUser(ctx, q, relation.ChildUserID)
|
||||||
|
if err != nil {
|
||||||
|
return hostdomain.ReviewSubCoinSellerApplicationResult{}, err
|
||||||
|
}
|
||||||
|
return hostdomain.ReviewSubCoinSellerApplicationResult{Application: application, Relation: relation, Child: child}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func queryCoinSellerRelation(ctx context.Context, q sqlQueryer, clause string, args ...any) (hostdomain.CoinSellerSubRelation, error) {
|
func queryCoinSellerRelation(ctx context.Context, q sqlQueryer, clause string, args ...any) (hostdomain.CoinSellerSubRelation, error) {
|
||||||
clause, args = appScopedClause(ctx, clause, args...)
|
clause, args = appScopedClause(ctx, clause, args...)
|
||||||
relation, err := scanCoinSellerRelation(q.QueryRowContext(ctx, fmt.Sprintf(`
|
relation, err := scanCoinSellerRelation(q.QueryRowContext(ctx, fmt.Sprintf(`
|
||||||
|
|||||||
@ -514,6 +514,26 @@ func toProtoCoinSellerSubRelation(relation hostdomain.CoinSellerSubRelation) *us
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toProtoCoinSellerSubApplication 把子币商审核申请转换为内部 gRPC 投影。
|
||||||
|
func toProtoCoinSellerSubApplication(application hostdomain.CoinSellerSubApplication) *userv1.CoinSellerSubApplication {
|
||||||
|
if application.ApplicationID == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &userv1.CoinSellerSubApplication{
|
||||||
|
ApplicationId: application.ApplicationID,
|
||||||
|
CommandId: application.CommandID,
|
||||||
|
ParentUserId: application.ParentUserID,
|
||||||
|
TargetUserId: application.TargetUserID,
|
||||||
|
Status: application.Status,
|
||||||
|
RelationId: application.RelationID,
|
||||||
|
ReviewedByAdminId: application.ReviewedByAdminID,
|
||||||
|
ReviewReason: application.ReviewReason,
|
||||||
|
ReviewedAtMs: application.ReviewedAtMs,
|
||||||
|
CreatedAtMs: application.CreatedAtMs,
|
||||||
|
UpdatedAtMs: application.UpdatedAtMs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// toProtoUserRoleSummary 把 host domain 入口显隐摘要转换为 gRPC 投影。
|
// toProtoUserRoleSummary 把 host domain 入口显隐摘要转换为 gRPC 投影。
|
||||||
func toProtoUserRoleSummary(summary hostdomain.UserRoleSummary) *userv1.UserRoleSummary {
|
func toProtoUserRoleSummary(summary hostdomain.UserRoleSummary) *userv1.UserRoleSummary {
|
||||||
if summary.UserID == 0 {
|
if summary.UserID == 0 {
|
||||||
|
|||||||
@ -416,6 +416,26 @@ func (s *Server) CreateSubCoinSeller(ctx context.Context, req *userv1.CreateSubC
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SubmitSubCoinSellerApplication 处理父币商邀请子币商申请;提交后等待后台审批,不立即创建关系。
|
||||||
|
func (s *Server) SubmitSubCoinSellerApplication(ctx context.Context, req *userv1.SubmitSubCoinSellerApplicationRequest) (*userv1.SubmitSubCoinSellerApplicationResponse, 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.SubmitSubCoinSellerApplication(ctx, hostservice.SubmitSubCoinSellerApplicationInput{
|
||||||
|
CommandID: req.GetCommandId(),
|
||||||
|
ParentUserID: req.GetParentUserId(),
|
||||||
|
TargetUserID: req.GetTargetUserId(),
|
||||||
|
RequestID: req.GetMeta().GetRequestId(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
return &userv1.SubmitSubCoinSellerApplicationResponse{
|
||||||
|
Application: toProtoCoinSellerSubApplication(result.Application),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListSubCoinSellers 返回父币商直属子币商分页列表。
|
// ListSubCoinSellers 返回父币商直属子币商分页列表。
|
||||||
func (s *Server) ListSubCoinSellers(ctx context.Context, req *userv1.ListSubCoinSellersRequest) (*userv1.ListSubCoinSellersResponse, error) {
|
func (s *Server) ListSubCoinSellers(ctx context.Context, req *userv1.ListSubCoinSellersRequest) (*userv1.ListSubCoinSellersResponse, error) {
|
||||||
if s.hostSvc == nil {
|
if s.hostSvc == nil {
|
||||||
|
|||||||
@ -110,6 +110,30 @@ func (s *Server) SetCoinSellerStatus(ctx context.Context, req *userv1.SetCoinSel
|
|||||||
return &userv1.SetCoinSellerStatusResponse{CoinSellerProfile: toProtoCoinSellerProfile(profile)}, nil
|
return &userv1.SetCoinSellerStatusResponse{CoinSellerProfile: toProtoCoinSellerProfile(profile)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReviewSubCoinSellerApplication 处理后台对子币商邀请申请的通过或拒绝。
|
||||||
|
func (s *Server) ReviewSubCoinSellerApplication(ctx context.Context, req *userv1.ReviewSubCoinSellerApplicationRequest) (*userv1.ReviewSubCoinSellerApplicationResponse, 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.ReviewSubCoinSellerApplication(ctx, hostservice.ReviewSubCoinSellerApplicationInput{
|
||||||
|
CommandID: req.GetCommandId(),
|
||||||
|
AdminUserID: req.GetAdminUserId(),
|
||||||
|
ApplicationID: req.GetApplicationId(),
|
||||||
|
Decision: req.GetDecision(),
|
||||||
|
Reason: req.GetReason(),
|
||||||
|
RequestID: req.GetMeta().GetRequestId(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
return &userv1.ReviewSubCoinSellerApplicationResponse{
|
||||||
|
Application: toProtoCoinSellerSubApplication(result.Application),
|
||||||
|
Relation: toProtoCoinSellerSubRelation(result.Relation),
|
||||||
|
Child: toProtoCoinSellerListItem(result.Child),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CreateAgency 处理后台直接创建 Agency,并返回 owner host/membership 事实。
|
// CreateAgency 处理后台直接创建 Agency,并返回 owner host/membership 事实。
|
||||||
func (s *Server) CreateAgency(ctx context.Context, req *userv1.CreateAgencyRequest) (*userv1.CreateAgencyResponse, error) {
|
func (s *Server) CreateAgency(ctx context.Context, req *userv1.CreateAgencyRequest) (*userv1.CreateAgencyResponse, error) {
|
||||||
if s.hostSvc == nil {
|
if s.hostSvc == nil {
|
||||||
|
|||||||
@ -234,6 +234,7 @@ type VerifyCoinSellerRechargeReceiptCommand struct {
|
|||||||
OrderDateMS int64
|
OrderDateMS int64
|
||||||
ProviderCountryCode string
|
ProviderCountryCode string
|
||||||
ProviderCurrencyCode string
|
ProviderCurrencyCode string
|
||||||
|
ProviderAmountMinor int64
|
||||||
ProviderPayType string
|
ProviderPayType string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -106,7 +106,8 @@ func fillMifaPayReceiptSnapshot(snapshot ledger.CoinSellerRechargeReceiptVerific
|
|||||||
snapshot.Status = strings.TrimSpace(query.Status)
|
snapshot.Status = strings.TrimSpace(query.Status)
|
||||||
snapshot.CurrencyCode = strings.ToUpper(strings.TrimSpace(query.Currency))
|
snapshot.CurrencyCode = strings.ToUpper(strings.TrimSpace(query.Currency))
|
||||||
snapshot.RawJSON = strings.TrimSpace(query.RawJSON)
|
snapshot.RawJSON = strings.TrimSpace(query.RawJSON)
|
||||||
if amount, err := strconv.ParseInt(strings.TrimSpace(query.Amount), 10, 64); err == nil {
|
amount, amountErr := parseMifaPayReceiptAmountMinor(query.Amount, snapshot.CurrencyCode)
|
||||||
|
if amountErr == nil {
|
||||||
snapshot.ProviderAmountMinor = amount
|
snapshot.ProviderAmountMinor = amount
|
||||||
}
|
}
|
||||||
if query.OrderID != "" && !strings.EqualFold(query.OrderID, command.ExternalOrderNo) {
|
if query.OrderID != "" && !strings.EqualFold(query.OrderID, command.ExternalOrderNo) {
|
||||||
@ -121,6 +122,17 @@ func fillMifaPayReceiptSnapshot(snapshot ledger.CoinSellerRechargeReceiptVerific
|
|||||||
snapshot.FailureReason = "mifapay currency mismatch"
|
snapshot.FailureReason = "mifapay currency mismatch"
|
||||||
return snapshot
|
return snapshot
|
||||||
}
|
}
|
||||||
|
if amountErr != nil || snapshot.ProviderAmountMinor <= 0 {
|
||||||
|
// MiFaPay 付款成功但金额字段缺失/坏值时,后台无法建立资金事实,必须失败而不是退化成只验订单号。
|
||||||
|
snapshot.FailureReason = "mifapay amount is invalid"
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
if command.ProviderAmountMinor > 0 {
|
||||||
|
if snapshot.ProviderAmountMinor != command.ProviderAmountMinor {
|
||||||
|
snapshot.FailureReason = "mifapay amount mismatch"
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
if command.ProviderPayType != "" && query.PayType != "" && !strings.EqualFold(query.PayType, command.ProviderPayType) {
|
if command.ProviderPayType != "" && query.PayType != "" && !strings.EqualFold(query.PayType, command.ProviderPayType) {
|
||||||
snapshot.FailureReason = "mifapay pay_type mismatch"
|
snapshot.FailureReason = "mifapay pay_type mismatch"
|
||||||
return snapshot
|
return snapshot
|
||||||
@ -180,7 +192,8 @@ func fillV5PayReceiptSnapshot(snapshot ledger.CoinSellerRechargeReceiptVerificat
|
|||||||
snapshot.Status = strings.TrimSpace(query.Status)
|
snapshot.Status = strings.TrimSpace(query.Status)
|
||||||
snapshot.CurrencyCode = strings.ToUpper(strings.TrimSpace(query.Currency))
|
snapshot.CurrencyCode = strings.ToUpper(strings.TrimSpace(query.Currency))
|
||||||
snapshot.RawJSON = strings.TrimSpace(query.RawJSON)
|
snapshot.RawJSON = strings.TrimSpace(query.RawJSON)
|
||||||
if amount, err := parseProviderAmountMinor(query.Amount); err == nil {
|
amount, amountErr := parseProviderAmountMinor(query.Amount)
|
||||||
|
if amountErr == nil {
|
||||||
snapshot.ProviderAmountMinor = amount
|
snapshot.ProviderAmountMinor = amount
|
||||||
}
|
}
|
||||||
if query.OrderID != "" && !strings.EqualFold(query.OrderID, command.ExternalOrderNo) {
|
if query.OrderID != "" && !strings.EqualFold(query.OrderID, command.ExternalOrderNo) {
|
||||||
@ -195,6 +208,17 @@ func fillV5PayReceiptSnapshot(snapshot ledger.CoinSellerRechargeReceiptVerificat
|
|||||||
snapshot.FailureReason = "v5pay currency mismatch"
|
snapshot.FailureReason = "v5pay currency mismatch"
|
||||||
return snapshot
|
return snapshot
|
||||||
}
|
}
|
||||||
|
if amountErr != nil || snapshot.ProviderAmountMinor <= 0 {
|
||||||
|
// V5Pay 人工校验依赖回包金额建立付款事实;即使内部调用未传期望金额,坏金额也不能放行。
|
||||||
|
snapshot.FailureReason = "v5pay amount is invalid"
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
if command.ProviderAmountMinor > 0 {
|
||||||
|
if snapshot.ProviderAmountMinor != command.ProviderAmountMinor {
|
||||||
|
snapshot.FailureReason = "v5pay amount mismatch"
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
if command.ProviderPayType != "" && query.ProductType != "" && !strings.EqualFold(query.ProductType, command.ProviderPayType) {
|
if command.ProviderPayType != "" && query.ProductType != "" && !strings.EqualFold(query.ProductType, command.ProviderPayType) {
|
||||||
snapshot.FailureReason = "v5pay product_type mismatch"
|
snapshot.FailureReason = "v5pay product_type mismatch"
|
||||||
return snapshot
|
return snapshot
|
||||||
@ -203,6 +227,22 @@ func fillV5PayReceiptSnapshot(snapshot ledger.CoinSellerRechargeReceiptVerificat
|
|||||||
return snapshot
|
return snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseMifaPayReceiptAmountMinor(value string, currencyCode string) (int64, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return 0, strconv.ErrSyntax
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if mifaPayUsesWholeMajorAmount(currencyCode) {
|
||||||
|
// MiFaPay INR 查单回包沿用上游 contract:amount 是整数卢比主单位;本地快照仍统一存 minor。
|
||||||
|
return parsed * 100, nil
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
type v5PayReceiptQueryCandidate struct {
|
type v5PayReceiptQueryCandidate struct {
|
||||||
CountryCode string
|
CountryCode string
|
||||||
CurrencyCode string
|
CurrencyCode string
|
||||||
|
|||||||
@ -31,6 +31,7 @@ func TestVerifyCoinSellerRechargeReceiptMifaPayReturnsPaidSnapshot(t *testing.T)
|
|||||||
ExternalOrderNo: "receipt-mifa-1",
|
ExternalOrderNo: "receipt-mifa-1",
|
||||||
OrderDateMS: 1767225600000,
|
OrderDateMS: 1767225600000,
|
||||||
ProviderCurrencyCode: "php",
|
ProviderCurrencyCode: "php",
|
||||||
|
ProviderAmountMinor: 125000,
|
||||||
ProviderPayType: "gcash",
|
ProviderPayType: "gcash",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -44,6 +45,117 @@ func TestVerifyCoinSellerRechargeReceiptMifaPayReturnsPaidSnapshot(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVerifyCoinSellerRechargeReceiptMifaPayRejectsAmountMismatch(t *testing.T) {
|
||||||
|
mifaPay := &fakeMifaPayClient{
|
||||||
|
queryResp: walletservice.MifaPayQueryOrderResponse{
|
||||||
|
OrderID: "receipt-mifa-amount",
|
||||||
|
Status: "1",
|
||||||
|
Amount: "125000",
|
||||||
|
Currency: "PHP",
|
||||||
|
RawJSON: `{"status":"1"}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := walletservice.New(nil)
|
||||||
|
svc.SetMifaPayClient(mifaPay)
|
||||||
|
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
|
||||||
|
AppCode: "lalu",
|
||||||
|
ProviderCode: ledger.PaymentProviderMifaPay,
|
||||||
|
ExternalOrderNo: "receipt-mifa-amount",
|
||||||
|
OrderDateMS: 1767225600000,
|
||||||
|
ProviderCurrencyCode: "PHP",
|
||||||
|
ProviderAmountMinor: 125001,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyCoinSellerRechargeReceipt mifapay amount mismatch failed: %v", err)
|
||||||
|
}
|
||||||
|
if receipt.Verified || receipt.FailureReason != "mifapay amount mismatch" {
|
||||||
|
t.Fatalf("mifapay amount mismatch should return failed snapshot, got %+v", receipt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyCoinSellerRechargeReceiptMifaPayConvertsINRMajorAmount(t *testing.T) {
|
||||||
|
mifaPay := &fakeMifaPayClient{
|
||||||
|
queryResp: walletservice.MifaPayQueryOrderResponse{
|
||||||
|
OrderID: "receipt-mifa-inr",
|
||||||
|
Status: "1",
|
||||||
|
Amount: "100",
|
||||||
|
Currency: "INR",
|
||||||
|
RawJSON: `{"status":"1","amount":"100","currency":"INR"}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := walletservice.New(nil)
|
||||||
|
svc.SetMifaPayClient(mifaPay)
|
||||||
|
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
|
||||||
|
AppCode: "lalu",
|
||||||
|
ProviderCode: ledger.PaymentProviderMifaPay,
|
||||||
|
ExternalOrderNo: "receipt-mifa-inr",
|
||||||
|
OrderDateMS: 1767225600000,
|
||||||
|
ProviderCurrencyCode: "INR",
|
||||||
|
ProviderAmountMinor: 10000,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyCoinSellerRechargeReceipt mifapay INR failed: %v", err)
|
||||||
|
}
|
||||||
|
if !receipt.Verified || receipt.ProviderAmountMinor != 10000 {
|
||||||
|
t.Fatalf("mifapay INR whole-major amount should verify as local minor amount, got %+v", receipt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyCoinSellerRechargeReceiptMifaPayRejectsInvalidAmountWhenExpected(t *testing.T) {
|
||||||
|
mifaPay := &fakeMifaPayClient{
|
||||||
|
queryResp: walletservice.MifaPayQueryOrderResponse{
|
||||||
|
OrderID: "receipt-mifa-bad-amount",
|
||||||
|
Status: "1",
|
||||||
|
Amount: "bad",
|
||||||
|
Currency: "PHP",
|
||||||
|
RawJSON: `{"status":"1","amount":"bad"}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := walletservice.New(nil)
|
||||||
|
svc.SetMifaPayClient(mifaPay)
|
||||||
|
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
|
||||||
|
AppCode: "lalu",
|
||||||
|
ProviderCode: ledger.PaymentProviderMifaPay,
|
||||||
|
ExternalOrderNo: "receipt-mifa-bad-amount",
|
||||||
|
OrderDateMS: 1767225600000,
|
||||||
|
ProviderCurrencyCode: "PHP",
|
||||||
|
ProviderAmountMinor: 125000,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyCoinSellerRechargeReceipt mifapay invalid amount failed: %v", err)
|
||||||
|
}
|
||||||
|
if receipt.Verified || receipt.FailureReason != "mifapay amount is invalid" {
|
||||||
|
t.Fatalf("mifapay invalid amount should return failed snapshot, got %+v", receipt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyCoinSellerRechargeReceiptMifaPayRejectsInvalidAmountWithoutExpected(t *testing.T) {
|
||||||
|
mifaPay := &fakeMifaPayClient{
|
||||||
|
queryResp: walletservice.MifaPayQueryOrderResponse{
|
||||||
|
OrderID: "receipt-mifa-empty-amount",
|
||||||
|
Status: "1",
|
||||||
|
Amount: "",
|
||||||
|
Currency: "PHP",
|
||||||
|
RawJSON: `{"status":"1","amount":""}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := walletservice.New(nil)
|
||||||
|
svc.SetMifaPayClient(mifaPay)
|
||||||
|
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
|
||||||
|
AppCode: "lalu",
|
||||||
|
ProviderCode: ledger.PaymentProviderMifaPay,
|
||||||
|
ExternalOrderNo: "receipt-mifa-empty-amount",
|
||||||
|
OrderDateMS: 1767225600000,
|
||||||
|
ProviderCurrencyCode: "PHP",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyCoinSellerRechargeReceipt mifapay empty amount failed: %v", err)
|
||||||
|
}
|
||||||
|
if receipt.Verified || receipt.FailureReason != "mifapay amount is invalid" {
|
||||||
|
t.Fatalf("mifapay empty amount should return failed snapshot, got %+v", receipt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVerifyCoinSellerRechargeReceiptMifaPayWithoutDateUsesRecentLookup(t *testing.T) {
|
func TestVerifyCoinSellerRechargeReceiptMifaPayWithoutDateUsesRecentLookup(t *testing.T) {
|
||||||
mifaPay := &fakeMifaPayClient{
|
mifaPay := &fakeMifaPayClient{
|
||||||
querySeq: []fakeMifaPayQueryResult{
|
querySeq: []fakeMifaPayQueryResult{
|
||||||
@ -133,6 +245,7 @@ func TestVerifyCoinSellerRechargeReceiptV5PayRejectsCurrencyMismatchWithoutWriti
|
|||||||
ExternalOrderNo: "receipt-v5-1",
|
ExternalOrderNo: "receipt-v5-1",
|
||||||
ProviderCountryCode: "TR",
|
ProviderCountryCode: "TR",
|
||||||
ProviderCurrencyCode: "PHP",
|
ProviderCurrencyCode: "PHP",
|
||||||
|
ProviderAmountMinor: 4800,
|
||||||
ProviderPayType: "BANK_TRANSFER",
|
ProviderPayType: "BANK_TRANSFER",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -146,6 +259,129 @@ func TestVerifyCoinSellerRechargeReceiptV5PayRejectsCurrencyMismatchWithoutWriti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVerifyCoinSellerRechargeReceiptV5PayRejectsAmountMismatch(t *testing.T) {
|
||||||
|
v5Pay := &fakeV5PayClient{
|
||||||
|
queryResp: walletservice.V5PayQueryOrderResponse{
|
||||||
|
OrderID: "receipt-v5-amount",
|
||||||
|
ProviderOrderID: "tx_receipt_v5_amount",
|
||||||
|
Status: "2",
|
||||||
|
Amount: "48.00",
|
||||||
|
Currency: "TRY",
|
||||||
|
ProductType: "BANK_TRANSFER",
|
||||||
|
RawJSON: `{"status":"2"}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := walletservice.New(nil)
|
||||||
|
svc.SetV5PayClient(v5Pay)
|
||||||
|
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
|
||||||
|
AppCode: "lalu",
|
||||||
|
ProviderCode: ledger.PaymentProviderV5Pay,
|
||||||
|
ExternalOrderNo: "receipt-v5-amount",
|
||||||
|
ProviderCountryCode: "TR",
|
||||||
|
ProviderCurrencyCode: "TRY",
|
||||||
|
ProviderAmountMinor: 4700,
|
||||||
|
ProviderPayType: "BANK_TRANSFER",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyCoinSellerRechargeReceipt v5pay amount mismatch failed: %v", err)
|
||||||
|
}
|
||||||
|
if receipt.Verified || receipt.FailureReason != "v5pay amount mismatch" || receipt.ProviderAmountMinor != 4800 {
|
||||||
|
t.Fatalf("v5pay amount mismatch should return a failed snapshot, got %+v", receipt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyCoinSellerRechargeReceiptV5PayRejectsInvalidAmountWhenExpected(t *testing.T) {
|
||||||
|
v5Pay := &fakeV5PayClient{
|
||||||
|
queryResp: walletservice.V5PayQueryOrderResponse{
|
||||||
|
OrderID: "receipt-v5-bad-amount",
|
||||||
|
ProviderOrderID: "tx_receipt_v5_bad_amount",
|
||||||
|
Status: "2",
|
||||||
|
Amount: "bad",
|
||||||
|
Currency: "TRY",
|
||||||
|
ProductType: "BANK_TRANSFER",
|
||||||
|
RawJSON: `{"status":"2","amount":"bad"}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := walletservice.New(nil)
|
||||||
|
svc.SetV5PayClient(v5Pay)
|
||||||
|
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
|
||||||
|
AppCode: "lalu",
|
||||||
|
ProviderCode: ledger.PaymentProviderV5Pay,
|
||||||
|
ExternalOrderNo: "receipt-v5-bad-amount",
|
||||||
|
ProviderCountryCode: "TR",
|
||||||
|
ProviderCurrencyCode: "TRY",
|
||||||
|
ProviderAmountMinor: 4800,
|
||||||
|
ProviderPayType: "BANK_TRANSFER",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyCoinSellerRechargeReceipt v5pay invalid amount failed: %v", err)
|
||||||
|
}
|
||||||
|
if receipt.Verified || receipt.FailureReason != "v5pay amount is invalid" {
|
||||||
|
t.Fatalf("v5pay invalid amount should return a failed snapshot, got %+v", receipt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyCoinSellerRechargeReceiptV5PayRejectsInvalidAmountWithoutExpected(t *testing.T) {
|
||||||
|
v5Pay := &fakeV5PayClient{
|
||||||
|
queryResp: walletservice.V5PayQueryOrderResponse{
|
||||||
|
OrderID: "receipt-v5-empty-amount",
|
||||||
|
ProviderOrderID: "tx_receipt_v5_empty_amount",
|
||||||
|
Status: "2",
|
||||||
|
Amount: "",
|
||||||
|
Currency: "TRY",
|
||||||
|
ProductType: "BANK_TRANSFER",
|
||||||
|
RawJSON: `{"status":"2","amount":""}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := walletservice.New(nil)
|
||||||
|
svc.SetV5PayClient(v5Pay)
|
||||||
|
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
|
||||||
|
AppCode: "lalu",
|
||||||
|
ProviderCode: ledger.PaymentProviderV5Pay,
|
||||||
|
ExternalOrderNo: "receipt-v5-empty-amount",
|
||||||
|
ProviderCountryCode: "TR",
|
||||||
|
ProviderCurrencyCode: "TRY",
|
||||||
|
ProviderPayType: "BANK_TRANSFER",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyCoinSellerRechargeReceipt v5pay empty amount failed: %v", err)
|
||||||
|
}
|
||||||
|
if receipt.Verified || receipt.FailureReason != "v5pay amount is invalid" {
|
||||||
|
t.Fatalf("v5pay empty amount should return failed snapshot, got %+v", receipt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyCoinSellerRechargeReceiptV5PayConvertsWholeMajorAmount(t *testing.T) {
|
||||||
|
v5Pay := &fakeV5PayClient{
|
||||||
|
queryResp: walletservice.V5PayQueryOrderResponse{
|
||||||
|
OrderID: "receipt-v5-whole-major",
|
||||||
|
ProviderOrderID: "tx_receipt_v5_whole_major",
|
||||||
|
Status: "2",
|
||||||
|
Amount: "100",
|
||||||
|
Currency: "TRY",
|
||||||
|
ProductType: "BANK_TRANSFER",
|
||||||
|
RawJSON: `{"status":"2","amount":"100"}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := walletservice.New(nil)
|
||||||
|
svc.SetV5PayClient(v5Pay)
|
||||||
|
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
|
||||||
|
AppCode: "lalu",
|
||||||
|
ProviderCode: ledger.PaymentProviderV5Pay,
|
||||||
|
ExternalOrderNo: "receipt-v5-whole-major",
|
||||||
|
ProviderCountryCode: "TR",
|
||||||
|
ProviderCurrencyCode: "TRY",
|
||||||
|
ProviderAmountMinor: 10000,
|
||||||
|
ProviderPayType: "BANK_TRANSFER",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyCoinSellerRechargeReceipt v5pay whole major amount failed: %v", err)
|
||||||
|
}
|
||||||
|
if !receipt.Verified || receipt.ProviderAmountMinor != 10000 {
|
||||||
|
t.Fatalf("v5pay whole major amount should verify as local minor amount, got %+v", receipt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVerifyCoinSellerRechargeReceiptV5PayWithoutCountryCurrencyUsesEnabledCandidates(t *testing.T) {
|
func TestVerifyCoinSellerRechargeReceiptV5PayWithoutCountryCurrencyUsesEnabledCandidates(t *testing.T) {
|
||||||
repository := &fakeReceiptRepository{channels: []ledger.ThirdPartyPaymentChannel{{
|
repository := &fakeReceiptRepository{channels: []ledger.ThirdPartyPaymentChannel{{
|
||||||
AppCode: "lalu",
|
AppCode: "lalu",
|
||||||
|
|||||||
@ -233,6 +233,7 @@ func (s *Server) VerifyCoinSellerRechargeReceipt(ctx context.Context, req *walle
|
|||||||
OrderDateMS: req.GetOrderDateMs(),
|
OrderDateMS: req.GetOrderDateMs(),
|
||||||
ProviderCountryCode: req.GetProviderCountryCode(),
|
ProviderCountryCode: req.GetProviderCountryCode(),
|
||||||
ProviderCurrencyCode: req.GetProviderCurrencyCode(),
|
ProviderCurrencyCode: req.GetProviderCurrencyCode(),
|
||||||
|
ProviderAmountMinor: req.GetProviderAmountMinor(),
|
||||||
ProviderPayType: req.GetProviderPayType(),
|
ProviderPayType: req.GetProviderPayType(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user