子币商以及充值流水

This commit is contained in:
zhx 2026-07-09 18:15:57 +08:00
parent dd20dbcd4d
commit ccc2a8b108
47 changed files with 6628 additions and 3489 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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 使
// hostAgencymanagerBD 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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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';

View File

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

View File

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

View File

@ -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 != "" {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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='币商子币商邀请审核申请表';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -234,6 +234,7 @@ type VerifyCoinSellerRechargeReceiptCommand struct {
OrderDateMS int64
ProviderCountryCode string
ProviderCurrencyCode string
ProviderAmountMinor int64
ProviderPayType string
}

View File

@ -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 查单回包沿用上游 contractamount 是整数卢比主单位;本地快照仍统一存 minor。
return parsed * 100, nil
}
return parsed, nil
}
type v5PayReceiptQueryCandidate struct {
CountryCode string
CurrencyCode string

View File

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

View File

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