积分收益政策h5
This commit is contained in:
parent
4794507044
commit
ca15f09850
File diff suppressed because it is too large
Load Diff
@ -698,6 +698,39 @@ message UpdateAgencyProfileResponse {
|
||||
Agency agency = 1;
|
||||
}
|
||||
|
||||
// HostEngagementStats 是 Fami 等公会中心使用的用户侧统计聚合;金额/礼物流水由 wallet-service 单独拥有。
|
||||
message HostEngagementStats {
|
||||
int64 online_duration_ms = 1;
|
||||
int64 valid_mic_duration_ms = 2;
|
||||
int64 valid_mic_days = 3;
|
||||
int64 private_message_senders = 4;
|
||||
int64 new_followers = 5;
|
||||
}
|
||||
|
||||
message GetHostEngagementStatsRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 host_user_id = 2;
|
||||
int64 start_at_ms = 3;
|
||||
int64 end_at_ms = 4;
|
||||
}
|
||||
|
||||
message GetHostEngagementStatsResponse {
|
||||
HostEngagementStats stats = 1;
|
||||
}
|
||||
|
||||
// RecordPrivateMessageEvent 由经过腾讯回调鉴权的 gateway 写入私信事实;重复 event_id 必须幂等。
|
||||
message RecordPrivateMessageEventRequest {
|
||||
RequestMeta meta = 1;
|
||||
string event_id = 2;
|
||||
int64 sender_user_id = 3;
|
||||
int64 target_user_id = 4;
|
||||
int64 occurred_at_ms = 5;
|
||||
}
|
||||
|
||||
message RecordPrivateMessageEventResponse {
|
||||
bool created = 1;
|
||||
}
|
||||
|
||||
// UserHostService 是 user-service 内部 host domain 的 gRPC 面,不是独立微服务。
|
||||
service UserHostService {
|
||||
rpc SearchAgencies(SearchAgenciesRequest) returns (SearchAgenciesResponse);
|
||||
@ -731,6 +764,8 @@ service UserHostService {
|
||||
rpc GetAgencyMembers(GetAgencyMembersRequest) returns (GetAgencyMembersResponse);
|
||||
rpc GetAgencyApplications(GetAgencyApplicationsRequest) returns (GetAgencyApplicationsResponse);
|
||||
rpc UpdateAgencyProfile(UpdateAgencyProfileRequest) returns (UpdateAgencyProfileResponse);
|
||||
rpc GetHostEngagementStats(GetHostEngagementStatsRequest) returns (GetHostEngagementStatsResponse);
|
||||
rpc RecordPrivateMessageEvent(RecordPrivateMessageEventRequest) returns (RecordPrivateMessageEventResponse);
|
||||
}
|
||||
|
||||
// UserHostAdminService 是后台关系管理入口;公网 admin 鉴权由 admin-server 承担。
|
||||
|
||||
@ -49,6 +49,8 @@ const (
|
||||
UserHostService_GetAgencyMembers_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgencyMembers"
|
||||
UserHostService_GetAgencyApplications_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgencyApplications"
|
||||
UserHostService_UpdateAgencyProfile_FullMethodName = "/hyapp.user.v1.UserHostService/UpdateAgencyProfile"
|
||||
UserHostService_GetHostEngagementStats_FullMethodName = "/hyapp.user.v1.UserHostService/GetHostEngagementStats"
|
||||
UserHostService_RecordPrivateMessageEvent_FullMethodName = "/hyapp.user.v1.UserHostService/RecordPrivateMessageEvent"
|
||||
)
|
||||
|
||||
// UserHostServiceClient is the client API for UserHostService service.
|
||||
@ -88,6 +90,8 @@ type UserHostServiceClient interface {
|
||||
GetAgencyMembers(ctx context.Context, in *GetAgencyMembersRequest, opts ...grpc.CallOption) (*GetAgencyMembersResponse, error)
|
||||
GetAgencyApplications(ctx context.Context, in *GetAgencyApplicationsRequest, opts ...grpc.CallOption) (*GetAgencyApplicationsResponse, error)
|
||||
UpdateAgencyProfile(ctx context.Context, in *UpdateAgencyProfileRequest, opts ...grpc.CallOption) (*UpdateAgencyProfileResponse, error)
|
||||
GetHostEngagementStats(ctx context.Context, in *GetHostEngagementStatsRequest, opts ...grpc.CallOption) (*GetHostEngagementStatsResponse, error)
|
||||
RecordPrivateMessageEvent(ctx context.Context, in *RecordPrivateMessageEventRequest, opts ...grpc.CallOption) (*RecordPrivateMessageEventResponse, error)
|
||||
}
|
||||
|
||||
type userHostServiceClient struct {
|
||||
@ -398,6 +402,26 @@ func (c *userHostServiceClient) UpdateAgencyProfile(ctx context.Context, in *Upd
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userHostServiceClient) GetHostEngagementStats(ctx context.Context, in *GetHostEngagementStatsRequest, opts ...grpc.CallOption) (*GetHostEngagementStatsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetHostEngagementStatsResponse)
|
||||
err := c.cc.Invoke(ctx, UserHostService_GetHostEngagementStats_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userHostServiceClient) RecordPrivateMessageEvent(ctx context.Context, in *RecordPrivateMessageEventRequest, opts ...grpc.CallOption) (*RecordPrivateMessageEventResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(RecordPrivateMessageEventResponse)
|
||||
err := c.cc.Invoke(ctx, UserHostService_RecordPrivateMessageEvent_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UserHostServiceServer is the server API for UserHostService service.
|
||||
// All implementations must embed UnimplementedUserHostServiceServer
|
||||
// for forward compatibility.
|
||||
@ -435,6 +459,8 @@ type UserHostServiceServer interface {
|
||||
GetAgencyMembers(context.Context, *GetAgencyMembersRequest) (*GetAgencyMembersResponse, error)
|
||||
GetAgencyApplications(context.Context, *GetAgencyApplicationsRequest) (*GetAgencyApplicationsResponse, error)
|
||||
UpdateAgencyProfile(context.Context, *UpdateAgencyProfileRequest) (*UpdateAgencyProfileResponse, error)
|
||||
GetHostEngagementStats(context.Context, *GetHostEngagementStatsRequest) (*GetHostEngagementStatsResponse, error)
|
||||
RecordPrivateMessageEvent(context.Context, *RecordPrivateMessageEventRequest) (*RecordPrivateMessageEventResponse, error)
|
||||
mustEmbedUnimplementedUserHostServiceServer()
|
||||
}
|
||||
|
||||
@ -535,6 +561,12 @@ func (UnimplementedUserHostServiceServer) GetAgencyApplications(context.Context,
|
||||
func (UnimplementedUserHostServiceServer) UpdateAgencyProfile(context.Context, *UpdateAgencyProfileRequest) (*UpdateAgencyProfileResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method UpdateAgencyProfile not implemented")
|
||||
}
|
||||
func (UnimplementedUserHostServiceServer) GetHostEngagementStats(context.Context, *GetHostEngagementStatsRequest) (*GetHostEngagementStatsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetHostEngagementStats not implemented")
|
||||
}
|
||||
func (UnimplementedUserHostServiceServer) RecordPrivateMessageEvent(context.Context, *RecordPrivateMessageEventRequest) (*RecordPrivateMessageEventResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method RecordPrivateMessageEvent not implemented")
|
||||
}
|
||||
func (UnimplementedUserHostServiceServer) mustEmbedUnimplementedUserHostServiceServer() {}
|
||||
func (UnimplementedUserHostServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
@ -1096,6 +1128,42 @@ func _UserHostService_UpdateAgencyProfile_Handler(srv interface{}, ctx context.C
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserHostService_GetHostEngagementStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetHostEngagementStatsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(UserHostServiceServer).GetHostEngagementStats(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: UserHostService_GetHostEngagementStats_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(UserHostServiceServer).GetHostEngagementStats(ctx, req.(*GetHostEngagementStatsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserHostService_RecordPrivateMessageEvent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(RecordPrivateMessageEventRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(UserHostServiceServer).RecordPrivateMessageEvent(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: UserHostService_RecordPrivateMessageEvent_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(UserHostServiceServer).RecordPrivateMessageEvent(ctx, req.(*RecordPrivateMessageEventRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// UserHostService_ServiceDesc is the grpc.ServiceDesc for UserHostService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@ -1223,6 +1291,14 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "UpdateAgencyProfile",
|
||||
Handler: _UserHostService_UpdateAgencyProfile_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetHostEngagementStats",
|
||||
Handler: _UserHostService_GetHostEngagementStats_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "RecordPrivateMessageEvent",
|
||||
Handler: _UserHostService_RecordPrivateMessageEvent_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "proto/user/v1/host.proto",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -2763,6 +2763,122 @@ message GetGiftCatalogVersionResponse {
|
||||
int64 version = 1;
|
||||
}
|
||||
|
||||
// HostRevenueStats 聚合主播在自然日区间内的收益 POINT 和送礼用户;gateway 对外仍按产品文案展示为钻石。
|
||||
message HostRevenueStats {
|
||||
int64 diamond_earnings = 1;
|
||||
int64 diamond_exchanged = 2;
|
||||
int64 gift_senders = 3;
|
||||
}
|
||||
|
||||
message GetHostRevenueStatsRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
int64 host_user_id = 3;
|
||||
int64 start_at_ms = 4;
|
||||
int64 end_at_ms = 5;
|
||||
}
|
||||
|
||||
message GetHostRevenueStatsResponse {
|
||||
HostRevenueStats stats = 1;
|
||||
}
|
||||
|
||||
// AgencyPointShareStats 只统计独立 Agency 分成流水,不把 owner 自己的主播礼物收益重复算入分成。
|
||||
message AgencyPointShareStats {
|
||||
int64 share_income = 1;
|
||||
int64 gifted_host_count = 2;
|
||||
}
|
||||
|
||||
message GetAgencyPointShareStatsRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
int64 agency_owner_user_id = 3;
|
||||
int64 start_at_ms = 4;
|
||||
int64 end_at_ms = 5;
|
||||
}
|
||||
|
||||
message GetAgencyPointShareStatsResponse {
|
||||
AgencyPointShareStats stats = 1;
|
||||
}
|
||||
|
||||
message AgencyHostGiftStats {
|
||||
int64 gift_income = 1;
|
||||
int64 gifted_host_count = 2;
|
||||
}
|
||||
|
||||
message GetAgencyHostGiftStatsRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
repeated int64 host_user_ids = 3;
|
||||
int64 start_at_ms = 4;
|
||||
int64 end_at_ms = 5;
|
||||
}
|
||||
|
||||
message GetAgencyHostGiftStatsResponse {
|
||||
AgencyHostGiftStats stats = 1;
|
||||
}
|
||||
|
||||
// PointWithdrawalCoinSellerConfig 是按 App 隔离的 POINT 提现币商白名单与兑换比例。
|
||||
// 比例用整数分子/分母保存,避免浮点金额在 H5、gateway 和账本间产生舍入分歧。
|
||||
message PointWithdrawalCoinSellerConfig {
|
||||
string app_code = 1;
|
||||
int64 seller_user_id = 2;
|
||||
int32 sort_order = 3;
|
||||
int64 point_amount = 4;
|
||||
int64 seller_coin_amount = 5;
|
||||
repeated string service_country_codes = 6;
|
||||
string status = 7;
|
||||
int64 created_at_ms = 8;
|
||||
int64 updated_at_ms = 9;
|
||||
}
|
||||
|
||||
message ListPointWithdrawalCoinSellersRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
// country_code 由 gateway 从登录用户资料注入;为空只用于 Admin/内部查询。
|
||||
string country_code = 3;
|
||||
bool include_disabled = 4;
|
||||
}
|
||||
|
||||
message ListPointWithdrawalCoinSellersResponse {
|
||||
repeated PointWithdrawalCoinSellerConfig sellers = 1;
|
||||
}
|
||||
|
||||
message TransferPointToCoinSellerRequest {
|
||||
string command_id = 1;
|
||||
string app_code = 2;
|
||||
int64 source_user_id = 3;
|
||||
int64 seller_user_id = 4;
|
||||
int64 point_amount = 5;
|
||||
// source_country_code 由 gateway 从用户资料读取,wallet 会再次校验币商服务国家。
|
||||
string source_country_code = 6;
|
||||
string reason = 7;
|
||||
}
|
||||
|
||||
message TransferPointToCoinSellerResponse {
|
||||
string transaction_id = 1;
|
||||
int64 source_point_balance_after = 2;
|
||||
int64 seller_balance_after = 3;
|
||||
int64 point_amount = 4;
|
||||
int64 seller_coin_amount = 5;
|
||||
int64 ratio_point_amount = 6;
|
||||
int64 ratio_seller_coin_amount = 7;
|
||||
}
|
||||
|
||||
message GetPointWithdrawalConfigRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
int64 region_id = 3;
|
||||
int64 now_ms = 4;
|
||||
}
|
||||
|
||||
message GetPointWithdrawalConfigResponse {
|
||||
bool found = 1;
|
||||
int64 points_per_usd = 2;
|
||||
int32 fee_bps = 3;
|
||||
int64 minimum_points = 4;
|
||||
string policy_instance_code = 5;
|
||||
}
|
||||
|
||||
// WalletCronService 只给 cron-service 调用;账务状态仍由 wallet-service owner 修改。
|
||||
service WalletCronService {
|
||||
rpc ProcessHostSalaryDailySettlementBatch(CronBatchRequest) returns (CronBatchResponse);
|
||||
@ -2780,6 +2896,9 @@ service WalletService {
|
||||
rpc GetBalances(GetBalancesRequest) returns (GetBalancesResponse);
|
||||
rpc GetActiveHostSalaryPolicy(GetActiveHostSalaryPolicyRequest) returns (GetActiveHostSalaryPolicyResponse);
|
||||
rpc GetHostSalaryProgress(GetHostSalaryProgressRequest) returns (GetHostSalaryProgressResponse);
|
||||
rpc GetHostRevenueStats(GetHostRevenueStatsRequest) returns (GetHostRevenueStatsResponse);
|
||||
rpc GetAgencyPointShareStats(GetAgencyPointShareStatsRequest) returns (GetAgencyPointShareStatsResponse);
|
||||
rpc GetAgencyHostGiftStats(GetAgencyHostGiftStatsRequest) returns (GetAgencyHostGiftStatsResponse);
|
||||
// GetTeamHostSalaryStats 按 Agency 收款人集合聚合主播预收入工资;经理中心团队工资卡片使用。
|
||||
rpc GetTeamHostSalaryStats(GetTeamHostSalaryStatsRequest) returns (GetTeamHostSalaryStatsResponse);
|
||||
rpc AdminCreditAsset(AdminCreditAssetRequest) returns (AdminCreditAssetResponse);
|
||||
@ -2789,6 +2908,9 @@ service WalletService {
|
||||
rpc ListCoinSellerSalaryExchangeRateTiers(ListCoinSellerSalaryExchangeRateTiersRequest) returns (ListCoinSellerSalaryExchangeRateTiersResponse);
|
||||
rpc ExchangeSalaryToCoin(ExchangeSalaryToCoinRequest) returns (ExchangeSalaryToCoinResponse);
|
||||
rpc TransferSalaryToCoinSeller(TransferSalaryToCoinSellerRequest) returns (TransferSalaryToCoinSellerResponse);
|
||||
rpc ListPointWithdrawalCoinSellers(ListPointWithdrawalCoinSellersRequest) returns (ListPointWithdrawalCoinSellersResponse);
|
||||
rpc TransferPointToCoinSeller(TransferPointToCoinSellerRequest) returns (TransferPointToCoinSellerResponse);
|
||||
rpc GetPointWithdrawalConfig(GetPointWithdrawalConfigRequest) returns (GetPointWithdrawalConfigResponse);
|
||||
rpc FreezeSalaryWithdrawal(FreezeSalaryWithdrawalRequest) returns (FreezeSalaryWithdrawalResponse);
|
||||
rpc SettleSalaryWithdrawal(SettleSalaryWithdrawalRequest) returns (SettleSalaryWithdrawalResponse);
|
||||
rpc ReleaseSalaryWithdrawal(ReleaseSalaryWithdrawalRequest) returns (ReleaseSalaryWithdrawalResponse);
|
||||
|
||||
@ -246,6 +246,9 @@ const (
|
||||
WalletService_GetBalances_FullMethodName = "/hyapp.wallet.v1.WalletService/GetBalances"
|
||||
WalletService_GetActiveHostSalaryPolicy_FullMethodName = "/hyapp.wallet.v1.WalletService/GetActiveHostSalaryPolicy"
|
||||
WalletService_GetHostSalaryProgress_FullMethodName = "/hyapp.wallet.v1.WalletService/GetHostSalaryProgress"
|
||||
WalletService_GetHostRevenueStats_FullMethodName = "/hyapp.wallet.v1.WalletService/GetHostRevenueStats"
|
||||
WalletService_GetAgencyPointShareStats_FullMethodName = "/hyapp.wallet.v1.WalletService/GetAgencyPointShareStats"
|
||||
WalletService_GetAgencyHostGiftStats_FullMethodName = "/hyapp.wallet.v1.WalletService/GetAgencyHostGiftStats"
|
||||
WalletService_GetTeamHostSalaryStats_FullMethodName = "/hyapp.wallet.v1.WalletService/GetTeamHostSalaryStats"
|
||||
WalletService_AdminCreditAsset_FullMethodName = "/hyapp.wallet.v1.WalletService/AdminCreditAsset"
|
||||
WalletService_AdminCreditCoinSellerStock_FullMethodName = "/hyapp.wallet.v1.WalletService/AdminCreditCoinSellerStock"
|
||||
@ -254,6 +257,9 @@ const (
|
||||
WalletService_ListCoinSellerSalaryExchangeRateTiers_FullMethodName = "/hyapp.wallet.v1.WalletService/ListCoinSellerSalaryExchangeRateTiers"
|
||||
WalletService_ExchangeSalaryToCoin_FullMethodName = "/hyapp.wallet.v1.WalletService/ExchangeSalaryToCoin"
|
||||
WalletService_TransferSalaryToCoinSeller_FullMethodName = "/hyapp.wallet.v1.WalletService/TransferSalaryToCoinSeller"
|
||||
WalletService_ListPointWithdrawalCoinSellers_FullMethodName = "/hyapp.wallet.v1.WalletService/ListPointWithdrawalCoinSellers"
|
||||
WalletService_TransferPointToCoinSeller_FullMethodName = "/hyapp.wallet.v1.WalletService/TransferPointToCoinSeller"
|
||||
WalletService_GetPointWithdrawalConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetPointWithdrawalConfig"
|
||||
WalletService_FreezeSalaryWithdrawal_FullMethodName = "/hyapp.wallet.v1.WalletService/FreezeSalaryWithdrawal"
|
||||
WalletService_SettleSalaryWithdrawal_FullMethodName = "/hyapp.wallet.v1.WalletService/SettleSalaryWithdrawal"
|
||||
WalletService_ReleaseSalaryWithdrawal_FullMethodName = "/hyapp.wallet.v1.WalletService/ReleaseSalaryWithdrawal"
|
||||
@ -372,6 +378,9 @@ type WalletServiceClient interface {
|
||||
GetBalances(ctx context.Context, in *GetBalancesRequest, opts ...grpc.CallOption) (*GetBalancesResponse, error)
|
||||
GetActiveHostSalaryPolicy(ctx context.Context, in *GetActiveHostSalaryPolicyRequest, opts ...grpc.CallOption) (*GetActiveHostSalaryPolicyResponse, error)
|
||||
GetHostSalaryProgress(ctx context.Context, in *GetHostSalaryProgressRequest, opts ...grpc.CallOption) (*GetHostSalaryProgressResponse, error)
|
||||
GetHostRevenueStats(ctx context.Context, in *GetHostRevenueStatsRequest, opts ...grpc.CallOption) (*GetHostRevenueStatsResponse, error)
|
||||
GetAgencyPointShareStats(ctx context.Context, in *GetAgencyPointShareStatsRequest, opts ...grpc.CallOption) (*GetAgencyPointShareStatsResponse, error)
|
||||
GetAgencyHostGiftStats(ctx context.Context, in *GetAgencyHostGiftStatsRequest, opts ...grpc.CallOption) (*GetAgencyHostGiftStatsResponse, error)
|
||||
// GetTeamHostSalaryStats 按 Agency 收款人集合聚合主播预收入工资;经理中心团队工资卡片使用。
|
||||
GetTeamHostSalaryStats(ctx context.Context, in *GetTeamHostSalaryStatsRequest, opts ...grpc.CallOption) (*GetTeamHostSalaryStatsResponse, error)
|
||||
AdminCreditAsset(ctx context.Context, in *AdminCreditAssetRequest, opts ...grpc.CallOption) (*AdminCreditAssetResponse, error)
|
||||
@ -381,6 +390,9 @@ type WalletServiceClient interface {
|
||||
ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, in *ListCoinSellerSalaryExchangeRateTiersRequest, opts ...grpc.CallOption) (*ListCoinSellerSalaryExchangeRateTiersResponse, error)
|
||||
ExchangeSalaryToCoin(ctx context.Context, in *ExchangeSalaryToCoinRequest, opts ...grpc.CallOption) (*ExchangeSalaryToCoinResponse, error)
|
||||
TransferSalaryToCoinSeller(ctx context.Context, in *TransferSalaryToCoinSellerRequest, opts ...grpc.CallOption) (*TransferSalaryToCoinSellerResponse, error)
|
||||
ListPointWithdrawalCoinSellers(ctx context.Context, in *ListPointWithdrawalCoinSellersRequest, opts ...grpc.CallOption) (*ListPointWithdrawalCoinSellersResponse, error)
|
||||
TransferPointToCoinSeller(ctx context.Context, in *TransferPointToCoinSellerRequest, opts ...grpc.CallOption) (*TransferPointToCoinSellerResponse, error)
|
||||
GetPointWithdrawalConfig(ctx context.Context, in *GetPointWithdrawalConfigRequest, opts ...grpc.CallOption) (*GetPointWithdrawalConfigResponse, error)
|
||||
FreezeSalaryWithdrawal(ctx context.Context, in *FreezeSalaryWithdrawalRequest, opts ...grpc.CallOption) (*FreezeSalaryWithdrawalResponse, error)
|
||||
SettleSalaryWithdrawal(ctx context.Context, in *SettleSalaryWithdrawalRequest, opts ...grpc.CallOption) (*SettleSalaryWithdrawalResponse, error)
|
||||
ReleaseSalaryWithdrawal(ctx context.Context, in *ReleaseSalaryWithdrawalRequest, opts ...grpc.CallOption) (*ReleaseSalaryWithdrawalResponse, error)
|
||||
@ -564,6 +576,36 @@ func (c *walletServiceClient) GetHostSalaryProgress(ctx context.Context, in *Get
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GetHostRevenueStats(ctx context.Context, in *GetHostRevenueStatsRequest, opts ...grpc.CallOption) (*GetHostRevenueStatsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetHostRevenueStatsResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_GetHostRevenueStats_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GetAgencyPointShareStats(ctx context.Context, in *GetAgencyPointShareStatsRequest, opts ...grpc.CallOption) (*GetAgencyPointShareStatsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetAgencyPointShareStatsResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_GetAgencyPointShareStats_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GetAgencyHostGiftStats(ctx context.Context, in *GetAgencyHostGiftStatsRequest, opts ...grpc.CallOption) (*GetAgencyHostGiftStatsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetAgencyHostGiftStatsResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_GetAgencyHostGiftStats_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GetTeamHostSalaryStats(ctx context.Context, in *GetTeamHostSalaryStatsRequest, opts ...grpc.CallOption) (*GetTeamHostSalaryStatsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetTeamHostSalaryStatsResponse)
|
||||
@ -644,6 +686,36 @@ func (c *walletServiceClient) TransferSalaryToCoinSeller(ctx context.Context, in
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) ListPointWithdrawalCoinSellers(ctx context.Context, in *ListPointWithdrawalCoinSellersRequest, opts ...grpc.CallOption) (*ListPointWithdrawalCoinSellersResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListPointWithdrawalCoinSellersResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_ListPointWithdrawalCoinSellers_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) TransferPointToCoinSeller(ctx context.Context, in *TransferPointToCoinSellerRequest, opts ...grpc.CallOption) (*TransferPointToCoinSellerResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(TransferPointToCoinSellerResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_TransferPointToCoinSeller_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GetPointWithdrawalConfig(ctx context.Context, in *GetPointWithdrawalConfigRequest, opts ...grpc.CallOption) (*GetPointWithdrawalConfigResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetPointWithdrawalConfigResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_GetPointWithdrawalConfig_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) FreezeSalaryWithdrawal(ctx context.Context, in *FreezeSalaryWithdrawalRequest, opts ...grpc.CallOption) (*FreezeSalaryWithdrawalResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(FreezeSalaryWithdrawalResponse)
|
||||
@ -1687,6 +1759,9 @@ type WalletServiceServer interface {
|
||||
GetBalances(context.Context, *GetBalancesRequest) (*GetBalancesResponse, error)
|
||||
GetActiveHostSalaryPolicy(context.Context, *GetActiveHostSalaryPolicyRequest) (*GetActiveHostSalaryPolicyResponse, error)
|
||||
GetHostSalaryProgress(context.Context, *GetHostSalaryProgressRequest) (*GetHostSalaryProgressResponse, error)
|
||||
GetHostRevenueStats(context.Context, *GetHostRevenueStatsRequest) (*GetHostRevenueStatsResponse, error)
|
||||
GetAgencyPointShareStats(context.Context, *GetAgencyPointShareStatsRequest) (*GetAgencyPointShareStatsResponse, error)
|
||||
GetAgencyHostGiftStats(context.Context, *GetAgencyHostGiftStatsRequest) (*GetAgencyHostGiftStatsResponse, error)
|
||||
// GetTeamHostSalaryStats 按 Agency 收款人集合聚合主播预收入工资;经理中心团队工资卡片使用。
|
||||
GetTeamHostSalaryStats(context.Context, *GetTeamHostSalaryStatsRequest) (*GetTeamHostSalaryStatsResponse, error)
|
||||
AdminCreditAsset(context.Context, *AdminCreditAssetRequest) (*AdminCreditAssetResponse, error)
|
||||
@ -1696,6 +1771,9 @@ type WalletServiceServer interface {
|
||||
ListCoinSellerSalaryExchangeRateTiers(context.Context, *ListCoinSellerSalaryExchangeRateTiersRequest) (*ListCoinSellerSalaryExchangeRateTiersResponse, error)
|
||||
ExchangeSalaryToCoin(context.Context, *ExchangeSalaryToCoinRequest) (*ExchangeSalaryToCoinResponse, error)
|
||||
TransferSalaryToCoinSeller(context.Context, *TransferSalaryToCoinSellerRequest) (*TransferSalaryToCoinSellerResponse, error)
|
||||
ListPointWithdrawalCoinSellers(context.Context, *ListPointWithdrawalCoinSellersRequest) (*ListPointWithdrawalCoinSellersResponse, error)
|
||||
TransferPointToCoinSeller(context.Context, *TransferPointToCoinSellerRequest) (*TransferPointToCoinSellerResponse, error)
|
||||
GetPointWithdrawalConfig(context.Context, *GetPointWithdrawalConfigRequest) (*GetPointWithdrawalConfigResponse, error)
|
||||
FreezeSalaryWithdrawal(context.Context, *FreezeSalaryWithdrawalRequest) (*FreezeSalaryWithdrawalResponse, error)
|
||||
SettleSalaryWithdrawal(context.Context, *SettleSalaryWithdrawalRequest) (*SettleSalaryWithdrawalResponse, error)
|
||||
ReleaseSalaryWithdrawal(context.Context, *ReleaseSalaryWithdrawalRequest) (*ReleaseSalaryWithdrawalResponse, error)
|
||||
@ -1830,6 +1908,15 @@ func (UnimplementedWalletServiceServer) GetActiveHostSalaryPolicy(context.Contex
|
||||
func (UnimplementedWalletServiceServer) GetHostSalaryProgress(context.Context, *GetHostSalaryProgressRequest) (*GetHostSalaryProgressResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetHostSalaryProgress not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GetHostRevenueStats(context.Context, *GetHostRevenueStatsRequest) (*GetHostRevenueStatsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetHostRevenueStats not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GetAgencyPointShareStats(context.Context, *GetAgencyPointShareStatsRequest) (*GetAgencyPointShareStatsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetAgencyPointShareStats not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GetAgencyHostGiftStats(context.Context, *GetAgencyHostGiftStatsRequest) (*GetAgencyHostGiftStatsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetAgencyHostGiftStats not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GetTeamHostSalaryStats(context.Context, *GetTeamHostSalaryStatsRequest) (*GetTeamHostSalaryStatsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetTeamHostSalaryStats not implemented")
|
||||
}
|
||||
@ -1854,6 +1941,15 @@ func (UnimplementedWalletServiceServer) ExchangeSalaryToCoin(context.Context, *E
|
||||
func (UnimplementedWalletServiceServer) TransferSalaryToCoinSeller(context.Context, *TransferSalaryToCoinSellerRequest) (*TransferSalaryToCoinSellerResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method TransferSalaryToCoinSeller not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) ListPointWithdrawalCoinSellers(context.Context, *ListPointWithdrawalCoinSellersRequest) (*ListPointWithdrawalCoinSellersResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListPointWithdrawalCoinSellers not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) TransferPointToCoinSeller(context.Context, *TransferPointToCoinSellerRequest) (*TransferPointToCoinSellerResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method TransferPointToCoinSeller not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GetPointWithdrawalConfig(context.Context, *GetPointWithdrawalConfigRequest) (*GetPointWithdrawalConfigResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetPointWithdrawalConfig not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) FreezeSalaryWithdrawal(context.Context, *FreezeSalaryWithdrawalRequest) (*FreezeSalaryWithdrawalResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method FreezeSalaryWithdrawal not implemented")
|
||||
}
|
||||
@ -2310,6 +2406,60 @@ func _WalletService_GetHostSalaryProgress_Handler(srv interface{}, ctx context.C
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GetHostRevenueStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetHostRevenueStatsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).GetHostRevenueStats(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_GetHostRevenueStats_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).GetHostRevenueStats(ctx, req.(*GetHostRevenueStatsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GetAgencyPointShareStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetAgencyPointShareStatsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).GetAgencyPointShareStats(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_GetAgencyPointShareStats_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).GetAgencyPointShareStats(ctx, req.(*GetAgencyPointShareStatsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GetAgencyHostGiftStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetAgencyHostGiftStatsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).GetAgencyHostGiftStats(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_GetAgencyHostGiftStats_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).GetAgencyHostGiftStats(ctx, req.(*GetAgencyHostGiftStatsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GetTeamHostSalaryStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetTeamHostSalaryStatsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -2454,6 +2604,60 @@ func _WalletService_TransferSalaryToCoinSeller_Handler(srv interface{}, ctx cont
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_ListPointWithdrawalCoinSellers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListPointWithdrawalCoinSellersRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).ListPointWithdrawalCoinSellers(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_ListPointWithdrawalCoinSellers_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).ListPointWithdrawalCoinSellers(ctx, req.(*ListPointWithdrawalCoinSellersRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_TransferPointToCoinSeller_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(TransferPointToCoinSellerRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).TransferPointToCoinSeller(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_TransferPointToCoinSeller_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).TransferPointToCoinSeller(ctx, req.(*TransferPointToCoinSellerRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GetPointWithdrawalConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetPointWithdrawalConfigRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).GetPointWithdrawalConfig(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_GetPointWithdrawalConfig_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).GetPointWithdrawalConfig(ctx, req.(*GetPointWithdrawalConfigRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_FreezeSalaryWithdrawal_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(FreezeSalaryWithdrawalRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -4343,6 +4547,18 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "GetHostSalaryProgress",
|
||||
Handler: _WalletService_GetHostSalaryProgress_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetHostRevenueStats",
|
||||
Handler: _WalletService_GetHostRevenueStats_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetAgencyPointShareStats",
|
||||
Handler: _WalletService_GetAgencyPointShareStats_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetAgencyHostGiftStats",
|
||||
Handler: _WalletService_GetAgencyHostGiftStats_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetTeamHostSalaryStats",
|
||||
Handler: _WalletService_GetTeamHostSalaryStats_Handler,
|
||||
@ -4375,6 +4591,18 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "TransferSalaryToCoinSeller",
|
||||
Handler: _WalletService_TransferSalaryToCoinSeller_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListPointWithdrawalCoinSellers",
|
||||
Handler: _WalletService_ListPointWithdrawalCoinSellers_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "TransferPointToCoinSeller",
|
||||
Handler: _WalletService_TransferPointToCoinSeller_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetPointWithdrawalConfig",
|
||||
Handler: _WalletService_GetPointWithdrawalConfig_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "FreezeSalaryWithdrawal",
|
||||
Handler: _WalletService_FreezeSalaryWithdrawal_Handler,
|
||||
|
||||
@ -63,6 +63,7 @@ import (
|
||||
menumodule "hyapp-admin-server/internal/modules/menu"
|
||||
opscentermodule "hyapp-admin-server/internal/modules/opscenter"
|
||||
paymentmodule "hyapp-admin-server/internal/modules/payment"
|
||||
pointwithdrawalconfigmodule "hyapp-admin-server/internal/modules/pointwithdrawalconfig"
|
||||
policyconfigmodule "hyapp-admin-server/internal/modules/policyconfig"
|
||||
prettyidmodule "hyapp-admin-server/internal/modules/prettyid"
|
||||
rbacmodule "hyapp-admin-server/internal/modules/rbac"
|
||||
@ -397,40 +398,41 @@ func main() {
|
||||
gamemanagementmodule.WithRobotProfileSource(robotProfileSource),
|
||||
gamemanagementmodule.WithRobotAppearanceServices(walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn)),
|
||||
),
|
||||
GiftDiamond: giftdiamondmodule.New(walletDB, auditHandler),
|
||||
Health: healthmodule.New(store, redisClient, jobStatus),
|
||||
HostAgencyPolicy: hostagencypolicymodule.New(store, walletDB, auditHandler),
|
||||
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
|
||||
HostSalarySettlement: hostsalarysettlementmodule.New(walletDB, userDB, walletv1.NewWalletCronServiceClient(walletConn), cfg.WalletService.RequestTimeout, auditHandler),
|
||||
HostWithdrawal: hostwithdrawalmodule.New(sqlDB, walletDB, userDB),
|
||||
InviteActivityReward: inviteactivityrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
Job: jobmodule.New(store, cfg, auditHandler),
|
||||
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), walletclient.NewGRPC(walletConn), auditHandler),
|
||||
Menu: menumodule.New(store, auditHandler),
|
||||
OpsCenter: opscentermodule.New(appRegistryService, luckyGiftHandler),
|
||||
Payment: paymentHandler,
|
||||
PolicyConfig: policyconfigmodule.New(sqlDB, walletDB, userDB, activityclient.NewGRPC(activityConn), auditHandler),
|
||||
PrettyID: prettyidmodule.New(userclient.NewGRPC(userConn), auditHandler),
|
||||
RBAC: rbacmodule.New(store, auditHandler),
|
||||
RedPacket: redpacketmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
||||
Report: reportmodule.New(userDB, roomClient),
|
||||
RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
RegionBlock: regionblockmodule.New(userDB, userclient.NewGRPC(userConn), auditHandler),
|
||||
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, cfg.WalletService.RequestTimeout, auditHandler),
|
||||
RiskConfig: riskconfigmodule.New(userclient.NewGRPC(userConn), auditHandler),
|
||||
RoomAdmin: roomadminmodule.New(userDB, store, roomClient, robotClient, auditHandler),
|
||||
RoomRocket: roomrocketmodule.New(roomClient, auditHandler),
|
||||
RoomTurnoverReward: roomturnoverrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
Search: searchmodule.New(store),
|
||||
SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
Team: teammodule.New(store, auditHandler),
|
||||
TeamSalaryPolicy: teamsalarypolicymodule.New(store, auditHandler),
|
||||
TeamSalarySettlement: teamSalarySettlementHandler,
|
||||
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
|
||||
UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomClient),
|
||||
VIPConfig: vipconfigmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
||||
WeeklyStar: weeklystarmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
Wheel: wheelmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler),
|
||||
GiftDiamond: giftdiamondmodule.New(walletDB, auditHandler),
|
||||
Health: healthmodule.New(store, redisClient, jobStatus),
|
||||
HostAgencyPolicy: hostagencypolicymodule.New(store, walletDB, auditHandler),
|
||||
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
|
||||
HostSalarySettlement: hostsalarysettlementmodule.New(walletDB, userDB, walletv1.NewWalletCronServiceClient(walletConn), cfg.WalletService.RequestTimeout, auditHandler),
|
||||
HostWithdrawal: hostwithdrawalmodule.New(sqlDB, walletDB, userDB),
|
||||
InviteActivityReward: inviteactivityrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
Job: jobmodule.New(store, cfg, auditHandler),
|
||||
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), walletclient.NewGRPC(walletConn), auditHandler),
|
||||
Menu: menumodule.New(store, auditHandler),
|
||||
OpsCenter: opscentermodule.New(appRegistryService, luckyGiftHandler),
|
||||
Payment: paymentHandler,
|
||||
PolicyConfig: policyconfigmodule.New(sqlDB, walletDB, userDB, activityclient.NewGRPC(activityConn), auditHandler),
|
||||
PointWithdrawalConfig: pointwithdrawalconfigmodule.New(walletDB, userDB, auditHandler),
|
||||
PrettyID: prettyidmodule.New(userclient.NewGRPC(userConn), auditHandler),
|
||||
RBAC: rbacmodule.New(store, auditHandler),
|
||||
RedPacket: redpacketmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
||||
Report: reportmodule.New(userDB, roomClient),
|
||||
RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
RegionBlock: regionblockmodule.New(userDB, userclient.NewGRPC(userConn), auditHandler),
|
||||
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, cfg.WalletService.RequestTimeout, auditHandler),
|
||||
RiskConfig: riskconfigmodule.New(userclient.NewGRPC(userConn), auditHandler),
|
||||
RoomAdmin: roomadminmodule.New(userDB, store, roomClient, robotClient, auditHandler),
|
||||
RoomRocket: roomrocketmodule.New(roomClient, auditHandler),
|
||||
RoomTurnoverReward: roomturnoverrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
Search: searchmodule.New(store),
|
||||
SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
Team: teammodule.New(store, auditHandler),
|
||||
TeamSalaryPolicy: teamsalarypolicymodule.New(store, auditHandler),
|
||||
TeamSalarySettlement: teamSalarySettlementHandler,
|
||||
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
|
||||
UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomClient),
|
||||
VIPConfig: vipconfigmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
||||
WeeklyStar: weeklystarmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
Wheel: wheelmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler),
|
||||
}
|
||||
engine := router.New(cfg, auth, handlers)
|
||||
|
||||
|
||||
@ -653,6 +653,11 @@ type UserWithdrawalApplication struct {
|
||||
SalaryAssetType string `gorm:"size:64;not null;default:''" json:"salaryAssetType"`
|
||||
WithdrawAmount string `gorm:"type:decimal(18,2);not null;default:0.00" json:"withdrawAmount"`
|
||||
WithdrawAmountMinor int64 `gorm:"not null;default:0" json:"withdrawAmountMinor"`
|
||||
PointFeeAmount int64 `gorm:"not null;default:0" json:"pointFeeAmount"`
|
||||
PointNetAmount int64 `gorm:"not null;default:0" json:"pointNetAmount"`
|
||||
PointsPerUSD int64 `gorm:"not null;default:100000" json:"pointsPerUsd"`
|
||||
PointFeeBPS int32 `gorm:"not null;default:500" json:"pointFeeBps"`
|
||||
PointPolicyInstance string `gorm:"column:point_policy_instance_code;size:96;not null;default:''" json:"pointPolicyInstanceCode"`
|
||||
WithdrawMethod string `gorm:"size:64;index:idx_admin_withdrawal_method;not null;default:''" json:"withdrawMethod"`
|
||||
WithdrawAddress string `gorm:"size:255;not null;default:''" json:"withdrawAddress"`
|
||||
FreezeCommandID string `gorm:"size:128;not null;default:''" json:"freezeCommandId"`
|
||||
|
||||
@ -15,6 +15,7 @@ type withdrawalApplicationDTO struct {
|
||||
PointNetAmount int64 `json:"pointNetAmount,omitempty"`
|
||||
PointsPerUSD int64 `json:"pointsPerUsd,omitempty"`
|
||||
PointFeeBPS int32 `json:"pointFeeBps,omitempty"`
|
||||
PointPolicyInstance string `json:"pointPolicyInstanceCode,omitempty"`
|
||||
WithdrawMethod string `json:"withdrawMethod"`
|
||||
WithdrawAddress string `json:"withdrawAddress"`
|
||||
FreezeTransactionID string `json:"freezeTransactionId"`
|
||||
@ -52,17 +53,35 @@ func withdrawalApplicationDTOFromModel(item model.UserWithdrawalApplication) wit
|
||||
UpdatedAtMS: item.UpdatedAtMS,
|
||||
}
|
||||
if isPointWithdrawalAssetType(item.SalaryAssetType) {
|
||||
grossPoints := item.WithdrawAmountMinor
|
||||
feePoints := grossPoints * int64(pointWithdrawalDefaultFeeBPS) / 10000
|
||||
dto.PointGrossAmount = grossPoints
|
||||
feePoints, netPoints, pointsPerUSD, feeBPS := pointWithdrawalSnapshot(item)
|
||||
dto.PointGrossAmount = item.WithdrawAmountMinor
|
||||
dto.PointFeeAmount = feePoints
|
||||
dto.PointNetAmount = grossPoints - feePoints
|
||||
dto.PointsPerUSD = pointWithdrawalDefaultPointsPerUSD
|
||||
dto.PointFeeBPS = pointWithdrawalDefaultFeeBPS
|
||||
dto.PointNetAmount = netPoints
|
||||
dto.PointsPerUSD = pointsPerUSD
|
||||
dto.PointFeeBPS = feeBPS
|
||||
dto.PointPolicyInstance = item.PointPolicyInstance
|
||||
}
|
||||
return dto
|
||||
}
|
||||
|
||||
func pointWithdrawalSnapshot(item model.UserWithdrawalApplication) (feePoints int64, netPoints int64, pointsPerUSD int64, feeBPS int32) {
|
||||
pointsPerUSD = item.PointsPerUSD
|
||||
if pointsPerUSD <= 0 {
|
||||
pointsPerUSD = pointWithdrawalDefaultPointsPerUSD
|
||||
}
|
||||
feeBPS = item.PointFeeBPS
|
||||
if feeBPS < 0 || feeBPS > 10000 {
|
||||
feeBPS = pointWithdrawalDefaultFeeBPS
|
||||
}
|
||||
feePoints, netPoints = item.PointFeeAmount, item.PointNetAmount
|
||||
if feePoints < 0 || netPoints < 0 || feePoints+netPoints != item.WithdrawAmountMinor {
|
||||
// 090 之前的存量 POINT 申请没有快照;只对这些不一致旧行按历史 5% 默认回填。
|
||||
feePoints = item.WithdrawAmountMinor * int64(feeBPS) / 10000
|
||||
netPoints = item.WithdrawAmountMinor - feePoints
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func withdrawalApplicationDTOsFromModel(items []model.UserWithdrawalApplication) []withdrawalApplicationDTO {
|
||||
out := make([]withdrawalApplicationDTO, 0, len(items))
|
||||
for _, item := range items {
|
||||
|
||||
@ -92,7 +92,7 @@ func (s *Service) auditApplication(ctx context.Context, actor shared.Actor, id u
|
||||
}
|
||||
commandID := withdrawalAuditCommandID(id, decision)
|
||||
amountMinor := item.WithdrawAmountMinor
|
||||
transactionID, err := s.applyWalletDecision(ctx, decision, commandID, appCode, userID, item.SalaryAssetType, amountMinor, actor, strings.TrimSpace(remark), id)
|
||||
transactionID, err := s.applyWalletDecision(ctx, decision, commandID, appCode, userID, item.SalaryAssetType, amountMinor, item.PointFeeAmount, item.PointNetAmount, item.PointsPerUSD, item.PointFeeBPS, actor, strings.TrimSpace(remark), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -130,7 +130,7 @@ func (s *Service) auditApplication(ctx context.Context, actor shared.Actor, id u
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (s *Service) applyWalletDecision(ctx context.Context, decision string, commandID string, appCode string, userID int64, assetType string, amountMinor int64, actor shared.Actor, remark string, applicationID uint) (string, error) {
|
||||
func (s *Service) applyWalletDecision(ctx context.Context, decision string, commandID string, appCode string, userID int64, assetType string, amountMinor int64, storedFeePoints int64, storedNetPoints int64, storedPointsPerUSD int64, storedFeeBPS int32, actor shared.Actor, remark string, applicationID uint) (string, error) {
|
||||
reason := strings.TrimSpace(remark)
|
||||
if reason == "" {
|
||||
reason = "salary withdrawal " + decision
|
||||
@ -141,8 +141,10 @@ func (s *Service) applyWalletDecision(ctx context.Context, decision string, comm
|
||||
if !ok {
|
||||
return "", errors.New("point withdrawal wallet executor is not configured")
|
||||
}
|
||||
feePoints := amountMinor * int64(pointWithdrawalDefaultFeeBPS) / 10000
|
||||
netPoints := amountMinor - feePoints
|
||||
feePoints, netPoints, pointsPerUSD, feeBPS := pointWithdrawalSnapshot(model.UserWithdrawalApplication{
|
||||
WithdrawAmountMinor: amountMinor, PointFeeAmount: storedFeePoints, PointNetAmount: storedNetPoints,
|
||||
PointsPerUSD: storedPointsPerUSD, PointFeeBPS: storedFeeBPS,
|
||||
})
|
||||
switch decision {
|
||||
case model.WithdrawalApplicationStatusApproved:
|
||||
resp, err := pointWallet.SettlePointWithdrawal(ctx, &walletv1.SettlePointWithdrawalRequest{
|
||||
@ -152,8 +154,8 @@ func (s *Service) applyWalletDecision(ctx context.Context, decision string, comm
|
||||
GrossPointAmount: amountMinor,
|
||||
FeePointAmount: feePoints,
|
||||
NetPointAmount: netPoints,
|
||||
PointsPerUsd: pointWithdrawalDefaultPointsPerUSD,
|
||||
FeeBps: pointWithdrawalDefaultFeeBPS,
|
||||
PointsPerUsd: pointsPerUSD,
|
||||
FeeBps: feeBPS,
|
||||
OperatorUserId: int64(actor.UserID),
|
||||
Reason: reason,
|
||||
AppCode: appCode,
|
||||
@ -171,8 +173,8 @@ func (s *Service) applyWalletDecision(ctx context.Context, decision string, comm
|
||||
GrossPointAmount: amountMinor,
|
||||
FeePointAmount: feePoints,
|
||||
NetPointAmount: netPoints,
|
||||
PointsPerUsd: pointWithdrawalDefaultPointsPerUSD,
|
||||
FeeBps: pointWithdrawalDefaultFeeBPS,
|
||||
PointsPerUsd: pointsPerUSD,
|
||||
FeeBps: feeBPS,
|
||||
OperatorUserId: int64(actor.UserID),
|
||||
Reason: reason,
|
||||
AppCode: appCode,
|
||||
|
||||
@ -14,7 +14,7 @@ func TestApplyWalletDecisionRoutesPointApprovalToPointSettlement(t *testing.T) {
|
||||
wallet := &fakeAuditWalletClient{}
|
||||
svc := &Service{wallet: wallet}
|
||||
|
||||
transactionID, err := svc.applyWalletDecision(context.Background(), model.WithdrawalApplicationStatusApproved, "audit:77:approved", "huwaa", 42001, "POINT", 1_000_000, shared.Actor{UserID: 90001}, "paid", 77)
|
||||
transactionID, err := svc.applyWalletDecision(context.Background(), model.WithdrawalApplicationStatusApproved, "audit:77:approved", "fami", 42001, "POINT", 1_000_000, 25_000, 975_000, 200_000, 250, shared.Actor{UserID: 90001}, "paid", 77)
|
||||
if err != nil {
|
||||
t.Fatalf("apply point approval failed: %v", err)
|
||||
}
|
||||
@ -23,12 +23,14 @@ func TestApplyWalletDecisionRoutesPointApprovalToPointSettlement(t *testing.T) {
|
||||
}
|
||||
if wallet.settlePoint == nil ||
|
||||
wallet.settlePoint.GetCommandId() != "audit:77:approved" ||
|
||||
wallet.settlePoint.GetAppCode() != "huwaa" ||
|
||||
wallet.settlePoint.GetAppCode() != "fami" ||
|
||||
wallet.settlePoint.GetUserId() != 42001 ||
|
||||
wallet.settlePoint.GetAssetType() != "POINT" ||
|
||||
wallet.settlePoint.GetGrossPointAmount() != 1_000_000 ||
|
||||
wallet.settlePoint.GetFeePointAmount() != 50_000 ||
|
||||
wallet.settlePoint.GetNetPointAmount() != 950_000 ||
|
||||
wallet.settlePoint.GetFeePointAmount() != 25_000 ||
|
||||
wallet.settlePoint.GetNetPointAmount() != 975_000 ||
|
||||
wallet.settlePoint.GetPointsPerUsd() != 200_000 ||
|
||||
wallet.settlePoint.GetFeeBps() != 250 ||
|
||||
wallet.settlePoint.GetOperatorUserId() != 90001 ||
|
||||
wallet.settlePoint.GetWithdrawalApplicationId() != "77" {
|
||||
t.Fatalf("SettlePointWithdrawal payload mismatch: %+v", wallet.settlePoint)
|
||||
@ -42,7 +44,7 @@ func TestApplyWalletDecisionRoutesPointRejectionToPointRelease(t *testing.T) {
|
||||
wallet := &fakeAuditWalletClient{}
|
||||
svc := &Service{wallet: wallet}
|
||||
|
||||
transactionID, err := svc.applyWalletDecision(context.Background(), model.WithdrawalApplicationStatusRejected, "audit:78:rejected", "huwaa", 42002, "COIN_SELLER_POINT", 2_000_000, shared.Actor{UserID: 90002}, "bad address", 78)
|
||||
transactionID, err := svc.applyWalletDecision(context.Background(), model.WithdrawalApplicationStatusRejected, "audit:78:rejected", "huwaa", 42002, "COIN_SELLER_POINT", 2_000_000, 100_000, 1_900_000, 100_000, 500, shared.Actor{UserID: 90002}, "bad address", 78)
|
||||
if err != nil {
|
||||
t.Fatalf("apply point rejection failed: %v", err)
|
||||
}
|
||||
@ -71,7 +73,7 @@ func TestApplyWalletDecisionKeepsLegacySalaryAssetsOnSalaryRPC(t *testing.T) {
|
||||
wallet := &fakeAuditWalletClient{}
|
||||
svc := &Service{wallet: wallet}
|
||||
|
||||
transactionID, err := svc.applyWalletDecision(context.Background(), model.WithdrawalApplicationStatusApproved, "audit:79:approved", "lalu", 42003, "HOST_SALARY_USD", 12_345, shared.Actor{UserID: 90003}, "", 79)
|
||||
transactionID, err := svc.applyWalletDecision(context.Background(), model.WithdrawalApplicationStatusApproved, "audit:79:approved", "lalu", 42003, "HOST_SALARY_USD", 12_345, 0, 0, 0, 0, shared.Actor{UserID: 90003}, "", 79)
|
||||
if err != nil {
|
||||
t.Fatalf("apply salary approval failed: %v", err)
|
||||
}
|
||||
|
||||
26
server/admin/internal/modules/pointwithdrawalconfig/dto.go
Normal file
26
server/admin/internal/modules/pointwithdrawalconfig/dto.go
Normal file
@ -0,0 +1,26 @@
|
||||
package pointwithdrawalconfig
|
||||
|
||||
type itemDTO struct {
|
||||
AppCode string `json:"appCode"`
|
||||
SellerUserID string `json:"sellerUserId"`
|
||||
DisplayUserID string `json:"displayUserId"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
CountryName string `json:"countryName"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
PointAmount int64 `json:"pointAmount"`
|
||||
SellerCoinAmount int64 `json:"sellerCoinAmount"`
|
||||
ServiceCountryCodes []string `json:"serviceCountryCodes"`
|
||||
Status string `json:"status"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
type upsertRequest struct {
|
||||
SellerUserID int64 `json:"sellerUserId" binding:"required"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
PointAmount int64 `json:"pointAmount" binding:"required"`
|
||||
SellerCoinAmount int64 `json:"sellerCoinAmount" binding:"required"`
|
||||
ServiceCountryCodes []string `json:"serviceCountryCodes"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
package pointwithdrawalconfig
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
"hyapp-admin-server/internal/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
audit shared.OperationLogger
|
||||
}
|
||||
|
||||
func New(walletDB *sql.DB, userDB *sql.DB, audit shared.OperationLogger) *Handler {
|
||||
return &Handler{service: NewService(walletDB, userDB), audit: audit}
|
||||
}
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
items, err := h.service.List(c.Request.Context(), appctx.FromContext(c.Request.Context()))
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取币商提现白名单失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (h *Handler) Upsert(c *gin.Context) {
|
||||
var req upsertRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "币商提现配置参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.Upsert(c.Request.Context(), appctx.FromContext(c.Request.Context()), req, int64(shared.ActorFromContext(c).UserID))
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
shared.OperationLogWithResourceID(c, h.audit, "point-withdrawal-coin-seller-upsert", "point_withdrawal_coin_seller_configs", item.AppCode+":"+item.SellerUserID, "success", "ratio="+strconv.FormatInt(item.PointAmount, 10)+":"+strconv.FormatInt(item.SellerCoinAmount, 10))
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
sellerUserID, err := strconv.ParseInt(strings.TrimSpace(c.Param("seller_user_id")), 10, 64)
|
||||
if err != nil || sellerUserID <= 0 {
|
||||
response.BadRequest(c, "币商 ID 不正确")
|
||||
return
|
||||
}
|
||||
appCode := appctx.FromContext(c.Request.Context())
|
||||
if err := h.service.Delete(c.Request.Context(), appCode, sellerUserID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
response.NotFound(c, "配置不存在")
|
||||
return
|
||||
}
|
||||
response.ServerError(c, "删除币商提现配置失败")
|
||||
return
|
||||
}
|
||||
shared.OperationLogWithResourceID(c, h.audit, "point-withdrawal-coin-seller-delete", "point_withdrawal_coin_seller_configs", appCode+":"+strconv.FormatInt(sellerUserID, 10), "success", "")
|
||||
response.OK(c, map[string]any{"deleted": true})
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package pointwithdrawalconfig
|
||||
|
||||
import (
|
||||
"hyapp-admin-server/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
protected.GET("/admin/point-withdrawal/coin-sellers", middleware.RequirePermission("point-withdrawal-config:view"), h.List)
|
||||
protected.PUT("/admin/point-withdrawal/coin-sellers", middleware.RequirePermission("point-withdrawal-config:update"), h.Upsert)
|
||||
protected.DELETE("/admin/point-withdrawal/coin-sellers/:seller_user_id", middleware.RequirePermission("point-withdrawal-config:update"), h.Delete)
|
||||
}
|
||||
201
server/admin/internal/modules/pointwithdrawalconfig/service.go
Normal file
201
server/admin/internal/modules/pointwithdrawalconfig/service.go
Normal file
@ -0,0 +1,201 @@
|
||||
package pointwithdrawalconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
walletDB *sql.DB
|
||||
userDB *sql.DB
|
||||
}
|
||||
|
||||
func NewService(walletDB *sql.DB, userDB *sql.DB) *Service {
|
||||
return &Service{walletDB: walletDB, userDB: userDB}
|
||||
}
|
||||
|
||||
// List 按 app_code 读取同一套公共配置表,并补齐用户资料;孤儿配置仍返回,便于运营删除而不是静默隐藏。
|
||||
func (s *Service) List(ctx context.Context, appCode string) ([]itemDTO, error) {
|
||||
if err := s.ensureSchema(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.walletDB.QueryContext(ctx, `
|
||||
SELECT app_code, seller_user_id, sort_order, point_amount, seller_coin_amount,
|
||||
service_country_codes, status, created_at_ms, updated_at_ms
|
||||
FROM point_withdrawal_coin_seller_configs
|
||||
WHERE app_code = ?
|
||||
ORDER BY sort_order ASC, seller_user_id ASC`, normalizeAppCode(appCode))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]itemDTO, 0)
|
||||
for rows.Next() {
|
||||
var item itemDTO
|
||||
var sellerUserID int64
|
||||
var countriesJSON []byte
|
||||
if err := rows.Scan(&item.AppCode, &sellerUserID, &item.SortOrder, &item.PointAmount, &item.SellerCoinAmount, &countriesJSON, &item.Status, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.SellerUserID = strconv.FormatInt(sellerUserID, 10)
|
||||
if err := json.Unmarshal(countriesJSON, &item.ServiceCountryCodes); err != nil {
|
||||
return nil, fmt.Errorf("parse service countries for seller %d: %w", sellerUserID, err)
|
||||
}
|
||||
item.ServiceCountryCodes = normalizeCountries(item.ServiceCountryCodes)
|
||||
displayUserID, nickname, avatar, countryName, lookupErr := s.lookupSeller(ctx, item.AppCode, sellerUserID, false)
|
||||
if lookupErr != nil && !errors.Is(lookupErr, sql.ErrNoRows) {
|
||||
return nil, lookupErr
|
||||
}
|
||||
item.DisplayUserID, item.Nickname, item.Avatar, item.CountryName = displayUserID, nickname, avatar, countryName
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// Upsert 在写白名单前验证同 App 的 active 币商身份,防止普通用户被配置成账务收款方。
|
||||
func (s *Service) Upsert(ctx context.Context, appCode string, req upsertRequest, adminID int64) (itemDTO, error) {
|
||||
appCode = normalizeAppCode(appCode)
|
||||
if req.SellerUserID <= 0 || req.PointAmount <= 0 || req.SellerCoinAmount <= 0 || req.SortOrder < 0 {
|
||||
return itemDTO{}, errors.New("币商、排序和兑换比例必须是正数")
|
||||
}
|
||||
status := strings.ToLower(strings.TrimSpace(req.Status))
|
||||
if status == "" {
|
||||
status = "active"
|
||||
}
|
||||
if status != "active" && status != "disabled" {
|
||||
return itemDTO{}, errors.New("状态只能是 active 或 disabled")
|
||||
}
|
||||
if _, _, _, _, err := s.lookupSeller(ctx, appCode, req.SellerUserID, true); err != nil {
|
||||
return itemDTO{}, err
|
||||
}
|
||||
if err := s.ensureSchema(ctx); err != nil {
|
||||
return itemDTO{}, err
|
||||
}
|
||||
countries := normalizeCountries(req.ServiceCountryCodes)
|
||||
countriesJSON, err := json.Marshal(countries)
|
||||
if err != nil {
|
||||
return itemDTO{}, err
|
||||
}
|
||||
nowMS := time.Now().UTC().UnixMilli()
|
||||
_, err = s.walletDB.ExecContext(ctx, `
|
||||
INSERT INTO point_withdrawal_coin_seller_configs (
|
||||
app_code, seller_user_id, sort_order, point_amount, seller_coin_amount, service_country_codes,
|
||||
status, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
sort_order = VALUES(sort_order), point_amount = VALUES(point_amount),
|
||||
seller_coin_amount = VALUES(seller_coin_amount), service_country_codes = VALUES(service_country_codes),
|
||||
status = VALUES(status), updated_by_admin_id = VALUES(updated_by_admin_id), updated_at_ms = VALUES(updated_at_ms)`,
|
||||
appCode, req.SellerUserID, req.SortOrder, req.PointAmount, req.SellerCoinAmount, countriesJSON,
|
||||
status, adminID, adminID, nowMS, nowMS)
|
||||
if err != nil {
|
||||
return itemDTO{}, err
|
||||
}
|
||||
return s.Get(ctx, appCode, req.SellerUserID)
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, appCode string, sellerUserID int64) (itemDTO, error) {
|
||||
items, err := s.List(ctx, appCode)
|
||||
if err != nil {
|
||||
return itemDTO{}, err
|
||||
}
|
||||
needle := strconv.FormatInt(sellerUserID, 10)
|
||||
for _, item := range items {
|
||||
if item.SellerUserID == needle {
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
return itemDTO{}, sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, appCode string, sellerUserID int64) error {
|
||||
if sellerUserID <= 0 {
|
||||
return errors.New("币商 ID 不正确")
|
||||
}
|
||||
if err := s.ensureSchema(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := s.walletDB.ExecContext(ctx, `DELETE FROM point_withdrawal_coin_seller_configs WHERE app_code = ? AND seller_user_id = ?`, normalizeAppCode(appCode), sellerUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) lookupSeller(ctx context.Context, appCode string, sellerUserID int64, requireActive bool) (displayUserID string, nickname string, avatar string, countryName string, err error) {
|
||||
if s.userDB == nil {
|
||||
return "", "", "", "", errors.New("user db is not configured")
|
||||
}
|
||||
query := `
|
||||
SELECT COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''), COALESCE(u.avatar, ''),
|
||||
COALESCE(c.country_display_name, c.country_name, u.country, '')
|
||||
FROM coin_seller_profiles csp
|
||||
JOIN users u ON u.user_id = csp.user_id AND u.app_code = csp.app_code
|
||||
LEFT JOIN countries c ON c.app_code = u.app_code AND c.country_code = u.country
|
||||
WHERE csp.app_code = ? AND csp.user_id = ?`
|
||||
if requireActive {
|
||||
query += " AND csp.status = 'active' AND u.status = 'active'"
|
||||
}
|
||||
query += " LIMIT 1"
|
||||
err = s.userDB.QueryRowContext(ctx, query, appCode, sellerUserID).Scan(&displayUserID, &nickname, &avatar, &countryName)
|
||||
if errors.Is(err, sql.ErrNoRows) && requireActive {
|
||||
return "", "", "", "", errors.New("目标用户不是当前 App 的启用币商")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Service) ensureSchema(ctx context.Context) error {
|
||||
if s == nil || s.walletDB == nil {
|
||||
return errors.New("wallet db is not configured")
|
||||
}
|
||||
_, err := s.walletDB.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS point_withdrawal_coin_seller_configs (
|
||||
app_code VARCHAR(32) NOT NULL, seller_user_id BIGINT NOT NULL, sort_order INT NOT NULL DEFAULT 0,
|
||||
point_amount BIGINT NOT NULL, seller_coin_amount BIGINT NOT NULL, service_country_codes JSON NOT NULL,
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'active', created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, seller_user_id),
|
||||
KEY idx_point_withdraw_seller_list (app_code, status, sort_order, seller_user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='POINT 提现币商白名单与兑换比例'`)
|
||||
return err
|
||||
}
|
||||
|
||||
func normalizeCountries(values []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
if value == "" || len(value) > 16 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeAppCode(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
return "lalu"
|
||||
}
|
||||
return value
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package pointwithdrawalconfig
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeCountriesCanonicalizesWhitelist(t *testing.T) {
|
||||
got := normalizeCountries([]string{" sa ", "TR", "sa", "", "country-code-that-is-too-long"})
|
||||
want := []string{"SA", "TR"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("normalizeCountries() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAppCodeKeepsLegacyDefault(t *testing.T) {
|
||||
if got := normalizeAppCode(""); got != "lalu" {
|
||||
t.Fatalf("empty app code = %q, want lalu", got)
|
||||
}
|
||||
if got := normalizeAppCode(" FAMI "); got != "fami" {
|
||||
t.Fatalf("Fami app code = %q, want fami", got)
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/integration/activityclient"
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
@ -64,6 +65,7 @@ type policyInstanceRow struct {
|
||||
|
||||
type compiledPolicy struct {
|
||||
HostPointRatioPPM int64
|
||||
AgencyPointRatioPPM int64
|
||||
PointsPerUSD int64
|
||||
WithdrawFeeBPS int64
|
||||
TaskRewardAssetType string
|
||||
@ -496,12 +498,12 @@ func (s *Service) publishWalletRuntime(ctx context.Context, item policyInstanceR
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO wallet_policy_instances (
|
||||
app_code, instance_code, template_code, template_version, region_id, status,
|
||||
effective_from_ms, effective_to_ms, host_point_asset_type, host_point_ratio_ppm,
|
||||
effective_from_ms, effective_to_ms, host_point_asset_type, host_point_ratio_ppm, agency_point_ratio_ppm,
|
||||
points_per_usd, withdraw_fee_bps, rule_json, published_by_admin_id,
|
||||
published_at_ms, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'POINT', ?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'POINT', ?, ?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?)`,
|
||||
item.AppCode, item.InstanceCode, item.TemplateCode, item.TemplateVersion, regionID, item.Status,
|
||||
item.EffectiveFromMS, item.EffectiveToMS, compiled.HostPointRatioPPM, compiled.PointsPerUSD,
|
||||
item.EffectiveFromMS, item.EffectiveToMS, compiled.HostPointRatioPPM, compiled.AgencyPointRatioPPM, compiled.PointsPerUSD,
|
||||
compiled.WithdrawFeeBPS, string(compiled.RuleJSON), actorID, nowMS, nowMS, nowMS,
|
||||
); err != nil {
|
||||
return err
|
||||
@ -563,6 +565,7 @@ func ensureWalletPolicyRuntimeTable(ctx context.Context, db *sql.DB) error {
|
||||
effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '0 表示长期有效',
|
||||
host_point_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT' COMMENT '主播收益积分资产',
|
||||
host_point_ratio_ppm BIGINT NOT NULL DEFAULT 700000 COMMENT '有效付费礼物转 POINT 比例,ppm',
|
||||
agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '有效付费礼物给 Agency owner 的 POINT 分成比例,ppm',
|
||||
points_per_usd BIGINT NOT NULL DEFAULT 100000 COMMENT 'POINT/USD 展示换算比例',
|
||||
withdraw_fee_bps INT NOT NULL DEFAULT 500 COMMENT '提现手续费 bps',
|
||||
rule_json JSON NOT NULL COMMENT '完整政策快照',
|
||||
@ -573,7 +576,19 @@ func ensureWalletPolicyRuntimeTable(ctx context.Context, db *sql.DB) error {
|
||||
PRIMARY KEY (app_code, instance_code, region_id),
|
||||
KEY idx_wallet_policy_active (app_code, region_id, status, effective_from_ms, effective_to_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包运行侧收益政策实例表'`)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 运行库可能是旧版本建表;幂等补列保证发布新策略时不会因列缺失中断。
|
||||
if _, err := db.ExecContext(ctx, `ALTER TABLE wallet_policy_instances ADD COLUMN agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '有效付费礼物给 Agency owner 的 POINT 分成比例,ppm' AFTER host_point_ratio_ppm`); err != nil && !isDuplicateColumnError(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isDuplicateColumnError(err error) bool {
|
||||
var mysqlErr *mysqlDriver.MySQLError
|
||||
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1060
|
||||
}
|
||||
|
||||
func (s *Service) insertPublishItems(ctx context.Context, jobID uint64, appCode string, regionIDs []int64, activitySucceeded bool, nowMS int64) error {
|
||||
@ -635,11 +650,15 @@ func compilePolicy(raw json.RawMessage) (compiledPolicy, error) {
|
||||
pointsPerUSD := int64FromJSON(decoded["points_per_usd"], 100000)
|
||||
host, _ := decoded["host"].(map[string]any)
|
||||
hostRatioPercent := floatFromJSON(host["point_ratio_percent"], 70)
|
||||
agency, _ := decoded["agency"].(map[string]any)
|
||||
// Agency 比例由跨 App 共用的策略模板显式配置;旧模板缺字段时保持 0,避免发布动作隐式改变存量 App 账务。
|
||||
agencyRatioPercent := floatFromJSON(agency["point_ratio_percent"], 0)
|
||||
withdrawFeeBPS := int64FromJSON(host["withdraw_fee_bps"], 500)
|
||||
ratioPPM := int64(math.Round(hostRatioPercent * 10000))
|
||||
agencyRatioPPM := int64(math.Round(agencyRatioPercent * 10000))
|
||||
tasks, _ := decoded["tasks"].(map[string]any)
|
||||
taskRewardAssetType := strings.ToUpper(strings.TrimSpace(stringFromJSON(tasks["reward_asset_type"], policyTaskRewardAssetCoin)))
|
||||
if pointsPerUSD <= 0 || ratioPPM <= 0 || ratioPPM > 1000000 || withdrawFeeBPS < 0 || withdrawFeeBPS > 10000 {
|
||||
if pointsPerUSD <= 0 || ratioPPM <= 0 || ratioPPM > 1000000 || agencyRatioPPM < 0 || agencyRatioPPM > 1000000 || ratioPPM+agencyRatioPPM > 1000000 || withdrawFeeBPS < 0 || withdrawFeeBPS > 10000 {
|
||||
return compiledPolicy{}, errors.New("rule_json host point policy is invalid")
|
||||
}
|
||||
if taskRewardAssetType != policyTaskRewardAssetCoin && taskRewardAssetType != "POINT" {
|
||||
@ -647,6 +666,7 @@ func compilePolicy(raw json.RawMessage) (compiledPolicy, error) {
|
||||
}
|
||||
return compiledPolicy{
|
||||
HostPointRatioPPM: ratioPPM,
|
||||
AgencyPointRatioPPM: agencyRatioPPM,
|
||||
PointsPerUSD: pointsPerUSD,
|
||||
WithdrawFeeBPS: withdrawFeeBPS,
|
||||
TaskRewardAssetType: taskRewardAssetType,
|
||||
|
||||
@ -3,6 +3,7 @@ package policyconfig
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
@ -60,6 +61,7 @@ func TestPublishRuntimeWritesWalletRowsAndActivityTaskRewardPolicy(t *testing.T)
|
||||
}
|
||||
compiled := compiledPolicy{
|
||||
HostPointRatioPPM: 700000,
|
||||
AgencyPointRatioPPM: 0,
|
||||
PointsPerUSD: 100000,
|
||||
WithdrawFeeBPS: 500,
|
||||
TaskRewardAssetType: "POINT",
|
||||
@ -67,6 +69,7 @@ func TestPublishRuntimeWritesWalletRowsAndActivityTaskRewardPolicy(t *testing.T)
|
||||
}
|
||||
|
||||
walletMock.ExpectExec(`(?s)CREATE TABLE IF NOT EXISTS wallet_policy_instances`).WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
walletMock.ExpectExec(`(?s)ALTER TABLE wallet_policy_instances ADD COLUMN agency_point_ratio_ppm`).WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
walletMock.ExpectBegin()
|
||||
walletMock.ExpectExec(`DELETE FROM wallet_policy_instances WHERE app_code = \? AND instance_code = \?`).
|
||||
WithArgs("huwaa", "huwaa-point-live").
|
||||
@ -83,6 +86,7 @@ func TestPublishRuntimeWritesWalletRowsAndActivityTaskRewardPolicy(t *testing.T)
|
||||
int64(1700000000000),
|
||||
int64(0),
|
||||
int64(700000),
|
||||
int64(0),
|
||||
int64(100000),
|
||||
int64(500),
|
||||
string(compiled.RuleJSON),
|
||||
@ -147,6 +151,7 @@ func TestUpdateInstanceStatusSyncsWalletRuntimeAndActivityRuntime(t *testing.T)
|
||||
WithArgs(policyStatusDisabled, uint(90002), sqlmock.AnyArg(), "huwaa", uint64(9)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
walletMock.ExpectExec(`(?s)CREATE TABLE IF NOT EXISTS wallet_policy_instances`).WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
walletMock.ExpectExec(`(?s)ALTER TABLE wallet_policy_instances ADD COLUMN agency_point_ratio_ppm`).WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
walletMock.ExpectExec(`(?s)UPDATE wallet_policy_instances\s+SET status = \?, updated_at_ms = \?\s+WHERE app_code = \? AND instance_code = \?`).
|
||||
WithArgs(policyStatusDisabled, sqlmock.AnyArg(), "huwaa", "huwaa-point-live").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
@ -191,6 +196,24 @@ func TestUpdateInstanceStatusSyncsWalletRuntimeAndActivityRuntime(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompilePolicyUsesExplicitAgencyRatioAndKeepsLegacyDefaultZero(t *testing.T) {
|
||||
ruleJSON := json.RawMessage(`{"host":{"point_ratio_percent":70}}`)
|
||||
fami, err := compilePolicy(ruleJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("compile Fami policy failed: %v", err)
|
||||
}
|
||||
if fami.AgencyPointRatioPPM != 0 {
|
||||
t.Fatalf("legacy policy without agency section must remain zero: %+v", fami)
|
||||
}
|
||||
configured, err := compilePolicy(json.RawMessage(`{"host":{"point_ratio_percent":70},"agency":{"point_ratio_percent":20}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("compile configured policy failed: %v", err)
|
||||
}
|
||||
if configured.AgencyPointRatioPPM != 200000 {
|
||||
t.Fatalf("configured agency ratio mismatch: %+v", configured)
|
||||
}
|
||||
}
|
||||
|
||||
type fakePolicyActivityClient struct {
|
||||
activityclient.Client
|
||||
last *activityv1.PublishTaskRewardPolicyRequest
|
||||
|
||||
@ -37,6 +37,7 @@ import (
|
||||
"hyapp-admin-server/internal/modules/menu"
|
||||
"hyapp-admin-server/internal/modules/opscenter"
|
||||
"hyapp-admin-server/internal/modules/payment"
|
||||
"hyapp-admin-server/internal/modules/pointwithdrawalconfig"
|
||||
"hyapp-admin-server/internal/modules/policyconfig"
|
||||
"hyapp-admin-server/internal/modules/prettyid"
|
||||
"hyapp-admin-server/internal/modules/rbac"
|
||||
@ -66,62 +67,63 @@ import (
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
AdminUser *adminuser.Handler
|
||||
AgencyOpening *agencyopening.Handler
|
||||
AchievementConfig *achievementconfig.Handler
|
||||
AppConfig *appconfig.Handler
|
||||
AppRegistry *appregistry.Handler
|
||||
AppUser *appuser.Handler
|
||||
Audit *audit.Handler
|
||||
Auth *authroutes.Handler
|
||||
CoinLedger *coinledger.Handler
|
||||
CountryRegion *countryregion.Handler
|
||||
CPRelation *cprelation.Handler
|
||||
CPWeeklyRank *cpweeklyrank.Handler
|
||||
CumulativeRecharge *cumulativerechargereward.Handler
|
||||
DailyTask *dailytask.Handler
|
||||
Dashboard *dashboard.Handler
|
||||
Databi *databi.Handler
|
||||
FirstRechargeReward *firstrechargereward.Handler
|
||||
FinanceApplication *financeapplication.Handler
|
||||
FinanceOrder *financeorder.Handler
|
||||
FinanceWithdrawal *financewithdrawal.Handler
|
||||
FullServerNotice *fullservernotice.Handler
|
||||
Game *gamemanagement.Handler
|
||||
GiftDiamond *giftdiamond.Handler
|
||||
Health *health.Handler
|
||||
HostAgencyPolicy *hostagencypolicy.Handler
|
||||
HostOrg *hostorg.Handler
|
||||
HostSalarySettlement *hostsalarysettlement.Handler
|
||||
HostWithdrawal *hostwithdrawal.Handler
|
||||
InviteActivityReward *inviteactivityreward.Handler
|
||||
Job *job.Handler
|
||||
LevelConfig *levelconfig.Handler
|
||||
Menu *menu.Handler
|
||||
OpsCenter *opscenter.Handler
|
||||
Payment *payment.Handler
|
||||
PolicyConfig *policyconfig.Handler
|
||||
PrettyID *prettyid.Handler
|
||||
RBAC *rbac.Handler
|
||||
RedPacket *redpacket.Handler
|
||||
Report *reportmodule.Handler
|
||||
RegistrationReward *registrationreward.Handler
|
||||
RegionBlock *regionblock.Handler
|
||||
Resource *resourcemodule.Handler
|
||||
RiskConfig *riskconfig.Handler
|
||||
RoomAdmin *roomadmin.Handler
|
||||
RoomRocket *roomrocket.Handler
|
||||
RoomTurnoverReward *roomturnoverreward.Handler
|
||||
Search *search.Handler
|
||||
SevenDayCheckIn *sevendaycheckin.Handler
|
||||
Team *team.Handler
|
||||
TeamSalaryPolicy *teamsalarypolicy.Handler
|
||||
TeamSalarySettlement *teamsalarysettlement.Handler
|
||||
Upload *upload.Handler
|
||||
UserLeaderboard *userleaderboard.Handler
|
||||
VIPConfig *vipconfig.Handler
|
||||
WeeklyStar *weeklystar.Handler
|
||||
Wheel *wheel.Handler
|
||||
AdminUser *adminuser.Handler
|
||||
AgencyOpening *agencyopening.Handler
|
||||
AchievementConfig *achievementconfig.Handler
|
||||
AppConfig *appconfig.Handler
|
||||
AppRegistry *appregistry.Handler
|
||||
AppUser *appuser.Handler
|
||||
Audit *audit.Handler
|
||||
Auth *authroutes.Handler
|
||||
CoinLedger *coinledger.Handler
|
||||
CountryRegion *countryregion.Handler
|
||||
CPRelation *cprelation.Handler
|
||||
CPWeeklyRank *cpweeklyrank.Handler
|
||||
CumulativeRecharge *cumulativerechargereward.Handler
|
||||
DailyTask *dailytask.Handler
|
||||
Dashboard *dashboard.Handler
|
||||
Databi *databi.Handler
|
||||
FirstRechargeReward *firstrechargereward.Handler
|
||||
FinanceApplication *financeapplication.Handler
|
||||
FinanceOrder *financeorder.Handler
|
||||
FinanceWithdrawal *financewithdrawal.Handler
|
||||
FullServerNotice *fullservernotice.Handler
|
||||
Game *gamemanagement.Handler
|
||||
GiftDiamond *giftdiamond.Handler
|
||||
Health *health.Handler
|
||||
HostAgencyPolicy *hostagencypolicy.Handler
|
||||
HostOrg *hostorg.Handler
|
||||
HostSalarySettlement *hostsalarysettlement.Handler
|
||||
HostWithdrawal *hostwithdrawal.Handler
|
||||
InviteActivityReward *inviteactivityreward.Handler
|
||||
Job *job.Handler
|
||||
LevelConfig *levelconfig.Handler
|
||||
Menu *menu.Handler
|
||||
OpsCenter *opscenter.Handler
|
||||
Payment *payment.Handler
|
||||
PolicyConfig *policyconfig.Handler
|
||||
PointWithdrawalConfig *pointwithdrawalconfig.Handler
|
||||
PrettyID *prettyid.Handler
|
||||
RBAC *rbac.Handler
|
||||
RedPacket *redpacket.Handler
|
||||
Report *reportmodule.Handler
|
||||
RegistrationReward *registrationreward.Handler
|
||||
RegionBlock *regionblock.Handler
|
||||
Resource *resourcemodule.Handler
|
||||
RiskConfig *riskconfig.Handler
|
||||
RoomAdmin *roomadmin.Handler
|
||||
RoomRocket *roomrocket.Handler
|
||||
RoomTurnoverReward *roomturnoverreward.Handler
|
||||
Search *search.Handler
|
||||
SevenDayCheckIn *sevendaycheckin.Handler
|
||||
Team *team.Handler
|
||||
TeamSalaryPolicy *teamsalarypolicy.Handler
|
||||
TeamSalarySettlement *teamsalarysettlement.Handler
|
||||
Upload *upload.Handler
|
||||
UserLeaderboard *userleaderboard.Handler
|
||||
VIPConfig *vipconfig.Handler
|
||||
WeeklyStar *weeklystar.Handler
|
||||
Wheel *wheel.Handler
|
||||
}
|
||||
|
||||
func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
|
||||
@ -176,6 +178,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
|
||||
audit.RegisterRoutes(protected, h.Audit)
|
||||
payment.RegisterRoutes(protected, h.Payment)
|
||||
policyconfig.RegisterRoutes(protected, h.PolicyConfig)
|
||||
pointwithdrawalconfig.RegisterRoutes(protected, h.PointWithdrawalConfig)
|
||||
prettyid.RegisterRoutes(protected, h.PrettyID)
|
||||
job.RegisterRoutes(protected, h.Job)
|
||||
levelconfig.RegisterRoutes(protected, h.LevelConfig)
|
||||
|
||||
66
server/admin/migrations/088_fami_guild_revenue_policy.sql
Normal file
66
server/admin/migrations/088_fami_guild_revenue_policy.sql
Normal file
@ -0,0 +1,66 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
|
||||
|
||||
-- 收益策略模板是 Lalu/Huwaa/Fami 共用的产品配置入口。存量模板显式补 0,避免旧实例重发时产生新分成。
|
||||
UPDATE admin_policy_templates
|
||||
SET rule_json = JSON_SET(rule_json, '$.agency.point_ratio_percent', 0),
|
||||
updated_at_ms = @now_ms
|
||||
WHERE template_code = 'first_google70000_coin_seller_92000_100000_v1'
|
||||
AND template_version = 'v1'
|
||||
AND JSON_EXTRACT(rule_json, '$.agency.point_ratio_percent') IS NULL;
|
||||
|
||||
UPDATE admin_policy_template_versions
|
||||
SET rule_json = JSON_SET(rule_json, '$.agency.point_ratio_percent', 0),
|
||||
updated_at_ms = @now_ms
|
||||
WHERE template_code = 'first_google70000_coin_seller_92000_100000_v1'
|
||||
AND template_version = 'v1'
|
||||
AND JSON_EXTRACT(rule_json, '$.agency.point_ratio_percent') IS NULL;
|
||||
|
||||
-- Fami 从同一份公共政策结构派生独立模板,默认 20%;运营可在 Admin 编辑并发布,不需要改代码。
|
||||
INSERT INTO admin_policy_templates (
|
||||
template_code, template_version, name, status, rule_json, description,
|
||||
created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
|
||||
)
|
||||
SELECT
|
||||
'fami_guild_revenue_policy', 'v1', 'Fami 公会收益政策', 'active',
|
||||
JSON_SET(rule_json, '$.agency.point_ratio_percent', 20),
|
||||
'Fami 主播与 Agency POINT 收益政策;Agency 默认 20%,以发布后的运行快照为准。',
|
||||
0, 0, @now_ms, @now_ms
|
||||
FROM admin_policy_templates
|
||||
WHERE template_code = 'first_google70000_coin_seller_92000_100000_v1' AND template_version = 'v1'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
status = VALUES(status),
|
||||
description = VALUES(description),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT INTO admin_policy_template_versions (
|
||||
template_code, template_version, status, rule_json,
|
||||
created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
|
||||
)
|
||||
SELECT
|
||||
template_code, template_version, status, rule_json,
|
||||
created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
|
||||
FROM admin_policy_templates
|
||||
WHERE template_code = 'fami_guild_revenue_policy' AND template_version = 'v1'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = VALUES(status),
|
||||
rule_json = VALUES(rule_json),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT INTO admin_policy_instances (
|
||||
app_code, instance_code, template_code, template_version, region_scope, status,
|
||||
effective_from_ms, effective_to_ms, publish_status, created_by_admin_id, updated_by_admin_id,
|
||||
created_at_ms, updated_at_ms
|
||||
) VALUES (
|
||||
'fami', 'fami_guild_revenue_policy', 'fami_guild_revenue_policy', 'v1',
|
||||
'all_active_regions', 'active', @now_ms, 0, 'draft', 0, 0, @now_ms, @now_ms
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
template_code = VALUES(template_code),
|
||||
template_version = VALUES(template_version),
|
||||
region_scope = VALUES(region_scope),
|
||||
status = VALUES(status),
|
||||
effective_to_ms = VALUES(effective_to_ms),
|
||||
updated_at_ms = @now_ms;
|
||||
@ -0,0 +1,33 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 币商提现白名单是按当前后台 App 范围编辑的公共能力;不在表中复制 Who/Sofun 等固定开关,
|
||||
-- 新增 App 时复用 X-App-Code 作用域即可,避免每增加一套公会页面就增加一列。
|
||||
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
|
||||
|
||||
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
|
||||
('币商提现白名单查看', 'point-withdrawal-config:view', 'menu', '查看当前 App 的 POINT 提现币商、服务国家和兑换比例', @now_ms, @now_ms),
|
||||
('币商提现白名单编辑', 'point-withdrawal-config:update', 'button', '新增、编辑、禁用或删除当前 App 的 POINT 提现币商配置', @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, '币商提现白名单', 'host-org-point-withdrawal-config', '/host/point-withdrawal-config', 'wallet', 'point-withdrawal-config:view', 89, 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;
|
||||
|
||||
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||
SELECT role.id, permission.id
|
||||
FROM admin_roles role
|
||||
JOIN admin_permissions permission
|
||||
WHERE role.code IN ('platform-admin', 'ops-admin')
|
||||
AND permission.code IN ('point-withdrawal-config:view', 'point-withdrawal-config:update');
|
||||
|
||||
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||
SELECT role.id, permission.id
|
||||
FROM admin_roles role
|
||||
JOIN admin_permissions permission
|
||||
WHERE role.code IN ('auditor', 'readonly')
|
||||
AND permission.code = 'point-withdrawal-config:view';
|
||||
@ -0,0 +1,9 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- POINT 提现审核必须使用用户提交时的已发布政策快照;否则 Admin 后续修改汇率/手续费会让冻结、展示和审核扣款口径不一致。
|
||||
ALTER TABLE admin_user_withdrawal_applications
|
||||
ADD COLUMN point_fee_amount BIGINT NOT NULL DEFAULT 0 COMMENT 'POINT 提现手续费积分快照' AFTER withdraw_amount_minor,
|
||||
ADD COLUMN point_net_amount BIGINT NOT NULL DEFAULT 0 COMMENT 'POINT 提现预计到账积分快照' AFTER point_fee_amount,
|
||||
ADD COLUMN points_per_usd BIGINT NOT NULL DEFAULT 100000 COMMENT 'POINT/USD 汇率快照' AFTER point_net_amount,
|
||||
ADD COLUMN point_fee_bps INT NOT NULL DEFAULT 500 COMMENT 'POINT 提现手续费 bps 快照' AFTER points_per_usd,
|
||||
ADD COLUMN point_policy_instance_code VARCHAR(96) NOT NULL DEFAULT '' COMMENT '已发布钱包政策实例快照' AFTER point_fee_bps;
|
||||
@ -635,6 +635,14 @@ func (c *grpcUserHostClient) KickAgencyHost(ctx context.Context, req *userv1.Kic
|
||||
return c.client.KickAgencyHost(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcUserHostClient) GetHostEngagementStats(ctx context.Context, req *userv1.GetHostEngagementStatsRequest) (*userv1.GetHostEngagementStatsResponse, error) {
|
||||
return c.client.GetHostEngagementStats(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcUserHostClient) RecordPrivateMessageEvent(ctx context.Context, req *userv1.RecordPrivateMessageEventRequest) (*userv1.RecordPrivateMessageEventResponse, error) {
|
||||
return c.client.RecordPrivateMessageEvent(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcUserHostAdminClient) CreateBDLeader(ctx context.Context, req *userv1.CreateBDLeaderRequest) (*userv1.CreateBDLeaderResponse, error) {
|
||||
return c.client.CreateBDLeader(ctx, req)
|
||||
}
|
||||
|
||||
@ -99,6 +99,18 @@ func (c *grpcWalletClient) GetHostSalaryProgress(ctx context.Context, req *walle
|
||||
return c.client.GetHostSalaryProgress(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcWalletClient) GetHostRevenueStats(ctx context.Context, req *walletv1.GetHostRevenueStatsRequest) (*walletv1.GetHostRevenueStatsResponse, error) {
|
||||
return c.client.GetHostRevenueStats(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcWalletClient) GetAgencyPointShareStats(ctx context.Context, req *walletv1.GetAgencyPointShareStatsRequest) (*walletv1.GetAgencyPointShareStatsResponse, error) {
|
||||
return c.client.GetAgencyPointShareStats(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcWalletClient) GetAgencyHostGiftStats(ctx context.Context, req *walletv1.GetAgencyHostGiftStatsRequest) (*walletv1.GetAgencyHostGiftStatsResponse, error) {
|
||||
return c.client.GetAgencyHostGiftStats(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcWalletClient) GetTeamHostSalaryStats(ctx context.Context, req *walletv1.GetTeamHostSalaryStatsRequest) (*walletv1.GetTeamHostSalaryStatsResponse, error) {
|
||||
return c.client.GetTeamHostSalaryStats(ctx, req)
|
||||
}
|
||||
@ -251,6 +263,18 @@ func (c *grpcWalletClient) TransferSalaryToCoinSeller(ctx context.Context, req *
|
||||
return c.client.TransferSalaryToCoinSeller(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcWalletClient) ListPointWithdrawalCoinSellers(ctx context.Context, req *walletv1.ListPointWithdrawalCoinSellersRequest) (*walletv1.ListPointWithdrawalCoinSellersResponse, error) {
|
||||
return c.client.ListPointWithdrawalCoinSellers(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcWalletClient) TransferPointToCoinSeller(ctx context.Context, req *walletv1.TransferPointToCoinSellerRequest) (*walletv1.TransferPointToCoinSellerResponse, error) {
|
||||
return c.client.TransferPointToCoinSeller(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcWalletClient) GetPointWithdrawalConfig(ctx context.Context, req *walletv1.GetPointWithdrawalConfigRequest) (*walletv1.GetPointWithdrawalConfigResponse, error) {
|
||||
return c.client.GetPointWithdrawalConfig(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcWalletClient) FreezeSalaryWithdrawal(ctx context.Context, req *walletv1.FreezeSalaryWithdrawalRequest) (*walletv1.FreezeSalaryWithdrawalResponse, error) {
|
||||
return c.client.FreezeSalaryWithdrawal(ctx, req)
|
||||
}
|
||||
|
||||
@ -25,6 +25,11 @@ type CreateApplicationCommand struct {
|
||||
SalaryAssetType string
|
||||
WithdrawAmount string
|
||||
WithdrawAmountMinor int64
|
||||
PointFeeAmount int64
|
||||
PointNetAmount int64
|
||||
PointsPerUSD int64
|
||||
PointFeeBPS int32
|
||||
PointPolicyInstance string
|
||||
WithdrawMethod string
|
||||
WithdrawAddress string
|
||||
FreezeCommandID string
|
||||
@ -40,6 +45,11 @@ type Application struct {
|
||||
SalaryAssetType string
|
||||
WithdrawAmount string
|
||||
WithdrawAmountMinor int64
|
||||
PointFeeAmount int64
|
||||
PointNetAmount int64
|
||||
PointsPerUSD int64
|
||||
PointFeeBPS int32
|
||||
PointPolicyInstance string
|
||||
WithdrawMethod string
|
||||
WithdrawAddress string
|
||||
FreezeCommandID string
|
||||
@ -106,14 +116,21 @@ func (w *MySQLWriter) CreateApplication(ctx context.Context, command CreateAppli
|
||||
|
||||
result, err := w.db.ExecContext(ctx, `
|
||||
INSERT INTO admin_user_withdrawal_applications
|
||||
(app_code, user_id, salary_asset_type, withdraw_amount, withdraw_amount_minor, withdraw_method, withdraw_address, freeze_command_id, freeze_transaction_id, status, created_at_ms, updated_at_ms)
|
||||
(app_code, user_id, salary_asset_type, withdraw_amount, withdraw_amount_minor,
|
||||
point_fee_amount, point_net_amount, points_per_usd, point_fee_bps, point_policy_instance_code,
|
||||
withdraw_method, withdraw_address, freeze_command_id, freeze_transaction_id, status, created_at_ms, updated_at_ms)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
command.AppCode,
|
||||
formatUserID(command.UserID),
|
||||
command.SalaryAssetType,
|
||||
command.WithdrawAmount,
|
||||
command.WithdrawAmountMinor,
|
||||
command.PointFeeAmount,
|
||||
command.PointNetAmount,
|
||||
command.PointsPerUSD,
|
||||
command.PointFeeBPS,
|
||||
command.PointPolicyInstance,
|
||||
command.WithdrawMethod,
|
||||
command.WithdrawAddress,
|
||||
command.FreezeCommandID,
|
||||
@ -144,6 +161,11 @@ VALUES
|
||||
SalaryAssetType: command.SalaryAssetType,
|
||||
WithdrawAmount: command.WithdrawAmount,
|
||||
WithdrawAmountMinor: command.WithdrawAmountMinor,
|
||||
PointFeeAmount: command.PointFeeAmount,
|
||||
PointNetAmount: command.PointNetAmount,
|
||||
PointsPerUSD: command.PointsPerUSD,
|
||||
PointFeeBPS: command.PointFeeBPS,
|
||||
PointPolicyInstance: command.PointPolicyInstance,
|
||||
WithdrawMethod: command.WithdrawMethod,
|
||||
WithdrawAddress: command.WithdrawAddress,
|
||||
FreezeCommandID: command.FreezeCommandID,
|
||||
@ -157,6 +179,7 @@ VALUES
|
||||
func (w *MySQLWriter) getApplicationByFreezeCommand(ctx context.Context, appCode string, freezeCommandID string) (Application, bool, error) {
|
||||
row := w.db.QueryRowContext(ctx, `
|
||||
SELECT id, app_code, user_id, salary_asset_type, withdraw_amount, withdraw_amount_minor,
|
||||
point_fee_amount, point_net_amount, points_per_usd, point_fee_bps, point_policy_instance_code,
|
||||
withdraw_method, withdraw_address, freeze_command_id, freeze_transaction_id,
|
||||
status, created_at_ms, updated_at_ms
|
||||
FROM admin_user_withdrawal_applications
|
||||
@ -171,6 +194,11 @@ LIMIT 1`, appCode, freezeCommandID)
|
||||
&item.SalaryAssetType,
|
||||
&item.WithdrawAmount,
|
||||
&item.WithdrawAmountMinor,
|
||||
&item.PointFeeAmount,
|
||||
&item.PointNetAmount,
|
||||
&item.PointsPerUSD,
|
||||
&item.PointFeeBPS,
|
||||
&item.PointPolicyInstance,
|
||||
&item.WithdrawMethod,
|
||||
&item.WithdrawAddress,
|
||||
&item.FreezeCommandID,
|
||||
@ -205,6 +233,7 @@ func normalizeCreateApplicationCommand(command CreateApplicationCommand) CreateA
|
||||
command.WithdrawAddress = strings.TrimSpace(command.WithdrawAddress)
|
||||
command.FreezeCommandID = strings.TrimSpace(command.FreezeCommandID)
|
||||
command.FreezeTransactionID = strings.TrimSpace(command.FreezeTransactionID)
|
||||
command.PointPolicyInstance = strings.TrimSpace(command.PointPolicyInstance)
|
||||
if command.WithdrawMethod == "" {
|
||||
command.WithdrawMethod = MethodUSDTTRC20
|
||||
}
|
||||
@ -234,6 +263,12 @@ func validateCreateApplicationCommand(command CreateApplicationCommand) error {
|
||||
case command.CreatedAtMS <= 0:
|
||||
return errors.New("created_at_ms is required")
|
||||
default:
|
||||
if command.SalaryAssetType == "POINT" || command.SalaryAssetType == "COIN_SELLER_POINT" {
|
||||
// POINT 的政策快照会被财务审核原样复用;写入前先验证内部一致性,避免冻结成功但审核无法结算。
|
||||
if command.PointsPerUSD <= 0 || command.PointFeeBPS < 0 || command.PointFeeBPS > 10000 || command.PointFeeAmount < 0 || command.PointNetAmount < 0 || command.PointFeeAmount+command.PointNetAmount != command.WithdrawAmountMinor || command.PointFeeAmount != command.WithdrawAmountMinor*int64(command.PointFeeBPS)/10000 {
|
||||
return errors.New("point withdrawal policy snapshot is invalid")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,6 +39,11 @@ func TestCreateApplicationReturnsExistingByAppAndFreezeCommand(t *testing.T) {
|
||||
"POINT",
|
||||
"9.50",
|
||||
int64(1000000),
|
||||
int64(50000),
|
||||
int64(950000),
|
||||
int64(100000),
|
||||
int32(500),
|
||||
"huwaa-policy-v1",
|
||||
MethodUSDTTRC20,
|
||||
"TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF",
|
||||
"cmd-point-freeze",
|
||||
@ -54,6 +59,11 @@ func TestCreateApplicationReturnsExistingByAppAndFreezeCommand(t *testing.T) {
|
||||
SalaryAssetType: "POINT",
|
||||
WithdrawAmount: "9.50",
|
||||
WithdrawAmountMinor: 1_000_000,
|
||||
PointFeeAmount: 50_000,
|
||||
PointNetAmount: 950_000,
|
||||
PointsPerUSD: 100_000,
|
||||
PointFeeBPS: 500,
|
||||
PointPolicyInstance: "huwaa-policy-v1",
|
||||
WithdrawMethod: MethodUSDTTRC20,
|
||||
WithdrawAddress: "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF",
|
||||
FreezeCommandID: "cmd-point-freeze",
|
||||
@ -86,6 +96,11 @@ func TestCreateApplicationInsertsAfterAppScopedMiss(t *testing.T) {
|
||||
"POINT",
|
||||
"9.50",
|
||||
int64(1000000),
|
||||
int64(50000),
|
||||
int64(950000),
|
||||
int64(100000),
|
||||
int32(500),
|
||||
"huwaa-policy-v1",
|
||||
MethodUSDTTRC20,
|
||||
"TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF",
|
||||
"cmd-point-freeze",
|
||||
@ -102,6 +117,11 @@ func TestCreateApplicationInsertsAfterAppScopedMiss(t *testing.T) {
|
||||
SalaryAssetType: "point",
|
||||
WithdrawAmount: "9.50",
|
||||
WithdrawAmountMinor: 1_000_000,
|
||||
PointFeeAmount: 50_000,
|
||||
PointNetAmount: 950_000,
|
||||
PointsPerUSD: 100_000,
|
||||
PointFeeBPS: 500,
|
||||
PointPolicyInstance: "huwaa-policy-v1",
|
||||
WithdrawAddress: "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF",
|
||||
FreezeCommandID: "cmd-point-freeze",
|
||||
FreezeTransactionID: "tx-freeze",
|
||||
@ -126,6 +146,11 @@ func withdrawalApplicationRows() *sqlmock.Rows {
|
||||
"salary_asset_type",
|
||||
"withdraw_amount",
|
||||
"withdraw_amount_minor",
|
||||
"point_fee_amount",
|
||||
"point_net_amount",
|
||||
"points_per_usd",
|
||||
"point_fee_bps",
|
||||
"point_policy_instance_code",
|
||||
"withdraw_method",
|
||||
"withdraw_address",
|
||||
"freeze_command_id",
|
||||
|
||||
@ -28,6 +28,7 @@ type Handler struct {
|
||||
roomClient client.RoomClient
|
||||
roomGuardClient client.RoomGuardClient
|
||||
userProfileClient client.UserProfileClient
|
||||
userHostClient client.UserHostClient
|
||||
tencentIM TencentIMConfig
|
||||
tencentRTC TencentRTCConfig
|
||||
}
|
||||
@ -36,6 +37,7 @@ type Config struct {
|
||||
RoomClient client.RoomClient
|
||||
RoomGuardClient client.RoomGuardClient
|
||||
UserProfileClient client.UserProfileClient
|
||||
UserHostClient client.UserHostClient
|
||||
TencentIM TencentIMConfig
|
||||
TencentRTC TencentRTCConfig
|
||||
}
|
||||
@ -45,6 +47,7 @@ func New(config Config) *Handler {
|
||||
roomClient: config.RoomClient,
|
||||
roomGuardClient: config.RoomGuardClient,
|
||||
userProfileClient: config.UserProfileClient,
|
||||
userHostClient: config.UserHostClient,
|
||||
tencentIM: config.TencentIM,
|
||||
tencentRTC: config.TencentRTC,
|
||||
}
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
package callbackapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -18,6 +21,7 @@ import (
|
||||
const (
|
||||
tencentCallbackJoinGroup = "Group.CallbackBeforeApplyJoinGroup"
|
||||
tencentCallbackSendMsg = "Group.CallbackBeforeSendMsg"
|
||||
tencentCallbackC2CSent = "C2C.CallbackAfterSendMsg"
|
||||
)
|
||||
|
||||
// tencentIMCallbackBody 覆盖本阶段需要处理的腾讯云 IM 群回调字段。
|
||||
@ -28,6 +32,11 @@ type tencentIMCallbackBody struct {
|
||||
Requestor string `json:"Requestor_Account"`
|
||||
FromAccount string `json:"From_Account"`
|
||||
OperatorAccount string `json:"Operator_Account"`
|
||||
ToAccount string `json:"To_Account"`
|
||||
MsgKey string `json:"MsgKey"`
|
||||
MsgSeq int64 `json:"MsgSeq"`
|
||||
MsgRandom int64 `json:"MsgRandom"`
|
||||
MsgTime int64 `json:"MsgTime"`
|
||||
}
|
||||
|
||||
// tencentIMCallbackResponse 是腾讯云 IM 回调要求的原生响应结构。
|
||||
@ -86,11 +95,60 @@ func (h *Handler) handleTencentIMCallback(writer http.ResponseWriter, request *h
|
||||
h.handleTencentIMJoinCallback(writer, request, body)
|
||||
case tencentCallbackSendMsg:
|
||||
h.handleTencentIMSendCallback(writer, request, body)
|
||||
case tencentCallbackC2CSent:
|
||||
h.handleTencentIMC2CSentCallback(writer, request, body)
|
||||
default:
|
||||
writeTencentCallback(writer, http.StatusOK, 0, "")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleTencentIMC2CSentCallback(writer http.ResponseWriter, request *http.Request, body tencentIMCallbackBody) {
|
||||
// 服务端系统通知不属于主播“收到私信的人数”,否则批量余额/VIP 通知会污染运营指标。
|
||||
if h.tencentIMCallbackSenderIsServer(body) {
|
||||
writeTencentCallback(writer, http.StatusOK, 0, "")
|
||||
return
|
||||
}
|
||||
senderUserID, senderOK := parseTencentIMUserID(body.FromAccount)
|
||||
targetUserID, targetOK := parseTencentIMUserID(body.ToAccount)
|
||||
callbackAppCode := appcode.Normalize(request.URL.Query().Get("app_code"))
|
||||
if callbackAppCode == "" {
|
||||
callbackAppCode = appcode.FromContext(request.Context())
|
||||
}
|
||||
recorder, recorderOK := h.userHostClient.(interface {
|
||||
RecordPrivateMessageEvent(context.Context, *userv1.RecordPrivateMessageEventRequest) (*userv1.RecordPrivateMessageEventResponse, error)
|
||||
})
|
||||
if !senderOK || !targetOK || senderUserID == targetUserID || !recorderOK {
|
||||
// 统计旁路不能阻断用户已经发送成功的私信;缺配置只记告警,腾讯仍收到成功响应。
|
||||
slog.Warn("tencent_im_private_message_stats_skipped", "app_code", callbackAppCode, "from", body.FromAccount, "to", body.ToAccount)
|
||||
writeTencentCallback(writer, http.StatusOK, 0, "")
|
||||
return
|
||||
}
|
||||
occurredAtMS := body.MsgTime * 1000
|
||||
if occurredAtMS <= 0 {
|
||||
occurredAtMS = time.Now().UTC().UnixMilli()
|
||||
}
|
||||
eventID := strings.TrimSpace(body.MsgKey)
|
||||
if eventID == "" {
|
||||
eventID = fmt.Sprintf("c2c:%d:%d:%d:%d:%d", senderUserID, targetUserID, body.MsgSeq, body.MsgRandom, occurredAtMS)
|
||||
}
|
||||
_, err := recorder.RecordPrivateMessageEvent(appcode.WithContext(request.Context(), callbackAppCode), &userv1.RecordPrivateMessageEventRequest{
|
||||
Meta: &userv1.RequestMeta{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
Caller: "gateway-service",
|
||||
SentAtMs: time.Now().UTC().UnixMilli(),
|
||||
AppCode: callbackAppCode,
|
||||
},
|
||||
EventId: eventID,
|
||||
SenderUserId: senderUserID,
|
||||
TargetUserId: targetUserID,
|
||||
OccurredAtMs: occurredAtMS,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("tencent_im_private_message_stats_failed", "app_code", callbackAppCode, "event_id", eventID, "error", err)
|
||||
}
|
||||
writeTencentCallback(writer, http.StatusOK, 0, "")
|
||||
}
|
||||
|
||||
func (h *Handler) handleTencentIMJoinCallback(writer http.ResponseWriter, request *http.Request, body tencentIMCallbackBody) {
|
||||
parsed := imgroup.ParseWithPrefix(body.GroupID, h.tencentIM.GroupIDPrefix)
|
||||
userID, ok := parseTencentIMUserID(body.Requestor)
|
||||
@ -294,7 +352,7 @@ func resolveTencentIMCallbackCommand(queryCommand string, bodyCommand string) (s
|
||||
}
|
||||
|
||||
func knownTencentIMCallback(command string) bool {
|
||||
return command == tencentCallbackJoinGroup || command == tencentCallbackSendMsg
|
||||
return command == tencentCallbackJoinGroup || command == tencentCallbackSendMsg || command == tencentCallbackC2CSent
|
||||
}
|
||||
|
||||
func parseTencentIMUserID(value string) (int64, bool) {
|
||||
|
||||
@ -90,8 +90,10 @@ type UserHandlers struct {
|
||||
ListAgencyRooms http.HandlerFunc
|
||||
UpdateAgencyProfile http.HandlerFunc
|
||||
GetHostCenterAgency http.HandlerFunc
|
||||
GetHostCenterStats http.HandlerFunc
|
||||
GetHostCenterPlatformPolicy http.HandlerFunc
|
||||
GetAgencyCenterOverview http.HandlerFunc
|
||||
GetAgencyCenterStats http.HandlerFunc
|
||||
GetAgencyCenterPlatformPolicy http.HandlerFunc
|
||||
ListAgencyCenterHosts http.HandlerFunc
|
||||
ListAgencyCenterApplications http.HandlerFunc
|
||||
@ -104,6 +106,7 @@ type UserHandlers struct {
|
||||
InviteBDLeaderBD http.HandlerFunc
|
||||
InviteBDLeaderAgency http.HandlerFunc
|
||||
GetBDCenterOverview http.HandlerFunc
|
||||
GetBDCenterStats http.HandlerFunc
|
||||
ListBDCenterAgencies http.HandlerFunc
|
||||
InviteBDAgency http.HandlerFunc
|
||||
ListMyRoleInvitations http.HandlerFunc
|
||||
@ -287,6 +290,7 @@ type WalletHandlers struct {
|
||||
WithdrawSalaryWallet http.HandlerFunc
|
||||
GetPointWalletOverview http.HandlerFunc
|
||||
WithdrawPointWallet http.HandlerFunc
|
||||
TransferPointWalletToCoinSeller http.HandlerFunc
|
||||
GetRedPacketConfig http.HandlerFunc
|
||||
ListRoomRedPackets http.HandlerFunc
|
||||
CreateRoomRedPacket http.HandlerFunc
|
||||
@ -470,8 +474,10 @@ func (r routes) registerUserRoutes() {
|
||||
r.profile("/agencies/rooms", http.MethodGet, h.ListAgencyRooms)
|
||||
r.profile("/agencies/profile/update", http.MethodPost, h.UpdateAgencyProfile)
|
||||
r.profile("/host-center/agency", http.MethodGet, h.GetHostCenterAgency)
|
||||
r.profile("/host-center/stats", http.MethodGet, h.GetHostCenterStats)
|
||||
r.profile("/host-center/platform-policy", http.MethodGet, h.GetHostCenterPlatformPolicy)
|
||||
r.profile("/agency-center/overview", http.MethodGet, h.GetAgencyCenterOverview)
|
||||
r.profile("/agency-center/stats", http.MethodGet, h.GetAgencyCenterStats)
|
||||
r.profile("/agency-center/platform-policy", http.MethodGet, h.GetAgencyCenterPlatformPolicy)
|
||||
r.profile("/agency-center/hosts", http.MethodGet, h.ListAgencyCenterHosts)
|
||||
r.profile("/agency-center/applications", http.MethodGet, h.ListAgencyCenterApplications)
|
||||
@ -484,6 +490,7 @@ func (r routes) registerUserRoutes() {
|
||||
r.profile("/bd-leader/invitations/bd", http.MethodPost, h.InviteBDLeaderBD)
|
||||
r.profile("/bd-leader/invitations/agency", http.MethodPost, h.InviteBDLeaderAgency)
|
||||
r.profile("/bd-center/overview", http.MethodGet, h.GetBDCenterOverview)
|
||||
r.profile("/bd-center/stats", http.MethodGet, h.GetBDCenterStats)
|
||||
r.profile("/bd-center/agencies", http.MethodGet, h.ListBDCenterAgencies)
|
||||
r.profile("/bd/invitations/agency", http.MethodPost, h.InviteBDAgency)
|
||||
r.profile("/role-invitations", http.MethodGet, h.ListMyRoleInvitations)
|
||||
@ -690,6 +697,7 @@ func (r routes) registerWalletRoutes() {
|
||||
r.profile("/salary-wallet/withdraw", http.MethodPost, h.WithdrawSalaryWallet)
|
||||
r.profile("/point-wallet/overview", http.MethodGet, h.GetPointWalletOverview)
|
||||
r.profile("/point-wallet/withdraw", http.MethodPost, h.WithdrawPointWallet)
|
||||
r.profile("/point-wallet/transfer-to-coin-seller", http.MethodPost, h.TransferPointWalletToCoinSeller)
|
||||
r.profile("/red-packets/config", http.MethodGet, h.GetRedPacketConfig)
|
||||
r.profile("/rooms/{room_id}/red-packets", http.MethodGet, h.ListRoomRedPackets)
|
||||
r.profile("/rooms/{room_id}/red-packets/create", http.MethodPost, h.CreateRoomRedPacket)
|
||||
|
||||
@ -134,6 +134,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
||||
RoomClient: h.roomClient,
|
||||
RoomGuardClient: h.roomGuardClient,
|
||||
UserProfileClient: h.userProfileClient,
|
||||
UserHostClient: h.userHostClient,
|
||||
TencentIM: callbackapi.TencentIMConfig{
|
||||
SDKAppID: h.tencentIM.SDKAppID,
|
||||
AdminIdentifier: h.tencentIM.AdminIdentifier,
|
||||
|
||||
@ -33,6 +33,7 @@ type agencyCenterUserData struct {
|
||||
DisplayUserID string `json:"display_user_id"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
}
|
||||
|
||||
type agencyCenterHostData struct {
|
||||
@ -465,6 +466,7 @@ func agencyCenterUserFromProto(user *userv1.User) agencyCenterUserData {
|
||||
DisplayUserID: user.GetDisplayUserId(),
|
||||
Username: user.GetUsername(),
|
||||
Avatar: user.GetAvatar(),
|
||||
Country: firstBDLeaderNonBlank(user.GetCountryDisplayName(), user.GetCountryName(), user.GetCountry()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,165 @@
|
||||
package userapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
)
|
||||
|
||||
type agencyRevenueStatsClient interface {
|
||||
GetHostRevenueStats(context.Context, *walletv1.GetHostRevenueStatsRequest) (*walletv1.GetHostRevenueStatsResponse, error)
|
||||
GetAgencyPointShareStats(context.Context, *walletv1.GetAgencyPointShareStatsRequest) (*walletv1.GetAgencyPointShareStatsResponse, error)
|
||||
GetAgencyHostGiftStats(context.Context, *walletv1.GetAgencyHostGiftStatsRequest) (*walletv1.GetAgencyHostGiftStatsResponse, error)
|
||||
}
|
||||
|
||||
type agencyCenterStatsSummaryData struct {
|
||||
TotalEarnings int64 `json:"total_earnings"`
|
||||
GiftIncome int64 `json:"gift_income"`
|
||||
ShareIncome int64 `json:"share_income"`
|
||||
TotalHosts int `json:"total_hosts"`
|
||||
GiftedHostCount int64 `json:"gifted_host_count"`
|
||||
}
|
||||
|
||||
type agencyCenterHostStatsData struct {
|
||||
MembershipID string `json:"membership_id"`
|
||||
HostUserID string `json:"host_user_id"`
|
||||
DisplayUserID string `json:"display_user_id"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
JoinedAtMS int64 `json:"joined_at_ms"`
|
||||
DiamondEarnings int64 `json:"diamond_earnings"`
|
||||
DiamondExchanged int64 `json:"diamond_exchanged"`
|
||||
GiftSenders int64 `json:"gift_senders"`
|
||||
OnlineDurationMS int64 `json:"online_duration_ms"`
|
||||
ValidMicDurationMS int64 `json:"valid_mic_duration_ms"`
|
||||
ValidMicDays int64 `json:"valid_mic_days"`
|
||||
Removable bool `json:"removable"`
|
||||
}
|
||||
|
||||
// getAgencyCenterStats 只做跨 owner 编排:成员关系/互动来自 user-service,金额和分成来自 wallet-service。
|
||||
// 每个主播的两次 RPC 使用有界并发,避免大公会串行超时,也避免无上限 goroutine 压垮下游。
|
||||
func (h *Handler) getAgencyCenterStats(writer http.ResponseWriter, request *http.Request) {
|
||||
agency, ok := h.resolveCurrentOwnerAgency(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
revenueClient, revenueOK := h.walletClient.(agencyRevenueStatsClient)
|
||||
engagementClient, engagementOK := h.userHostClient.(hostEngagementStatsClient)
|
||||
if !revenueOK || !engagementOK {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
startDate, endDate, startAtMS, endAtMS, ok := parseHostCenterStatsRange(request)
|
||||
if !ok {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid date range")
|
||||
return
|
||||
}
|
||||
|
||||
membersResp, err := h.userHostClient.GetAgencyMembers(request.Context(), &userv1.GetAgencyMembersRequest{
|
||||
Meta: httpkit.UserMeta(request, ""), AgencyId: agency.GetAgencyId(), Status: membershipStatusActive,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
memberships := make([]*userv1.AgencyMembership, 0, len(membersResp.GetMemberships()))
|
||||
userIDs := make([]int64, 0, len(membersResp.GetMemberships()))
|
||||
for _, membership := range membersResp.GetMemberships() {
|
||||
memberships = append(memberships, membership)
|
||||
userIDs = append(userIDs, membership.GetHostUserId())
|
||||
}
|
||||
profiles, err := h.userProfiles(request, userIDs)
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
requestID := httpkit.RequestIDFromContext(request.Context())
|
||||
appCode := appcode.FromContext(request.Context())
|
||||
shareResp, err := revenueClient.GetAgencyPointShareStats(request.Context(), &walletv1.GetAgencyPointShareStatsRequest{
|
||||
RequestId: requestID, AppCode: appCode, AgencyOwnerUserId: agency.GetOwnerUserId(), StartAtMs: startAtMS, EndAtMs: endAtMS,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
giftResp, err := revenueClient.GetAgencyHostGiftStats(request.Context(), &walletv1.GetAgencyHostGiftStatsRequest{
|
||||
RequestId: requestID, AppCode: appCode, HostUserIds: userIDs, StartAtMs: startAtMS, EndAtMs: endAtMS,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]agencyCenterHostStatsData, len(memberships))
|
||||
ctx, cancel := context.WithCancel(request.Context())
|
||||
defer cancel()
|
||||
semaphore := make(chan struct{}, 8)
|
||||
var wait sync.WaitGroup
|
||||
var firstErr error
|
||||
var errorMu sync.Mutex
|
||||
for index, membership := range memberships {
|
||||
index, membership := index, membership
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
revenue, callErr := revenueClient.GetHostRevenueStats(ctx, &walletv1.GetHostRevenueStatsRequest{
|
||||
RequestId: requestID, AppCode: appCode, HostUserId: membership.GetHostUserId(), StartAtMs: startAtMS, EndAtMs: endAtMS,
|
||||
})
|
||||
if callErr == nil {
|
||||
var engagement *userv1.GetHostEngagementStatsResponse
|
||||
engagement, callErr = engagementClient.GetHostEngagementStats(ctx, &userv1.GetHostEngagementStatsRequest{
|
||||
Meta: httpkit.UserMeta(request, ""), HostUserId: membership.GetHostUserId(), StartAtMs: startAtMS, EndAtMs: endAtMS,
|
||||
})
|
||||
if callErr == nil {
|
||||
profile := profiles[membership.GetHostUserId()]
|
||||
items[index] = agencyCenterHostStatsData{
|
||||
MembershipID: int64String(membership.GetMembershipId()), HostUserID: int64String(membership.GetHostUserId()),
|
||||
DisplayUserID: profile.DisplayUserID, Username: profile.Username, Avatar: profile.Avatar, JoinedAtMS: membership.GetJoinedAtMs(),
|
||||
DiamondEarnings: revenue.GetStats().GetDiamondEarnings(), DiamondExchanged: revenue.GetStats().GetDiamondExchanged(), GiftSenders: revenue.GetStats().GetGiftSenders(),
|
||||
OnlineDurationMS: engagement.GetStats().GetOnlineDurationMs(), ValidMicDurationMS: engagement.GetStats().GetValidMicDurationMs(), ValidMicDays: engagement.GetStats().GetValidMicDays(),
|
||||
Removable: membership.GetHostUserId() != agency.GetOwnerUserId() && membership.GetMembershipType() != membershipTypeOwner,
|
||||
}
|
||||
}
|
||||
}
|
||||
if callErr != nil {
|
||||
errorMu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = callErr
|
||||
cancel()
|
||||
}
|
||||
errorMu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wait.Wait()
|
||||
if firstErr != nil {
|
||||
httpkit.WriteRPCError(writer, request, firstErr)
|
||||
return
|
||||
}
|
||||
shareIncome := shareResp.GetStats().GetShareIncome()
|
||||
// 公会长同时是主播时,其 POINT 流水包含 Agency 分成;主播行只展示个人收益,必须扣除同区间分成。
|
||||
for index := range items {
|
||||
if items[index].HostUserID == int64String(agency.GetOwnerUserId()) {
|
||||
items[index].DiamondEarnings -= shareIncome
|
||||
if items[index].DiamondEarnings < 0 {
|
||||
items[index].DiamondEarnings = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
giftIncome := giftResp.GetStats().GetGiftIncome()
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"range": hostCenterStatsRangeData{StartDate: startDate, EndDate: endDate, Timezone: "UTC"},
|
||||
"summary": agencyCenterStatsSummaryData{
|
||||
TotalEarnings: giftIncome + shareIncome, GiftIncome: giftIncome, ShareIncome: shareIncome,
|
||||
TotalHosts: len(items), GiftedHostCount: giftResp.GetStats().GetGiftedHostCount(),
|
||||
},
|
||||
"hosts": items,
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,117 @@
|
||||
package userapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
)
|
||||
|
||||
type agencyHostGiftStatsClient interface {
|
||||
GetAgencyHostGiftStats(context.Context, *walletv1.GetAgencyHostGiftStatsRequest) (*walletv1.GetAgencyHostGiftStatsResponse, error)
|
||||
}
|
||||
|
||||
type bdCenterAgencyStatsData struct {
|
||||
Agency hostAgencyData `json:"agency"`
|
||||
Owner agencyCenterUserData `json:"owner"`
|
||||
Country string `json:"country"`
|
||||
TotalHosts int `json:"total_hosts"`
|
||||
GiftedHostCount int64 `json:"gifted_host_count"`
|
||||
DiamondEarnings int64 `json:"diamond_earnings"`
|
||||
JoinedAtMS int64 `json:"joined_at_ms"`
|
||||
}
|
||||
|
||||
// getBDCenterStats 返回 BD 直属 Agency 的收礼统计;工资和 Agency 分成不属于设计稿口径,刻意不返回。
|
||||
func (h *Handler) getBDCenterStats(writer http.ResponseWriter, request *http.Request) {
|
||||
userID := auth.UserIDFromContext(request.Context())
|
||||
if _, ok := h.resolveActiveBD(writer, request, userID); !ok {
|
||||
return
|
||||
}
|
||||
statsClient, ok := h.walletClient.(agencyHostGiftStatsClient)
|
||||
if !ok {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
startDate, endDate, startAtMS, endAtMS, ok := parseHostCenterStatsRange(request)
|
||||
if !ok {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid date range")
|
||||
return
|
||||
}
|
||||
agenciesResp, err := h.userHostClient.ListBDAgencies(request.Context(), &userv1.ListBDAgenciesRequest{
|
||||
Meta: httpkit.UserMeta(request, ""), BdUserId: userID, Status: agencyStatusActive, PageSize: 100,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
ownerIDs := make([]int64, 0, len(agenciesResp.GetAgencies()))
|
||||
for _, agency := range agenciesResp.GetAgencies() {
|
||||
ownerIDs = append(ownerIDs, agency.GetOwnerUserId())
|
||||
}
|
||||
owners, err := h.userProfiles(request, ownerIDs)
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]bdCenterAgencyStatsData, len(agenciesResp.GetAgencies()))
|
||||
ctx, cancel := context.WithCancel(request.Context())
|
||||
defer cancel()
|
||||
semaphore := make(chan struct{}, 8)
|
||||
var wait sync.WaitGroup
|
||||
var firstErr error
|
||||
var errorMu sync.Mutex
|
||||
for index, agency := range agenciesResp.GetAgencies() {
|
||||
index, agency := index, agency
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
members, callErr := h.userHostClient.GetAgencyMembers(ctx, &userv1.GetAgencyMembersRequest{
|
||||
Meta: httpkit.UserMeta(request, ""), AgencyId: agency.GetAgencyId(), Status: membershipStatusActive,
|
||||
})
|
||||
hostIDs := make([]int64, 0, len(members.GetMemberships()))
|
||||
if callErr == nil {
|
||||
for _, membership := range members.GetMemberships() {
|
||||
hostIDs = append(hostIDs, membership.GetHostUserId())
|
||||
}
|
||||
var stats *walletv1.GetAgencyHostGiftStatsResponse
|
||||
stats, callErr = statsClient.GetAgencyHostGiftStats(ctx, &walletv1.GetAgencyHostGiftStatsRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
|
||||
HostUserIds: hostIDs, StartAtMs: startAtMS, EndAtMs: endAtMS,
|
||||
})
|
||||
if callErr == nil {
|
||||
owner := owners[agency.GetOwnerUserId()]
|
||||
items[index] = bdCenterAgencyStatsData{
|
||||
Agency: hostAgencyFromProto(agency), Owner: owner, Country: owner.Country,
|
||||
TotalHosts: len(hostIDs), GiftedHostCount: stats.GetStats().GetGiftedHostCount(),
|
||||
DiamondEarnings: stats.GetStats().GetGiftIncome(), JoinedAtMS: agency.GetCreatedAtMs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
if callErr != nil {
|
||||
errorMu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = callErr
|
||||
cancel()
|
||||
}
|
||||
errorMu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wait.Wait()
|
||||
if firstErr != nil {
|
||||
httpkit.WriteRPCError(writer, request, firstErr)
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"range": hostCenterStatsRangeData{StartDate: startDate, EndDate: endDate, Timezone: "UTC"},
|
||||
"items": items, "total": len(items),
|
||||
})
|
||||
}
|
||||
@ -73,8 +73,10 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers {
|
||||
ListAgencyRooms: h.listAgencyRooms,
|
||||
UpdateAgencyProfile: h.updateAgencyProfile,
|
||||
GetHostCenterAgency: h.getHostCenterAgency,
|
||||
GetHostCenterStats: h.getHostCenterStats,
|
||||
GetHostCenterPlatformPolicy: h.getHostCenterPlatformPolicy,
|
||||
GetAgencyCenterOverview: h.getAgencyCenterOverview,
|
||||
GetAgencyCenterStats: h.getAgencyCenterStats,
|
||||
GetAgencyCenterPlatformPolicy: h.getAgencyCenterPlatformPolicy,
|
||||
ListAgencyCenterHosts: h.listAgencyCenterHosts,
|
||||
ListAgencyCenterApplications: h.listAgencyCenterApplications,
|
||||
@ -87,6 +89,7 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers {
|
||||
InviteBDLeaderBD: h.inviteBDLeaderBD,
|
||||
InviteBDLeaderAgency: h.inviteBDLeaderAgency,
|
||||
GetBDCenterOverview: h.getBDCenterOverview,
|
||||
GetBDCenterStats: h.getBDCenterStats,
|
||||
ListBDCenterAgencies: h.listBDCenterAgencies,
|
||||
InviteBDAgency: h.inviteBDAgency,
|
||||
ListMyRoleInvitations: h.listMyRoleInvitations,
|
||||
|
||||
@ -8,10 +8,11 @@ import (
|
||||
)
|
||||
|
||||
type hostCenterAgencyData struct {
|
||||
AgencyID string `json:"agency_id"`
|
||||
ShortID string `json:"short_id"`
|
||||
Name string `json:"name"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
AgencyID string `json:"agency_id"`
|
||||
ShortID string `json:"short_id"`
|
||||
Name string `json:"name"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
JoinedAtMS int64 `json:"joined_at_ms,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) getHostCenterAgency(writer http.ResponseWriter, request *http.Request) {
|
||||
@ -49,6 +50,21 @@ func (h *Handler) getHostCenterAgency(writer http.ResponseWriter, request *http.
|
||||
Name: agency.GetName(),
|
||||
Avatar: agency.GetAvatar(),
|
||||
}
|
||||
// 加入时间来自当前 membership 事实,不使用 host 首次成为主播时间;主播换公会后两者语义不同。
|
||||
membersResp, err := h.userHostClient.GetAgencyMembers(request.Context(), &userv1.GetAgencyMembersRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
AgencyId: agency.GetAgencyId(),
|
||||
Status: "active",
|
||||
})
|
||||
if err == nil {
|
||||
// 加入时间是新增展示字段;该补充查询失败时保留旧响应,不能让 Lalu 历史页面因非关键字段不可用而整体失败。
|
||||
for _, membership := range membersResp.GetMemberships() {
|
||||
if membership.GetMembershipId() == profile.GetCurrentMembershipId() && membership.GetHostUserId() == profile.GetUserId() {
|
||||
data.JoinedAtMS = membership.GetJoinedAtMs()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if agency.GetOwnerUserId() > 0 {
|
||||
profiles, err := h.userProfiles(request, []int64{agency.GetOwnerUserId()})
|
||||
if err != nil {
|
||||
|
||||
@ -0,0 +1,114 @@
|
||||
package userapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
)
|
||||
|
||||
type hostEngagementStatsClient interface {
|
||||
GetHostEngagementStats(context.Context, *userv1.GetHostEngagementStatsRequest) (*userv1.GetHostEngagementStatsResponse, error)
|
||||
}
|
||||
|
||||
type hostRevenueStatsClient interface {
|
||||
GetHostRevenueStats(context.Context, *walletv1.GetHostRevenueStatsRequest) (*walletv1.GetHostRevenueStatsResponse, error)
|
||||
}
|
||||
|
||||
const hostCenterMaxStatsDays = 366
|
||||
|
||||
type hostCenterStatsRangeData struct {
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
type hostCenterStatsMetricsData struct {
|
||||
DiamondEarnings int64 `json:"diamond_earnings"`
|
||||
DiamondExchanged int64 `json:"diamond_exchanged"`
|
||||
GiftSenders int64 `json:"gift_senders"`
|
||||
OnlineDurationMS int64 `json:"online_duration_ms"`
|
||||
ValidMicDurationMS int64 `json:"valid_mic_duration_ms"`
|
||||
ValidMicDays int64 `json:"valid_mic_days"`
|
||||
PrivateMessageSenders int64 `json:"private_message_senders"`
|
||||
NewFollowers int64 `json:"new_followers"`
|
||||
}
|
||||
|
||||
// getHostCenterStats 只编排 user/wallet 两个 owner 的只读聚合,不在 gateway 重算账务或在线时长。
|
||||
func (h *Handler) getHostCenterStats(writer http.ResponseWriter, request *http.Request) {
|
||||
profile, ok := h.resolveCurrentActiveHostProfile(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
engagementClient, engagementOK := h.userHostClient.(hostEngagementStatsClient)
|
||||
revenueClient, revenueOK := h.walletClient.(hostRevenueStatsClient)
|
||||
if !engagementOK || !revenueOK {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
startDate, endDate, startAtMS, endAtMS, ok := parseHostCenterStatsRange(request)
|
||||
if !ok {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid date range")
|
||||
return
|
||||
}
|
||||
|
||||
engagementResp, err := engagementClient.GetHostEngagementStats(request.Context(), &userv1.GetHostEngagementStatsRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
HostUserId: profile.GetUserId(),
|
||||
StartAtMs: startAtMS,
|
||||
EndAtMs: endAtMS,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
revenueResp, err := revenueClient.GetHostRevenueStats(request.Context(), &walletv1.GetHostRevenueStatsRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
HostUserId: profile.GetUserId(),
|
||||
StartAtMs: startAtMS,
|
||||
EndAtMs: endAtMS,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
engagement := engagementResp.GetStats()
|
||||
revenue := revenueResp.GetStats()
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"range": hostCenterStatsRangeData{StartDate: startDate, EndDate: endDate, Timezone: "UTC"},
|
||||
"metrics": hostCenterStatsMetricsData{
|
||||
DiamondEarnings: revenue.GetDiamondEarnings(),
|
||||
DiamondExchanged: revenue.GetDiamondExchanged(),
|
||||
GiftSenders: revenue.GetGiftSenders(),
|
||||
OnlineDurationMS: engagement.GetOnlineDurationMs(),
|
||||
ValidMicDurationMS: engagement.GetValidMicDurationMs(),
|
||||
ValidMicDays: engagement.GetValidMicDays(),
|
||||
PrivateMessageSenders: engagement.GetPrivateMessageSenders(),
|
||||
NewFollowers: engagement.GetNewFollowers(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func parseHostCenterStatsRange(request *http.Request) (string, string, int64, int64, bool) {
|
||||
startText := strings.TrimSpace(request.URL.Query().Get("start_date"))
|
||||
endText := strings.TrimSpace(request.URL.Query().Get("end_date"))
|
||||
if startText == "" || endText == "" {
|
||||
return "", "", 0, 0, false
|
||||
}
|
||||
start, startErr := time.Parse("2006-01-02", startText)
|
||||
end, endErr := time.Parse("2006-01-02", endText)
|
||||
if startErr != nil || endErr != nil || end.Before(start) {
|
||||
return "", "", 0, 0, false
|
||||
}
|
||||
endExclusive := end.Add(24 * time.Hour)
|
||||
if int(endExclusive.Sub(start)/(24*time.Hour)) > hostCenterMaxStatsDays {
|
||||
return "", "", 0, 0, false
|
||||
}
|
||||
return startText, endText, start.UnixMilli(), endExclusive.UnixMilli(), true
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package userapi
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseHostCenterStatsRangeUsesInclusiveNaturalDays(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", "/api/v1/host-center/stats?start_date=2026-07-06&end_date=2026-07-12", nil)
|
||||
startDate, endDate, startAtMS, endAtMS, ok := parseHostCenterStatsRange(request)
|
||||
if !ok || startDate != "2026-07-06" || endDate != "2026-07-12" {
|
||||
t.Fatalf("range parse mismatch: ok=%t start=%s end=%s", ok, startDate, endDate)
|
||||
}
|
||||
if got := endAtMS - startAtMS; got != 7*24*60*60*1000 {
|
||||
t.Fatalf("end must be exclusive after seven inclusive days, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHostCenterStatsRangeRejectsInvalidOrOversizedRanges(t *testing.T) {
|
||||
for _, rawURL := range []string{
|
||||
"/api/v1/host-center/stats?start_date=2026-07-12&end_date=2026-07-01",
|
||||
"/api/v1/host-center/stats?start_date=2026-07-12",
|
||||
"/api/v1/host-center/stats?start_date=2025-01-01&end_date=2026-07-12",
|
||||
} {
|
||||
request := httptest.NewRequest("GET", rawURL, nil)
|
||||
if _, _, _, _, ok := parseHostCenterStatsRange(request); ok {
|
||||
t.Fatalf("expected invalid range for %s", rawURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -118,6 +118,7 @@ func (h *Handler) WalletHandlers() httproutes.WalletHandlers {
|
||||
WithdrawSalaryWallet: h.withdrawSalaryWallet,
|
||||
GetPointWalletOverview: h.getPointWalletOverview,
|
||||
WithdrawPointWallet: h.withdrawPointWallet,
|
||||
TransferPointWalletToCoinSeller: h.transferPointWalletToCoinSeller,
|
||||
GetRedPacketConfig: h.getRedPacketConfig,
|
||||
ListRoomRedPackets: h.listRoomRedPackets,
|
||||
CreateRoomRedPacket: h.createRoomRedPacket,
|
||||
|
||||
@ -18,16 +18,15 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
pointWalletAssetType = "POINT"
|
||||
pointWalletMinimumGross = int64(1000000)
|
||||
pointWalletPointsPerUSD = int64(100000)
|
||||
pointWalletWithdrawFeeBPS = int32(500)
|
||||
pointWalletWithdrawalScope = "huwaa"
|
||||
pointWalletAssetType = "POINT"
|
||||
)
|
||||
|
||||
type pointWithdrawalGatewayClient interface {
|
||||
FreezePointWithdrawal(ctx context.Context, req *walletv1.FreezePointWithdrawalRequest) (*walletv1.FreezePointWithdrawalResponse, error)
|
||||
ReleasePointWithdrawal(ctx context.Context, req *walletv1.ReleasePointWithdrawalRequest) (*walletv1.ReleasePointWithdrawalResponse, error)
|
||||
ListPointWithdrawalCoinSellers(ctx context.Context, req *walletv1.ListPointWithdrawalCoinSellersRequest) (*walletv1.ListPointWithdrawalCoinSellersResponse, error)
|
||||
TransferPointToCoinSeller(ctx context.Context, req *walletv1.TransferPointToCoinSellerRequest) (*walletv1.TransferPointToCoinSellerResponse, error)
|
||||
GetPointWithdrawalConfig(ctx context.Context, req *walletv1.GetPointWithdrawalConfigRequest) (*walletv1.GetPointWithdrawalConfigResponse, error)
|
||||
}
|
||||
|
||||
type pointWalletOverviewData struct {
|
||||
@ -37,6 +36,19 @@ type pointWalletOverviewData struct {
|
||||
PointsPerUSD int64 `json:"points_per_usd"`
|
||||
FeeBPS int32 `json:"fee_bps"`
|
||||
MinimumPoints int64 `json:"minimum_points"`
|
||||
CoinSellers []pointWalletCoinSellerData `json:"coin_sellers"`
|
||||
PolicyInstance string `json:"policy_instance_code"`
|
||||
}
|
||||
|
||||
type pointWalletCoinSellerData struct {
|
||||
UserID string `json:"user_id"`
|
||||
DisplayUserID string `json:"display_user_id"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
PointAmount int64 `json:"point_amount"`
|
||||
SellerCoinAmount int64 `json:"seller_coin_amount"`
|
||||
ServiceCountryCodes []string `json:"service_country_codes"`
|
||||
}
|
||||
|
||||
type pointWalletBalanceData struct {
|
||||
@ -66,6 +78,36 @@ type pointWalletWithdrawBody struct {
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
type pointWalletCoinSellerTransferBody struct {
|
||||
CommandID string `json:"command_id"`
|
||||
CommandIDCamel string `json:"commandId"`
|
||||
SellerUserID flexibleJSONInt64 `json:"seller_user_id"`
|
||||
SellerUserIDCamel flexibleJSONInt64 `json:"sellerUserId"`
|
||||
PointAmount int64 `json:"point_amount"`
|
||||
PointAmountCamel int64 `json:"pointAmount"`
|
||||
}
|
||||
|
||||
func (b pointWalletCoinSellerTransferBody) commandID() string {
|
||||
if value := strings.TrimSpace(b.CommandID); value != "" {
|
||||
return value
|
||||
}
|
||||
return strings.TrimSpace(b.CommandIDCamel)
|
||||
}
|
||||
|
||||
func (b pointWalletCoinSellerTransferBody) sellerUserID() int64 {
|
||||
if b.SellerUserID.Int64() > 0 {
|
||||
return b.SellerUserID.Int64()
|
||||
}
|
||||
return b.SellerUserIDCamel.Int64()
|
||||
}
|
||||
|
||||
func (b pointWalletCoinSellerTransferBody) amount() int64 {
|
||||
if b.PointAmount > 0 {
|
||||
return b.PointAmount
|
||||
}
|
||||
return b.PointAmountCamel
|
||||
}
|
||||
|
||||
func (b pointWalletWithdrawBody) commandID() string {
|
||||
if value := strings.TrimSpace(b.CommandID); value != "" {
|
||||
return value
|
||||
@ -102,7 +144,7 @@ func (b pointWalletWithdrawBody) usdtTRC20Address() string {
|
||||
}
|
||||
|
||||
func (h *Handler) getPointWalletOverview(writer http.ResponseWriter, request *http.Request) {
|
||||
if !isHuwaaPointWalletRequest(request) {
|
||||
if !isPointWalletRequest(request) {
|
||||
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
|
||||
return
|
||||
}
|
||||
@ -110,7 +152,37 @@ func (h *Handler) getPointWalletOverview(writer http.ResponseWriter, request *ht
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
pointWallet, ok := h.walletClient.(pointWithdrawalGatewayClient)
|
||||
if !ok {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
userID := auth.UserIDFromContext(request.Context())
|
||||
profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
|
||||
Meta: httpkit.UserMeta(request, ""), UserId: userID,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
profile := profileResp.GetUser()
|
||||
if profile == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
config, err := pointWallet.GetPointWithdrawalConfig(request.Context(), &walletv1.GetPointWithdrawalConfigRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
|
||||
RegionId: profile.GetRegionId(), NowMs: time.Now().UTC().UnixMilli(),
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
if !config.GetFound() {
|
||||
// 没有已发布政策就关闭资金入口;不能回退到编译期常量造成 Admin 看似配置但账务仍走旧值。
|
||||
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "point wallet policy is not configured")
|
||||
return
|
||||
}
|
||||
balanceResp, err := h.walletClient.GetBalances(request.Context(), &walletv1.GetBalancesRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
UserId: userID,
|
||||
@ -121,26 +193,33 @@ func (h *Handler) getPointWalletOverview(writer http.ResponseWriter, request *ht
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
UserId: userID,
|
||||
sellerResp, err := pointWallet.ListPointWithdrawalCoinSellers(request.Context(), &walletv1.ListPointWithdrawalCoinSellersRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
|
||||
CountryCode: pointWalletCountryCode(profile),
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
sellers, err := h.pointWalletCoinSellerData(request, sellerResp.GetSellers())
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, pointWalletOverviewData{
|
||||
AssetType: pointWalletAssetType,
|
||||
Balance: pointBalanceFromProto(firstBalance(balanceResp.GetBalances())),
|
||||
Balance: pointBalanceFromProto(firstBalance(balanceResp.GetBalances()), config.GetPointsPerUsd()),
|
||||
WithdrawAddress: salaryWithdrawAddressFromProto(profileResp.GetUser()),
|
||||
PointsPerUSD: pointWalletPointsPerUSD,
|
||||
FeeBPS: pointWalletWithdrawFeeBPS,
|
||||
MinimumPoints: pointWalletMinimumGross,
|
||||
PointsPerUSD: config.GetPointsPerUsd(),
|
||||
FeeBPS: config.GetFeeBps(),
|
||||
MinimumPoints: config.GetMinimumPoints(),
|
||||
CoinSellers: sellers,
|
||||
PolicyInstance: config.GetPolicyInstanceCode(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http.Request) {
|
||||
if !isHuwaaPointWalletRequest(request) {
|
||||
if !isPointWalletRequest(request) {
|
||||
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
|
||||
return
|
||||
}
|
||||
@ -164,13 +243,31 @@ func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http.
|
||||
return
|
||||
}
|
||||
grossPoints := body.grossPointAmount()
|
||||
if grossPoints < pointWalletMinimumGross {
|
||||
userID := auth.UserIDFromContext(request.Context())
|
||||
profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{Meta: httpkit.UserMeta(request, ""), UserId: userID})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
profile := profileResp.GetUser()
|
||||
if profile == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
config, err := pointWallet.GetPointWithdrawalConfig(request.Context(), &walletv1.GetPointWithdrawalConfigRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
|
||||
RegionId: profile.GetRegionId(), NowMs: time.Now().UTC().UnixMilli(),
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
if !config.GetFound() || grossPoints < config.GetMinimumPoints() {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
feePoints := grossPoints * int64(pointWalletWithdrawFeeBPS) / 10000
|
||||
feePoints := grossPoints * int64(config.GetFeeBps()) / 10000
|
||||
netPoints := grossPoints - feePoints
|
||||
userID := auth.UserIDFromContext(request.Context())
|
||||
balanceResp, err := h.walletClient.GetBalances(request.Context(), &walletv1.GetBalancesRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
UserId: userID,
|
||||
@ -199,8 +296,8 @@ func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http.
|
||||
GrossPointAmount: grossPoints,
|
||||
FeePointAmount: feePoints,
|
||||
NetPointAmount: netPoints,
|
||||
PointsPerUsd: pointWalletPointsPerUSD,
|
||||
FeeBps: pointWalletWithdrawFeeBPS,
|
||||
PointsPerUsd: config.GetPointsPerUsd(),
|
||||
FeeBps: config.GetFeeBps(),
|
||||
Reason: "point withdrawal submitted",
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
WithdrawalRef: httpkit.RequestIDFromContext(request.Context()),
|
||||
@ -213,8 +310,13 @@ func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http.
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
UserID: userID,
|
||||
SalaryAssetType: pointWalletAssetType,
|
||||
WithdrawAmount: formatPointUSD(netPoints, pointWalletPointsPerUSD),
|
||||
WithdrawAmount: formatPointUSD(netPoints, config.GetPointsPerUsd()),
|
||||
WithdrawAmountMinor: grossPoints,
|
||||
PointFeeAmount: feePoints,
|
||||
PointNetAmount: netPoints,
|
||||
PointsPerUSD: config.GetPointsPerUsd(),
|
||||
PointFeeBPS: config.GetFeeBps(),
|
||||
PointPolicyInstance: config.GetPolicyInstanceCode(),
|
||||
WithdrawMethod: financewithdrawal.MethodUSDTTRC20,
|
||||
WithdrawAddress: address,
|
||||
FreezeCommandID: freezeCommandID,
|
||||
@ -222,7 +324,7 @@ func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http.
|
||||
CreatedAtMS: nowMS,
|
||||
})
|
||||
if err != nil {
|
||||
rollbackPointWithdrawalFreeze(request, pointWallet, freezeCommandID, userID, grossPoints, feePoints, netPoints)
|
||||
rollbackPointWithdrawalFreeze(request, pointWallet, freezeCommandID, userID, grossPoints, feePoints, netPoints, config.GetPointsPerUsd(), config.GetFeeBps())
|
||||
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
|
||||
return
|
||||
}
|
||||
@ -239,12 +341,13 @@ func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http.
|
||||
"gross_point_amount": grossPoints,
|
||||
"fee_point_amount": feePoints,
|
||||
"net_point_amount": netPoints,
|
||||
"points_per_usd": pointWalletPointsPerUSD,
|
||||
"fee_bps": pointWalletWithdrawFeeBPS,
|
||||
"gross_usd": formatPointUSD(grossPoints, pointWalletPointsPerUSD),
|
||||
"fee_usd": formatPointUSD(feePoints, pointWalletPointsPerUSD),
|
||||
"net_usd": formatPointUSD(netPoints, pointWalletPointsPerUSD),
|
||||
"balance_after": pointBalanceFromProto(freezeResp.GetBalance()),
|
||||
"points_per_usd": config.GetPointsPerUsd(),
|
||||
"fee_bps": config.GetFeeBps(),
|
||||
"policy_instance_code": config.GetPolicyInstanceCode(),
|
||||
"gross_usd": formatPointUSD(grossPoints, config.GetPointsPerUsd()),
|
||||
"fee_usd": formatPointUSD(feePoints, config.GetPointsPerUsd()),
|
||||
"net_usd": formatPointUSD(netPoints, config.GetPointsPerUsd()),
|
||||
"balance_after": pointBalanceFromProto(freezeResp.GetBalance(), config.GetPointsPerUsd()),
|
||||
"withdraw_method": application.WithdrawMethod,
|
||||
"withdraw_address": application.WithdrawAddress,
|
||||
"freeze_transaction_id": freezeResp.GetTransactionId(),
|
||||
@ -252,11 +355,17 @@ func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http.
|
||||
})
|
||||
}
|
||||
|
||||
func isHuwaaPointWalletRequest(request *http.Request) bool {
|
||||
return strings.EqualFold(appcode.FromContext(request.Context()), pointWalletWithdrawalScope)
|
||||
func isPointWalletRequest(request *http.Request) bool {
|
||||
// 新钱包首先服务 Huwaa 与 Fami;Lalu 旧 gonghui 页面和既有工资钱包路由不做行为变更。
|
||||
switch strings.ToLower(strings.TrimSpace(appcode.FromContext(request.Context()))) {
|
||||
case "huwaa", "fami":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func pointBalanceFromProto(balance *walletv1.AssetBalance) pointWalletBalanceData {
|
||||
func pointBalanceFromProto(balance *walletv1.AssetBalance, pointsPerUSD int64) pointWalletBalanceData {
|
||||
if balance == nil {
|
||||
return pointWalletBalanceData{AssetType: pointWalletAssetType, DisplayUSD: "0.00"}
|
||||
}
|
||||
@ -265,7 +374,7 @@ func pointBalanceFromProto(balance *walletv1.AssetBalance) pointWalletBalanceDat
|
||||
AvailableAmount: balance.GetAvailableAmount(),
|
||||
FrozenAmount: balance.GetFrozenAmount(),
|
||||
Version: balance.GetVersion(),
|
||||
DisplayUSD: formatPointUSD(balance.GetAvailableAmount(), pointWalletPointsPerUSD),
|
||||
DisplayUSD: formatPointUSD(balance.GetAvailableAmount(), pointsPerUSD),
|
||||
}
|
||||
}
|
||||
|
||||
@ -288,7 +397,7 @@ func pointWithdrawalFreezeCommandID(submittedCommandID string, requestID string,
|
||||
return fmt.Sprintf("point-withdrawal:%d", nowMS)
|
||||
}
|
||||
|
||||
func rollbackPointWithdrawalFreeze(request *http.Request, walletClient pointWithdrawalGatewayClient, freezeCommandID string, userID int64, grossPoints int64, feePoints int64, netPoints int64) {
|
||||
func rollbackPointWithdrawalFreeze(request *http.Request, walletClient pointWithdrawalGatewayClient, freezeCommandID string, userID int64, grossPoints int64, feePoints int64, netPoints int64, pointsPerUSD int64, feeBPS int32) {
|
||||
rollbackCommandID := strings.TrimSpace(freezeCommandID) + ":rollback"
|
||||
if len(rollbackCommandID) > 128 {
|
||||
rollbackCommandID = fmt.Sprintf("point-withdrawal-rollback:%s", httpkit.RequestIDFromContext(request.Context()))
|
||||
@ -302,8 +411,8 @@ func rollbackPointWithdrawalFreeze(request *http.Request, walletClient pointWith
|
||||
GrossPointAmount: grossPoints,
|
||||
FeePointAmount: feePoints,
|
||||
NetPointAmount: netPoints,
|
||||
PointsPerUsd: pointWalletPointsPerUSD,
|
||||
FeeBps: pointWalletWithdrawFeeBPS,
|
||||
PointsPerUsd: pointsPerUSD,
|
||||
FeeBps: feeBPS,
|
||||
Reason: "point withdrawal application create failed",
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
})
|
||||
@ -311,3 +420,119 @@ func rollbackPointWithdrawalFreeze(request *http.Request, walletClient pointWith
|
||||
slog.Error("point_withdrawal_freeze_rollback_failed", "user_id", userID, "gross_point_amount", grossPoints, "freeze_command_id", freezeCommandID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// transferPointWalletToCoinSeller 只接收目标和 POINT 金额;wallet 事务会重新读取 active 白名单与比例。
|
||||
func (h *Handler) transferPointWalletToCoinSeller(writer http.ResponseWriter, request *http.Request) {
|
||||
if !isPointWalletRequest(request) {
|
||||
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
|
||||
return
|
||||
}
|
||||
pointWallet, ok := h.walletClient.(pointWithdrawalGatewayClient)
|
||||
if !ok || h.userProfileClient == nil || h.userHostClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
var body pointWalletCoinSellerTransferBody
|
||||
if !httpkit.Decode(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
commandID := body.commandID()
|
||||
sellerUserID := body.sellerUserID()
|
||||
pointAmount := body.amount()
|
||||
if commandID == "" || sellerUserID <= 0 || pointAmount <= 0 {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
userID := auth.UserIDFromContext(request.Context())
|
||||
profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{Meta: httpkit.UserMeta(request, ""), UserId: userID})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
if profileResp.GetUser() == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
runtimeConfig, err := pointWallet.GetPointWithdrawalConfig(request.Context(), &walletv1.GetPointWithdrawalConfigRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
|
||||
RegionId: profileResp.GetUser().GetRegionId(), NowMs: time.Now().UTC().UnixMilli(),
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
if !runtimeConfig.GetFound() || pointAmount < runtimeConfig.GetMinimumPoints() {
|
||||
// 币商和 USDT 两种提现共用已发布政策中的最低门槛,客户端不能通过切换页签绕过。
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "point withdrawal amount is below minimum")
|
||||
return
|
||||
}
|
||||
sellerProfileResp, err := h.userHostClient.GetCoinSellerProfile(request.Context(), &userv1.GetCoinSellerProfileRequest{
|
||||
Meta: httpkit.UserMeta(request, ""), UserId: sellerUserID,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
sellerProfile := sellerProfileResp.GetCoinSellerProfile()
|
||||
if sellerProfile == nil || sellerProfile.GetStatus() != "active" || sellerProfile.GetMerchantAssetType() != "COIN_SELLER_COIN" {
|
||||
// Admin 白名单只证明配置存在;转账时仍要检查实时币商身份,防止币商被停用后继续收款。
|
||||
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "coin seller is not active")
|
||||
return
|
||||
}
|
||||
receipt, err := pointWallet.TransferPointToCoinSeller(request.Context(), &walletv1.TransferPointToCoinSellerRequest{
|
||||
CommandId: commandID, AppCode: appcode.FromContext(request.Context()), SourceUserId: userID,
|
||||
SellerUserId: sellerUserID, PointAmount: pointAmount,
|
||||
SourceCountryCode: pointWalletCountryCode(profileResp.GetUser()), Reason: "point wallet withdrawal to coin seller",
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"transaction_id": receipt.GetTransactionId(), "seller_user_id": strconv.FormatInt(sellerUserID, 10),
|
||||
"point_amount": receipt.GetPointAmount(), "seller_coin_amount": receipt.GetSellerCoinAmount(),
|
||||
"source_point_balance_after": receipt.GetSourcePointBalanceAfter(), "seller_balance_after": receipt.GetSellerBalanceAfter(),
|
||||
"ratio_point_amount": receipt.GetRatioPointAmount(), "ratio_seller_coin_amount": receipt.GetRatioSellerCoinAmount(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) pointWalletCoinSellerData(request *http.Request, configs []*walletv1.PointWithdrawalCoinSellerConfig) ([]pointWalletCoinSellerData, error) {
|
||||
if len(configs) == 0 {
|
||||
return []pointWalletCoinSellerData{}, nil
|
||||
}
|
||||
userIDs := make([]int64, 0, len(configs))
|
||||
for _, config := range configs {
|
||||
if config != nil && config.GetSellerUserId() > 0 {
|
||||
userIDs = append(userIDs, config.GetSellerUserId())
|
||||
}
|
||||
}
|
||||
profiles, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{Meta: httpkit.UserMeta(request, ""), UserIds: userIDs})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]pointWalletCoinSellerData, 0, len(configs))
|
||||
for _, config := range configs {
|
||||
if config == nil {
|
||||
continue
|
||||
}
|
||||
profile := profiles.GetUsers()[config.GetSellerUserId()]
|
||||
// 白名单可在用户删除/禁用后短暂残留;读模型跳过缺失用户,转账入口还会实时校验 coin-seller 身份。
|
||||
if profile == nil || profile.GetStatus() != userv1.UserStatus_USER_STATUS_ACTIVE {
|
||||
continue
|
||||
}
|
||||
items = append(items, pointWalletCoinSellerData{
|
||||
UserID: strconv.FormatInt(profile.GetUserId(), 10), DisplayUserID: profile.GetDisplayUserId(),
|
||||
Nickname: profile.GetUsername(), Avatar: profile.GetAvatar(), SortOrder: config.GetSortOrder(),
|
||||
PointAmount: config.GetPointAmount(), SellerCoinAmount: config.GetSellerCoinAmount(),
|
||||
ServiceCountryCodes: append([]string(nil), config.GetServiceCountryCodes()...),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func pointWalletCountryCode(profile *userv1.User) string {
|
||||
if profile == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(strings.TrimSpace(profile.GetCountry()))
|
||||
}
|
||||
|
||||
@ -91,6 +91,11 @@ func TestWithdrawPointWalletFreezesWithClientCommandIDAndCreatesApplication(t *t
|
||||
writer.last.SalaryAssetType != "POINT" ||
|
||||
writer.last.WithdrawAmount != "9.50" ||
|
||||
writer.last.WithdrawAmountMinor != 1_000_000 ||
|
||||
writer.last.PointFeeAmount != 50_000 ||
|
||||
writer.last.PointNetAmount != 950_000 ||
|
||||
writer.last.PointsPerUSD != 100_000 ||
|
||||
writer.last.PointFeeBPS != 500 ||
|
||||
writer.last.PointPolicyInstance != "test-policy" ||
|
||||
writer.last.FreezeCommandID != "client-point-withdraw-1" ||
|
||||
writer.last.FreezeTransactionID != "point-freeze-tx" ||
|
||||
writer.last.WithdrawAddress != "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF" {
|
||||
@ -112,13 +117,34 @@ func TestWithdrawPointWalletFreezesWithClientCommandIDAndCreatesApplication(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransferPointWalletToCoinSellerUsesStringIDAndTrustedCountry(t *testing.T) {
|
||||
wallet := &fakePointWalletClient{}
|
||||
handler := New(Config{
|
||||
WalletClient: wallet,
|
||||
UserProfileClient: &fakePointUserProfileClient{country: "SA"},
|
||||
UserHostClient: &fakePointUserHostClient{},
|
||||
})
|
||||
recorder := httptest.NewRecorder()
|
||||
request := pointWalletRequest(`{"command_id":"point-seller-1","seller_user_id":"9007199254740001","point_amount":1000000}`, "fami")
|
||||
|
||||
httpkit.WithRequestID(http.HandlerFunc(handler.transferPointWalletToCoinSeller)).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("point seller transfer status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if wallet.lastPointTransfer == nil || wallet.lastPointTransfer.GetSellerUserId() != 9007199254740001 || wallet.lastPointTransfer.GetSourceUserId() != 42001 || wallet.lastPointTransfer.GetSourceCountryCode() != "SA" || wallet.lastPointTransfer.GetAppCode() != "fami" {
|
||||
t.Fatalf("point seller transfer request mismatch: %+v", wallet.lastPointTransfer)
|
||||
}
|
||||
}
|
||||
|
||||
type fakePointWalletClient struct {
|
||||
client.WalletClient
|
||||
balances []*walletv1.AssetBalance
|
||||
lastBalance *walletv1.GetBalancesRequest
|
||||
lastFreeze *walletv1.FreezePointWithdrawalRequest
|
||||
lastRelease *walletv1.ReleasePointWithdrawalRequest
|
||||
freezeErr error
|
||||
balances []*walletv1.AssetBalance
|
||||
lastBalance *walletv1.GetBalancesRequest
|
||||
lastFreeze *walletv1.FreezePointWithdrawalRequest
|
||||
lastRelease *walletv1.ReleasePointWithdrawalRequest
|
||||
lastPointTransfer *walletv1.TransferPointToCoinSellerRequest
|
||||
freezeErr error
|
||||
}
|
||||
|
||||
func (f *fakePointWalletClient) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
|
||||
@ -151,9 +177,23 @@ func (f *fakePointWalletClient) ReleasePointWithdrawal(_ context.Context, req *w
|
||||
return &walletv1.ReleasePointWithdrawalResponse{TransactionId: "point-release-tx"}, nil
|
||||
}
|
||||
|
||||
func (f *fakePointWalletClient) GetPointWithdrawalConfig(_ context.Context, _ *walletv1.GetPointWithdrawalConfigRequest) (*walletv1.GetPointWithdrawalConfigResponse, error) {
|
||||
return &walletv1.GetPointWithdrawalConfigResponse{Found: true, PointsPerUsd: 100_000, FeeBps: 500, MinimumPoints: 1_000_000, PolicyInstanceCode: "test-policy"}, nil
|
||||
}
|
||||
|
||||
func (f *fakePointWalletClient) ListPointWithdrawalCoinSellers(_ context.Context, _ *walletv1.ListPointWithdrawalCoinSellersRequest) (*walletv1.ListPointWithdrawalCoinSellersResponse, error) {
|
||||
return &walletv1.ListPointWithdrawalCoinSellersResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakePointWalletClient) TransferPointToCoinSeller(_ context.Context, req *walletv1.TransferPointToCoinSellerRequest) (*walletv1.TransferPointToCoinSellerResponse, error) {
|
||||
f.lastPointTransfer = req
|
||||
return &walletv1.TransferPointToCoinSellerResponse{TransactionId: "point-seller-tx", PointAmount: req.GetPointAmount(), SellerCoinAmount: 920000}, nil
|
||||
}
|
||||
|
||||
type fakePointUserProfileClient struct {
|
||||
client.UserProfileClient
|
||||
address string
|
||||
country string
|
||||
err error
|
||||
}
|
||||
|
||||
@ -161,7 +201,15 @@ func (f *fakePointUserProfileClient) GetUser(_ context.Context, req *userv1.GetU
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return &userv1.GetUserResponse{User: &userv1.User{UserId: req.GetUserId(), WithdrawUsdtTrc20Address: f.address}}, nil
|
||||
return &userv1.GetUserResponse{User: &userv1.User{UserId: req.GetUserId(), Country: f.country, WithdrawUsdtTrc20Address: f.address}}, nil
|
||||
}
|
||||
|
||||
type fakePointUserHostClient struct {
|
||||
client.UserHostClient
|
||||
}
|
||||
|
||||
func (f *fakePointUserHostClient) GetCoinSellerProfile(_ context.Context, req *userv1.GetCoinSellerProfileRequest) (*userv1.GetCoinSellerProfileResponse, error) {
|
||||
return &userv1.GetCoinSellerProfileResponse{CoinSellerProfile: &userv1.CoinSellerProfile{UserId: req.GetUserId(), Status: "active", MerchantAssetType: "COIN_SELLER_COIN"}}, nil
|
||||
}
|
||||
|
||||
func (f *fakePointUserProfileClient) UpdateUserWithdrawAddress(_ context.Context, req *userv1.UpdateUserWithdrawAddressRequest) (*userv1.UpdateUserWithdrawAddressResponse, error) {
|
||||
@ -190,6 +238,11 @@ func (f *fakePointWithdrawalWriter) CreateApplication(_ context.Context, command
|
||||
SalaryAssetType: command.SalaryAssetType,
|
||||
WithdrawAmount: command.WithdrawAmount,
|
||||
WithdrawAmountMinor: command.WithdrawAmountMinor,
|
||||
PointFeeAmount: command.PointFeeAmount,
|
||||
PointNetAmount: command.PointNetAmount,
|
||||
PointsPerUSD: command.PointsPerUSD,
|
||||
PointFeeBPS: command.PointFeeBPS,
|
||||
PointPolicyInstance: command.PointPolicyInstance,
|
||||
WithdrawMethod: command.WithdrawMethod,
|
||||
WithdrawAddress: command.WithdrawAddress,
|
||||
FreezeCommandID: command.FreezeCommandID,
|
||||
|
||||
@ -127,6 +127,28 @@ CREATE TABLE IF NOT EXISTS user_follows (
|
||||
KEY idx_user_follows_follower_status (app_code, follower_user_id, status, followed_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户关注表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_follow_events (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
event_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '关注状态变化事件 ID',
|
||||
follower_user_id BIGINT NOT NULL COMMENT '关注人用户 ID',
|
||||
followee_user_id BIGINT NOT NULL COMMENT '被关注人用户 ID',
|
||||
delta TINYINT NOT NULL COMMENT '关注 +1,取消关注 -1',
|
||||
occurred_at_ms BIGINT NOT NULL COMMENT '事件时间,UTC epoch ms',
|
||||
PRIMARY KEY (event_id),
|
||||
KEY idx_user_follow_events_followee_time (app_code, followee_user_id, occurred_at_ms, event_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户关注净增统计事件表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_private_message_events (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
event_id VARCHAR(128) NOT NULL COMMENT '腾讯 IM 私信事件幂等 ID',
|
||||
sender_user_id BIGINT NOT NULL COMMENT '消息发送用户 ID',
|
||||
target_user_id BIGINT NOT NULL COMMENT '消息接收用户 ID',
|
||||
occurred_at_ms BIGINT NOT NULL COMMENT '发送时间,UTC epoch ms',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '落库时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, event_id),
|
||||
KEY idx_private_message_target_time (app_code, target_user_id, occurred_at_ms, sender_user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户私信发送事实,仅保存统计字段';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_friend_applications (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
requester_user_id BIGINT NOT NULL COMMENT '申请人用户 ID',
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
USE hyapp_user;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_follow_events (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
event_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '关注状态变化事件 ID',
|
||||
follower_user_id BIGINT NOT NULL COMMENT '关注人用户 ID',
|
||||
followee_user_id BIGINT NOT NULL COMMENT '被关注人用户 ID',
|
||||
delta TINYINT NOT NULL COMMENT '关注 +1,取消关注 -1',
|
||||
occurred_at_ms BIGINT NOT NULL COMMENT '事件时间,UTC epoch ms',
|
||||
PRIMARY KEY (event_id),
|
||||
KEY idx_user_follow_events_followee_time (app_code, followee_user_id, occurred_at_ms, event_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户关注净增统计事件表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_private_message_events (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
event_id VARCHAR(128) NOT NULL COMMENT '腾讯 IM 私信事件幂等 ID',
|
||||
sender_user_id BIGINT NOT NULL COMMENT '消息发送用户 ID',
|
||||
target_user_id BIGINT NOT NULL COMMENT '消息接收用户 ID',
|
||||
occurred_at_ms BIGINT NOT NULL COMMENT '发送时间,UTC epoch ms',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '落库时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, event_id),
|
||||
KEY idx_private_message_target_time (app_code, target_user_id, occurred_at_ms, sender_user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户私信发送事实,仅保存统计字段';
|
||||
25
services/user-service/internal/domain/host/stats.go
Normal file
25
services/user-service/internal/domain/host/stats.go
Normal file
@ -0,0 +1,25 @@
|
||||
package host
|
||||
|
||||
// EngagementStats 聚合 user-service 拥有的 App 在线、有效麦时、私信发送者和粉丝净增事实。
|
||||
type EngagementStats struct {
|
||||
OnlineDurationMS int64
|
||||
ValidMicDurationMS int64
|
||||
ValidMicDays int64
|
||||
PrivateMessageSenders int64
|
||||
NewFollowers int64
|
||||
}
|
||||
|
||||
// EngagementStatsQuery 使用左闭右开 UTC 毫秒区间,避免跨页/跨日查询重复统计边界事件。
|
||||
type EngagementStatsQuery struct {
|
||||
HostUserID int64
|
||||
StartAtMS int64
|
||||
EndAtMS int64
|
||||
}
|
||||
|
||||
// PrivateMessageEvent 是腾讯 IM 已发送私信的最小可审计事实,不保存消息正文。
|
||||
type PrivateMessageEvent struct {
|
||||
EventID string
|
||||
SenderUserID int64
|
||||
TargetUserID int64
|
||||
OccurredAtMS int64
|
||||
}
|
||||
@ -57,6 +57,8 @@ type Repository interface {
|
||||
HasActiveManagerCapability(ctx context.Context, userID int64, capability string) (bool, error)
|
||||
ListAgencyMembers(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyMembership, error)
|
||||
ListAgencyApplications(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyApplication, error)
|
||||
GetHostEngagementStats(ctx context.Context, query hostdomain.EngagementStatsQuery) (hostdomain.EngagementStats, error)
|
||||
RecordPrivateMessageEvent(ctx context.Context, event hostdomain.PrivateMessageEvent) (bool, error)
|
||||
}
|
||||
|
||||
// IDGenerator 生成 Host 领域内部事实 ID。
|
||||
|
||||
38
services/user-service/internal/service/host/stats.go
Normal file
38
services/user-service/internal/service/host/stats.go
Normal file
@ -0,0 +1,38 @@
|
||||
package host
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/xerr"
|
||||
hostdomain "hyapp/services/user-service/internal/domain/host"
|
||||
)
|
||||
|
||||
const maxHostStatsRange = 366 * 24 * time.Hour
|
||||
|
||||
// GetHostEngagementStats 读取 user-service 拥有的统计事实;收益和送礼人数由 wallet-service 聚合。
|
||||
func (s *Service) GetHostEngagementStats(ctx context.Context, query hostdomain.EngagementStatsQuery) (hostdomain.EngagementStats, error) {
|
||||
if s.repository == nil {
|
||||
return hostdomain.EngagementStats{}, xerr.New(xerr.Unavailable, "host repository is not configured")
|
||||
}
|
||||
if query.HostUserID <= 0 || query.StartAtMS <= 0 || query.EndAtMS <= query.StartAtMS {
|
||||
return hostdomain.EngagementStats{}, xerr.New(xerr.InvalidArgument, "host stats range is invalid")
|
||||
}
|
||||
if query.EndAtMS-query.StartAtMS > int64(maxHostStatsRange/time.Millisecond) {
|
||||
return hostdomain.EngagementStats{}, xerr.New(xerr.InvalidArgument, "host stats range is too large")
|
||||
}
|
||||
return s.repository.GetHostEngagementStats(ctx, query)
|
||||
}
|
||||
|
||||
// RecordPrivateMessageEvent 只保存发送者、接收者和事件时间;消息正文不进入统计库。
|
||||
func (s *Service) RecordPrivateMessageEvent(ctx context.Context, event hostdomain.PrivateMessageEvent) (bool, error) {
|
||||
if s.repository == nil {
|
||||
return false, xerr.New(xerr.Unavailable, "host repository is not configured")
|
||||
}
|
||||
event.EventID = strings.TrimSpace(event.EventID)
|
||||
if event.EventID == "" || len(event.EventID) > 128 || event.SenderUserID <= 0 || event.TargetUserID <= 0 || event.OccurredAtMS <= 0 {
|
||||
return false, xerr.New(xerr.InvalidArgument, "private message event is invalid")
|
||||
}
|
||||
return s.repository.RecordPrivateMessageEvent(ctx, event)
|
||||
}
|
||||
144
services/user-service/internal/storage/mysql/host/stats.go
Normal file
144
services/user-service/internal/storage/mysql/host/stats.go
Normal file
@ -0,0 +1,144 @@
|
||||
package host
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
hostdomain "hyapp/services/user-service/internal/domain/host"
|
||||
)
|
||||
|
||||
const validMicDayThresholdMS = int64(time.Hour / time.Millisecond)
|
||||
|
||||
// GetHostEngagementStats 聚合 user-service 自己拥有的事实表,不从当前 Agency 关系反推历史归属。
|
||||
func (r *Repository) GetHostEngagementStats(ctx context.Context, query hostdomain.EngagementStatsQuery) (hostdomain.EngagementStats, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return hostdomain.EngagementStats{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
stats := hostdomain.EngagementStats{}
|
||||
appCode := appcode.FromContext(ctx)
|
||||
startDate := time.UnixMilli(query.StartAtMS).UTC().Format("2006-01-02")
|
||||
endDate := time.UnixMilli(query.EndAtMS - 1).UTC().Format("2006-01-02")
|
||||
|
||||
if err := r.db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(SUM(mic_online_ms), 0),
|
||||
COALESCE(SUM(CASE WHEN mic_online_ms >= ? THEN 1 ELSE 0 END), 0)
|
||||
FROM user_mic_daily_stats
|
||||
WHERE app_code = ? AND user_id = ? AND stat_date BETWEEN ? AND ?`,
|
||||
validMicDayThresholdMS, appCode, query.HostUserID, startDate, endDate,
|
||||
).Scan(&stats.ValidMicDurationMS, &stats.ValidMicDays); err != nil {
|
||||
return hostdomain.EngagementStats{}, err
|
||||
}
|
||||
|
||||
if err := r.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(DISTINCT sender_user_id)
|
||||
FROM user_private_message_events
|
||||
WHERE app_code = ? AND target_user_id = ? AND occurred_at_ms >= ? AND occurred_at_ms < ?`,
|
||||
appCode, query.HostUserID, query.StartAtMS, query.EndAtMS,
|
||||
).Scan(&stats.PrivateMessageSenders); err != nil {
|
||||
return hostdomain.EngagementStats{}, err
|
||||
}
|
||||
|
||||
if err := r.db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(SUM(delta), 0)
|
||||
FROM user_follow_events
|
||||
WHERE app_code = ? AND followee_user_id = ? AND occurred_at_ms >= ? AND occurred_at_ms < ?`,
|
||||
appCode, query.HostUserID, query.StartAtMS, query.EndAtMS,
|
||||
).Scan(&stats.NewFollowers); err != nil {
|
||||
return hostdomain.EngagementStats{}, err
|
||||
}
|
||||
|
||||
onlineMS, err := r.hostOnlineDuration(ctx, query)
|
||||
if err != nil {
|
||||
return hostdomain.EngagementStats{}, err
|
||||
}
|
||||
stats.OnlineDurationMS = onlineMS
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// hostOnlineDuration 按每天“最早会话开始到最后一次心跳”累计;多设备/多 session 不重复相加。
|
||||
func (r *Repository) hostOnlineDuration(ctx context.Context, query hostdomain.EngagementStatsQuery) (int64, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT created_at_ms, last_heartbeat_at_ms
|
||||
FROM auth_sessions
|
||||
WHERE app_code = ? AND user_id = ?
|
||||
AND last_heartbeat_at_ms >= ? AND created_at_ms < ?`,
|
||||
appcode.FromContext(ctx), query.HostUserID, query.StartAtMS, query.EndAtMS,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type daySpan struct{ first, last int64 }
|
||||
spans := map[string]daySpan{}
|
||||
for rows.Next() {
|
||||
var startedAtMS, heartbeatAtMS int64
|
||||
if err := rows.Scan(&startedAtMS, &heartbeatAtMS); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
start := maxInt64(startedAtMS, query.StartAtMS)
|
||||
end := minInt64(heartbeatAtMS, query.EndAtMS-1)
|
||||
if end < start {
|
||||
continue
|
||||
}
|
||||
for day := utcDayStart(start); day <= utcDayStart(end); day += int64(24 * time.Hour / time.Millisecond) {
|
||||
segmentStart := maxInt64(start, day)
|
||||
segmentEnd := minInt64(end, day+int64(24*time.Hour/time.Millisecond)-1)
|
||||
key := time.UnixMilli(day).UTC().Format("2006-01-02")
|
||||
span, exists := spans[key]
|
||||
if !exists || segmentStart < span.first {
|
||||
span.first = segmentStart
|
||||
}
|
||||
if !exists || segmentEnd > span.last {
|
||||
span.last = segmentEnd
|
||||
}
|
||||
spans[key] = span
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var total int64
|
||||
for _, span := range spans {
|
||||
if span.last >= span.first {
|
||||
total += span.last - span.first
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// RecordPrivateMessageEvent 用 app_code + event_id 幂等,不保存消息正文或扩展负载。
|
||||
func (r *Repository) RecordPrivateMessageEvent(ctx context.Context, event hostdomain.PrivateMessageEvent) (bool, error) {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
INSERT IGNORE INTO user_private_message_events (
|
||||
app_code, event_id, sender_user_id, target_user_id, occurred_at_ms, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
appcode.FromContext(ctx), event.EventID, event.SenderUserID, event.TargetUserID, event.OccurredAtMS, time.Now().UTC().UnixMilli(),
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
return rows > 0, err
|
||||
}
|
||||
|
||||
func utcDayStart(value int64) int64 {
|
||||
t := time.UnixMilli(value).UTC()
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC).UnixMilli()
|
||||
}
|
||||
|
||||
func minInt64(left, right int64) int64 {
|
||||
if left < right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
func maxInt64(left, right int64) int64 {
|
||||
if left > right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
package host
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"hyapp/pkg/appcode"
|
||||
hostdomain "hyapp/services/user-service/internal/domain/host"
|
||||
)
|
||||
|
||||
func TestGetHostEngagementStatsAggregatesOwnedFacts(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
mock.ExpectQuery("FROM user_mic_daily_stats").
|
||||
WithArgs(validMicDayThresholdMS, "fami", int64(42), "2026-07-06", "2026-07-12").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"mic_ms", "valid_days"}).AddRow(int64(5400000), int64(1)))
|
||||
mock.ExpectQuery("FROM user_private_message_events").
|
||||
WithArgs("fami", int64(42), int64(1783296000000), int64(1783900800000)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"senders"}).AddRow(int64(6)))
|
||||
mock.ExpectQuery("FROM user_follow_events").
|
||||
WithArgs("fami", int64(42), int64(1783296000000), int64(1783900800000)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"followers"}).AddRow(int64(4)))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`
|
||||
SELECT created_at_ms, last_heartbeat_at_ms
|
||||
FROM auth_sessions
|
||||
WHERE app_code = ? AND user_id = ?
|
||||
AND last_heartbeat_at_ms >= ? AND created_at_ms < ?`)).
|
||||
WithArgs("fami", int64(42), int64(1783296000000), int64(1783900800000)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"created_at_ms", "last_heartbeat_at_ms"}).
|
||||
AddRow(int64(1783299600000), int64(1783303200000)).
|
||||
AddRow(int64(1783301400000), int64(1783306800000)))
|
||||
|
||||
repo := New(db)
|
||||
stats, err := repo.GetHostEngagementStats(appcode.WithContext(context.Background(), "fami"), hostdomain.EngagementStatsQuery{
|
||||
HostUserID: 42,
|
||||
StartAtMS: 1783296000000,
|
||||
EndAtMS: 1783900800000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetHostEngagementStats failed: %v", err)
|
||||
}
|
||||
// 两个重叠 session 按当天最早开始到最晚心跳合并,不能相加成重复在线时长。
|
||||
if stats.OnlineDurationMS != 7200000 || stats.ValidMicDurationMS != 5400000 || stats.ValidMicDays != 1 || stats.PrivateMessageSenders != 6 || stats.NewFollowers != 4 {
|
||||
t.Fatalf("unexpected engagement stats: %+v", stats)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet SQL expectations: %v", err)
|
||||
}
|
||||
}
|
||||
@ -136,6 +136,15 @@ func (r *Repository) FollowUser(ctx context.Context, followerUserID int64, follo
|
||||
}
|
||||
}
|
||||
if shouldIncrement {
|
||||
// 新增粉丝按事件净增统计;状态未变化的重复关注不写事件,避免客户端重试重复 +1。
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO user_follow_events (
|
||||
app_code, follower_user_id, followee_user_id, delta, occurred_at_ms
|
||||
) VALUES (?, ?, ?, 1, ?)`,
|
||||
appCode, followerUserID, followeeUserID, nowMs,
|
||||
); err != nil {
|
||||
return userdomain.ProfileStats{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE user_profile_stats
|
||||
SET following_count = following_count + 1, updated_at_ms = ?
|
||||
@ -176,6 +185,15 @@ func (r *Repository) UnfollowUser(ctx context.Context, followerUserID int64, fol
|
||||
); err != nil {
|
||||
return userdomain.ProfileStats{}, err
|
||||
}
|
||||
// 取消关注写 -1,确保筛选区间内的新增粉丝展示为净增而不是只累计关注动作。
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO user_follow_events (
|
||||
app_code, follower_user_id, followee_user_id, delta, occurred_at_ms
|
||||
) VALUES (?, ?, ?, -1, ?)`,
|
||||
appCode, followerUserID, followeeUserID, nowMs,
|
||||
); err != nil {
|
||||
return userdomain.ProfileStats{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE user_profile_stats
|
||||
SET following_count = GREATEST(following_count - 1, 0), updated_at_ms = ?
|
||||
|
||||
50
services/user-service/internal/transport/grpc/host_stats.go
Normal file
50
services/user-service/internal/transport/grpc/host_stats.go
Normal file
@ -0,0 +1,50 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
"hyapp/pkg/xerr"
|
||||
hostdomain "hyapp/services/user-service/internal/domain/host"
|
||||
)
|
||||
|
||||
// GetHostEngagementStats 返回 user-service 拥有的主播互动统计;调用方不能提交聚合值。
|
||||
func (s *Server) GetHostEngagementStats(ctx context.Context, req *userv1.GetHostEngagementStatsRequest) (*userv1.GetHostEngagementStatsResponse, error) {
|
||||
if s.hostSvc == nil {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))
|
||||
}
|
||||
ctx = contextWithApp(ctx, req.GetMeta())
|
||||
stats, err := s.hostSvc.GetHostEngagementStats(ctx, hostdomain.EngagementStatsQuery{
|
||||
HostUserID: req.GetHostUserId(),
|
||||
StartAtMS: req.GetStartAtMs(),
|
||||
EndAtMS: req.GetEndAtMs(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &userv1.GetHostEngagementStatsResponse{Stats: &userv1.HostEngagementStats{
|
||||
OnlineDurationMs: stats.OnlineDurationMS,
|
||||
ValidMicDurationMs: stats.ValidMicDurationMS,
|
||||
ValidMicDays: stats.ValidMicDays,
|
||||
PrivateMessageSenders: stats.PrivateMessageSenders,
|
||||
NewFollowers: stats.NewFollowers,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// RecordPrivateMessageEvent 只接受 gateway 已鉴权回调产生的最小事件事实。
|
||||
func (s *Server) RecordPrivateMessageEvent(ctx context.Context, req *userv1.RecordPrivateMessageEventRequest) (*userv1.RecordPrivateMessageEventResponse, error) {
|
||||
if s.hostSvc == nil {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))
|
||||
}
|
||||
ctx = contextWithApp(ctx, req.GetMeta())
|
||||
created, err := s.hostSvc.RecordPrivateMessageEvent(ctx, hostdomain.PrivateMessageEvent{
|
||||
EventID: req.GetEventId(),
|
||||
SenderUserID: req.GetSenderUserId(),
|
||||
TargetUserID: req.GetTargetUserId(),
|
||||
OccurredAtMS: req.GetOccurredAtMs(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &userv1.RecordPrivateMessageEventResponse{Created: created}, nil
|
||||
}
|
||||
@ -131,6 +131,7 @@ CREATE TABLE IF NOT EXISTS wallet_policy_instances (
|
||||
effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '0 表示长期有效',
|
||||
host_point_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT' COMMENT '主播收益积分资产',
|
||||
host_point_ratio_ppm BIGINT NOT NULL DEFAULT 700000 COMMENT '有效付费礼物转 POINT 比例,ppm',
|
||||
agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '有效付费礼物给 Agency owner 的 POINT 分成比例,ppm',
|
||||
points_per_usd BIGINT NOT NULL DEFAULT 100000 COMMENT 'POINT/USD 展示换算比例',
|
||||
withdraw_fee_bps INT NOT NULL DEFAULT 500 COMMENT '提现手续费 bps',
|
||||
rule_json JSON NOT NULL COMMENT '完整政策快照',
|
||||
@ -142,6 +143,41 @@ CREATE TABLE IF NOT EXISTS wallet_policy_instances (
|
||||
KEY idx_wallet_policy_active (app_code, region_id, status, effective_from_ms, effective_to_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包运行侧收益政策实例表';
|
||||
|
||||
-- 三套 App 共享同一张配置表,但 app_code 是主键的一部分;新 App 只需配置数据,不再复制表和业务代码。
|
||||
-- 未配置任何 active 行时 H5 币商提现入口显示为空,不能用伪造默认币商兜底。
|
||||
CREATE TABLE IF NOT EXISTS point_withdrawal_coin_seller_configs (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
|
||||
seller_user_id BIGINT NOT NULL COMMENT '币商用户 ID',
|
||||
sort_order INT NOT NULL DEFAULT 0 COMMENT '展示顺序,越小越靠前',
|
||||
point_amount BIGINT NOT NULL COMMENT '兑换比例中的 POINT 分母',
|
||||
seller_coin_amount BIGINT NOT NULL COMMENT '兑换比例中的币商金币分子',
|
||||
service_country_codes JSON NOT NULL COMMENT '服务国家码数组;空数组表示不限国家',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT 'active/disabled',
|
||||
created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, seller_user_id),
|
||||
KEY idx_point_withdraw_seller_list (app_code, status, sort_order, seller_user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='POINT 提现币商白名单与兑换比例';
|
||||
|
||||
-- Agency 分成独立流水同时承担归属快照和审计依据;不能按当前成员关系反推历史收益。
|
||||
CREATE TABLE IF NOT EXISTS agency_point_share_entries (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
transaction_id VARCHAR(96) NOT NULL COMMENT '礼物钱包交易 ID',
|
||||
host_user_id BIGINT NOT NULL COMMENT '收礼主播用户 ID 快照',
|
||||
agency_owner_user_id BIGINT NOT NULL COMMENT '送礼时 Agency owner 用户 ID 快照',
|
||||
sender_user_id BIGINT NOT NULL COMMENT '送礼用户 ID',
|
||||
room_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '房间 ID,私聊送礼为空',
|
||||
point_delta BIGINT NOT NULL COMMENT 'Agency owner 本次 POINT 分成',
|
||||
ratio_ppm BIGINT NOT NULL COMMENT 'Agency 分成比例快照',
|
||||
policy_instance_code VARCHAR(96) NOT NULL DEFAULT '' COMMENT '收益策略实例快照',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, transaction_id, agency_owner_user_id),
|
||||
KEY idx_agency_point_share_owner_time (app_code, agency_owner_user_id, created_at_ms),
|
||||
KEY idx_agency_point_share_host_time (app_code, host_user_id, created_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Agency POINT 礼物分成流水';
|
||||
|
||||
-- 主播工资周期钻石账户:只记录政策结算用累计值,不作为用户可消费或可提现的钱包资产。
|
||||
-- cycle_key 当前固定为 UTC yyyy-MM;后续日结/半月结不清空该账户,月末清算任务按策略处理。
|
||||
CREATE TABLE IF NOT EXISTS host_period_diamond_accounts (
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
package ledger
|
||||
|
||||
type AgencyHostGiftStats struct {
|
||||
GiftIncome int64
|
||||
GiftedHostCount int64
|
||||
}
|
||||
|
||||
type AgencyHostGiftStatsQuery struct {
|
||||
HostUserIDs []int64
|
||||
StartAtMS int64
|
||||
EndAtMS int64
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package ledger
|
||||
|
||||
// AgencyPointShareStats 聚合送礼时已固化归属的 Agency POINT 分成。
|
||||
type AgencyPointShareStats struct {
|
||||
ShareIncome int64
|
||||
GiftedHostCount int64
|
||||
}
|
||||
|
||||
type AgencyPointShareStatsQuery struct {
|
||||
AgencyOwnerUserID int64
|
||||
StartAtMS int64
|
||||
EndAtMS int64
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package ledger
|
||||
|
||||
// HostRevenueStats 是主播中心按日期展示的 POINT 收益、已兑换数量和去重送礼人数。
|
||||
type HostRevenueStats struct {
|
||||
DiamondEarnings int64
|
||||
DiamondExchanged int64
|
||||
GiftSenders int64
|
||||
}
|
||||
|
||||
// HostRevenueStatsQuery 使用左闭右开 UTC 毫秒区间;调用方必须限制区间长度。
|
||||
type HostRevenueStatsQuery struct {
|
||||
HostUserID int64
|
||||
StartAtMS int64
|
||||
EndAtMS int64
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
package ledger
|
||||
|
||||
// PointWithdrawalCoinSellerConfig 是 POINT 提现可选币商及其服务范围、兑换比例的运行快照。
|
||||
type PointWithdrawalCoinSellerConfig struct {
|
||||
AppCode string
|
||||
SellerUserID int64
|
||||
SortOrder int
|
||||
PointAmount int64
|
||||
SellerCoinAmount int64
|
||||
ServiceCountryCodes []string
|
||||
Status string
|
||||
CreatedAtMS int64
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
// PointToCoinSellerCommand 只接受用户、目标和金额;比例必须由 wallet 数据库的 active 白名单解析。
|
||||
type PointToCoinSellerCommand struct {
|
||||
AppCode string
|
||||
CommandID string
|
||||
SourceUserID int64
|
||||
SellerUserID int64
|
||||
PointAmount int64
|
||||
SourceCountryCode string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// PointToCoinSellerReceipt 是 POINT 扣款和币商库存入账的双边稳定回执。
|
||||
type PointToCoinSellerReceipt struct {
|
||||
TransactionID string
|
||||
SourceUserID int64
|
||||
SellerUserID int64
|
||||
SourcePointBalanceAfter int64
|
||||
SellerBalanceAfter int64
|
||||
PointAmount int64
|
||||
SellerCoinAmount int64
|
||||
RatioPointAmount int64
|
||||
RatioSellerCoinAmount int64
|
||||
CreatedAtMS int64
|
||||
}
|
||||
|
||||
// PointWithdrawalRuntimeConfig 是已发布政策中钱包页需要展示并用于冻结的参数。
|
||||
type PointWithdrawalRuntimeConfig struct {
|
||||
Found bool
|
||||
PointsPerUSD int64
|
||||
FeeBPS int64
|
||||
MinimumPoints int64
|
||||
PolicyInstanceCode string
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||
)
|
||||
|
||||
const maxAgencyHostGiftStatsRange = 366 * 24 * time.Hour
|
||||
|
||||
func (s *Service) GetAgencyHostGiftStats(ctx context.Context, appCode string, query ledger.AgencyHostGiftStatsQuery) (ledger.AgencyHostGiftStats, error) {
|
||||
if s.repository == nil {
|
||||
return ledger.AgencyHostGiftStats{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
||||
}
|
||||
if len(query.HostUserIDs) > 1000 || query.StartAtMS <= 0 || query.EndAtMS <= query.StartAtMS {
|
||||
return ledger.AgencyHostGiftStats{}, xerr.New(xerr.InvalidArgument, "agency host gift stats range is invalid")
|
||||
}
|
||||
for _, userID := range query.HostUserIDs {
|
||||
if userID <= 0 {
|
||||
return ledger.AgencyHostGiftStats{}, xerr.New(xerr.InvalidArgument, "host_user_ids is invalid")
|
||||
}
|
||||
}
|
||||
if query.EndAtMS-query.StartAtMS > int64(maxAgencyHostGiftStatsRange/time.Millisecond) {
|
||||
return ledger.AgencyHostGiftStats{}, xerr.New(xerr.InvalidArgument, "agency host gift stats range is too large")
|
||||
}
|
||||
ctx = appcode.WithContext(ctx, appcode.Normalize(appCode))
|
||||
return s.repository.GetAgencyHostGiftStats(ctx, query)
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||
)
|
||||
|
||||
const maxAgencyPointShareStatsRange = 366 * 24 * time.Hour
|
||||
|
||||
func (s *Service) GetAgencyPointShareStats(ctx context.Context, appCode string, query ledger.AgencyPointShareStatsQuery) (ledger.AgencyPointShareStats, error) {
|
||||
if s.repository == nil {
|
||||
return ledger.AgencyPointShareStats{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
||||
}
|
||||
if query.AgencyOwnerUserID <= 0 || query.StartAtMS <= 0 || query.EndAtMS <= query.StartAtMS {
|
||||
return ledger.AgencyPointShareStats{}, xerr.New(xerr.InvalidArgument, "agency point share stats range is invalid")
|
||||
}
|
||||
if query.EndAtMS-query.StartAtMS > int64(maxAgencyPointShareStatsRange/time.Millisecond) {
|
||||
return ledger.AgencyPointShareStats{}, xerr.New(xerr.InvalidArgument, "agency point share stats range is too large")
|
||||
}
|
||||
ctx = appcode.WithContext(ctx, appcode.Normalize(appCode))
|
||||
return s.repository.GetAgencyPointShareStats(ctx, query)
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||
)
|
||||
|
||||
const maxHostRevenueStatsRange = 366 * 24 * time.Hour
|
||||
|
||||
// GetHostRevenueStats 聚合钱包 owner 内的 POINT 收益/兑换和去重送礼人,不让 gateway 扫账本。
|
||||
func (s *Service) GetHostRevenueStats(ctx context.Context, appCode string, query ledger.HostRevenueStatsQuery) (ledger.HostRevenueStats, error) {
|
||||
if s.repository == nil {
|
||||
return ledger.HostRevenueStats{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
||||
}
|
||||
if query.HostUserID <= 0 || query.StartAtMS <= 0 || query.EndAtMS <= query.StartAtMS {
|
||||
return ledger.HostRevenueStats{}, xerr.New(xerr.InvalidArgument, "host revenue stats range is invalid")
|
||||
}
|
||||
if query.EndAtMS-query.StartAtMS > int64(maxHostRevenueStatsRange/time.Millisecond) {
|
||||
return ledger.HostRevenueStats{}, xerr.New(xerr.InvalidArgument, "host revenue stats range is too large")
|
||||
}
|
||||
ctx = appcode.WithContext(ctx, appcode.Normalize(appCode))
|
||||
return s.repository.GetHostRevenueStats(ctx, query)
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||
)
|
||||
|
||||
// ListPointWithdrawalCoinSellers 返回服务端过滤后的币商白名单;国家由可信用户资料提供而不是客户端表单。
|
||||
func (s *Service) ListPointWithdrawalCoinSellers(ctx context.Context, appCode string, countryCode string, includeDisabled bool) ([]ledger.PointWithdrawalCoinSellerConfig, error) {
|
||||
if strings.TrimSpace(appCode) == "" {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "app_code is required")
|
||||
}
|
||||
if s.repository == nil {
|
||||
return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
||||
}
|
||||
appCode = appcode.Normalize(appCode)
|
||||
return s.repository.ListPointWithdrawalCoinSellers(appcode.WithContext(ctx, appCode), appCode, strings.ToUpper(strings.TrimSpace(countryCode)), includeDisabled)
|
||||
}
|
||||
|
||||
// TransferPointToCoinSeller 验证客户端允许提交的最小字段;兑换比例由 repository 事务内锁定配置解析。
|
||||
func (s *Service) TransferPointToCoinSeller(ctx context.Context, command ledger.PointToCoinSellerCommand) (ledger.PointToCoinSellerReceipt, error) {
|
||||
if strings.TrimSpace(command.AppCode) == "" || strings.TrimSpace(command.CommandID) == "" || command.SourceUserID <= 0 || command.SellerUserID <= 0 || command.PointAmount <= 0 {
|
||||
return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "point coin seller transfer command is incomplete")
|
||||
}
|
||||
command.AppCode = appcode.Normalize(command.AppCode)
|
||||
command.CommandID = strings.TrimSpace(command.CommandID)
|
||||
command.SourceCountryCode = strings.ToUpper(strings.TrimSpace(command.SourceCountryCode))
|
||||
command.Reason = strings.TrimSpace(command.Reason)
|
||||
if len(command.CommandID) > 128 || len(command.SourceCountryCode) > 16 || len(command.Reason) > 512 {
|
||||
return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "point coin seller transfer text fields are too long")
|
||||
}
|
||||
if s.repository == nil {
|
||||
return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
||||
}
|
||||
return s.repository.TransferPointToCoinSeller(appcode.WithContext(ctx, command.AppCode), command)
|
||||
}
|
||||
|
||||
// GetPointWithdrawalConfig 读取已发布政策快照;nowMS 由服务端补齐,避免客户端选择历史政策。
|
||||
func (s *Service) GetPointWithdrawalConfig(ctx context.Context, appCode string, regionID int64, nowMS int64) (ledger.PointWithdrawalRuntimeConfig, error) {
|
||||
if strings.TrimSpace(appCode) == "" || regionID < 0 {
|
||||
return ledger.PointWithdrawalRuntimeConfig{}, xerr.New(xerr.InvalidArgument, "point withdrawal config query is invalid")
|
||||
}
|
||||
if s.repository == nil {
|
||||
return ledger.PointWithdrawalRuntimeConfig{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
||||
}
|
||||
if nowMS <= 0 {
|
||||
nowMS = time.Now().UnixMilli()
|
||||
}
|
||||
appCode = appcode.Normalize(appCode)
|
||||
return s.repository.GetPointWithdrawalConfig(appcode.WithContext(ctx, appCode), appCode, regionID, nowMS)
|
||||
}
|
||||
@ -10,12 +10,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPointWithdrawalFeeBPS int64 = 500
|
||||
defaultPointWithdrawalPointsPerUSD int64 = 100000
|
||||
minPointWithdrawalGrossPoints int64 = 1000000
|
||||
)
|
||||
|
||||
// FreezePointWithdrawal 把 Huwaa 收益积分从 available 冻结到 frozen;它复用钱包单边冻结账本,但不开放旧工资资产。
|
||||
// FreezePointWithdrawal 把已启用 App 的收益积分从 available 冻结到 frozen;gateway 必须先读取已发布政策快照。
|
||||
func (s *Service) FreezePointWithdrawal(ctx context.Context, command ledger.PointWithdrawalCommand) (ledger.PointWithdrawalReceipt, error) {
|
||||
if err := normalizePointWithdrawalCommand(&command, false); err != nil {
|
||||
return ledger.PointWithdrawalReceipt{}, err
|
||||
@ -83,9 +82,13 @@ func normalizePointWithdrawalCommand(command *ledger.PointWithdrawalCommand, req
|
||||
return xerr.New(xerr.InvalidArgument, "operator_user_id is required")
|
||||
}
|
||||
command.CommandID = strings.TrimSpace(command.CommandID)
|
||||
if strings.TrimSpace(command.AppCode) == "" {
|
||||
return xerr.New(xerr.InvalidArgument, "app_code is required")
|
||||
}
|
||||
command.AppCode = appcode.Normalize(command.AppCode)
|
||||
if command.AppCode != "huwaa" {
|
||||
return xerr.New(xerr.InvalidArgument, "point withdrawal is only enabled for huwaa")
|
||||
if command.AppCode != "huwaa" && command.AppCode != "fami" {
|
||||
// Lalu 继续使用旧公会工资提现链路;只有显式迁移到 POINT 钱包的 App 才能调用本接口。
|
||||
return xerr.New(xerr.InvalidArgument, "point withdrawal is not enabled for app")
|
||||
}
|
||||
command.AssetType = strings.ToUpper(strings.TrimSpace(command.AssetType))
|
||||
if !ledger.ValidPointWithdrawalAssetType(command.AssetType) {
|
||||
@ -97,9 +100,6 @@ func normalizePointWithdrawalCommand(command *ledger.PointWithdrawalCommand, req
|
||||
if command.PointsPerUSD <= 0 {
|
||||
command.PointsPerUSD = defaultPointWithdrawalPointsPerUSD
|
||||
}
|
||||
if command.FeeBPS <= 0 {
|
||||
command.FeeBPS = defaultPointWithdrawalFeeBPS
|
||||
}
|
||||
if command.FeeBPS < 0 || command.FeeBPS > 10000 {
|
||||
return xerr.New(xerr.InvalidArgument, "point withdrawal fee_bps is invalid")
|
||||
}
|
||||
|
||||
@ -55,6 +55,9 @@ type HostSalaryStore interface {
|
||||
GetActiveHostSalaryPolicy(ctx context.Context, regionID int64, settlementMode string, triggerMode string, nowMs int64) (ledger.HostSalaryPolicy, bool, error)
|
||||
GetHostSalaryProgress(ctx context.Context, userID int64, cycleKey string) (ledger.HostSalaryProgress, error)
|
||||
GetTeamHostSalaryStats(ctx context.Context, query ledger.TeamHostSalaryStatsQuery) ([]ledger.TeamHostSalaryCycleStat, error)
|
||||
GetHostRevenueStats(ctx context.Context, query ledger.HostRevenueStatsQuery) (ledger.HostRevenueStats, error)
|
||||
GetAgencyPointShareStats(ctx context.Context, query ledger.AgencyPointShareStatsQuery) (ledger.AgencyPointShareStats, error)
|
||||
GetAgencyHostGiftStats(ctx context.Context, query ledger.AgencyHostGiftStatsQuery) (ledger.AgencyHostGiftStats, error)
|
||||
ProcessHostSalarySettlementBatch(ctx context.Context, command ledger.HostSalarySettlementBatchCommand) (ledger.HostSalarySettlementBatchResult, error)
|
||||
}
|
||||
|
||||
@ -66,6 +69,9 @@ type CoinSellerStore interface {
|
||||
ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, appCode string, regionID int64, includeDisabled bool) ([]ledger.CoinSellerSalaryExchangeRateTier, error)
|
||||
ExchangeSalaryToCoin(ctx context.Context, command ledger.SalaryExchangeCommand) (ledger.SalaryExchangeReceipt, error)
|
||||
TransferSalaryToCoinSeller(ctx context.Context, command ledger.SalaryTransferToCoinSellerCommand) (ledger.SalaryTransferToCoinSellerReceipt, error)
|
||||
ListPointWithdrawalCoinSellers(ctx context.Context, appCode string, countryCode string, includeDisabled bool) ([]ledger.PointWithdrawalCoinSellerConfig, error)
|
||||
TransferPointToCoinSeller(ctx context.Context, command ledger.PointToCoinSellerCommand) (ledger.PointToCoinSellerReceipt, error)
|
||||
GetPointWithdrawalConfig(ctx context.Context, appCode string, regionID int64, nowMS int64) (ledger.PointWithdrawalRuntimeConfig, error)
|
||||
FreezeSalaryWithdrawal(ctx context.Context, command ledger.SalaryWithdrawalCommand) (ledger.SalaryWithdrawalReceipt, error)
|
||||
SettleSalaryWithdrawal(ctx context.Context, command ledger.SalaryWithdrawalCommand) (ledger.SalaryWithdrawalReceipt, error)
|
||||
ReleaseSalaryWithdrawal(ctx context.Context, command ledger.SalaryWithdrawalCommand) (ledger.SalaryWithdrawalReceipt, error)
|
||||
|
||||
@ -168,6 +168,44 @@ func TestHuwaaHostGiftPolicyCreditsPointWithoutCoinIncomeAndAllowsSelfGift(t *te
|
||||
}
|
||||
}
|
||||
|
||||
func TestFamiHostGiftPolicyCreditsAgencyShareWithHistoricalOwnershipSnapshot(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
repository.SetBalanceForApp("fami", 32001, 1000)
|
||||
repository.SetGiftPriceForApp("fami", "fami-rose", "v1", 100, 0, 100)
|
||||
repository.SeedWalletPolicyInstanceWithAgency("fami", "fami-guild-live", 0, "active", 700000, 200000)
|
||||
svc := walletservice.New(repository)
|
||||
|
||||
receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
|
||||
AppCode: "fami", CommandID: "cmd-fami-agency-share", RoomID: "room-fami",
|
||||
SenderUserID: 32001, TargetUserID: 32002, TargetIsHost: true, TargetHostRegionID: 12,
|
||||
TargetAgencyOwnerUserID: 32003, GiftID: "fami-rose", GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DebitGift with Fami agency policy failed: %v", err)
|
||||
}
|
||||
if receipt.HostPointAdded != 70 || receipt.HostPointBalanceAfter != 70 {
|
||||
t.Fatalf("Fami host point receipt mismatch: %+v", receipt)
|
||||
}
|
||||
assertBalanceForApp(t, svc, "fami", 32002, ledger.AssetPoint, 70)
|
||||
assertBalanceForApp(t, svc, "fami", 32003, ledger.AssetPoint, 20)
|
||||
if got := repository.CountRows("agency_point_share_entries", "transaction_id = ? AND host_user_id = ? AND agency_owner_user_id = ? AND point_delta = ? AND ratio_ppm = ?", receipt.TransactionID, int64(32002), int64(32003), int64(20), int64(200000)); got != 1 {
|
||||
t.Fatalf("Fami agency share snapshot mismatch, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "AgencyPointCredited"); got != 1 {
|
||||
t.Fatalf("Fami agency share must publish one event, got %d", got)
|
||||
}
|
||||
|
||||
replayed, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
|
||||
AppCode: "fami", CommandID: "cmd-fami-agency-share", RoomID: "room-fami",
|
||||
SenderUserID: 32001, TargetUserID: 32002, TargetIsHost: true, TargetHostRegionID: 12,
|
||||
TargetAgencyOwnerUserID: 32003, GiftID: "fami-rose", GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12,
|
||||
})
|
||||
if err != nil || replayed.TransactionID != receipt.TransactionID {
|
||||
t.Fatalf("Fami agency share replay mismatch: receipt=%+v err=%v", replayed, err)
|
||||
}
|
||||
assertBalanceForApp(t, svc, "fami", 32003, ledger.AssetPoint, 20)
|
||||
}
|
||||
|
||||
func TestHostGiftWithoutActiveHuwaaPolicyKeepsLegacyCoinIncome(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
repository.SetBalance(31011, 1000)
|
||||
@ -2147,6 +2185,7 @@ func TestPointWithdrawalAppGateWithoutMySQL(t *testing.T) {
|
||||
UserID: 23012,
|
||||
AssetType: ledger.AssetPoint,
|
||||
GrossPointAmount: 1_000_000,
|
||||
FeeBPS: 500,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Huwaa POINT withdrawal should reach repository: %v", err)
|
||||
@ -2163,6 +2202,21 @@ func TestPointWithdrawalAppGateWithoutMySQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFamiPointWithdrawalAllowsAdminConfiguredZeroFee(t *testing.T) {
|
||||
repository := &fakePointGateRepository{}
|
||||
svc := walletservice.New(repository)
|
||||
receipt, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{
|
||||
AppCode: "fami", CommandID: "cmd-fami-zero-fee", UserID: 23013,
|
||||
AssetType: ledger.AssetPoint, GrossPointAmount: 1_000_000, PointsPerUSD: 200_000, FeeBPS: 0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("zero-fee Fami point withdrawal failed: %v", err)
|
||||
}
|
||||
if receipt.FeePointAmount != 0 || receipt.NetPointAmount != 1_000_000 || receipt.PointsPerUSD != 200_000 || receipt.FeeBPS != 0 {
|
||||
t.Fatalf("zero-fee policy snapshot mismatch: %+v", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPointWithdrawalFreezeSettleReleaseIsHuwaaOnly(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
repository.SetAssetBalanceForApp("huwaa", 22001, ledger.AssetPoint, 2_000_000)
|
||||
@ -2187,6 +2241,7 @@ func TestPointWithdrawalFreezeSettleReleaseIsHuwaaOnly(t *testing.T) {
|
||||
UserID: 22001,
|
||||
AssetType: ledger.AssetPoint,
|
||||
GrossPointAmount: 1_000_000,
|
||||
FeeBPS: 500,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FreezePointWithdrawal failed: %v", err)
|
||||
@ -2202,6 +2257,7 @@ func TestPointWithdrawalFreezeSettleReleaseIsHuwaaOnly(t *testing.T) {
|
||||
UserID: 22001,
|
||||
AssetType: ledger.AssetPoint,
|
||||
GrossPointAmount: 1_000_000,
|
||||
FeeBPS: 500,
|
||||
OperatorUserID: 90001,
|
||||
WithdrawalApplicationID: "77",
|
||||
})
|
||||
@ -2219,6 +2275,7 @@ func TestPointWithdrawalFreezeSettleReleaseIsHuwaaOnly(t *testing.T) {
|
||||
UserID: 22002,
|
||||
AssetType: ledger.AssetPoint,
|
||||
GrossPointAmount: 1_000_000,
|
||||
FeeBPS: 500,
|
||||
}); err != nil {
|
||||
t.Fatalf("FreezePointWithdrawal for release failed: %v", err)
|
||||
}
|
||||
@ -2228,6 +2285,7 @@ func TestPointWithdrawalFreezeSettleReleaseIsHuwaaOnly(t *testing.T) {
|
||||
UserID: 22002,
|
||||
AssetType: ledger.AssetPoint,
|
||||
GrossPointAmount: 1_000_000,
|
||||
FeeBPS: 500,
|
||||
WithdrawalApplicationID: "78",
|
||||
})
|
||||
if err != nil {
|
||||
@ -2239,6 +2297,52 @@ func TestPointWithdrawalFreezeSettleReleaseIsHuwaaOnly(t *testing.T) {
|
||||
requireWalletBalanceForApp(t, svc, "huwaa", 22002, ledger.AssetPoint, 2_000_000, 0)
|
||||
}
|
||||
|
||||
// TestTransferPointToCoinSellerUsesServerWhitelist 验证 H5 不能自报比例,wallet 会按 App、国家和 active 配置原子扣 POINT/加币商库存。
|
||||
func TestTransferPointToCoinSellerUsesServerWhitelist(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
repository.SetAssetBalanceForApp("fami", 22501, ledger.AssetPoint, 2_000_000)
|
||||
repository.SetPointWithdrawalCoinSellerConfig("fami", 22502, 100_000, 92_000, []string{"SA"}, "active", 1)
|
||||
svc := walletservice.New(repository)
|
||||
|
||||
sellers, err := svc.ListPointWithdrawalCoinSellers(context.Background(), "fami", "SA", false)
|
||||
if err != nil || len(sellers) != 1 || sellers[0].SellerUserID != 22502 {
|
||||
t.Fatalf("eligible point withdrawal sellers mismatch: sellers=%+v err=%v", sellers, err)
|
||||
}
|
||||
if blocked, err := svc.ListPointWithdrawalCoinSellers(context.Background(), "fami", "TR", false); err != nil || len(blocked) != 0 {
|
||||
t.Fatalf("country-filtered seller must be hidden: sellers=%+v err=%v", blocked, err)
|
||||
}
|
||||
|
||||
command := ledger.PointToCoinSellerCommand{
|
||||
AppCode: "fami", CommandID: "cmd-fami-point-seller-1", SourceUserID: 22501,
|
||||
SellerUserID: 22502, PointAmount: 1_000_000, SourceCountryCode: "SA", Reason: "withdraw to seller",
|
||||
}
|
||||
receipt, err := svc.TransferPointToCoinSeller(context.Background(), command)
|
||||
if err != nil {
|
||||
t.Fatalf("TransferPointToCoinSeller failed: %v", err)
|
||||
}
|
||||
if receipt.SellerCoinAmount != 920_000 || receipt.RatioPointAmount != 100_000 || receipt.RatioSellerCoinAmount != 92_000 || receipt.SourcePointBalanceAfter != 1_000_000 || receipt.SellerBalanceAfter != 920_000 {
|
||||
t.Fatalf("point seller transfer receipt mismatch: %+v", receipt)
|
||||
}
|
||||
replayed, err := svc.TransferPointToCoinSeller(context.Background(), command)
|
||||
if err != nil || replayed.TransactionID != receipt.TransactionID || replayed.SellerCoinAmount != receipt.SellerCoinAmount {
|
||||
t.Fatalf("point seller transfer replay mismatch: replay=%+v err=%v", replayed, err)
|
||||
}
|
||||
requireWalletBalanceForApp(t, svc, "fami", 22501, ledger.AssetPoint, 1_000_000, 0)
|
||||
requireWalletBalanceForApp(t, svc, "fami", 22502, ledger.AssetCoinSellerCoin, 920_000, 0)
|
||||
if got := repository.CountRows("wallet_transactions", "app_code = ? AND biz_type = ?", "fami", "point_transfer_to_coin_seller"); got != 1 {
|
||||
t.Fatalf("idempotent point seller transfer must write one transaction, got %d", got)
|
||||
}
|
||||
|
||||
_, err = svc.TransferPointToCoinSeller(context.Background(), ledger.PointToCoinSellerCommand{
|
||||
AppCode: "fami", CommandID: "cmd-fami-point-seller-country-denied", SourceUserID: 22501,
|
||||
SellerUserID: 22502, PointAmount: 100_000, SourceCountryCode: "TR",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("seller country restriction must reject transfer")
|
||||
}
|
||||
requireWalletBalanceForApp(t, svc, "fami", 22501, ledger.AssetPoint, 1_000_000, 0)
|
||||
}
|
||||
|
||||
// TestCreditLuckyGiftRewardIsIdempotent 验证幸运礼物返奖只按 draw_id 派一次,并产出余额通知事实。
|
||||
func TestCreditLuckyGiftRewardIsIdempotent(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||
)
|
||||
|
||||
// GetAgencyHostGiftStats 直接读取送礼交易里的 host_point_added 快照,只统计礼物收益;
|
||||
// 任务奖励、提现释放和 Agency 分成虽然也进入 POINT 账户,但都不能混入 BD 的公会收礼口径。
|
||||
func (r *Repository) GetAgencyHostGiftStats(ctx context.Context, query ledger.AgencyHostGiftStatsQuery) (ledger.AgencyHostGiftStats, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return ledger.AgencyHostGiftStats{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
if len(query.HostUserIDs) == 0 {
|
||||
return ledger.AgencyHostGiftStats{}, nil
|
||||
}
|
||||
placeholders := make([]string, len(query.HostUserIDs))
|
||||
args := make([]any, 0, len(query.HostUserIDs)+5)
|
||||
args = append(args, appcode.FromContext(ctx), bizTypeGiftDebit, bizTypeDirectGiftDebit, query.StartAtMS, query.EndAtMS)
|
||||
for index, userID := range query.HostUserIDs {
|
||||
placeholders[index] = "?"
|
||||
args = append(args, userID)
|
||||
}
|
||||
querySQL := fmt.Sprintf(`
|
||||
SELECT
|
||||
COALESCE(SUM(CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.host_point_added')), '0') AS SIGNED)), 0),
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.host_point_added')), '0') AS SIGNED) > 0
|
||||
THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.target_user_id')) AS SIGNED)
|
||||
END)
|
||||
FROM wallet_transactions
|
||||
WHERE app_code = ? AND biz_type IN (?, ?) AND status = 'succeeded'
|
||||
AND created_at_ms >= ? AND created_at_ms < ?
|
||||
AND CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.target_user_id')) AS SIGNED) IN (%s)`, strings.Join(placeholders, ","))
|
||||
var stats ledger.AgencyHostGiftStats
|
||||
err := r.db.QueryRowContext(ctx, querySQL, args...).Scan(&stats.GiftIncome, &stats.GiftedHostCount)
|
||||
return stats, err
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
)
|
||||
|
||||
// insertAgencyPointShare 把“当时属于哪个 Agency”与实际入账金额固化在同一送礼事务内;
|
||||
// 后续主播转会时统计仍按这条快照归属,不能用当前成员关系重算历史收入。
|
||||
func (r *Repository) insertAgencyPointShare(ctx context.Context, tx *sql.Tx, transactionID string, metadata giftMetadata, nowMS int64) error {
|
||||
if metadata.AgencyPointAdded <= 0 || metadata.TargetAgencyOwnerUserID <= 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO agency_point_share_entries (
|
||||
app_code, transaction_id, host_user_id, agency_owner_user_id, sender_user_id,
|
||||
room_id, point_delta, ratio_ppm, policy_instance_code, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
appcode.FromContext(ctx), transactionID, metadata.TargetUserID, metadata.TargetAgencyOwnerUserID,
|
||||
metadata.SenderUserID, metadata.RoomID, metadata.AgencyPointAdded, metadata.AgencyPointRatioPPM,
|
||||
metadata.HostPointPolicyInstanceCode, nowMS,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||
)
|
||||
|
||||
// GetAgencyPointShareStats 按入账时归属快照统计,不受主播之后转会或被移除影响。
|
||||
func (r *Repository) GetAgencyPointShareStats(ctx context.Context, query ledger.AgencyPointShareStatsQuery) (ledger.AgencyPointShareStats, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return ledger.AgencyPointShareStats{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
var stats ledger.AgencyPointShareStats
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(SUM(point_delta), 0), COUNT(DISTINCT host_user_id)
|
||||
FROM agency_point_share_entries
|
||||
WHERE app_code = ? AND agency_owner_user_id = ?
|
||||
AND created_at_ms >= ? AND created_at_ms < ?`,
|
||||
appcode.FromContext(ctx), query.AgencyOwnerUserID, query.StartAtMS, query.EndAtMS,
|
||||
).Scan(&stats.ShareIncome, &stats.GiftedHostCount)
|
||||
return stats, err
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||
)
|
||||
|
||||
func TestGetAgencyPointShareStatsUsesOwnershipSnapshot(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`
|
||||
SELECT COALESCE(SUM(point_delta), 0), COUNT(DISTINCT host_user_id)
|
||||
FROM agency_point_share_entries
|
||||
WHERE app_code = ? AND agency_owner_user_id = ?
|
||||
AND created_at_ms >= ? AND created_at_ms < ?`)).
|
||||
WithArgs("fami", int64(73), int64(1000), int64(2000)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"share_income", "gifted_hosts"}).AddRow(int64(2400), int64(3)))
|
||||
|
||||
repo := &Repository{db: db}
|
||||
stats, err := repo.GetAgencyPointShareStats(appcode.WithContext(context.Background(), "fami"), ledger.AgencyPointShareStatsQuery{
|
||||
AgencyOwnerUserID: 73, StartAtMS: 1000, EndAtMS: 2000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetAgencyPointShareStats failed: %v", err)
|
||||
}
|
||||
if stats.ShareIncome != 2400 || stats.GiftedHostCount != 3 {
|
||||
t.Fatalf("unexpected agency stats: %+v", stats)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet SQL expectations: %v", err)
|
||||
}
|
||||
}
|
||||
@ -19,6 +19,7 @@ const (
|
||||
bizTypeCoinSellerCoinCompensation = "coin_seller_coin_compensation"
|
||||
bizTypeSalaryExchangeToCoin = "salary_exchange_to_coin"
|
||||
bizTypeSalaryTransferToCoinSeller = "salary_transfer_to_coin_seller"
|
||||
bizTypePointTransferToCoinSeller = "point_transfer_to_coin_seller"
|
||||
bizTypeSalaryWithdrawalFreeze = "salary_withdrawal_freeze"
|
||||
bizTypeSalaryWithdrawalSettle = "salary_withdrawal_settle"
|
||||
bizTypeSalaryWithdrawalRelease = "salary_withdrawal_release"
|
||||
|
||||
@ -112,10 +112,12 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
|
||||
}
|
||||
|
||||
type targetHostPointPolicy struct {
|
||||
policy walletPolicyInstance
|
||||
amount int64
|
||||
userID int64
|
||||
active bool
|
||||
policy walletPolicyInstance
|
||||
amount int64
|
||||
userID int64
|
||||
agencyAmount int64
|
||||
agencyOwnerUserID int64
|
||||
active bool
|
||||
}
|
||||
hostPointPolicies := make(map[string]targetHostPointPolicy, len(command.Targets))
|
||||
if chargeSource != giftChargeSourceBag {
|
||||
@ -139,7 +141,21 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
|
||||
if err != nil {
|
||||
return ledger.BatchGiftReceipt{}, err
|
||||
}
|
||||
hostPointPolicies[target.CommandID] = targetHostPointPolicy{policy: policy, amount: amount, userID: target.TargetUserID, active: true}
|
||||
agencyAmount := int64(0)
|
||||
if target.TargetAgencyOwnerUserID > 0 && policy.AgencyPointRatioPPM > 0 {
|
||||
agencyAmount, err = giftDiamondAmount(chargeAmount, policy.AgencyPointRatioPPM)
|
||||
if err != nil {
|
||||
return ledger.BatchGiftReceipt{}, err
|
||||
}
|
||||
}
|
||||
hostPointPolicies[target.CommandID] = targetHostPointPolicy{
|
||||
policy: policy,
|
||||
amount: amount,
|
||||
userID: target.TargetUserID,
|
||||
agencyAmount: agencyAmount,
|
||||
agencyOwnerUserID: target.TargetAgencyOwnerUserID,
|
||||
active: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -170,6 +186,13 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
|
||||
AssetType: ledger.AssetPoint,
|
||||
CreateIfMissing: true,
|
||||
})
|
||||
if hostPointPolicy.agencyAmount > 0 {
|
||||
lockRequests = append(lockRequests, walletAccountLockRequest{
|
||||
UserID: hostPointPolicy.agencyOwnerUserID,
|
||||
AssetType: ledger.AssetPoint,
|
||||
CreateIfMissing: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
accountStates, err := r.lockAccountsOrdered(ctx, tx, lockRequests, nowMs)
|
||||
if err != nil {
|
||||
@ -267,6 +290,18 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
|
||||
}
|
||||
hostPointBalanceAfter = targetPointState.account.AvailableAmount + hostPointPolicy.amount
|
||||
}
|
||||
agencyPointBalanceAfter := int64(0)
|
||||
var agencyPointState *walletAccountState
|
||||
if hostPointPolicy.agencyAmount > 0 {
|
||||
agencyPointState = accountStates[walletAccountLockKey(hostPointPolicy.agencyOwnerUserID, ledger.AssetPoint)]
|
||||
if agencyPointState == nil {
|
||||
return ledger.BatchGiftReceipt{}, xerr.New(xerr.Internal, "agency point account is missing")
|
||||
}
|
||||
agencyPointBalanceAfter = agencyPointState.account.AvailableAmount + hostPointPolicy.agencyAmount
|
||||
if agencyPointState == targetPointState {
|
||||
agencyPointBalanceAfter += hostPointPolicy.amount
|
||||
}
|
||||
}
|
||||
// 批量事务逐目标生成独立账务交易;这里从当前 sender 版本预测本目标完成后的版本,
|
||||
// 下一轮会读取已推进的 senderState,因此最后一条回执自然代表整批最终版本。
|
||||
senderBalanceVersionAfter := giftSenderBalanceVersionAfter(
|
||||
@ -322,6 +357,9 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
|
||||
HostPointTemplateCode: hostPointPolicy.policy.TemplateCode,
|
||||
HostPointTemplateVersion: hostPointPolicy.policy.TemplateVersion,
|
||||
HostPointRatioPPM: hostPointPolicy.policy.HostPointRatioPPM,
|
||||
AgencyPointAdded: hostPointPolicy.agencyAmount,
|
||||
AgencyPointBalanceAfter: agencyPointBalanceAfter,
|
||||
AgencyPointRatioPPM: hostPointPolicy.policy.AgencyPointRatioPPM,
|
||||
PointsPerUSD: hostPointPolicy.policy.PointsPerUSD,
|
||||
RoomID: command.RoomID,
|
||||
RoomRegionID: command.RegionID,
|
||||
@ -404,6 +442,29 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
|
||||
return ledger.BatchGiftReceipt{}, err
|
||||
}
|
||||
}
|
||||
if hostPointPolicy.agencyAmount > 0 {
|
||||
agencyPointAccount, err := r.applyTrackedAccountDelta(ctx, tx, agencyPointState, hostPointPolicy.agencyAmount, 0, nowMs)
|
||||
if err != nil {
|
||||
return ledger.BatchGiftReceipt{}, err
|
||||
}
|
||||
metadata.AgencyPointBalanceAfter = agencyPointAccount.AvailableAmount
|
||||
if err := r.insertEntry(ctx, tx, walletEntry{
|
||||
TransactionID: transactionID,
|
||||
UserID: hostPointPolicy.agencyOwnerUserID,
|
||||
AssetType: ledger.AssetPoint,
|
||||
AvailableDelta: hostPointPolicy.agencyAmount,
|
||||
AvailableAfter: agencyPointAccount.AvailableAmount,
|
||||
FrozenAfter: agencyPointAccount.FrozenAmount,
|
||||
CounterpartyUserID: command.SenderUserID,
|
||||
RoomID: command.RoomID,
|
||||
CreatedAtMS: nowMs,
|
||||
}); err != nil {
|
||||
return ledger.BatchGiftReceipt{}, err
|
||||
}
|
||||
if err := r.insertAgencyPointShare(ctx, tx, transactionID, metadata, nowMs); err != nil {
|
||||
return ledger.BatchGiftReceipt{}, err
|
||||
}
|
||||
}
|
||||
if hostPeriodDiamondAdded > 0 {
|
||||
hostPeriodDiamondAfter, hostPeriodDiamondVersion, err := r.creditHostPeriodDiamonds(ctx, tx, transactionID, target.CommandID, metadata, nowMs)
|
||||
if err != nil {
|
||||
@ -439,6 +500,10 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
|
||||
events = append(events, balanceChangedEvent(transactionID, target.CommandID, target.TargetUserID, ledger.AssetPoint, hostPointPolicy.amount, 0, metadata.HostPointBalanceAfter, targetPointState.account.FrozenAmount, targetPointState.account.Version, metadata, nowMs, bizTypeGiftDebit))
|
||||
events = append(events, hostPointCreditedEvent(transactionID, target.CommandID, metadata, nowMs))
|
||||
}
|
||||
if hostPointPolicy.agencyAmount > 0 {
|
||||
events = append(events, balanceChangedEvent(transactionID, target.CommandID, hostPointPolicy.agencyOwnerUserID, ledger.AssetPoint, hostPointPolicy.agencyAmount, 0, metadata.AgencyPointBalanceAfter, agencyPointState.account.FrozenAmount, agencyPointState.account.Version, metadata, nowMs, bizTypeGiftDebit))
|
||||
events = append(events, agencyPointCreditedEvent(transactionID, target.CommandID, metadata, nowMs))
|
||||
}
|
||||
if hostPeriodDiamondAdded > 0 {
|
||||
events = append(events, hostPeriodDiamondCreditedEvent(transactionID, target.CommandID, metadata, nowMs))
|
||||
}
|
||||
|
||||
@ -90,6 +90,7 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
||||
}
|
||||
}
|
||||
hostPointAdded := int64(0)
|
||||
agencyPointAdded := int64(0)
|
||||
hostPointPolicy := walletPolicyInstance{}
|
||||
hostPointPolicyActive := false
|
||||
hostPointRegionID := command.TargetHostRegionID
|
||||
@ -109,6 +110,13 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
||||
}
|
||||
// 命中新政策后不再给主播走旧 COIN 返还,避免同一笔礼物同时产生 COIN 收礼返还和 POINT 收益。
|
||||
giftIncomeCoinAmount = 0
|
||||
if command.TargetAgencyOwnerUserID > 0 && hostPointPolicy.AgencyPointRatioPPM > 0 {
|
||||
// Agency 分成只跟随服务端注入的送礼时归属快照,客户端不能指定;自送礼同样属于有效付费礼物。
|
||||
agencyPointAdded, err = giftDiamondAmount(chargeAmount, hostPointPolicy.AgencyPointRatioPPM)
|
||||
if err != nil {
|
||||
return ledger.Receipt{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
hostPeriodDiamondAdded := int64(0)
|
||||
@ -137,6 +145,7 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
||||
var senderState *walletAccountState
|
||||
var targetCoinState *walletAccountState
|
||||
var targetPointState *walletAccountState
|
||||
var agencyPointState *walletAccountState
|
||||
if command.RobotGift {
|
||||
|
||||
sender, err := r.lockAccount(ctx, tx, command.SenderUserID, accountAssetType, true, nowMs)
|
||||
@ -164,6 +173,13 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
||||
CreateIfMissing: true,
|
||||
})
|
||||
}
|
||||
if agencyPointAdded > 0 {
|
||||
lockRequests = append(lockRequests, walletAccountLockRequest{
|
||||
UserID: command.TargetAgencyOwnerUserID,
|
||||
AssetType: ledger.AssetPoint,
|
||||
CreateIfMissing: true,
|
||||
})
|
||||
}
|
||||
states, err := r.lockAccountsOrdered(ctx, tx, lockRequests, nowMs)
|
||||
if err != nil {
|
||||
return ledger.Receipt{}, err
|
||||
@ -175,6 +191,9 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
||||
if hostPointAdded > 0 {
|
||||
targetPointState = states[walletAccountLockKey(command.TargetUserID, ledger.AssetPoint)]
|
||||
}
|
||||
if agencyPointAdded > 0 {
|
||||
agencyPointState = states[walletAccountLockKey(command.TargetAgencyOwnerUserID, ledger.AssetPoint)]
|
||||
}
|
||||
}
|
||||
sender := senderState.account
|
||||
robotCoinTopUp := int64(0)
|
||||
@ -209,6 +228,16 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
||||
}
|
||||
hostPointBalanceAfter = targetPointState.account.AvailableAmount + hostPointAdded
|
||||
}
|
||||
agencyPointBalanceAfter := int64(0)
|
||||
if agencyPointAdded > 0 {
|
||||
if agencyPointState == nil {
|
||||
return ledger.Receipt{}, xerr.New(xerr.Internal, "agency point account is missing")
|
||||
}
|
||||
agencyPointBalanceAfter = agencyPointState.account.AvailableAmount + agencyPointAdded
|
||||
if agencyPointState == targetPointState {
|
||||
agencyPointBalanceAfter += hostPointAdded
|
||||
}
|
||||
}
|
||||
transactionID := transactionID(command.AppCode, command.CommandID)
|
||||
// metadata 在账户更新前写入交易表,因此版本必须按本事务即将执行的 sender 分录精确预测;
|
||||
// 事务内实际更新仍由 wallet_accounts.version 乐观锁裁决,失败会整体回滚该预测值。
|
||||
@ -265,6 +294,9 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
||||
HostPointTemplateCode: hostPointPolicy.TemplateCode,
|
||||
HostPointTemplateVersion: hostPointPolicy.TemplateVersion,
|
||||
HostPointRatioPPM: hostPointPolicy.HostPointRatioPPM,
|
||||
AgencyPointAdded: agencyPointAdded,
|
||||
AgencyPointBalanceAfter: agencyPointBalanceAfter,
|
||||
AgencyPointRatioPPM: hostPointPolicy.AgencyPointRatioPPM,
|
||||
PointsPerUSD: hostPointPolicy.PointsPerUSD,
|
||||
RoomID: command.RoomID,
|
||||
RoomRegionID: command.RegionID,
|
||||
@ -378,6 +410,29 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
||||
return ledger.Receipt{}, err
|
||||
}
|
||||
}
|
||||
if agencyPointAdded > 0 {
|
||||
agencyPointAccount, err := r.applyTrackedAccountDelta(ctx, tx, agencyPointState, agencyPointAdded, 0, nowMs)
|
||||
if err != nil {
|
||||
return ledger.Receipt{}, err
|
||||
}
|
||||
metadata.AgencyPointBalanceAfter = agencyPointAccount.AvailableAmount
|
||||
if err := r.insertEntry(ctx, tx, walletEntry{
|
||||
TransactionID: transactionID,
|
||||
UserID: command.TargetAgencyOwnerUserID,
|
||||
AssetType: ledger.AssetPoint,
|
||||
AvailableDelta: agencyPointAdded,
|
||||
AvailableAfter: agencyPointAccount.AvailableAmount,
|
||||
FrozenAfter: agencyPointAccount.FrozenAmount,
|
||||
CounterpartyUserID: command.SenderUserID,
|
||||
RoomID: command.RoomID,
|
||||
CreatedAtMS: nowMs,
|
||||
}); err != nil {
|
||||
return ledger.Receipt{}, err
|
||||
}
|
||||
if err := r.insertAgencyPointShare(ctx, tx, transactionID, metadata, nowMs); err != nil {
|
||||
return ledger.Receipt{}, err
|
||||
}
|
||||
}
|
||||
if hostPeriodDiamondAdded > 0 {
|
||||
|
||||
hostPeriodDiamondAfter, hostPeriodDiamondVersion, err := r.creditHostPeriodDiamonds(ctx, tx, transactionID, command.CommandID, metadata, nowMs)
|
||||
@ -417,6 +472,10 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
||||
events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetPoint, hostPointAdded, 0, metadata.HostPointBalanceAfter, targetPointState.account.FrozenAmount, targetPointState.account.Version, metadata, nowMs, bizType))
|
||||
events = append(events, hostPointCreditedEvent(transactionID, command.CommandID, metadata, nowMs))
|
||||
}
|
||||
if agencyPointAdded > 0 {
|
||||
events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.TargetAgencyOwnerUserID, ledger.AssetPoint, agencyPointAdded, 0, metadata.AgencyPointBalanceAfter, agencyPointState.account.FrozenAmount, agencyPointState.account.Version, metadata, nowMs, bizType))
|
||||
events = append(events, agencyPointCreditedEvent(transactionID, command.CommandID, metadata, nowMs))
|
||||
}
|
||||
}
|
||||
if hostPeriodDiamondAdded > 0 {
|
||||
events = append(events, hostPeriodDiamondCreditedEvent(transactionID, command.CommandID, metadata, nowMs))
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||
)
|
||||
|
||||
// GetHostRevenueStats 聚合 POINT 分录;兑换按 available 侧冻结扣减计数,审核结算不会再次累计。
|
||||
func (r *Repository) GetHostRevenueStats(ctx context.Context, query ledger.HostRevenueStatsQuery) (ledger.HostRevenueStats, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return ledger.HostRevenueStats{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
var stats ledger.HostRevenueStats
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN e.available_delta > 0 AND t.biz_type <> ? THEN e.available_delta ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN e.available_delta < 0 THEN -e.available_delta ELSE 0 END), 0),
|
||||
COUNT(DISTINCT CASE WHEN e.available_delta > 0 AND e.counterparty_user_id > 0 THEN e.counterparty_user_id END)
|
||||
FROM wallet_entries e
|
||||
INNER JOIN wallet_transactions t
|
||||
ON t.app_code = e.app_code AND t.transaction_id = e.transaction_id
|
||||
WHERE e.app_code = ? AND e.user_id = ? AND e.asset_type = ?
|
||||
AND e.created_at_ms >= ? AND e.created_at_ms < ?`,
|
||||
bizTypeSalaryWithdrawalRelease,
|
||||
appcode.FromContext(ctx), query.HostUserID, ledger.AssetPoint, query.StartAtMS, query.EndAtMS,
|
||||
).Scan(&stats.DiamondEarnings, &stats.DiamondExchanged, &stats.GiftSenders)
|
||||
return stats, err
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||
)
|
||||
|
||||
func TestGetHostRevenueStatsAggregatesPointLedger(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN e.available_delta > 0 AND t.biz_type <> ? THEN e.available_delta ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN e.available_delta < 0 THEN -e.available_delta ELSE 0 END), 0),
|
||||
COUNT(DISTINCT CASE WHEN e.available_delta > 0 AND e.counterparty_user_id > 0 THEN e.counterparty_user_id END)
|
||||
FROM wallet_entries e
|
||||
INNER JOIN wallet_transactions t
|
||||
ON t.app_code = e.app_code AND t.transaction_id = e.transaction_id
|
||||
WHERE e.app_code = ? AND e.user_id = ? AND e.asset_type = ?
|
||||
AND e.created_at_ms >= ? AND e.created_at_ms < ?`)).
|
||||
WithArgs(bizTypeSalaryWithdrawalRelease, "fami", int64(42), ledger.AssetPoint, int64(1000), int64(2000)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"earnings", "exchanged", "senders"}).AddRow(int64(8800), int64(1200), int64(7)))
|
||||
|
||||
repo := &Repository{db: db}
|
||||
stats, err := repo.GetHostRevenueStats(appcode.WithContext(context.Background(), "fami"), ledger.HostRevenueStatsQuery{
|
||||
HostUserID: 42,
|
||||
StartAtMS: 1000,
|
||||
EndAtMS: 2000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetHostRevenueStats failed: %v", err)
|
||||
}
|
||||
if stats.DiamondEarnings != 8800 || stats.DiamondExchanged != 1200 || stats.GiftSenders != 7 {
|
||||
t.Fatalf("unexpected host revenue stats: %+v", stats)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet SQL expectations: %v", err)
|
||||
}
|
||||
}
|
||||
@ -56,6 +56,9 @@ type giftMetadata struct {
|
||||
HostPointTemplateCode string `json:"host_point_template_code,omitempty"`
|
||||
HostPointTemplateVersion string `json:"host_point_template_version,omitempty"`
|
||||
HostPointRatioPPM int64 `json:"host_point_ratio_ppm,omitempty"`
|
||||
AgencyPointAdded int64 `json:"agency_point_added,omitempty"`
|
||||
AgencyPointBalanceAfter int64 `json:"agency_point_balance_after,omitempty"`
|
||||
AgencyPointRatioPPM int64 `json:"agency_point_ratio_ppm,omitempty"`
|
||||
PointsPerUSD int64 `json:"points_per_usd,omitempty"`
|
||||
RoomID string `json:"room_id"`
|
||||
RoomRegionID int64 `json:"room_region_id"`
|
||||
|
||||
@ -792,3 +792,37 @@ func hostPointCreditedEvent(transactionID string, commandID string, metadata gif
|
||||
CreatedAtMS: nowMs,
|
||||
}
|
||||
}
|
||||
|
||||
func agencyPointCreditedEvent(transactionID string, commandID string, metadata giftMetadata, nowMs int64) walletOutboxEvent {
|
||||
return walletOutboxEvent{
|
||||
EventID: eventID(transactionID, "AgencyPointCredited", metadata.TargetAgencyOwnerUserID, ledger.AssetPoint),
|
||||
EventType: "AgencyPointCredited",
|
||||
TransactionID: transactionID,
|
||||
CommandID: commandID,
|
||||
UserID: metadata.TargetAgencyOwnerUserID,
|
||||
AssetType: ledger.AssetPoint,
|
||||
AvailableDelta: metadata.AgencyPointAdded,
|
||||
FrozenDelta: 0,
|
||||
Payload: map[string]any{
|
||||
"transaction_id": transactionID,
|
||||
"command_id": commandID,
|
||||
"host_user_id": metadata.TargetUserID,
|
||||
"agency_owner_user_id": metadata.TargetAgencyOwnerUserID,
|
||||
"sender_user_id": metadata.SenderUserID,
|
||||
"room_id": metadata.RoomID,
|
||||
"asset_type": ledger.AssetPoint,
|
||||
"point_delta": metadata.AgencyPointAdded,
|
||||
"point_balance_after": metadata.AgencyPointBalanceAfter,
|
||||
"gift_charge_asset_type": metadata.ChargeAssetType,
|
||||
"gift_charge_amount": metadata.ChargeAmount,
|
||||
"gift_id": metadata.GiftID,
|
||||
"gift_count": metadata.GiftCount,
|
||||
"policy_instance_code": metadata.HostPointPolicyInstanceCode,
|
||||
"policy_template_code": metadata.HostPointTemplateCode,
|
||||
"policy_template_version": metadata.HostPointTemplateVersion,
|
||||
"agency_point_ratio_ppm": metadata.AgencyPointRatioPPM,
|
||||
"created_at_ms": nowMs,
|
||||
},
|
||||
CreatedAtMS: nowMs,
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,268 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||
)
|
||||
|
||||
const pointWithdrawalMinimumPoints int64 = 1_000_000
|
||||
|
||||
type pointToCoinSellerMetadata struct {
|
||||
AppCode string `json:"app_code"`
|
||||
SourceUserID int64 `json:"source_user_id"`
|
||||
SellerUserID int64 `json:"seller_user_id"`
|
||||
SourceCountryCode string `json:"source_country_code"`
|
||||
SourceAssetType string `json:"source_asset_type"`
|
||||
SellerAssetType string `json:"seller_asset_type"`
|
||||
PointAmount int64 `json:"point_amount"`
|
||||
SellerCoinAmount int64 `json:"seller_coin_amount"`
|
||||
RatioPointAmount int64 `json:"ratio_point_amount"`
|
||||
RatioSellerCoinAmount int64 `json:"ratio_seller_coin_amount"`
|
||||
SourcePointBalanceAfter int64 `json:"source_point_balance_after"`
|
||||
SellerBalanceAfter int64 `json:"seller_balance_after"`
|
||||
Reason string `json:"reason"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
}
|
||||
|
||||
// ListPointWithdrawalCoinSellers 返回当前 App 的白名单;国家过滤在 wallet 内执行,gateway 不能绕过服务范围。
|
||||
func (r *Repository) ListPointWithdrawalCoinSellers(ctx context.Context, appCode string, countryCode string, includeDisabled bool) ([]ledger.PointWithdrawalCoinSellerConfig, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
ctx = contextWithCommandApp(ctx, appCode)
|
||||
where := "app_code = ?"
|
||||
args := []any{appcode.FromContext(ctx)}
|
||||
if !includeDisabled {
|
||||
where += " AND status = 'active'"
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT app_code, seller_user_id, sort_order, point_amount, seller_coin_amount,
|
||||
service_country_codes, status, created_at_ms, updated_at_ms
|
||||
FROM point_withdrawal_coin_seller_configs
|
||||
WHERE `+where+`
|
||||
ORDER BY sort_order ASC, seller_user_id ASC`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
|
||||
items := make([]ledger.PointWithdrawalCoinSellerConfig, 0)
|
||||
for rows.Next() {
|
||||
var item ledger.PointWithdrawalCoinSellerConfig
|
||||
var countriesJSON []byte
|
||||
if err := rows.Scan(&item.AppCode, &item.SellerUserID, &item.SortOrder, &item.PointAmount, &item.SellerCoinAmount, &countriesJSON, &item.Status, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(countriesJSON, &item.ServiceCountryCodes); err != nil {
|
||||
return nil, xerr.New(xerr.Internal, "point withdrawal seller country config is invalid")
|
||||
}
|
||||
item.ServiceCountryCodes = normalizeCountryCodes(item.ServiceCountryCodes)
|
||||
if item.PointAmount <= 0 || item.SellerCoinAmount <= 0 {
|
||||
return nil, xerr.New(xerr.Internal, "point withdrawal seller ratio is invalid")
|
||||
}
|
||||
if countryCode != "" && !countryAllowed(item.ServiceCountryCodes, countryCode) {
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// TransferPointToCoinSeller 在一个事务中锁定白名单配置和双方账户;任何比例更新只影响新 command_id。
|
||||
func (r *Repository) TransferPointToCoinSeller(ctx context.Context, command ledger.PointToCoinSellerCommand) (ledger.PointToCoinSellerReceipt, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
ctx = contextWithCommandApp(ctx, command.AppCode)
|
||||
command.AppCode = appcode.FromContext(ctx)
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return ledger.PointToCoinSellerReceipt{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
requestHash := stableHash(fmt.Sprintf("point_transfer_coin_seller|%s|%d|%d|%d|%s|%s",
|
||||
command.AppCode, command.SourceUserID, command.SellerUserID, command.PointAmount,
|
||||
strings.ToUpper(strings.TrimSpace(command.SourceCountryCode)), strings.TrimSpace(command.Reason)))
|
||||
if txRow, exists, lookupErr := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizTypePointTransferToCoinSeller, xerr.IdempotencyConflict); lookupErr != nil || exists {
|
||||
if lookupErr != nil || !exists {
|
||||
return ledger.PointToCoinSellerReceipt{}, lookupErr
|
||||
}
|
||||
return r.receiptForPointToCoinSellerTransaction(ctx, tx, txRow.TransactionID)
|
||||
}
|
||||
|
||||
config, err := r.lockPointWithdrawalCoinSellerConfig(ctx, tx, command.SellerUserID)
|
||||
if err != nil {
|
||||
return ledger.PointToCoinSellerReceipt{}, err
|
||||
}
|
||||
countryCode := strings.ToUpper(strings.TrimSpace(command.SourceCountryCode))
|
||||
if !countryAllowed(config.ServiceCountryCodes, countryCode) {
|
||||
return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.PermissionDenied, "coin seller does not serve user country")
|
||||
}
|
||||
numerator, err := checkedMul(command.PointAmount, config.SellerCoinAmount)
|
||||
if err != nil {
|
||||
return ledger.PointToCoinSellerReceipt{}, err
|
||||
}
|
||||
sellerCoinAmount := numerator / config.PointAmount
|
||||
if sellerCoinAmount <= 0 {
|
||||
return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "point amount is below exchange precision")
|
||||
}
|
||||
|
||||
nowMS := time.Now().UnixMilli()
|
||||
source, err := r.lockAccount(ctx, tx, command.SourceUserID, ledger.AssetPoint, false, nowMS)
|
||||
if err != nil {
|
||||
return ledger.PointToCoinSellerReceipt{}, err
|
||||
}
|
||||
if source.AvailableAmount < command.PointAmount {
|
||||
return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient POINT balance")
|
||||
}
|
||||
seller, err := r.lockAccount(ctx, tx, command.SellerUserID, ledger.AssetCoinSellerCoin, true, nowMS)
|
||||
if err != nil {
|
||||
return ledger.PointToCoinSellerReceipt{}, err
|
||||
}
|
||||
sourceAfter := source.AvailableAmount - command.PointAmount
|
||||
sellerAfter, err := checkedAdd(seller.AvailableAmount, sellerCoinAmount)
|
||||
if err != nil {
|
||||
return ledger.PointToCoinSellerReceipt{}, err
|
||||
}
|
||||
transactionID := transactionID(command.AppCode, command.CommandID)
|
||||
metadata := pointToCoinSellerMetadata{
|
||||
AppCode: command.AppCode, SourceUserID: command.SourceUserID, SellerUserID: command.SellerUserID,
|
||||
SourceCountryCode: countryCode, SourceAssetType: ledger.AssetPoint, SellerAssetType: ledger.AssetCoinSellerCoin,
|
||||
PointAmount: command.PointAmount, SellerCoinAmount: sellerCoinAmount,
|
||||
RatioPointAmount: config.PointAmount, RatioSellerCoinAmount: config.SellerCoinAmount,
|
||||
SourcePointBalanceAfter: sourceAfter, SellerBalanceAfter: sellerAfter, Reason: command.Reason, CreatedAtMS: nowMS,
|
||||
}
|
||||
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypePointTransferToCoinSeller, requestHash, fmt.Sprintf("point_coin_seller:%d:%d", command.SourceUserID, command.SellerUserID), metadata, nowMS); err != nil {
|
||||
return ledger.PointToCoinSellerReceipt{}, err
|
||||
}
|
||||
if err := r.applyAccountDelta(ctx, tx, source, -command.PointAmount, 0, nowMS); err != nil {
|
||||
return ledger.PointToCoinSellerReceipt{}, err
|
||||
}
|
||||
if err := r.insertEntry(ctx, tx, walletEntry{TransactionID: transactionID, UserID: command.SourceUserID, AssetType: ledger.AssetPoint, AvailableDelta: -command.PointAmount, AvailableAfter: sourceAfter, FrozenAfter: source.FrozenAmount, CounterpartyUserID: command.SellerUserID, CreatedAtMS: nowMS}); err != nil {
|
||||
return ledger.PointToCoinSellerReceipt{}, err
|
||||
}
|
||||
if err := r.applyAccountDelta(ctx, tx, seller, sellerCoinAmount, 0, nowMS); err != nil {
|
||||
return ledger.PointToCoinSellerReceipt{}, err
|
||||
}
|
||||
if err := r.insertEntry(ctx, tx, walletEntry{TransactionID: transactionID, UserID: command.SellerUserID, AssetType: ledger.AssetCoinSellerCoin, AvailableDelta: sellerCoinAmount, AvailableAfter: sellerAfter, FrozenAfter: seller.FrozenAmount, CounterpartyUserID: command.SourceUserID, CreatedAtMS: nowMS}); err != nil {
|
||||
return ledger.PointToCoinSellerReceipt{}, err
|
||||
}
|
||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
||||
balanceChangedEvent(transactionID, command.CommandID, command.SourceUserID, ledger.AssetPoint, -command.PointAmount, 0, sourceAfter, source.FrozenAmount, source.Version+1, metadata, nowMS, bizTypePointTransferToCoinSeller),
|
||||
balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, sellerCoinAmount, 0, sellerAfter, seller.FrozenAmount, seller.Version+1, metadata, nowMS, bizTypePointTransferToCoinSeller),
|
||||
}); err != nil {
|
||||
return ledger.PointToCoinSellerReceipt{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ledger.PointToCoinSellerReceipt{}, err
|
||||
}
|
||||
return receiptFromPointToCoinSellerMetadata(transactionID, metadata), nil
|
||||
}
|
||||
|
||||
// GetPointWithdrawalConfig 只读取已发布钱包政策;不存在时显式 found=false,gateway 不回退到猜测参数。
|
||||
func (r *Repository) GetPointWithdrawalConfig(ctx context.Context, appCode string, regionID int64, nowMS int64) (ledger.PointWithdrawalRuntimeConfig, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return ledger.PointWithdrawalRuntimeConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
ctx = contextWithCommandApp(ctx, appCode)
|
||||
tx, err := r.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
|
||||
if err != nil {
|
||||
return ledger.PointWithdrawalRuntimeConfig{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
policy, found, err := r.resolveActiveWalletPolicy(ctx, tx, appcode.FromContext(ctx), regionID, nowMS)
|
||||
if err != nil || !found {
|
||||
return ledger.PointWithdrawalRuntimeConfig{Found: false}, err
|
||||
}
|
||||
return ledger.PointWithdrawalRuntimeConfig{Found: true, PointsPerUSD: policy.PointsPerUSD, FeeBPS: policy.WithdrawFeeBPS, MinimumPoints: pointWithdrawalMinimumPoints, PolicyInstanceCode: policy.InstanceCode}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) lockPointWithdrawalCoinSellerConfig(ctx context.Context, tx *sql.Tx, sellerUserID int64) (ledger.PointWithdrawalCoinSellerConfig, error) {
|
||||
var item ledger.PointWithdrawalCoinSellerConfig
|
||||
var countriesJSON []byte
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT app_code, seller_user_id, sort_order, point_amount, seller_coin_amount,
|
||||
service_country_codes, status, created_at_ms, updated_at_ms
|
||||
FROM point_withdrawal_coin_seller_configs
|
||||
WHERE app_code = ? AND seller_user_id = ? AND status = 'active'
|
||||
FOR UPDATE`, appcode.FromContext(ctx), sellerUserID).Scan(
|
||||
&item.AppCode, &item.SellerUserID, &item.SortOrder, &item.PointAmount, &item.SellerCoinAmount,
|
||||
&countriesJSON, &item.Status, &item.CreatedAtMS, &item.UpdatedAtMS,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return item, xerr.New(xerr.NotFound, "point withdrawal coin seller is not available")
|
||||
}
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
if err := json.Unmarshal(countriesJSON, &item.ServiceCountryCodes); err != nil || item.PointAmount <= 0 || item.SellerCoinAmount <= 0 {
|
||||
return item, xerr.New(xerr.Internal, "point withdrawal coin seller config is invalid")
|
||||
}
|
||||
item.ServiceCountryCodes = normalizeCountryCodes(item.ServiceCountryCodes)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) receiptForPointToCoinSellerTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.PointToCoinSellerReceipt, error) {
|
||||
var metadataJSON string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') FROM wallet_transactions WHERE app_code = ? AND transaction_id = ?`, appcode.FromContext(ctx), transactionID).Scan(&metadataJSON); err != nil {
|
||||
return ledger.PointToCoinSellerReceipt{}, err
|
||||
}
|
||||
var metadata pointToCoinSellerMetadata
|
||||
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
|
||||
return ledger.PointToCoinSellerReceipt{}, err
|
||||
}
|
||||
return receiptFromPointToCoinSellerMetadata(transactionID, metadata), nil
|
||||
}
|
||||
|
||||
func receiptFromPointToCoinSellerMetadata(transactionID string, metadata pointToCoinSellerMetadata) ledger.PointToCoinSellerReceipt {
|
||||
return ledger.PointToCoinSellerReceipt{
|
||||
TransactionID: transactionID, SourceUserID: metadata.SourceUserID, SellerUserID: metadata.SellerUserID,
|
||||
SourcePointBalanceAfter: metadata.SourcePointBalanceAfter, SellerBalanceAfter: metadata.SellerBalanceAfter,
|
||||
PointAmount: metadata.PointAmount, SellerCoinAmount: metadata.SellerCoinAmount,
|
||||
RatioPointAmount: metadata.RatioPointAmount, RatioSellerCoinAmount: metadata.RatioSellerCoinAmount, CreatedAtMS: metadata.CreatedAtMS,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCountryCodes(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func countryAllowed(configured []string, countryCode string) bool {
|
||||
if len(configured) == 0 {
|
||||
return true
|
||||
}
|
||||
countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
|
||||
if countryCode == "" {
|
||||
return false
|
||||
}
|
||||
for _, configuredCode := range configured {
|
||||
if strings.EqualFold(configuredCode, countryCode) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@ -12,14 +12,15 @@ import (
|
||||
)
|
||||
|
||||
type walletPolicyInstance struct {
|
||||
InstanceCode string
|
||||
TemplateCode string
|
||||
TemplateVersion string
|
||||
RegionID int64
|
||||
HostPointAsset string
|
||||
HostPointRatioPPM int64
|
||||
PointsPerUSD int64
|
||||
WithdrawFeeBPS int64
|
||||
InstanceCode string
|
||||
TemplateCode string
|
||||
TemplateVersion string
|
||||
RegionID int64
|
||||
HostPointAsset string
|
||||
HostPointRatioPPM int64
|
||||
AgencyPointRatioPPM int64
|
||||
PointsPerUSD int64
|
||||
WithdrawFeeBPS int64
|
||||
}
|
||||
|
||||
func (r *Repository) resolveActiveWalletPolicy(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, nowMS int64) (walletPolicyInstance, bool, error) {
|
||||
@ -29,7 +30,7 @@ func (r *Repository) resolveActiveWalletPolicy(ctx context.Context, tx *sql.Tx,
|
||||
}
|
||||
row := tx.QueryRowContext(ctx, `
|
||||
SELECT instance_code, template_code, template_version, region_id, host_point_asset_type,
|
||||
host_point_ratio_ppm, points_per_usd, withdraw_fee_bps
|
||||
host_point_ratio_ppm, agency_point_ratio_ppm, points_per_usd, withdraw_fee_bps
|
||||
FROM wallet_policy_instances
|
||||
WHERE app_code = ?
|
||||
AND region_id IN (?, 0)
|
||||
@ -41,7 +42,7 @@ func (r *Repository) resolveActiveWalletPolicy(ctx context.Context, tx *sql.Tx,
|
||||
appCode, regionID, nowMS, nowMS, regionID,
|
||||
)
|
||||
var item walletPolicyInstance
|
||||
if err := row.Scan(&item.InstanceCode, &item.TemplateCode, &item.TemplateVersion, &item.RegionID, &item.HostPointAsset, &item.HostPointRatioPPM, &item.PointsPerUSD, &item.WithdrawFeeBPS); err != nil {
|
||||
if err := row.Scan(&item.InstanceCode, &item.TemplateCode, &item.TemplateVersion, &item.RegionID, &item.HostPointAsset, &item.HostPointRatioPPM, &item.AgencyPointRatioPPM, &item.PointsPerUSD, &item.WithdrawFeeBPS); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return walletPolicyInstance{}, false, nil
|
||||
}
|
||||
@ -51,7 +52,7 @@ func (r *Repository) resolveActiveWalletPolicy(ctx context.Context, tx *sql.Tx,
|
||||
if item.HostPointAsset == "" {
|
||||
item.HostPointAsset = ledger.AssetPoint
|
||||
}
|
||||
if item.HostPointAsset != ledger.AssetPoint || item.HostPointRatioPPM <= 0 || item.HostPointRatioPPM > 1_000_000 || item.PointsPerUSD <= 0 || item.WithdrawFeeBPS < 0 || item.WithdrawFeeBPS > 10_000 {
|
||||
if item.HostPointAsset != ledger.AssetPoint || item.HostPointRatioPPM <= 0 || item.HostPointRatioPPM > 1_000_000 || item.AgencyPointRatioPPM < 0 || item.AgencyPointRatioPPM > 1_000_000 || item.HostPointRatioPPM+item.AgencyPointRatioPPM > 1_000_000 || item.PointsPerUSD <= 0 || item.WithdrawFeeBPS < 0 || item.WithdrawFeeBPS > 10_000 {
|
||||
return walletPolicyInstance{}, false, xerr.New(xerr.InvalidArgument, "wallet policy instance is invalid")
|
||||
}
|
||||
return item, true, nil
|
||||
|
||||
@ -71,6 +71,10 @@ func Open(ctx context.Context, dsn string) (*Repository, error) {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := ensurePointWithdrawalCoinSellerSchema(ctx, db); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Repository{db: db}, nil
|
||||
}
|
||||
|
||||
@ -207,6 +207,7 @@ func ensureWalletPolicyInstanceSchema(ctx context.Context, db *sql.DB) error {
|
||||
effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '0 表示长期有效',
|
||||
host_point_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT' COMMENT '主播收益积分资产',
|
||||
host_point_ratio_ppm BIGINT NOT NULL DEFAULT 700000 COMMENT '有效付费礼物转 POINT 比例,ppm',
|
||||
agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '有效付费礼物给 Agency owner 的 POINT 分成比例,ppm',
|
||||
points_per_usd BIGINT NOT NULL DEFAULT 100000 COMMENT 'POINT/USD 展示换算比例',
|
||||
withdraw_fee_bps INT NOT NULL DEFAULT 500 COMMENT '提现手续费 bps',
|
||||
rule_json JSON NOT NULL COMMENT '完整政策快照',
|
||||
@ -217,6 +218,50 @@ func ensureWalletPolicyInstanceSchema(ctx context.Context, db *sql.DB) error {
|
||||
PRIMARY KEY (app_code, instance_code, region_id),
|
||||
KEY idx_wallet_policy_active (app_code, region_id, status, effective_from_ms, effective_to_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包运行侧收益政策实例表'`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `ALTER TABLE wallet_policy_instances ADD COLUMN agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '有效付费礼物给 Agency owner 的 POINT 分成比例,ppm' AFTER host_point_ratio_ppm`); err != nil && !isDuplicateColumnError(err) {
|
||||
return err
|
||||
}
|
||||
_, err = db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS agency_point_share_entries (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
transaction_id VARCHAR(96) NOT NULL,
|
||||
host_user_id BIGINT NOT NULL,
|
||||
agency_owner_user_id BIGINT NOT NULL,
|
||||
sender_user_id BIGINT NOT NULL,
|
||||
room_id VARCHAR(96) NOT NULL DEFAULT '',
|
||||
point_delta BIGINT NOT NULL,
|
||||
ratio_ppm BIGINT NOT NULL,
|
||||
policy_instance_code VARCHAR(96) NOT NULL DEFAULT '',
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, transaction_id, agency_owner_user_id),
|
||||
KEY idx_agency_point_share_owner_time (app_code, agency_owner_user_id, created_at_ms),
|
||||
KEY idx_agency_point_share_host_time (app_code, host_user_id, created_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Agency POINT 礼物分成流水'`)
|
||||
return err
|
||||
}
|
||||
|
||||
// ensurePointWithdrawalCoinSellerSchema 创建跨 Fami/Huwaa/Lalu 共用、按 app_code 隔离的提现币商配置。
|
||||
// 表放在 wallet 数据库,保证兑换时读取比例和写账本处于同一可信服务边界,H5 不能自报比例。
|
||||
func ensurePointWithdrawalCoinSellerSchema(ctx context.Context, db *sql.DB) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS point_withdrawal_coin_seller_configs (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
|
||||
seller_user_id BIGINT NOT NULL COMMENT '币商用户 ID',
|
||||
sort_order INT NOT NULL DEFAULT 0 COMMENT '展示顺序,越小越靠前',
|
||||
point_amount BIGINT NOT NULL COMMENT '兑换比例中的 POINT 分母',
|
||||
seller_coin_amount BIGINT NOT NULL COMMENT '兑换比例中的币商金币分子',
|
||||
service_country_codes JSON NOT NULL COMMENT '服务国家码数组;空数组表示不限国家',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT 'active/disabled',
|
||||
created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, seller_user_id),
|
||||
KEY idx_point_withdraw_seller_list (app_code, status, sort_order, seller_user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='POINT 提现币商白名单与兑换比例'`)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ package mysqltest
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
@ -14,6 +15,31 @@ import (
|
||||
mysqlstorage "hyapp/services/wallet-service/internal/storage/mysql"
|
||||
)
|
||||
|
||||
// SetPointWithdrawalCoinSellerConfig seeds one app-scoped whitelist row for real ledger tests.
|
||||
func (r *Repository) SetPointWithdrawalCoinSellerConfig(appCode string, sellerUserID int64, pointAmount int64, sellerCoinAmount int64, countryCodes []string, status string, sortOrder int) {
|
||||
r.t.Helper()
|
||||
countriesJSON, err := json.Marshal(countryCodes)
|
||||
if err != nil {
|
||||
r.t.Fatalf("marshal point withdrawal countries failed: %v", err)
|
||||
}
|
||||
if status == "" {
|
||||
status = "active"
|
||||
}
|
||||
nowMS := time.Now().UnixMilli()
|
||||
if _, err := r.schema.DB.ExecContext(context.Background(), `
|
||||
INSERT INTO point_withdrawal_coin_seller_configs (
|
||||
app_code, seller_user_id, sort_order, point_amount, seller_coin_amount, service_country_codes,
|
||||
status, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
sort_order = VALUES(sort_order), point_amount = VALUES(point_amount), seller_coin_amount = VALUES(seller_coin_amount),
|
||||
service_country_codes = VALUES(service_country_codes), status = VALUES(status), updated_at_ms = VALUES(updated_at_ms)`,
|
||||
appCode, sellerUserID, sortOrder, pointAmount, sellerCoinAmount, countriesJSON, status, nowMS, nowMS,
|
||||
); err != nil {
|
||||
r.t.Fatalf("seed point withdrawal coin seller config failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Repository wraps the production MySQL wallet repository with test seed helpers.
|
||||
type Repository struct {
|
||||
*mysqlstorage.Repository
|
||||
@ -371,22 +397,28 @@ func (r *Repository) SetAssetBalanceForApp(appCode string, userID int64, assetTy
|
||||
|
||||
// SeedWalletPolicyInstance writes the wallet runtime policy row consumed by gift settlement tests.
|
||||
func (r *Repository) SeedWalletPolicyInstance(appCode string, instanceCode string, regionID int64, status string, hostPointRatioPPM int64) {
|
||||
r.SeedWalletPolicyInstanceWithAgency(appCode, instanceCode, regionID, status, hostPointRatioPPM, 0)
|
||||
}
|
||||
|
||||
// SeedWalletPolicyInstanceWithAgency 让账务测试显式声明 Agency 比例,避免普通 policy fixture 意外改变旧用例语义。
|
||||
func (r *Repository) SeedWalletPolicyInstanceWithAgency(appCode string, instanceCode string, regionID int64, status string, hostPointRatioPPM int64, agencyPointRatioPPM int64) {
|
||||
r.t.Helper()
|
||||
|
||||
nowMs := time.Now().UnixMilli()
|
||||
_, err := r.schema.DB.ExecContext(context.Background(), `
|
||||
INSERT INTO wallet_policy_instances (
|
||||
app_code, instance_code, template_code, template_version, region_id, status,
|
||||
effective_from_ms, effective_to_ms, host_point_asset_type, host_point_ratio_ppm,
|
||||
effective_from_ms, effective_to_ms, host_point_asset_type, host_point_ratio_ppm, agency_point_ratio_ppm,
|
||||
points_per_usd, withdraw_fee_bps, rule_json, published_by_admin_id,
|
||||
published_at_ms, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, 'v1', ?, ?, 0, 0, 'POINT', ?, 100000, 500, JSON_OBJECT(), 90001, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, 'v1', ?, ?, 0, 0, 'POINT', ?, ?, 100000, 500, JSON_OBJECT(), 90001, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = VALUES(status),
|
||||
host_point_ratio_ppm = VALUES(host_point_ratio_ppm),
|
||||
agency_point_ratio_ppm = VALUES(agency_point_ratio_ppm),
|
||||
published_at_ms = VALUES(published_at_ms),
|
||||
updated_at_ms = VALUES(updated_at_ms)
|
||||
`, strings.TrimSpace(appCode), strings.TrimSpace(instanceCode), strings.TrimSpace(instanceCode)+"_template", regionID, strings.TrimSpace(status), hostPointRatioPPM, nowMs, nowMs, nowMs)
|
||||
`, strings.TrimSpace(appCode), strings.TrimSpace(instanceCode), strings.TrimSpace(instanceCode)+"_template", regionID, strings.TrimSpace(status), hostPointRatioPPM, agencyPointRatioPPM, nowMs, nowMs, nowMs)
|
||||
if err != nil {
|
||||
r.t.Fatalf("seed wallet policy instance failed: %v", err)
|
||||
}
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||
)
|
||||
|
||||
func (s *Server) GetAgencyHostGiftStats(ctx context.Context, req *walletv1.GetAgencyHostGiftStatsRequest) (*walletv1.GetAgencyHostGiftStatsResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
||||
stats, err := s.svc.GetAgencyHostGiftStats(ctx, req.GetAppCode(), ledger.AgencyHostGiftStatsQuery{
|
||||
HostUserIDs: req.GetHostUserIds(), StartAtMS: req.GetStartAtMs(), EndAtMS: req.GetEndAtMs(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &walletv1.GetAgencyHostGiftStatsResponse{Stats: &walletv1.AgencyHostGiftStats{
|
||||
GiftIncome: stats.GiftIncome, GiftedHostCount: stats.GiftedHostCount,
|
||||
}}, nil
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||
)
|
||||
|
||||
func (s *Server) GetAgencyPointShareStats(ctx context.Context, req *walletv1.GetAgencyPointShareStatsRequest) (*walletv1.GetAgencyPointShareStatsResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
||||
stats, err := s.svc.GetAgencyPointShareStats(ctx, req.GetAppCode(), ledger.AgencyPointShareStatsQuery{
|
||||
AgencyOwnerUserID: req.GetAgencyOwnerUserId(),
|
||||
StartAtMS: req.GetStartAtMs(),
|
||||
EndAtMS: req.GetEndAtMs(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &walletv1.GetAgencyPointShareStatsResponse{Stats: &walletv1.AgencyPointShareStats{
|
||||
ShareIncome: stats.ShareIncome,
|
||||
GiftedHostCount: stats.GiftedHostCount,
|
||||
}}, nil
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||
)
|
||||
|
||||
// GetHostRevenueStats 返回钱包账本内的 POINT 收益、兑换和去重送礼用户。
|
||||
func (s *Server) GetHostRevenueStats(ctx context.Context, req *walletv1.GetHostRevenueStatsRequest) (*walletv1.GetHostRevenueStatsResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
||||
stats, err := s.svc.GetHostRevenueStats(ctx, req.GetAppCode(), ledger.HostRevenueStatsQuery{
|
||||
HostUserID: req.GetHostUserId(),
|
||||
StartAtMS: req.GetStartAtMs(),
|
||||
EndAtMS: req.GetEndAtMs(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &walletv1.GetHostRevenueStatsResponse{Stats: &walletv1.HostRevenueStats{
|
||||
DiamondEarnings: stats.DiamondEarnings,
|
||||
DiamondExchanged: stats.DiamondExchanged,
|
||||
GiftSenders: stats.GiftSenders,
|
||||
}}, nil
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||
)
|
||||
|
||||
// ListPointWithdrawalCoinSellers 返回 wallet 侧已执行 app/status/国家过滤的可信白名单。
|
||||
func (s *Server) ListPointWithdrawalCoinSellers(ctx context.Context, req *walletv1.ListPointWithdrawalCoinSellersRequest) (*walletv1.ListPointWithdrawalCoinSellersResponse, error) {
|
||||
items, err := s.svc.ListPointWithdrawalCoinSellers(ctx, req.GetAppCode(), req.GetCountryCode(), req.GetIncludeDisabled())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
response := &walletv1.ListPointWithdrawalCoinSellersResponse{Sellers: make([]*walletv1.PointWithdrawalCoinSellerConfig, 0, len(items))}
|
||||
for _, item := range items {
|
||||
response.Sellers = append(response.Sellers, &walletv1.PointWithdrawalCoinSellerConfig{
|
||||
AppCode: item.AppCode, SellerUserId: item.SellerUserID, SortOrder: int32(item.SortOrder),
|
||||
PointAmount: item.PointAmount, SellerCoinAmount: item.SellerCoinAmount,
|
||||
ServiceCountryCodes: append([]string(nil), item.ServiceCountryCodes...), Status: item.Status,
|
||||
CreatedAtMs: item.CreatedAtMS, UpdatedAtMs: item.UpdatedAtMS,
|
||||
})
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// TransferPointToCoinSeller 不接受兑换比例字段,确保所有调用方都只能使用数据库白名单快照。
|
||||
func (s *Server) TransferPointToCoinSeller(ctx context.Context, req *walletv1.TransferPointToCoinSellerRequest) (*walletv1.TransferPointToCoinSellerResponse, error) {
|
||||
receipt, err := s.svc.TransferPointToCoinSeller(ctx, ledger.PointToCoinSellerCommand{
|
||||
AppCode: req.GetAppCode(), CommandID: req.GetCommandId(), SourceUserID: req.GetSourceUserId(),
|
||||
SellerUserID: req.GetSellerUserId(), PointAmount: req.GetPointAmount(),
|
||||
SourceCountryCode: req.GetSourceCountryCode(), Reason: req.GetReason(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &walletv1.TransferPointToCoinSellerResponse{
|
||||
TransactionId: receipt.TransactionID, SourcePointBalanceAfter: receipt.SourcePointBalanceAfter,
|
||||
SellerBalanceAfter: receipt.SellerBalanceAfter, PointAmount: receipt.PointAmount,
|
||||
SellerCoinAmount: receipt.SellerCoinAmount, RatioPointAmount: receipt.RatioPointAmount,
|
||||
RatioSellerCoinAmount: receipt.RatioSellerCoinAmount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetPointWithdrawalConfig 暴露政策编译后的最小运行参数,H5 不解析 Admin rule_json。
|
||||
func (s *Server) GetPointWithdrawalConfig(ctx context.Context, req *walletv1.GetPointWithdrawalConfigRequest) (*walletv1.GetPointWithdrawalConfigResponse, error) {
|
||||
config, err := s.svc.GetPointWithdrawalConfig(ctx, req.GetAppCode(), req.GetRegionId(), req.GetNowMs())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &walletv1.GetPointWithdrawalConfigResponse{
|
||||
Found: config.Found, PointsPerUsd: config.PointsPerUSD, FeeBps: int32(config.FeeBPS),
|
||||
MinimumPoints: config.MinimumPoints, PolicyInstanceCode: config.PolicyInstanceCode,
|
||||
}, nil
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user