积分收益政策h5

This commit is contained in:
zhx 2026-07-12 17:38:48 +08:00
parent 4794507044
commit ca15f09850
81 changed files with 6778 additions and 1556 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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"`
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 与 FamiLalu 旧 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()))
}

View File

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

View File

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

View File

@ -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='用户私信发送事实,仅保存统计字段';

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

View File

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

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

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

View File

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

View File

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

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

View File

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

View File

@ -0,0 +1,12 @@
package ledger
type AgencyHostGiftStats struct {
GiftIncome int64
GiftedHostCount int64
}
type AgencyHostGiftStatsQuery struct {
HostUserIDs []int64
StartAtMS int64
EndAtMS int64
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,12 +10,11 @@ import (
)
const (
defaultPointWithdrawalFeeBPS int64 = 500
defaultPointWithdrawalPointsPerUSD int64 = 100000
minPointWithdrawalGrossPoints int64 = 1000000
)
// FreezePointWithdrawal 把 Huwaa 收益积分从 available 冻结到 frozen它复用钱包单边冻结账本但不开放旧工资资产
// FreezePointWithdrawal 把已启用 App 的收益积分从 available 冻结到 frozengateway 必须先读取已发布政策快照
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")
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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=falsegateway 不回退到猜测参数。
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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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