子币商以及充值流水
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;
|
||||
}
|
||||
|
||||
// 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 我的页和入口显隐使用的轻量角色摘要。
|
||||
// 它把 host、Agency、manager、BD 和币商身份收敛到一次 user-service RPC,避免 gateway 随角色扩张继续 fanout。
|
||||
message UserRoleSummary {
|
||||
@ -399,6 +414,17 @@ message CreateSubCoinSellerResponse {
|
||||
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 {
|
||||
RequestMeta meta = 1;
|
||||
int64 parent_user_id = 2;
|
||||
@ -540,6 +566,21 @@ message SetCoinSellerStatusResponse {
|
||||
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 {
|
||||
RequestMeta meta = 1;
|
||||
string command_id = 2;
|
||||
@ -660,6 +701,7 @@ service UserHostService {
|
||||
rpc GetCoinSellerProfile(GetCoinSellerProfileRequest) returns (GetCoinSellerProfileResponse);
|
||||
rpc ListActiveCoinSellersInMyRegion(ListActiveCoinSellersInMyRegionRequest) returns (ListActiveCoinSellersInMyRegionResponse);
|
||||
rpc CreateSubCoinSeller(CreateSubCoinSellerRequest) returns (CreateSubCoinSellerResponse);
|
||||
rpc SubmitSubCoinSellerApplication(SubmitSubCoinSellerApplicationRequest) returns (SubmitSubCoinSellerApplicationResponse);
|
||||
rpc ListSubCoinSellers(ListSubCoinSellersRequest) returns (ListSubCoinSellersResponse);
|
||||
rpc CheckCoinSellerSubRelation(CheckCoinSellerSubRelationRequest) returns (CheckCoinSellerSubRelationResponse);
|
||||
rpc GetUserRoleSummary(GetUserRoleSummaryRequest) returns (GetUserRoleSummaryResponse);
|
||||
@ -677,6 +719,7 @@ service UserHostAdminService {
|
||||
rpc SetBDStatus(SetBDStatusRequest) returns (SetBDStatusResponse);
|
||||
rpc CreateCoinSeller(CreateCoinSellerRequest) returns (CreateCoinSellerResponse);
|
||||
rpc SetCoinSellerStatus(SetCoinSellerStatusRequest) returns (SetCoinSellerStatusResponse);
|
||||
rpc ReviewSubCoinSellerApplication(ReviewSubCoinSellerApplicationRequest) returns (ReviewSubCoinSellerApplicationResponse);
|
||||
rpc CreateAgency(CreateAgencyRequest) returns (CreateAgencyResponse);
|
||||
rpc AdminAddAgencyHost(AdminAddAgencyHostRequest) returns (AdminAddAgencyHostResponse);
|
||||
rpc CloseAgency(CloseAgencyRequest) returns (CloseAgencyResponse);
|
||||
|
||||
@ -39,6 +39,7 @@ const (
|
||||
UserHostService_GetCoinSellerProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetCoinSellerProfile"
|
||||
UserHostService_ListActiveCoinSellersInMyRegion_FullMethodName = "/hyapp.user.v1.UserHostService/ListActiveCoinSellersInMyRegion"
|
||||
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_CheckCoinSellerSubRelation_FullMethodName = "/hyapp.user.v1.UserHostService/CheckCoinSellerSubRelation"
|
||||
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)
|
||||
ListActiveCoinSellersInMyRegion(ctx context.Context, in *ListActiveCoinSellersInMyRegionRequest, opts ...grpc.CallOption) (*ListActiveCoinSellersInMyRegionResponse, 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)
|
||||
CheckCoinSellerSubRelation(ctx context.Context, in *CheckCoinSellerSubRelationRequest, opts ...grpc.CallOption) (*CheckCoinSellerSubRelationResponse, 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
|
||||
}
|
||||
|
||||
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) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListSubCoinSellersResponse)
|
||||
@ -401,6 +413,7 @@ type UserHostServiceServer interface {
|
||||
GetCoinSellerProfile(context.Context, *GetCoinSellerProfileRequest) (*GetCoinSellerProfileResponse, error)
|
||||
ListActiveCoinSellersInMyRegion(context.Context, *ListActiveCoinSellersInMyRegionRequest) (*ListActiveCoinSellersInMyRegionResponse, error)
|
||||
CreateSubCoinSeller(context.Context, *CreateSubCoinSellerRequest) (*CreateSubCoinSellerResponse, error)
|
||||
SubmitSubCoinSellerApplication(context.Context, *SubmitSubCoinSellerApplicationRequest) (*SubmitSubCoinSellerApplicationResponse, error)
|
||||
ListSubCoinSellers(context.Context, *ListSubCoinSellersRequest) (*ListSubCoinSellersResponse, error)
|
||||
CheckCoinSellerSubRelation(context.Context, *CheckCoinSellerSubRelationRequest) (*CheckCoinSellerSubRelationResponse, error)
|
||||
GetUserRoleSummary(context.Context, *GetUserRoleSummaryRequest) (*GetUserRoleSummaryResponse, error)
|
||||
@ -479,6 +492,9 @@ func (UnimplementedUserHostServiceServer) ListActiveCoinSellersInMyRegion(contex
|
||||
func (UnimplementedUserHostServiceServer) CreateSubCoinSeller(context.Context, *CreateSubCoinSellerRequest) (*CreateSubCoinSellerResponse, error) {
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
in := new(ListSubCoinSellersRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -1115,6 +1149,10 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "CreateSubCoinSeller",
|
||||
Handler: _UserHostService_CreateSubCoinSeller_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "SubmitSubCoinSellerApplication",
|
||||
Handler: _UserHostService_SubmitSubCoinSellerApplication_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListSubCoinSellers",
|
||||
Handler: _UserHostService_ListSubCoinSellers_Handler,
|
||||
@ -1153,17 +1191,18 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
|
||||
}
|
||||
|
||||
const (
|
||||
UserHostAdminService_CreateBDLeader_FullMethodName = "/hyapp.user.v1.UserHostAdminService/CreateBDLeader"
|
||||
UserHostAdminService_CreateBD_FullMethodName = "/hyapp.user.v1.UserHostAdminService/CreateBD"
|
||||
UserHostAdminService_SetBDStatus_FullMethodName = "/hyapp.user.v1.UserHostAdminService/SetBDStatus"
|
||||
UserHostAdminService_CreateCoinSeller_FullMethodName = "/hyapp.user.v1.UserHostAdminService/CreateCoinSeller"
|
||||
UserHostAdminService_SetCoinSellerStatus_FullMethodName = "/hyapp.user.v1.UserHostAdminService/SetCoinSellerStatus"
|
||||
UserHostAdminService_CreateAgency_FullMethodName = "/hyapp.user.v1.UserHostAdminService/CreateAgency"
|
||||
UserHostAdminService_AdminAddAgencyHost_FullMethodName = "/hyapp.user.v1.UserHostAdminService/AdminAddAgencyHost"
|
||||
UserHostAdminService_CloseAgency_FullMethodName = "/hyapp.user.v1.UserHostAdminService/CloseAgency"
|
||||
UserHostAdminService_DeleteAgency_FullMethodName = "/hyapp.user.v1.UserHostAdminService/DeleteAgency"
|
||||
UserHostAdminService_SetAgencyStatus_FullMethodName = "/hyapp.user.v1.UserHostAdminService/SetAgencyStatus"
|
||||
UserHostAdminService_SetAgencyJoinEnabled_FullMethodName = "/hyapp.user.v1.UserHostAdminService/SetAgencyJoinEnabled"
|
||||
UserHostAdminService_CreateBDLeader_FullMethodName = "/hyapp.user.v1.UserHostAdminService/CreateBDLeader"
|
||||
UserHostAdminService_CreateBD_FullMethodName = "/hyapp.user.v1.UserHostAdminService/CreateBD"
|
||||
UserHostAdminService_SetBDStatus_FullMethodName = "/hyapp.user.v1.UserHostAdminService/SetBDStatus"
|
||||
UserHostAdminService_CreateCoinSeller_FullMethodName = "/hyapp.user.v1.UserHostAdminService/CreateCoinSeller"
|
||||
UserHostAdminService_SetCoinSellerStatus_FullMethodName = "/hyapp.user.v1.UserHostAdminService/SetCoinSellerStatus"
|
||||
UserHostAdminService_ReviewSubCoinSellerApplication_FullMethodName = "/hyapp.user.v1.UserHostAdminService/ReviewSubCoinSellerApplication"
|
||||
UserHostAdminService_CreateAgency_FullMethodName = "/hyapp.user.v1.UserHostAdminService/CreateAgency"
|
||||
UserHostAdminService_AdminAddAgencyHost_FullMethodName = "/hyapp.user.v1.UserHostAdminService/AdminAddAgencyHost"
|
||||
UserHostAdminService_CloseAgency_FullMethodName = "/hyapp.user.v1.UserHostAdminService/CloseAgency"
|
||||
UserHostAdminService_DeleteAgency_FullMethodName = "/hyapp.user.v1.UserHostAdminService/DeleteAgency"
|
||||
UserHostAdminService_SetAgencyStatus_FullMethodName = "/hyapp.user.v1.UserHostAdminService/SetAgencyStatus"
|
||||
UserHostAdminService_SetAgencyJoinEnabled_FullMethodName = "/hyapp.user.v1.UserHostAdminService/SetAgencyJoinEnabled"
|
||||
)
|
||||
|
||||
// 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)
|
||||
CreateCoinSeller(ctx context.Context, in *CreateCoinSellerRequest, opts ...grpc.CallOption) (*CreateCoinSellerResponse, 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)
|
||||
AdminAddAgencyHost(ctx context.Context, in *AdminAddAgencyHostRequest, opts ...grpc.CallOption) (*AdminAddAgencyHostResponse, 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
|
||||
}
|
||||
|
||||
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) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CreateAgencyResponse)
|
||||
@ -1314,6 +1364,7 @@ type UserHostAdminServiceServer interface {
|
||||
SetBDStatus(context.Context, *SetBDStatusRequest) (*SetBDStatusResponse, error)
|
||||
CreateCoinSeller(context.Context, *CreateCoinSellerRequest) (*CreateCoinSellerResponse, error)
|
||||
SetCoinSellerStatus(context.Context, *SetCoinSellerStatusRequest) (*SetCoinSellerStatusResponse, error)
|
||||
ReviewSubCoinSellerApplication(context.Context, *ReviewSubCoinSellerApplicationRequest) (*ReviewSubCoinSellerApplicationResponse, error)
|
||||
CreateAgency(context.Context, *CreateAgencyRequest) (*CreateAgencyResponse, error)
|
||||
AdminAddAgencyHost(context.Context, *AdminAddAgencyHostRequest) (*AdminAddAgencyHostResponse, error)
|
||||
CloseAgency(context.Context, *CloseAgencyRequest) (*CloseAgencyResponse, error)
|
||||
@ -1345,6 +1396,9 @@ func (UnimplementedUserHostAdminServiceServer) CreateCoinSeller(context.Context,
|
||||
func (UnimplementedUserHostAdminServiceServer) SetCoinSellerStatus(context.Context, *SetCoinSellerStatusRequest) (*SetCoinSellerStatusResponse, error) {
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
in := new(CreateAgencyRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -1609,6 +1681,10 @@ var UserHostAdminService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "SetCoinSellerStatus",
|
||||
Handler: _UserHostAdminService_SetCoinSellerStatus_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ReviewSubCoinSellerApplication",
|
||||
Handler: _UserHostAdminService_ReviewSubCoinSellerApplication_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CreateAgency",
|
||||
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_currency_code = 9;
|
||||
string provider_pay_type = 10;
|
||||
int64 provider_amount_minor = 11;
|
||||
}
|
||||
|
||||
message VerifyCoinSellerRechargeReceiptResponse {
|
||||
|
||||
@ -349,7 +349,7 @@ func main() {
|
||||
Databi: databimodule.New(databiService, store, auditHandler),
|
||||
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
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),
|
||||
FullServerNotice: fullservernoticemodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
Game: gamemanagementmodule.New(
|
||||
|
||||
@ -38,6 +38,7 @@ type Client interface {
|
||||
SetBDStatus(ctx context.Context, req SetBDStatusRequest) (*BDProfile, error)
|
||||
CreateCoinSeller(ctx context.Context, req CreateCoinSellerRequest) (*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)
|
||||
GetUserRoleSummary(ctx context.Context, req GetUserRoleSummaryRequest) (*UserRoleSummary, error)
|
||||
CreateAgency(ctx context.Context, req CreateAgencyRequest) (*CreateAgencyResult, error)
|
||||
@ -532,6 +533,16 @@ type SetCoinSellerStatusRequest struct {
|
||||
CanManageSubCoinSellers *bool
|
||||
}
|
||||
|
||||
type ReviewCoinSellerSubApplicationRequest struct {
|
||||
RequestID string
|
||||
Caller string
|
||||
CommandID string
|
||||
AdminUserID int64
|
||||
ApplicationID int64
|
||||
Decision string
|
||||
Reason string
|
||||
}
|
||||
|
||||
type GetCoinSellerProfileRequest struct {
|
||||
RequestID string
|
||||
Caller string
|
||||
@ -698,6 +709,50 @@ type CoinSellerProfile struct {
|
||||
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 {
|
||||
UserID int64 `json:"userId,string"`
|
||||
IsHost bool `json:"isHost"`
|
||||
@ -819,6 +874,25 @@ func (c *GRPCClient) SetCoinSellerStatus(ctx context.Context, req SetCoinSellerS
|
||||
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) {
|
||||
resp, err := c.hostClient.GetCoinSellerProfile(ctx, &userv1.GetCoinSellerProfileRequest{
|
||||
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 {
|
||||
if summary == nil {
|
||||
return nil
|
||||
|
||||
@ -23,6 +23,7 @@ const (
|
||||
coinSellerAssetType = "COIN_SELLER_COIN"
|
||||
coinManualCreditBizType = "manual_credit"
|
||||
coinSellerTransferBizType = "coin_seller_transfer"
|
||||
coinSellerSubTransferBizType = "coin_seller_sub_transfer"
|
||||
coinSellerRechargeBizType = "coin_seller_recharge"
|
||||
coinSellerStockPurchaseBizType = "coin_seller_stock_purchase"
|
||||
coinSellerStockDeductionBizType = "coin_seller_stock_deduction"
|
||||
@ -30,6 +31,7 @@ const (
|
||||
salaryTransferToCoinSellerBizType = "salary_transfer_to_coin_seller"
|
||||
coinSellerLedgerTypeAdminStockCredit = "admin_stock_credit"
|
||||
coinSellerLedgerTypeSellerTransfer = "seller_transfer"
|
||||
coinSellerLedgerTypeSubSellerTransfer = "sub_seller_transfer"
|
||||
coinSellerLedgerTypeSalaryTransferIncome = "salary_transfer_received"
|
||||
directionIn = "income"
|
||||
directionOut = "expense"
|
||||
@ -845,6 +847,7 @@ func coinSellerLedgerBizTypes(ledgerType string) ([]string, error) {
|
||||
coinSellerRechargeBizType,
|
||||
coinSellerCoinCompensationBizType,
|
||||
coinSellerTransferBizType,
|
||||
coinSellerSubTransferBizType,
|
||||
salaryTransferToCoinSellerBizType,
|
||||
coinManualCreditBizType,
|
||||
}, nil
|
||||
@ -854,6 +857,9 @@ func coinSellerLedgerBizTypes(ledgerType string) ([]string, error) {
|
||||
case coinSellerLedgerTypeSellerTransfer:
|
||||
// 币商转用户只展示币商侧出账分录,收款用户资料在展示投影里补齐。
|
||||
return []string{coinSellerTransferBizType}, nil
|
||||
case coinSellerLedgerTypeSubSellerTransfer:
|
||||
// 父子币商库存划拨按 wallet_entries 展示双边余额变动,不能混入普通玩家充值流水。
|
||||
return []string{coinSellerSubTransferBizType}, nil
|
||||
case coinSellerLedgerTypeSalaryTransferIncome:
|
||||
// 工资转币商是用户工资资产换入币商库存,币商侧表现为 COIN_SELLER_COIN 入账。
|
||||
return []string{salaryTransferToCoinSellerBizType}, nil
|
||||
@ -870,6 +876,8 @@ func coinSellerLedgerTypeForBizType(bizType string) string {
|
||||
return coinSellerLedgerTypeAdminStockCredit
|
||||
case coinSellerTransferBizType:
|
||||
return coinSellerLedgerTypeSellerTransfer
|
||||
case coinSellerSubTransferBizType:
|
||||
return coinSellerLedgerTypeSubSellerTransfer
|
||||
case salaryTransferToCoinSellerBizType:
|
||||
return coinSellerLedgerTypeSalaryTransferIncome
|
||||
default:
|
||||
@ -894,6 +902,8 @@ func coinSellerLedgerLabel(item coinSellerLedgerDTO) string {
|
||||
return "金币增加"
|
||||
case coinSellerTransferBizType:
|
||||
return "币商转用户"
|
||||
case coinSellerSubTransferBizType:
|
||||
return "向子币商转账"
|
||||
case salaryTransferToCoinSellerBizType:
|
||||
return "工资转币商"
|
||||
}
|
||||
@ -902,6 +912,8 @@ func coinSellerLedgerLabel(item coinSellerLedgerDTO) string {
|
||||
return "后台操作"
|
||||
case coinSellerLedgerTypeSellerTransfer:
|
||||
return "币商转用户"
|
||||
case coinSellerLedgerTypeSubSellerTransfer:
|
||||
return "向子币商转账"
|
||||
case coinSellerLedgerTypeSalaryTransferIncome:
|
||||
return "工资转币商"
|
||||
default:
|
||||
@ -950,6 +962,14 @@ func coinSellerLedgerReceiverUserID(bizType string, sellerUserID int64, counterp
|
||||
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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
query := coinSellerLedgerQuery{LedgerType: coinSellerLedgerTypeAdminStockCredit}
|
||||
where, args, err := coinSellerLedgerWhere("lalu", query, []int64{3001, 3002})
|
||||
@ -187,10 +201,10 @@ func TestCoinSellerLedgerWhereEmptyTypeUsesAllPublicTypes(t *testing.T) {
|
||||
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 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -207,6 +221,13 @@ func TestCoinSellerLedgerReceiverUserIDUsesActualReceiver(t *testing.T) {
|
||||
if got := coinSellerLedgerReceiverUserID(coinSellerTransferBizType, 3001, 0, metadata); got != 4001 {
|
||||
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 {
|
||||
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: "debit", 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 {
|
||||
if got := coinSellerLedgerLabel(tc.item); got != tc.want {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@ -11,6 +12,7 @@ import (
|
||||
|
||||
"hyapp-admin-server/internal/config"
|
||||
"hyapp-admin-server/internal/integration/userclient"
|
||||
"hyapp-admin-server/internal/model"
|
||||
"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)
|
||||
}
|
||||
|
||||
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) {
|
||||
page, err := s.statisticsGET(ctx, "/internal/v1/statistics/platform-grants/users", platformGrantValues(query, false))
|
||||
if err != nil {
|
||||
@ -307,6 +329,29 @@ func (s *DashboardService) statisticsGET(ctx context.Context, path string, value
|
||||
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 {
|
||||
values := url.Values{}
|
||||
values.Set("app_code", query.AppCode)
|
||||
|
||||
@ -20,12 +20,18 @@ type createCoinSellerRechargeOrderRequest struct {
|
||||
}
|
||||
|
||||
type verifyCoinSellerRechargeReceiptRequest struct {
|
||||
AppCode string `json:"appCode"`
|
||||
USDAmount float64 `json:"usdAmount"`
|
||||
USDMinorAmount int64 `json:"usdMinorAmount"`
|
||||
ProviderCode string `json:"providerCode"`
|
||||
Chain string `json:"chain"`
|
||||
ExternalOrderNo string `json:"externalOrderNo"`
|
||||
AppCode string `json:"appCode"`
|
||||
USDAmount float64 `json:"usdAmount"`
|
||||
USDMinorAmount int64 `json:"usdMinorAmount"`
|
||||
ProviderCode string `json:"providerCode"`
|
||||
Chain string `json:"chain"`
|
||||
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 {
|
||||
|
||||
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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -45,11 +46,22 @@ type Service struct {
|
||||
wallet walletclient.Client
|
||||
user userclient.Client
|
||||
now func() time.Time
|
||||
statsRecorder financeCoinSellerRechargeStatsRecorder
|
||||
legacyWriters map[string]LegacyCoinSellerRechargeWriter
|
||||
}
|
||||
|
||||
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 {
|
||||
return func(service *Service) {
|
||||
if service.legacyWriters == nil {
|
||||
@ -160,6 +172,7 @@ func (s *Service) CreateOrder(ctx context.Context, actor shared.Actor, req creat
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.recordVerifiedCoinSellerRechargeOrder(ctx, *item)
|
||||
dto := coinSellerRechargeOrderDTOFromModel(*item)
|
||||
return &dto, nil
|
||||
}
|
||||
@ -235,6 +248,7 @@ func (s *Service) VerifyOrder(ctx context.Context, actor shared.Actor, id uint,
|
||||
OrderDateMs: order.OrderDateMS,
|
||||
ProviderCountryCode: order.ProviderCountryCode,
|
||||
ProviderCurrencyCode: order.ProviderCurrencyCode,
|
||||
ProviderAmountMinor: order.ProviderAmountMinor,
|
||||
ProviderPayType: order.ProviderPayType,
|
||||
})
|
||||
if err != nil {
|
||||
@ -263,6 +277,9 @@ func (s *Service) VerifyOrder(ctx context.Context, actor shared.Actor, id uint,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if updated != nil && updated.VerifyStatus == model.CoinSellerRechargeVerifyStatusVerified {
|
||||
s.recordVerifiedCoinSellerRechargeOrder(ctx, *updated)
|
||||
}
|
||||
dto := coinSellerRechargeOrderDTOFromModel(*updated)
|
||||
return &dto, nil
|
||||
}
|
||||
@ -314,6 +331,16 @@ func (s *Service) GrantOrder(ctx context.Context, actor shared.Actor, id uint, r
|
||||
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 {
|
||||
AppCode string
|
||||
TargetUserID string
|
||||
@ -332,20 +359,30 @@ type normalizedCreateOrderInput struct {
|
||||
}
|
||||
|
||||
type normalizedReceiptVerificationInput struct {
|
||||
AppCode string
|
||||
USDMinorAmount int64
|
||||
ProviderCode string
|
||||
Chain string
|
||||
ExternalOrderNo string
|
||||
AppCode string
|
||||
USDMinorAmount int64
|
||||
ProviderCode string
|
||||
Chain string
|
||||
ExternalOrderNo string
|
||||
ProviderCountryCode string
|
||||
ProviderCurrencyCode string
|
||||
ProviderAmountMinor int64
|
||||
ProviderPayType string
|
||||
OrderDateMS int64
|
||||
}
|
||||
|
||||
func (input normalizedCreateOrderInput) receiptVerificationInput() normalizedReceiptVerificationInput {
|
||||
return normalizedReceiptVerificationInput{
|
||||
AppCode: input.AppCode,
|
||||
USDMinorAmount: input.USDMinorAmount,
|
||||
ProviderCode: input.ProviderCode,
|
||||
Chain: input.Chain,
|
||||
ExternalOrderNo: input.ExternalOrderNo,
|
||||
AppCode: input.AppCode,
|
||||
USDMinorAmount: input.USDMinorAmount,
|
||||
ProviderCode: input.ProviderCode,
|
||||
Chain: input.Chain,
|
||||
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 == "" {
|
||||
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{
|
||||
AppCode: appCode,
|
||||
@ -429,6 +474,16 @@ func normalizeReceiptVerificationRequest(req verifyCoinSellerRechargeReceiptRequ
|
||||
if usdMinorAmount < 0 {
|
||||
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 chain == "" {
|
||||
return normalizedReceiptVerificationInput{}, errors.New("USDT 充值必须选择链")
|
||||
@ -436,13 +491,26 @@ func normalizeReceiptVerificationRequest(req verifyCoinSellerRechargeReceiptRequ
|
||||
if usdMinorAmount <= 0 {
|
||||
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{
|
||||
AppCode: appCode,
|
||||
USDMinorAmount: usdMinorAmount,
|
||||
ProviderCode: providerCode,
|
||||
Chain: chain,
|
||||
ExternalOrderNo: externalOrderNo,
|
||||
AppCode: appCode,
|
||||
USDMinorAmount: usdMinorAmount,
|
||||
ProviderCode: providerCode,
|
||||
Chain: chain,
|
||||
ExternalOrderNo: externalOrderNo,
|
||||
ProviderCountryCode: providerCountryCode,
|
||||
ProviderCurrencyCode: providerCurrencyCode,
|
||||
ProviderAmountMinor: req.ProviderAmountMinor,
|
||||
ProviderPayType: providerPayType,
|
||||
OrderDateMS: orderDateMS,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -452,12 +520,17 @@ func (s *Service) verifyReceiptInput(ctx context.Context, input normalizedReceip
|
||||
}
|
||||
// admin-server 不直连三方支付和链节点;wallet-service 统一持有 provider 配置、收款地址和查单适配器。
|
||||
resp, err := s.wallet.VerifyCoinSellerRechargeReceipt(appctx.WithContext(ctx, input.AppCode), &walletv1.VerifyCoinSellerRechargeReceiptRequest{
|
||||
RequestId: strings.TrimSpace(requestID),
|
||||
AppCode: input.AppCode,
|
||||
ProviderCode: receiptVerificationProviderCode(input),
|
||||
ExternalOrderNo: input.ExternalOrderNo,
|
||||
Chain: receiptVerificationChain(input),
|
||||
UsdMinorAmount: input.USDMinorAmount,
|
||||
RequestId: strings.TrimSpace(requestID),
|
||||
AppCode: input.AppCode,
|
||||
ProviderCode: receiptVerificationProviderCode(input),
|
||||
ExternalOrderNo: input.ExternalOrderNo,
|
||||
Chain: receiptVerificationChain(input),
|
||||
UsdMinorAmount: input.USDMinorAmount,
|
||||
OrderDateMs: input.OrderDateMS,
|
||||
ProviderCountryCode: input.ProviderCountryCode,
|
||||
ProviderCurrencyCode: input.ProviderCurrencyCode,
|
||||
ProviderAmountMinor: input.ProviderAmountMinor,
|
||||
ProviderPayType: input.ProviderPayType,
|
||||
})
|
||||
if err != nil {
|
||||
return coinSellerRechargeReceiptVerificationDTO{}, err
|
||||
@ -688,7 +761,7 @@ func verifyReceiptMatchesInput(input normalizedCreateOrderInput, receipt coinSel
|
||||
if !strings.EqualFold(receipt.ExternalOrderNo, input.ExternalOrderNo) {
|
||||
return errors.New("校验返回订单号不匹配")
|
||||
}
|
||||
// 新流程不要求运营录入本地币种实付金额;非 USD 金额只作为 provider 快照留痕,不阻断创建。
|
||||
// USD/USDT 继续按后台美元金额核验;MiFaPay/V5Pay 的本地币种实付金额由表单强制填写,创建前再次和 provider 回包比对。
|
||||
currency := strings.ToUpper(strings.TrimSpace(firstNonEmpty(receipt.CurrencyCode, input.ProviderCurrencyCode)))
|
||||
if input.ProviderCurrencyCode != "" && currency != "" && !strings.EqualFold(currency, input.ProviderCurrencyCode) {
|
||||
return errors.New("校验返回币种不匹配")
|
||||
@ -803,6 +876,15 @@ func receiptVerificationProviderCode(input normalizedReceiptVerificationInput) s
|
||||
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 {
|
||||
switch strings.ToUpper(strings.TrimSpace(order.Chain)) {
|
||||
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"} {
|
||||
input, err := normalizeCreateOrderRequest(createCoinSellerRechargeOrderRequest{
|
||||
_, err := normalizeCreateOrderRequest(createCoinSellerRechargeOrderRequest{
|
||||
AppCode: "lalu",
|
||||
TargetUserID: "10001",
|
||||
USDAmount: 12.34,
|
||||
@ -50,24 +50,73 @@ func TestNormalizeCreateOrderDoesNotRequireProviderHints(t *testing.T) {
|
||||
ProviderCode: providerCode,
|
||||
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 {
|
||||
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 {
|
||||
t.Fatalf("provider hints should remain optional for %s: %+v", providerCode, input)
|
||||
if input.ProviderCurrencyCode != "PHP" || input.ProviderAmountMinor != 123400 || input.ProviderPayType != "GCASH" {
|
||||
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) {
|
||||
input, err := normalizeCreateOrderRequest(createCoinSellerRechargeOrderRequest{
|
||||
AppCode: "lalu",
|
||||
TargetUserID: "10001",
|
||||
USDAmount: 12.34,
|
||||
CoinAmount: 0,
|
||||
ProviderCode: "mifapay",
|
||||
ExternalOrderNo: "MIFA-001",
|
||||
Remark: " 补单,不发金币 ",
|
||||
AppCode: "lalu",
|
||||
TargetUserID: "10001",
|
||||
USDAmount: 12.34,
|
||||
CoinAmount: 0,
|
||||
ProviderCode: "mifapay",
|
||||
ExternalOrderNo: "MIFA-001",
|
||||
ProviderCurrencyCode: "PHP",
|
||||
ProviderAmountMinor: 1234,
|
||||
Remark: " 补单,不发金币 ",
|
||||
})
|
||||
if err != nil {
|
||||
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) {
|
||||
order := model.CoinSellerRechargeOrder{
|
||||
ExternalOrderNo: "merchant-1",
|
||||
@ -209,3 +322,33 @@ func TestReceiptProviderCodeForUSDTChains(t *testing.T) {
|
||||
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})
|
||||
}
|
||||
|
||||
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) {
|
||||
query, ok := parseSalaryWalletHistoryQuery(c)
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@ -33,6 +33,7 @@ type CoinSellerListItem struct {
|
||||
TotalRechargeUSDTMicro int64 `json:"totalRechargeUsdtMicro"`
|
||||
Contact string `json:"contact"`
|
||||
CanManageSubCoinSellers bool `json:"canManageSubCoinSellers"`
|
||||
SubCoinSellerCount int64 `json:"subCoinSellerCount"`
|
||||
DisplayUserID string `json:"displayUserId"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
@ -43,6 +44,29 @@ type CoinSellerListItem struct {
|
||||
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 {
|
||||
UserID int64 `json:"userId,string"`
|
||||
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,
|
||||
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),
|
||||
@ -1108,7 +1148,7 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
|
||||
%s
|
||||
ORDER BY csp.created_at_ms DESC, csp.user_id DESC
|
||||
%s
|
||||
`, whereSQL, limitSQL), queryArgs...)
|
||||
`, whereSQL, limitSQL), append([]any{coinSellerMerchantAssetType}, queryArgs...)...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@ -1124,6 +1164,7 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
|
||||
&item.MerchantAssetType,
|
||||
&item.Contact,
|
||||
&item.CanManageSubCoinSellers,
|
||||
&item.SubCoinSellerCount,
|
||||
&item.CreatedByUserID,
|
||||
&item.CreatedAtMs,
|
||||
&item.UpdatedAtMs,
|
||||
@ -1160,6 +1201,201 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
|
||||
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 {
|
||||
if r == nil || r.db == nil {
|
||||
return errUserDBNotConfigured()
|
||||
|
||||
@ -55,6 +55,8 @@ type listQuery struct {
|
||||
AgencyID int64
|
||||
ParentBDUserID int64
|
||||
ParentLeaderUserID int64
|
||||
ParentUserID int64
|
||||
TargetUserID int64
|
||||
SortBy string
|
||||
SortDirection string
|
||||
}
|
||||
@ -201,6 +203,11 @@ type coinSellerStatusRequest struct {
|
||||
CanManageSubCoinSellers *bool `json:"canManageSubCoinSellers"`
|
||||
}
|
||||
|
||||
type coinSellerSubApplicationReviewRequest struct {
|
||||
CommandID string `json:"commandId" binding:"required"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type coinSellerStockCreditRequest struct {
|
||||
CommandID string `json:"commandId" 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.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.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-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)
|
||||
|
||||
@ -84,6 +84,28 @@ func (s *Service) ListCoinSellers(ctx context.Context, query listQuery) ([]*Coin
|
||||
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) {
|
||||
return s.reader.ListCoinSellerSalaryRates(ctx, regionID)
|
||||
}
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/middleware"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
"hyapp-admin-server/internal/repository"
|
||||
"hyapp-admin-server/internal/response"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
|
||||
@ -83,6 +86,18 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
|
||||
response.ServerError(c, "获取财务概览失败")
|
||||
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)
|
||||
response.OK(c, overview)
|
||||
return
|
||||
@ -152,10 +167,89 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
|
||||
overview.Withdrawal.TransferCount = transfer.GetTransferCount()
|
||||
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)
|
||||
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 {
|
||||
if !legacyCoinSellerAggregateRequested(query) || !legacyCoinSellerAggregateFilterSupported(query) {
|
||||
return true
|
||||
|
||||
@ -49,6 +49,26 @@ type googleRechargePaidDTO struct {
|
||||
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 账单列表。
|
||||
func (h *Handler) listLegacyRechargeBills(c *gin.Context, source RechargeBillSource) {
|
||||
options := shared.ListOptions(c)
|
||||
@ -102,6 +122,18 @@ func (h *Handler) GetRechargeBillSummary(c *gin.Context) {
|
||||
response.ServerError(c, "获取充值汇总失败")
|
||||
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)
|
||||
return
|
||||
}
|
||||
@ -135,12 +167,27 @@ func (h *Handler) GetRechargeBillSummary(c *gin.Context) {
|
||||
writeWalletError(c, err, "获取充值汇总失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, rechargeBillSummaryDTO{
|
||||
summary := rechargeBillSummaryDTO{
|
||||
Total: rechargeBillSummaryBucketFromProto(resp.GetTotal()),
|
||||
GooglePlay: rechargeBillSummaryBucketFromProto(resp.GetGooglePlay()),
|
||||
ThirdParty: rechargeBillSummaryBucketFromProto(resp.GetThirdParty()),
|
||||
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 {
|
||||
@ -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 实付明细同步,供财务补齐历史订单实付。
|
||||
func (h *Handler) RefreshGoogleRechargePaidDetails(c *gin.Context) {
|
||||
var request googleRechargePaidRefreshRequest
|
||||
|
||||
@ -31,6 +31,45 @@ type CoinSellerRechargeOrderListOptions struct {
|
||||
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 {
|
||||
VerifiedByUserID uint
|
||||
VerifiedByName string
|
||||
@ -87,6 +126,57 @@ func (s *Store) ListCoinSellerRechargeOrders(options CoinSellerRechargeOrderList
|
||||
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) {
|
||||
return s.updateCoinSellerRechargeVerifyState(id, input, true)
|
||||
}
|
||||
@ -275,3 +365,89 @@ func applyCoinSellerRechargeOrderFilters(query *gorm.DB, options CoinSellerRecha
|
||||
}
|
||||
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:stock-credit", 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-seller-ledger: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-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: "主播提现", 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 {
|
||||
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",
|
||||
"agency:view", "agency:create", "agency:status", "agency:delete",
|
||||
"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",
|
||||
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
|
||||
"game:view", "game:create", "game:update", "game:status", "game:delete",
|
||||
@ -673,7 +676,7 @@ func defaultRolePermissionCodes(code string) []string {
|
||||
"upload:create",
|
||||
}
|
||||
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":
|
||||
return []string{
|
||||
"overview:view",
|
||||
@ -707,6 +710,7 @@ func defaultRolePermissionCodes(code string) []string {
|
||||
"agency:view",
|
||||
"bd:view",
|
||||
"coin-seller:view",
|
||||
"coin-seller-sub-application:view",
|
||||
"coin-ledger:view",
|
||||
"coin-seller-ledger:view",
|
||||
"coin-adjustment:view",
|
||||
@ -766,7 +770,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
|
||||
"country:view", "country:create", "country:update", "country: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",
|
||||
"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",
|
||||
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
|
||||
"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",
|
||||
}
|
||||
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:
|
||||
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)
|
||||
ListActiveCoinSellersInMyRegion(ctx context.Context, req *userv1.ListActiveCoinSellersInMyRegionRequest) (*userv1.ListActiveCoinSellersInMyRegionResponse, 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)
|
||||
CheckCoinSellerSubRelation(ctx context.Context, req *userv1.CheckCoinSellerSubRelationRequest) (*userv1.CheckCoinSellerSubRelationResponse, 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)
|
||||
}
|
||||
|
||||
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) {
|
||||
return c.client.ListSubCoinSellers(ctx, req)
|
||||
}
|
||||
|
||||
@ -506,6 +506,9 @@ type fakeUserHostClient struct {
|
||||
lastCreateSubSeller *userv1.CreateSubCoinSellerRequest
|
||||
createSubSellerResp *userv1.CreateSubCoinSellerResponse
|
||||
createSubSellerErr error
|
||||
lastSubmitSubApp *userv1.SubmitSubCoinSellerApplicationRequest
|
||||
submitSubAppResp *userv1.SubmitSubCoinSellerApplicationResponse
|
||||
submitSubAppErr error
|
||||
lastListSubSellers *userv1.ListSubCoinSellersRequest
|
||||
listSubSellersResp *userv1.ListSubCoinSellersResponse
|
||||
listSubSellersErr error
|
||||
@ -1846,6 +1849,17 @@ func (f *fakeUserHostClient) CreateSubCoinSeller(_ context.Context, req *userv1.
|
||||
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) {
|
||||
f.lastListSubSellers = req
|
||||
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) {
|
||||
if h.userHostClient == nil || h.userIdentityClient == nil {
|
||||
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 {
|
||||
return
|
||||
}
|
||||
resp, err := h.userHostClient.CreateSubCoinSeller(request.Context(), &userv1.CreateSubCoinSellerRequest{
|
||||
resp, err := h.userHostClient.SubmitSubCoinSellerApplication(request.Context(), &userv1.SubmitSubCoinSellerApplicationRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
CommandId: commandID,
|
||||
ParentUserId: auth.UserIDFromContext(request.Context()),
|
||||
ChildUserId: targetUserID,
|
||||
TargetUserId: targetUserID,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"relation": coinSellerSubRelationFromProto(resp.GetRelation()),
|
||||
"child": coinSellerFromProto(resp.GetChild()),
|
||||
"application": coinSellerSubApplicationFromProto(resp.GetApplication()),
|
||||
"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 {
|
||||
for _, value := range values {
|
||||
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 不复制这些规则。
|
||||
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")
|
||||
return
|
||||
}
|
||||
@ -220,24 +220,19 @@ func (h *Handler) inviteCoinSellerSubSeller(writer http.ResponseWriter, request
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
resp, err := h.userHostClient.CreateSubCoinSeller(request.Context(), &userv1.CreateSubCoinSellerRequest{
|
||||
resp, err := h.userHostClient.SubmitSubCoinSellerApplication(request.Context(), &userv1.SubmitSubCoinSellerApplicationRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
CommandId: commandID,
|
||||
ParentUserId: auth.UserIDFromContext(request.Context()),
|
||||
ChildUserId: childUserID,
|
||||
TargetUserId: childUserID,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
balance, err := h.coinSellerSubBalance(request, resp.GetChild().GetUserId())
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"relation": resp.GetRelation(),
|
||||
"child": coinSellerSubSellerFromProto(resp.GetChild(), balance),
|
||||
"application": coinSellerSubApplicationFromProto(resp.GetApplication()),
|
||||
"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/funnel", s.appFunnel)
|
||||
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-games/overview", s.selfGameOverview)
|
||||
s.server = &http.Server{Handler: mux, ReadHeaderTimeout: 2 * time.Second}
|
||||
@ -227,6 +228,21 @@ type appEventsRequest struct {
|
||||
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 {
|
||||
AppCode string `json:"app_code"`
|
||||
EventID string `json:"event_id"`
|
||||
@ -251,6 +267,53 @@ type appTrackingEventRequest struct {
|
||||
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) {
|
||||
if r.Method != http.MethodPost {
|
||||
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)
|
||||
}
|
||||
}
|
||||
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{
|
||||
{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()},
|
||||
@ -353,19 +370,30 @@ func TestQuerySocialRequirementsAggregatesP0ToP5(t *testing.T) {
|
||||
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 {
|
||||
t.Fatalf("query revenue requirements: %v", err)
|
||||
}
|
||||
revenueSection := socialTestSection(t, revenue, SocialSectionRevenue)
|
||||
if socialMetricInt(t, revenueSection.Total, "recharge_usd_minor") != 1200 ||
|
||||
socialMetricInt(t, revenueSection.Total, "google_recharge_usd_minor") != 500 ||
|
||||
socialMetricInt(t, revenueSection.Total, "third_party_recharge_usd_minor") != 700 ||
|
||||
socialMetricInt(t, revenueSection.Total, "recharge_count") != 2 ||
|
||||
socialMetricInt(t, revenueSection.Total, "first_recharge_users") != 1 ||
|
||||
socialMetricInt(t, revenueSection.Total, "repeat_recharge_users") != 1 ||
|
||||
math.Abs(socialMetricFloat(t, revenueSection.Total, "paid_rate")-1) > 0.0001 {
|
||||
t.Fatalf("P1 metrics mismatch: %+v", revenueSection.Total.Metrics)
|
||||
paidRevenueSection := socialTestSection(t, paidRevenue, SocialSectionRevenue)
|
||||
if socialMetricInt(t, paidRevenueSection.Total, "recharge_usd_minor") != 1200 ||
|
||||
socialMetricInt(t, paidRevenueSection.Total, "google_recharge_usd_minor") != 500 ||
|
||||
socialMetricInt(t, paidRevenueSection.Total, "third_party_recharge_usd_minor") != 0 ||
|
||||
socialMetricInt(t, paidRevenueSection.Total, "recharge_count") != 2 ||
|
||||
socialMetricInt(t, paidRevenueSection.Total, "first_recharge_users") != 1 ||
|
||||
socialMetricInt(t, paidRevenueSection.Total, "repeat_recharge_users") != 1 ||
|
||||
math.Abs(socialMetricFloat(t, paidRevenueSection.Total, "paid_rate")-1) > 0.0001 {
|
||||
t.Fatalf("P1 paid metrics mismatch: %+v", paidRevenueSection.Total.Metrics)
|
||||
}
|
||||
|
||||
retention := socialTestSection(t, result, SocialSectionRetention)
|
||||
|
||||
@ -15,11 +15,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
SourceUser = "user"
|
||||
SourceWallet = "wallet"
|
||||
SourceRoom = "room"
|
||||
SourceGame = "game"
|
||||
SourceH5 = "h5"
|
||||
SourceUser = "user"
|
||||
SourceWallet = "wallet"
|
||||
SourceRoom = "room"
|
||||
SourceGame = "game"
|
||||
SourceH5 = "h5"
|
||||
SourceFinance = "finance"
|
||||
)
|
||||
|
||||
@ -615,16 +615,16 @@ type CoinSellerStockEvent struct {
|
||||
}
|
||||
|
||||
type FinanceCoinSellerRechargeOrderEvent struct {
|
||||
AppCode string
|
||||
EventID string
|
||||
VerifyStatus string
|
||||
TargetUserID int64
|
||||
USDMinor int64
|
||||
CoinAmount int64
|
||||
CountryID int64
|
||||
RegionID int64
|
||||
CreatedAtMS int64
|
||||
OccurredAtMS int64
|
||||
AppCode string
|
||||
EventID string
|
||||
VerifyStatus string
|
||||
TargetUserID int64
|
||||
USDMinor int64
|
||||
CoinAmount int64
|
||||
CountryID int64
|
||||
RegionID int64
|
||||
CreatedAtMS int64
|
||||
OccurredAtMS int64
|
||||
}
|
||||
|
||||
type RoomGiftEvent struct {
|
||||
@ -1004,6 +1004,7 @@ func (r *Repository) ConsumeFinanceCoinSellerRechargeOrder(ctx context.Context,
|
||||
RechargeCount: 1,
|
||||
RechargeUSDMinor: event.USDMinor,
|
||||
ThirdPartyRechargeUSDMinor: event.USDMinor,
|
||||
CoinSellerRechargeUSDMinor: event.USDMinor,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -523,6 +523,7 @@ func socialSectionMetrics(section string, agg socialRequirementAggregate, retent
|
||||
switch section {
|
||||
case SocialSectionRevenue:
|
||||
totalConsume := agg.GameBetCoin + agg.NormalGiftCoin + agg.LuckyGiftCoin + agg.SuperGiftCoin + agg.OtherConsumeCoin
|
||||
ordinaryRechargeUSDMinor := agg.FirstRechargeUSDMinor + agg.RepeatRechargeUSDMinor
|
||||
return map[string]any{
|
||||
"recharge_usd_minor": agg.RechargeUSDMinor,
|
||||
"google_recharge_usd_minor": agg.GoogleRechargeUSDMinor,
|
||||
@ -537,7 +538,7 @@ func socialSectionMetrics(section string, agg socialRequirementAggregate, retent
|
||||
"repurchase_rate": ratio(agg.RepeatRecharge, agg.Recharge),
|
||||
"paid_rate": ratio(agg.Recharge, 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),
|
||||
"game_consumption_ratio": ratio(agg.GameBetCoin, totalConsume),
|
||||
"lucky_gift_consumption_ratio": ratio(agg.LuckyGiftCoin+agg.SuperGiftCoin, totalConsume),
|
||||
@ -740,11 +741,12 @@ func socialRequirementMetricDefinitions(section string) []MetricDefinition {
|
||||
switch section {
|
||||
case SocialSectionRevenue:
|
||||
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: "paid_rate", Definition: "充值用户 / DAU,支持用户身份和当日付费身份筛选。"},
|
||||
{Metric: "arpu_usd_minor", Definition: "充值金额 / DAU。"},
|
||||
{Metric: "arppu_usd_minor", Definition: "充值金额 / 充值用户。"},
|
||||
{Metric: "arppu_usd_minor", Definition: "普通充值金额 / 普通充值用户;finance 币商订单只进入收入金额和订单次数,不污染用户付费均值。"},
|
||||
}
|
||||
case SocialSectionRetention:
|
||||
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)
|
||||
) 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 (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
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"
|
||||
// CommandTypeSetCoinSellerStatus 记录后台启用或停用币商身份命令。
|
||||
CommandTypeSetCoinSellerStatus = "admin_set_coin_seller_status"
|
||||
// CommandTypeCreateSubCoinSeller 记录父币商在 H5 直开或绑定直属子币商的命令。
|
||||
// CommandTypeCreateSubCoinSeller 是历史直开子币商命令;审批制上线后不再允许新请求使用。
|
||||
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_application"
|
||||
@ -157,6 +161,8 @@ const (
|
||||
ResultTypeCoinSellerProfile = "coin_seller_profile"
|
||||
// ResultTypeCoinSellerRelation 表示命令结果是 coin_seller_relations 行。
|
||||
ResultTypeCoinSellerRelation = "coin_seller_relation"
|
||||
// ResultTypeCoinSellerSubApplication 表示命令结果是 coin_seller_sub_applications 行。
|
||||
ResultTypeCoinSellerSubApplication = "coin_seller_sub_application"
|
||||
)
|
||||
|
||||
// HostProfile 是用户长期主播身份快照。
|
||||
@ -287,12 +293,45 @@ type CoinSellerSubRelation struct {
|
||||
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 {
|
||||
Relation CoinSellerSubRelation
|
||||
Child CoinSellerListItem
|
||||
}
|
||||
|
||||
// SubmitSubCoinSellerApplicationResult 是 H5 提交子币商申请后的审核状态回执。
|
||||
type SubmitSubCoinSellerApplicationResult struct {
|
||||
Application CoinSellerSubApplication
|
||||
}
|
||||
|
||||
// ReviewSubCoinSellerApplicationResult 是后台审核后的申请和生效关系投影。
|
||||
type ReviewSubCoinSellerApplicationResult struct {
|
||||
Application CoinSellerSubApplication
|
||||
Relation CoinSellerSubRelation
|
||||
Child CoinSellerListItem
|
||||
}
|
||||
|
||||
// ListSubCoinSellersResult 是直属子币商分页列表。
|
||||
type ListSubCoinSellersResult struct {
|
||||
Items []CoinSellerListItem
|
||||
|
||||
@ -296,6 +296,14 @@ type CreateSubCoinSellerInput struct {
|
||||
RequestID string
|
||||
}
|
||||
|
||||
// SubmitSubCoinSellerApplicationInput 是 H5 父币商邀请子币商时提交后台审核的输入。
|
||||
type SubmitSubCoinSellerApplicationInput struct {
|
||||
CommandID string
|
||||
ParentUserID int64
|
||||
TargetUserID int64
|
||||
RequestID string
|
||||
}
|
||||
|
||||
// CreateSubCoinSellerCommand 是父子币商关系事务需要的完整命令。
|
||||
type CreateSubCoinSellerCommand struct {
|
||||
CommandID string
|
||||
@ -307,6 +315,39 @@ type CreateSubCoinSellerCommand struct {
|
||||
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 描述父币商读取直属子币商列表的分页条件。
|
||||
type ListSubCoinSellersCommand struct {
|
||||
ParentUserID int64
|
||||
|
||||
@ -34,6 +34,8 @@ type Repository interface {
|
||||
CreateCoinSeller(ctx context.Context, command CreateCoinSellerCommand) (hostdomain.CoinSellerProfile, error)
|
||||
SetCoinSellerStatus(ctx context.Context, command SetCoinSellerStatusCommand) (hostdomain.CoinSellerProfile, 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)
|
||||
CheckCoinSellerSubRelation(ctx context.Context, command CheckCoinSellerSubRelationCommand) (hostdomain.CoinSellerListItem, 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) {
|
||||
if err := s.requireWriteDependencies(); err != nil {
|
||||
return hostdomain.CreateSubCoinSellerResult{}, err
|
||||
}
|
||||
if strings.TrimSpace(command.CommandID) == "" || command.ParentUserID <= 0 || command.ChildUserID <= 0 {
|
||||
return hostdomain.CreateSubCoinSellerResult{}, xerr.New(xerr.InvalidArgument, "command_id, parent_user_id and child_user_id are required")
|
||||
}
|
||||
if command.ParentUserID == command.ChildUserID {
|
||||
return hostdomain.CreateSubCoinSellerResult{}, xerr.New(xerr.Conflict, "coin seller cannot add self as sub seller")
|
||||
}
|
||||
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()
|
||||
return s.repository.CreateSubCoinSeller(ctx, CreateSubCoinSellerCommand{
|
||||
CommandID: strings.TrimSpace(command.CommandID),
|
||||
RelationID: s.idGenerator.NewInt64(),
|
||||
ParentUserID: command.ParentUserID,
|
||||
ChildUserID: command.ChildUserID,
|
||||
RequestID: strings.TrimSpace(command.RequestID),
|
||||
EventID: s.nextEventIDRange(2),
|
||||
NowMs: nowMs,
|
||||
return s.repository.SubmitSubCoinSellerApplication(ctx, SubmitSubCoinSellerApplicationCommand{
|
||||
CommandID: strings.TrimSpace(command.CommandID),
|
||||
ApplicationID: s.idGenerator.NewInt64(),
|
||||
ParentUserID: command.ParentUserID,
|
||||
TargetUserID: command.TargetUserID,
|
||||
RequestID: strings.TrimSpace(command.RequestID),
|
||||
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) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
|
||||
@ -11,111 +11,164 @@ import (
|
||||
hostservice "hyapp/services/user-service/internal/service/host"
|
||||
)
|
||||
|
||||
// CreateSubCoinSeller 直开或绑定直属子币商,并把“创建币商身份”和“创建直属关系”放进同一事务。
|
||||
// CreateSubCoinSeller 是历史直开入口;保留方法只为接口兼容,新关系只能由 ReviewSubCoinSellerApplication 审批通过后创建。
|
||||
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)
|
||||
if err != nil {
|
||||
return hostdomain.CreateSubCoinSellerResult{}, err
|
||||
return hostdomain.SubmitSubCoinSellerApplicationResult{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if existing, ok, err := commandResult(ctx, tx, command.CommandID); err != nil {
|
||||
return hostdomain.CreateSubCoinSellerResult{}, err
|
||||
return hostdomain.SubmitSubCoinSellerApplicationResult{}, err
|
||||
} else if ok {
|
||||
if err := requireCommandResult(existing, hostdomain.CommandTypeCreateSubCoinSeller, hostdomain.ResultTypeCoinSellerRelation); err != nil {
|
||||
return hostdomain.CreateSubCoinSellerResult{}, err
|
||||
if err := requireCommandResult(existing, hostdomain.CommandTypeSubmitSubCoinSellerApplication, hostdomain.ResultTypeCoinSellerSubApplication); err != nil {
|
||||
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 {
|
||||
return hostdomain.CreateSubCoinSellerResult{}, err
|
||||
return hostdomain.SubmitSubCoinSellerApplicationResult{}, err
|
||||
}
|
||||
child, err := coinSellerListItemByUser(ctx, tx, relation.ChildUserID)
|
||||
if err != nil {
|
||||
return hostdomain.CreateSubCoinSellerResult{}, err
|
||||
}
|
||||
return hostdomain.CreateSubCoinSellerResult{Relation: relation, Child: child}, nil
|
||||
return hostdomain.SubmitSubCoinSellerApplicationResult{Application: application}, nil
|
||||
}
|
||||
|
||||
parentRegionID, parentProfile, err := r.requireActiveCoinSellerSubManager(ctx, tx, command.ParentUserID, true)
|
||||
parentRegionID, _, err := r.requireActiveCoinSellerSubManager(ctx, tx, command.ParentUserID, true)
|
||||
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 {
|
||||
return hostdomain.CreateSubCoinSellerResult{}, err
|
||||
return hostdomain.SubmitSubCoinSellerApplicationResult{}, err
|
||||
}
|
||||
if parentRegionID != childRegionID {
|
||||
// 父子币商库存只允许在同区域内流转,避免跨区域库存转移绕过后台区域治理。
|
||||
return hostdomain.CreateSubCoinSellerResult{}, xerr.New(xerr.RegionMismatch, "child region does not match parent region")
|
||||
if parentRegionID != targetRegionID {
|
||||
// 申请阶段就拦截跨区域目标,避免后台队列里出现必然无法转移库存的待办。
|
||||
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 {
|
||||
return hostdomain.CreateSubCoinSellerResult{}, err
|
||||
if err := r.ensureCoinSellerRelationHasNoCycle(ctx, tx, command.ParentUserID, command.TargetUserID); err != nil {
|
||||
return hostdomain.SubmitSubCoinSellerApplicationResult{}, err
|
||||
}
|
||||
if existingParent, ok, err := queryActiveCoinSellerParent(ctx, tx, command.ChildUserID, true); err != nil {
|
||||
return hostdomain.CreateSubCoinSellerResult{}, err
|
||||
if existingParent, ok, err := queryActiveCoinSellerParent(ctx, tx, command.TargetUserID, true); err != nil {
|
||||
return hostdomain.SubmitSubCoinSellerApplicationResult{}, err
|
||||
} else if ok {
|
||||
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)
|
||||
if err != nil {
|
||||
return hostdomain.CreateSubCoinSellerResult{}, err
|
||||
application := hostdomain.CoinSellerSubApplication{
|
||||
ApplicationID: command.ApplicationID,
|
||||
CommandID: command.CommandID,
|
||||
ParentUserID: command.ParentUserID,
|
||||
TargetUserID: command.TargetUserID,
|
||||
Status: hostdomain.CoinSellerSubApplicationStatusPending,
|
||||
CreatedAtMs: command.NowMs,
|
||||
UpdatedAtMs: command.NowMs,
|
||||
}
|
||||
profileCreated := false
|
||||
if !ok {
|
||||
// 搜索直开只补齐“从未是币商”的目标;被后台停用的币商不能被父币商从 H5 重新启用。
|
||||
childProfile = hostdomain.CoinSellerProfile{
|
||||
UserID: command.ChildUserID,
|
||||
Status: hostdomain.CoinSellerStatusActive,
|
||||
MerchantAssetType: hostdomain.CoinSellerMerchantAssetType,
|
||||
CanManageSubCoinSellers: true,
|
||||
CreatedByUserID: command.ParentUserID,
|
||||
CreatedAtMs: command.NowMs,
|
||||
UpdatedAtMs: command.NowMs,
|
||||
}
|
||||
if err := insertCoinSellerProfile(ctx, tx, childProfile); err != nil {
|
||||
return hostdomain.CreateSubCoinSellerResult{}, mapHostDuplicateError(err)
|
||||
}
|
||||
profileCreated = true
|
||||
} else if childProfile.Status != hostdomain.CoinSellerStatusActive || childProfile.MerchantAssetType != hostdomain.CoinSellerMerchantAssetType {
|
||||
return hostdomain.CreateSubCoinSellerResult{}, xerr.New(xerr.PermissionDenied, "child coin seller is not active")
|
||||
if err := insertCoinSellerSubApplication(ctx, tx, application); err != nil {
|
||||
return hostdomain.SubmitSubCoinSellerApplicationResult{}, mapHostDuplicateError(err)
|
||||
}
|
||||
|
||||
relation := hostdomain.CoinSellerSubRelation{
|
||||
RelationID: command.RelationID,
|
||||
ParentUserID: command.ParentUserID,
|
||||
ChildUserID: command.ChildUserID,
|
||||
Status: hostdomain.CoinSellerRelationStatusActive,
|
||||
CreatedByUserID: parentProfile.UserID,
|
||||
CreatedAtMs: command.NowMs,
|
||||
UpdatedAtMs: command.NowMs,
|
||||
}
|
||||
if err := insertCoinSellerRelation(ctx, tx, relation); err != nil {
|
||||
return hostdomain.CreateSubCoinSellerResult{}, mapHostDuplicateError(err)
|
||||
}
|
||||
if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{CommandID: command.CommandID, CommandType: hostdomain.CommandTypeCreateSubCoinSeller, ResultType: hostdomain.ResultTypeCoinSellerRelation, ResultID: relation.RelationID, CreatedAtMs: command.NowMs}); err != nil {
|
||||
return hostdomain.CreateSubCoinSellerResult{}, mapHostDuplicateError(err)
|
||||
}
|
||||
if profileCreated {
|
||||
if err := insertHostOutbox(ctx, tx, command.EventID, "CoinSellerCreated", "coin_seller_profile", childProfile.UserID, command.NowMs); err != nil {
|
||||
return hostdomain.CreateSubCoinSellerResult{}, err
|
||||
}
|
||||
}
|
||||
if err := insertHostOutbox(ctx, tx, command.EventID+1, "CoinSellerSubRelationCreated", "coin_seller_relation", relation.RelationID, command.NowMs); err != nil {
|
||||
return hostdomain.CreateSubCoinSellerResult{}, err
|
||||
if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{CommandID: command.CommandID, CommandType: hostdomain.CommandTypeSubmitSubCoinSellerApplication, ResultType: hostdomain.ResultTypeCoinSellerSubApplication, ResultID: application.ApplicationID, CreatedAtMs: command.NowMs}); err != nil {
|
||||
return hostdomain.SubmitSubCoinSellerApplicationResult{}, mapHostDuplicateError(err)
|
||||
}
|
||||
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 {
|
||||
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 返回直属子币商列表;读取前仍校验父币商当前权限,避免权限关闭后继续泄漏团队列表。
|
||||
@ -224,6 +277,77 @@ func (r *Repository) CheckCoinSellerSubRelation(ctx context.Context, command hos
|
||||
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) {
|
||||
lockClause := ""
|
||||
if lock {
|
||||
@ -337,6 +461,107 @@ func scanCoinSellerListItem(scanner rowScanner) (hostdomain.CoinSellerListItem,
|
||||
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) {
|
||||
clause, args = appScopedClause(ctx, clause, args...)
|
||||
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 投影。
|
||||
func toProtoUserRoleSummary(summary hostdomain.UserRoleSummary) *userv1.UserRoleSummary {
|
||||
if summary.UserID == 0 {
|
||||
|
||||
@ -416,6 +416,26 @@ func (s *Server) CreateSubCoinSeller(ctx context.Context, req *userv1.CreateSubC
|
||||
}, 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 返回父币商直属子币商分页列表。
|
||||
func (s *Server) ListSubCoinSellers(ctx context.Context, req *userv1.ListSubCoinSellersRequest) (*userv1.ListSubCoinSellersResponse, error) {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 事实。
|
||||
func (s *Server) CreateAgency(ctx context.Context, req *userv1.CreateAgencyRequest) (*userv1.CreateAgencyResponse, error) {
|
||||
if s.hostSvc == nil {
|
||||
|
||||
@ -234,6 +234,7 @@ type VerifyCoinSellerRechargeReceiptCommand struct {
|
||||
OrderDateMS int64
|
||||
ProviderCountryCode string
|
||||
ProviderCurrencyCode string
|
||||
ProviderAmountMinor int64
|
||||
ProviderPayType string
|
||||
}
|
||||
|
||||
|
||||
@ -106,7 +106,8 @@ func fillMifaPayReceiptSnapshot(snapshot ledger.CoinSellerRechargeReceiptVerific
|
||||
snapshot.Status = strings.TrimSpace(query.Status)
|
||||
snapshot.CurrencyCode = strings.ToUpper(strings.TrimSpace(query.Currency))
|
||||
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
|
||||
}
|
||||
if query.OrderID != "" && !strings.EqualFold(query.OrderID, command.ExternalOrderNo) {
|
||||
@ -121,6 +122,17 @@ func fillMifaPayReceiptSnapshot(snapshot ledger.CoinSellerRechargeReceiptVerific
|
||||
snapshot.FailureReason = "mifapay currency mismatch"
|
||||
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) {
|
||||
snapshot.FailureReason = "mifapay pay_type mismatch"
|
||||
return snapshot
|
||||
@ -180,7 +192,8 @@ func fillV5PayReceiptSnapshot(snapshot ledger.CoinSellerRechargeReceiptVerificat
|
||||
snapshot.Status = strings.TrimSpace(query.Status)
|
||||
snapshot.CurrencyCode = strings.ToUpper(strings.TrimSpace(query.Currency))
|
||||
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
|
||||
}
|
||||
if query.OrderID != "" && !strings.EqualFold(query.OrderID, command.ExternalOrderNo) {
|
||||
@ -195,6 +208,17 @@ func fillV5PayReceiptSnapshot(snapshot ledger.CoinSellerRechargeReceiptVerificat
|
||||
snapshot.FailureReason = "v5pay currency mismatch"
|
||||
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) {
|
||||
snapshot.FailureReason = "v5pay product_type mismatch"
|
||||
return snapshot
|
||||
@ -203,6 +227,22 @@ func fillV5PayReceiptSnapshot(snapshot ledger.CoinSellerRechargeReceiptVerificat
|
||||
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 {
|
||||
CountryCode string
|
||||
CurrencyCode string
|
||||
|
||||
@ -31,6 +31,7 @@ func TestVerifyCoinSellerRechargeReceiptMifaPayReturnsPaidSnapshot(t *testing.T)
|
||||
ExternalOrderNo: "receipt-mifa-1",
|
||||
OrderDateMS: 1767225600000,
|
||||
ProviderCurrencyCode: "php",
|
||||
ProviderAmountMinor: 125000,
|
||||
ProviderPayType: "gcash",
|
||||
})
|
||||
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) {
|
||||
mifaPay := &fakeMifaPayClient{
|
||||
querySeq: []fakeMifaPayQueryResult{
|
||||
@ -133,6 +245,7 @@ func TestVerifyCoinSellerRechargeReceiptV5PayRejectsCurrencyMismatchWithoutWriti
|
||||
ExternalOrderNo: "receipt-v5-1",
|
||||
ProviderCountryCode: "TR",
|
||||
ProviderCurrencyCode: "PHP",
|
||||
ProviderAmountMinor: 4800,
|
||||
ProviderPayType: "BANK_TRANSFER",
|
||||
})
|
||||
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) {
|
||||
repository := &fakeReceiptRepository{channels: []ledger.ThirdPartyPaymentChannel{{
|
||||
AppCode: "lalu",
|
||||
|
||||
@ -233,6 +233,7 @@ func (s *Server) VerifyCoinSellerRechargeReceipt(ctx context.Context, req *walle
|
||||
OrderDateMS: req.GetOrderDateMs(),
|
||||
ProviderCountryCode: req.GetProviderCountryCode(),
|
||||
ProviderCurrencyCode: req.GetProviderCurrencyCode(),
|
||||
ProviderAmountMinor: req.GetProviderAmountMinor(),
|
||||
ProviderPayType: req.GetProviderPayType(),
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user