Add salary wallet exchange flows

This commit is contained in:
zhx 2026-06-04 22:57:31 +08:00
parent 0ad227187f
commit d79c877ec0
38 changed files with 4835 additions and 1239 deletions

File diff suppressed because it is too large Load Diff

View File

@ -243,6 +243,17 @@ message ListBDLeaderAgenciesResponse {
repeated Agency agencies = 1;
}
message ListBDAgenciesRequest {
RequestMeta meta = 1;
int64 bd_user_id = 2;
string status = 3;
int32 page_size = 4;
}
message ListBDAgenciesResponse {
repeated Agency agencies = 1;
}
message ProcessRoleInvitationRequest {
RequestMeta meta = 1;
string command_id = 2;
@ -462,6 +473,7 @@ service UserHostService {
rpc InviteBD(InviteBDRequest) returns (InviteBDResponse);
rpc ListBDLeaderBDs(ListBDLeaderBDsRequest) returns (ListBDLeaderBDsResponse);
rpc ListBDLeaderAgencies(ListBDLeaderAgenciesRequest) returns (ListBDLeaderAgenciesResponse);
rpc ListBDAgencies(ListBDAgenciesRequest) returns (ListBDAgenciesResponse);
rpc ProcessRoleInvitation(ProcessRoleInvitationRequest) returns (ProcessRoleInvitationResponse);
rpc GetHostProfile(GetHostProfileRequest) returns (GetHostProfileResponse);
rpc GetBDProfile(GetBDProfileRequest) returns (GetBDProfileResponse);

View File

@ -27,6 +27,7 @@ const (
UserHostService_InviteBD_FullMethodName = "/hyapp.user.v1.UserHostService/InviteBD"
UserHostService_ListBDLeaderBDs_FullMethodName = "/hyapp.user.v1.UserHostService/ListBDLeaderBDs"
UserHostService_ListBDLeaderAgencies_FullMethodName = "/hyapp.user.v1.UserHostService/ListBDLeaderAgencies"
UserHostService_ListBDAgencies_FullMethodName = "/hyapp.user.v1.UserHostService/ListBDAgencies"
UserHostService_ProcessRoleInvitation_FullMethodName = "/hyapp.user.v1.UserHostService/ProcessRoleInvitation"
UserHostService_GetHostProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetHostProfile"
UserHostService_GetBDProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetBDProfile"
@ -53,6 +54,7 @@ type UserHostServiceClient interface {
InviteBD(ctx context.Context, in *InviteBDRequest, opts ...grpc.CallOption) (*InviteBDResponse, error)
ListBDLeaderBDs(ctx context.Context, in *ListBDLeaderBDsRequest, opts ...grpc.CallOption) (*ListBDLeaderBDsResponse, error)
ListBDLeaderAgencies(ctx context.Context, in *ListBDLeaderAgenciesRequest, opts ...grpc.CallOption) (*ListBDLeaderAgenciesResponse, error)
ListBDAgencies(ctx context.Context, in *ListBDAgenciesRequest, opts ...grpc.CallOption) (*ListBDAgenciesResponse, error)
ProcessRoleInvitation(ctx context.Context, in *ProcessRoleInvitationRequest, opts ...grpc.CallOption) (*ProcessRoleInvitationResponse, error)
GetHostProfile(ctx context.Context, in *GetHostProfileRequest, opts ...grpc.CallOption) (*GetHostProfileResponse, error)
GetBDProfile(ctx context.Context, in *GetBDProfileRequest, opts ...grpc.CallOption) (*GetBDProfileResponse, error)
@ -153,6 +155,16 @@ func (c *userHostServiceClient) ListBDLeaderAgencies(ctx context.Context, in *Li
return out, nil
}
func (c *userHostServiceClient) ListBDAgencies(ctx context.Context, in *ListBDAgenciesRequest, opts ...grpc.CallOption) (*ListBDAgenciesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListBDAgenciesResponse)
err := c.cc.Invoke(ctx, UserHostService_ListBDAgencies_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userHostServiceClient) ProcessRoleInvitation(ctx context.Context, in *ProcessRoleInvitationRequest, opts ...grpc.CallOption) (*ProcessRoleInvitationResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ProcessRoleInvitationResponse)
@ -267,6 +279,7 @@ type UserHostServiceServer interface {
InviteBD(context.Context, *InviteBDRequest) (*InviteBDResponse, error)
ListBDLeaderBDs(context.Context, *ListBDLeaderBDsRequest) (*ListBDLeaderBDsResponse, error)
ListBDLeaderAgencies(context.Context, *ListBDLeaderAgenciesRequest) (*ListBDLeaderAgenciesResponse, error)
ListBDAgencies(context.Context, *ListBDAgenciesRequest) (*ListBDAgenciesResponse, error)
ProcessRoleInvitation(context.Context, *ProcessRoleInvitationRequest) (*ProcessRoleInvitationResponse, error)
GetHostProfile(context.Context, *GetHostProfileRequest) (*GetHostProfileResponse, error)
GetBDProfile(context.Context, *GetBDProfileRequest) (*GetBDProfileResponse, error)
@ -311,6 +324,9 @@ func (UnimplementedUserHostServiceServer) ListBDLeaderBDs(context.Context, *List
func (UnimplementedUserHostServiceServer) ListBDLeaderAgencies(context.Context, *ListBDLeaderAgenciesRequest) (*ListBDLeaderAgenciesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListBDLeaderAgencies not implemented")
}
func (UnimplementedUserHostServiceServer) ListBDAgencies(context.Context, *ListBDAgenciesRequest) (*ListBDAgenciesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListBDAgencies not implemented")
}
func (UnimplementedUserHostServiceServer) ProcessRoleInvitation(context.Context, *ProcessRoleInvitationRequest) (*ProcessRoleInvitationResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ProcessRoleInvitation not implemented")
}
@ -506,6 +522,24 @@ func _UserHostService_ListBDLeaderAgencies_Handler(srv interface{}, ctx context.
return interceptor(ctx, in, info, handler)
}
func _UserHostService_ListBDAgencies_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListBDAgenciesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserHostServiceServer).ListBDAgencies(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserHostService_ListBDAgencies_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserHostServiceServer).ListBDAgencies(ctx, req.(*ListBDAgenciesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserHostService_ProcessRoleInvitation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ProcessRoleInvitationRequest)
if err := dec(in); err != nil {
@ -725,6 +759,10 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListBDLeaderAgencies",
Handler: _UserHostService_ListBDLeaderAgencies_Handler,
},
{
MethodName: "ListBDAgencies",
Handler: _UserHostService_ListBDAgencies_Handler,
},
{
MethodName: "ProcessRoleInvitation",
Handler: _UserHostService_ProcessRoleInvitation_Handler,

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,7 @@ message DebitGiftRequest {
// price_version 使 active
string price_version = 7;
string app_code = 8;
// region_id room-service visible_region_id
// region_id room-service visible_region_id
int64 region_id = 9;
// target_is_host gateway user-service active host profile
bool target_is_host = 10;
@ -23,7 +23,7 @@ message DebitGiftRequest {
int64 target_host_region_id = 11;
// target_agency_owner_user_id Agency owner
int64 target_agency_owner_user_id = 12;
// sender_region_id visible_region_id
// sender_region_id visible_region_id
int64 sender_region_id = 13;
}
@ -65,6 +65,7 @@ message BatchDebitGiftRequest {
int32 gift_count = 5;
string price_version = 6;
string app_code = 7;
// region_id room-service visible_region_id
int64 region_id = 8;
int64 sender_region_id = 9;
repeated DebitGiftTarget targets = 10;
@ -235,6 +236,70 @@ message TransferCoinFromSellerResponse {
int64 recharge_policy_usd_minor_amount = 10;
}
// CoinSellerSalaryExchangeRateTier
message CoinSellerSalaryExchangeRateTier {
int64 region_id = 1;
int64 min_usd_minor = 2;
int64 max_usd_minor = 3;
int64 coin_per_usd = 4;
string status = 5;
int32 sort_order = 6;
int64 updated_at_ms = 7;
}
message ListCoinSellerSalaryExchangeRateTiersRequest {
string request_id = 1;
string app_code = 2;
int64 region_id = 3;
bool include_disabled = 4;
}
message ListCoinSellerSalaryExchangeRateTiersResponse {
repeated CoinSellerSalaryExchangeRateTier tiers = 1;
}
// ExchangeSalaryToCoinRequest COIN
message ExchangeSalaryToCoinRequest {
string command_id = 1;
int64 user_id = 2;
string salary_asset_type = 3;
int64 salary_usd_minor = 4;
string reason = 5;
string app_code = 6;
}
message ExchangeSalaryToCoinResponse {
string transaction_id = 1;
int64 salary_balance_after = 2;
int64 coin_balance_after = 3;
int64 salary_usd_minor = 4;
int64 coin_amount = 5;
int64 coin_per_usd = 6;
}
// TransferSalaryToCoinSellerRequest
message TransferSalaryToCoinSellerRequest {
string command_id = 1;
int64 source_user_id = 2;
int64 seller_user_id = 3;
string salary_asset_type = 4;
int64 salary_usd_minor = 5;
int64 region_id = 6;
string reason = 7;
string app_code = 8;
}
message TransferSalaryToCoinSellerResponse {
string transaction_id = 1;
int64 source_salary_balance_after = 2;
int64 seller_balance_after = 3;
int64 salary_usd_minor = 4;
int64 coin_amount = 5;
int64 coin_per_usd = 6;
int64 rate_min_usd_minor = 7;
int64 rate_max_usd_minor = 8;
}
// Resource
message Resource {
string app_code = 1;
@ -1502,6 +1567,9 @@ service WalletService {
rpc AdminCreditAsset(AdminCreditAssetRequest) returns (AdminCreditAssetResponse);
rpc AdminCreditCoinSellerStock(AdminCreditCoinSellerStockRequest) returns (AdminCreditCoinSellerStockResponse);
rpc TransferCoinFromSeller(TransferCoinFromSellerRequest) returns (TransferCoinFromSellerResponse);
rpc ListCoinSellerSalaryExchangeRateTiers(ListCoinSellerSalaryExchangeRateTiersRequest) returns (ListCoinSellerSalaryExchangeRateTiersResponse);
rpc ExchangeSalaryToCoin(ExchangeSalaryToCoinRequest) returns (ExchangeSalaryToCoinResponse);
rpc TransferSalaryToCoinSeller(TransferSalaryToCoinSellerRequest) returns (TransferSalaryToCoinSellerResponse);
rpc ListResources(ListResourcesRequest) returns (ListResourcesResponse);
rpc GetResource(GetResourceRequest) returns (GetResourceResponse);
rpc CreateResource(CreateResourceRequest) returns (ResourceResponse);

View File

@ -201,70 +201,73 @@ var WalletCronService_ServiceDesc = grpc.ServiceDesc{
}
const (
WalletService_DebitGift_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitGift"
WalletService_BatchDebitGift_FullMethodName = "/hyapp.wallet.v1.WalletService/BatchDebitGift"
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_AdminCreditAsset_FullMethodName = "/hyapp.wallet.v1.WalletService/AdminCreditAsset"
WalletService_AdminCreditCoinSellerStock_FullMethodName = "/hyapp.wallet.v1.WalletService/AdminCreditCoinSellerStock"
WalletService_TransferCoinFromSeller_FullMethodName = "/hyapp.wallet.v1.WalletService/TransferCoinFromSeller"
WalletService_ListResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResources"
WalletService_GetResource_FullMethodName = "/hyapp.wallet.v1.WalletService/GetResource"
WalletService_CreateResource_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateResource"
WalletService_UpdateResource_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateResource"
WalletService_SetResourceStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetResourceStatus"
WalletService_ListResourceGroups_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceGroups"
WalletService_GetResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GetResourceGroup"
WalletService_CreateResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateResourceGroup"
WalletService_UpdateResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateResourceGroup"
WalletService_SetResourceGroupStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetResourceGroupStatus"
WalletService_ListGiftConfigs_FullMethodName = "/hyapp.wallet.v1.WalletService/ListGiftConfigs"
WalletService_ListGiftTypeConfigs_FullMethodName = "/hyapp.wallet.v1.WalletService/ListGiftTypeConfigs"
WalletService_CreateGiftConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateGiftConfig"
WalletService_UpdateGiftConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateGiftConfig"
WalletService_SetGiftConfigStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetGiftConfigStatus"
WalletService_UpsertGiftTypeConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpsertGiftTypeConfig"
WalletService_GrantResource_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantResource"
WalletService_GrantResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantResourceGroup"
WalletService_ListUserResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListUserResources"
WalletService_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource"
WalletService_UnequipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/UnequipUserResource"
WalletService_BatchGetUserEquippedResources_FullMethodName = "/hyapp.wallet.v1.WalletService/BatchGetUserEquippedResources"
WalletService_ListResourceGrants_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceGrants"
WalletService_ListResourceShopItems_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceShopItems"
WalletService_UpsertResourceShopItems_FullMethodName = "/hyapp.wallet.v1.WalletService/UpsertResourceShopItems"
WalletService_SetResourceShopItemStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetResourceShopItemStatus"
WalletService_PurchaseResourceShopItem_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseResourceShopItem"
WalletService_ListRechargeBills_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeBills"
WalletService_GetWalletOverview_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletOverview"
WalletService_GetWalletValueSummary_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletValueSummary"
WalletService_GetUserGiftWall_FullMethodName = "/hyapp.wallet.v1.WalletService/GetUserGiftWall"
WalletService_ListRechargeProducts_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeProducts"
WalletService_ConfirmGooglePayment_FullMethodName = "/hyapp.wallet.v1.WalletService/ConfirmGooglePayment"
WalletService_ListAdminRechargeProducts_FullMethodName = "/hyapp.wallet.v1.WalletService/ListAdminRechargeProducts"
WalletService_CreateRechargeProduct_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateRechargeProduct"
WalletService_UpdateRechargeProduct_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateRechargeProduct"
WalletService_DeleteRechargeProduct_FullMethodName = "/hyapp.wallet.v1.WalletService/DeleteRechargeProduct"
WalletService_GetDiamondExchangeConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetDiamondExchangeConfig"
WalletService_ListWalletTransactions_FullMethodName = "/hyapp.wallet.v1.WalletService/ListWalletTransactions"
WalletService_ListVipPackages_FullMethodName = "/hyapp.wallet.v1.WalletService/ListVipPackages"
WalletService_GetMyVip_FullMethodName = "/hyapp.wallet.v1.WalletService/GetMyVip"
WalletService_PurchaseVip_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseVip"
WalletService_GrantVip_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantVip"
WalletService_ListAdminVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/ListAdminVipLevels"
WalletService_UpdateAdminVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateAdminVipLevels"
WalletService_CreditTaskReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditTaskReward"
WalletService_CreditLuckyGiftReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditLuckyGiftReward"
WalletService_ApplyGameCoinChange_FullMethodName = "/hyapp.wallet.v1.WalletService/ApplyGameCoinChange"
WalletService_GetRedPacketConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetRedPacketConfig"
WalletService_UpdateRedPacketConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateRedPacketConfig"
WalletService_CreateRedPacket_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateRedPacket"
WalletService_ClaimRedPacket_FullMethodName = "/hyapp.wallet.v1.WalletService/ClaimRedPacket"
WalletService_ListRedPackets_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRedPackets"
WalletService_GetRedPacket_FullMethodName = "/hyapp.wallet.v1.WalletService/GetRedPacket"
WalletService_ExpireRedPackets_FullMethodName = "/hyapp.wallet.v1.WalletService/ExpireRedPackets"
WalletService_RetryRedPacketRefund_FullMethodName = "/hyapp.wallet.v1.WalletService/RetryRedPacketRefund"
WalletService_DebitGift_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitGift"
WalletService_BatchDebitGift_FullMethodName = "/hyapp.wallet.v1.WalletService/BatchDebitGift"
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_AdminCreditAsset_FullMethodName = "/hyapp.wallet.v1.WalletService/AdminCreditAsset"
WalletService_AdminCreditCoinSellerStock_FullMethodName = "/hyapp.wallet.v1.WalletService/AdminCreditCoinSellerStock"
WalletService_TransferCoinFromSeller_FullMethodName = "/hyapp.wallet.v1.WalletService/TransferCoinFromSeller"
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_ListResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResources"
WalletService_GetResource_FullMethodName = "/hyapp.wallet.v1.WalletService/GetResource"
WalletService_CreateResource_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateResource"
WalletService_UpdateResource_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateResource"
WalletService_SetResourceStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetResourceStatus"
WalletService_ListResourceGroups_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceGroups"
WalletService_GetResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GetResourceGroup"
WalletService_CreateResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateResourceGroup"
WalletService_UpdateResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateResourceGroup"
WalletService_SetResourceGroupStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetResourceGroupStatus"
WalletService_ListGiftConfigs_FullMethodName = "/hyapp.wallet.v1.WalletService/ListGiftConfigs"
WalletService_ListGiftTypeConfigs_FullMethodName = "/hyapp.wallet.v1.WalletService/ListGiftTypeConfigs"
WalletService_CreateGiftConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateGiftConfig"
WalletService_UpdateGiftConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateGiftConfig"
WalletService_SetGiftConfigStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetGiftConfigStatus"
WalletService_UpsertGiftTypeConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpsertGiftTypeConfig"
WalletService_GrantResource_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantResource"
WalletService_GrantResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantResourceGroup"
WalletService_ListUserResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListUserResources"
WalletService_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource"
WalletService_UnequipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/UnequipUserResource"
WalletService_BatchGetUserEquippedResources_FullMethodName = "/hyapp.wallet.v1.WalletService/BatchGetUserEquippedResources"
WalletService_ListResourceGrants_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceGrants"
WalletService_ListResourceShopItems_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceShopItems"
WalletService_UpsertResourceShopItems_FullMethodName = "/hyapp.wallet.v1.WalletService/UpsertResourceShopItems"
WalletService_SetResourceShopItemStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetResourceShopItemStatus"
WalletService_PurchaseResourceShopItem_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseResourceShopItem"
WalletService_ListRechargeBills_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeBills"
WalletService_GetWalletOverview_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletOverview"
WalletService_GetWalletValueSummary_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletValueSummary"
WalletService_GetUserGiftWall_FullMethodName = "/hyapp.wallet.v1.WalletService/GetUserGiftWall"
WalletService_ListRechargeProducts_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeProducts"
WalletService_ConfirmGooglePayment_FullMethodName = "/hyapp.wallet.v1.WalletService/ConfirmGooglePayment"
WalletService_ListAdminRechargeProducts_FullMethodName = "/hyapp.wallet.v1.WalletService/ListAdminRechargeProducts"
WalletService_CreateRechargeProduct_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateRechargeProduct"
WalletService_UpdateRechargeProduct_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateRechargeProduct"
WalletService_DeleteRechargeProduct_FullMethodName = "/hyapp.wallet.v1.WalletService/DeleteRechargeProduct"
WalletService_GetDiamondExchangeConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetDiamondExchangeConfig"
WalletService_ListWalletTransactions_FullMethodName = "/hyapp.wallet.v1.WalletService/ListWalletTransactions"
WalletService_ListVipPackages_FullMethodName = "/hyapp.wallet.v1.WalletService/ListVipPackages"
WalletService_GetMyVip_FullMethodName = "/hyapp.wallet.v1.WalletService/GetMyVip"
WalletService_PurchaseVip_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseVip"
WalletService_GrantVip_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantVip"
WalletService_ListAdminVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/ListAdminVipLevels"
WalletService_UpdateAdminVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateAdminVipLevels"
WalletService_CreditTaskReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditTaskReward"
WalletService_CreditLuckyGiftReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditLuckyGiftReward"
WalletService_ApplyGameCoinChange_FullMethodName = "/hyapp.wallet.v1.WalletService/ApplyGameCoinChange"
WalletService_GetRedPacketConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetRedPacketConfig"
WalletService_UpdateRedPacketConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateRedPacketConfig"
WalletService_CreateRedPacket_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateRedPacket"
WalletService_ClaimRedPacket_FullMethodName = "/hyapp.wallet.v1.WalletService/ClaimRedPacket"
WalletService_ListRedPackets_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRedPackets"
WalletService_GetRedPacket_FullMethodName = "/hyapp.wallet.v1.WalletService/GetRedPacket"
WalletService_ExpireRedPackets_FullMethodName = "/hyapp.wallet.v1.WalletService/ExpireRedPackets"
WalletService_RetryRedPacketRefund_FullMethodName = "/hyapp.wallet.v1.WalletService/RetryRedPacketRefund"
)
// WalletServiceClient is the client API for WalletService service.
@ -281,6 +284,9 @@ type WalletServiceClient interface {
AdminCreditAsset(ctx context.Context, in *AdminCreditAssetRequest, opts ...grpc.CallOption) (*AdminCreditAssetResponse, error)
AdminCreditCoinSellerStock(ctx context.Context, in *AdminCreditCoinSellerStockRequest, opts ...grpc.CallOption) (*AdminCreditCoinSellerStockResponse, error)
TransferCoinFromSeller(ctx context.Context, in *TransferCoinFromSellerRequest, opts ...grpc.CallOption) (*TransferCoinFromSellerResponse, error)
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)
ListResources(ctx context.Context, in *ListResourcesRequest, opts ...grpc.CallOption) (*ListResourcesResponse, error)
GetResource(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*GetResourceResponse, error)
CreateResource(ctx context.Context, in *CreateResourceRequest, opts ...grpc.CallOption) (*ResourceResponse, error)
@ -427,6 +433,36 @@ func (c *walletServiceClient) TransferCoinFromSeller(ctx context.Context, in *Tr
return out, nil
}
func (c *walletServiceClient) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, in *ListCoinSellerSalaryExchangeRateTiersRequest, opts ...grpc.CallOption) (*ListCoinSellerSalaryExchangeRateTiersResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListCoinSellerSalaryExchangeRateTiersResponse)
err := c.cc.Invoke(ctx, WalletService_ListCoinSellerSalaryExchangeRateTiers_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ExchangeSalaryToCoin(ctx context.Context, in *ExchangeSalaryToCoinRequest, opts ...grpc.CallOption) (*ExchangeSalaryToCoinResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ExchangeSalaryToCoinResponse)
err := c.cc.Invoke(ctx, WalletService_ExchangeSalaryToCoin_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) TransferSalaryToCoinSeller(ctx context.Context, in *TransferSalaryToCoinSellerRequest, opts ...grpc.CallOption) (*TransferSalaryToCoinSellerResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(TransferSalaryToCoinSellerResponse)
err := c.cc.Invoke(ctx, WalletService_TransferSalaryToCoinSeller_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ListResources(ctx context.Context, in *ListResourcesRequest, opts ...grpc.CallOption) (*ListResourcesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListResourcesResponse)
@ -1001,6 +1037,9 @@ type WalletServiceServer interface {
AdminCreditAsset(context.Context, *AdminCreditAssetRequest) (*AdminCreditAssetResponse, error)
AdminCreditCoinSellerStock(context.Context, *AdminCreditCoinSellerStockRequest) (*AdminCreditCoinSellerStockResponse, error)
TransferCoinFromSeller(context.Context, *TransferCoinFromSellerRequest) (*TransferCoinFromSellerResponse, error)
ListCoinSellerSalaryExchangeRateTiers(context.Context, *ListCoinSellerSalaryExchangeRateTiersRequest) (*ListCoinSellerSalaryExchangeRateTiersResponse, error)
ExchangeSalaryToCoin(context.Context, *ExchangeSalaryToCoinRequest) (*ExchangeSalaryToCoinResponse, error)
TransferSalaryToCoinSeller(context.Context, *TransferSalaryToCoinSellerRequest) (*TransferSalaryToCoinSellerResponse, error)
ListResources(context.Context, *ListResourcesRequest) (*ListResourcesResponse, error)
GetResource(context.Context, *GetResourceRequest) (*GetResourceResponse, error)
CreateResource(context.Context, *CreateResourceRequest) (*ResourceResponse, error)
@ -1091,6 +1130,15 @@ func (UnimplementedWalletServiceServer) AdminCreditCoinSellerStock(context.Conte
func (UnimplementedWalletServiceServer) TransferCoinFromSeller(context.Context, *TransferCoinFromSellerRequest) (*TransferCoinFromSellerResponse, error) {
return nil, status.Error(codes.Unimplemented, "method TransferCoinFromSeller not implemented")
}
func (UnimplementedWalletServiceServer) ListCoinSellerSalaryExchangeRateTiers(context.Context, *ListCoinSellerSalaryExchangeRateTiersRequest) (*ListCoinSellerSalaryExchangeRateTiersResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListCoinSellerSalaryExchangeRateTiers not implemented")
}
func (UnimplementedWalletServiceServer) ExchangeSalaryToCoin(context.Context, *ExchangeSalaryToCoinRequest) (*ExchangeSalaryToCoinResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ExchangeSalaryToCoin not implemented")
}
func (UnimplementedWalletServiceServer) TransferSalaryToCoinSeller(context.Context, *TransferSalaryToCoinSellerRequest) (*TransferSalaryToCoinSellerResponse, error) {
return nil, status.Error(codes.Unimplemented, "method TransferSalaryToCoinSeller not implemented")
}
func (UnimplementedWalletServiceServer) ListResources(context.Context, *ListResourcesRequest) (*ListResourcesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListResources not implemented")
}
@ -1424,6 +1472,60 @@ func _WalletService_TransferCoinFromSeller_Handler(srv interface{}, ctx context.
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListCoinSellerSalaryExchangeRateTiers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListCoinSellerSalaryExchangeRateTiersRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).ListCoinSellerSalaryExchangeRateTiers(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_ListCoinSellerSalaryExchangeRateTiers_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).ListCoinSellerSalaryExchangeRateTiers(ctx, req.(*ListCoinSellerSalaryExchangeRateTiersRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ExchangeSalaryToCoin_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ExchangeSalaryToCoinRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).ExchangeSalaryToCoin(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_ExchangeSalaryToCoin_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).ExchangeSalaryToCoin(ctx, req.(*ExchangeSalaryToCoinRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_TransferSalaryToCoinSeller_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(TransferSalaryToCoinSellerRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).TransferSalaryToCoinSeller(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_TransferSalaryToCoinSeller_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).TransferSalaryToCoinSeller(ctx, req.(*TransferSalaryToCoinSellerRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListResources_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListResourcesRequest)
if err := dec(in); err != nil {
@ -2471,6 +2573,18 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "TransferCoinFromSeller",
Handler: _WalletService_TransferCoinFromSeller_Handler,
},
{
MethodName: "ListCoinSellerSalaryExchangeRateTiers",
Handler: _WalletService_ListCoinSellerSalaryExchangeRateTiers_Handler,
},
{
MethodName: "ExchangeSalaryToCoin",
Handler: _WalletService_ExchangeSalaryToCoin_Handler,
},
{
MethodName: "TransferSalaryToCoinSeller",
Handler: _WalletService_TransferSalaryToCoinSeller_Handler,
},
{
MethodName: "ListResources",
Handler: _WalletService_ListResources_Handler,

View File

@ -140,17 +140,17 @@ func (s *Service) ensureSchema(ctx context.Context) error {
if _, err := s.db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS gift_diamond_ratio_configs (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码用于多租户隔离',
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '送礼用户区域0 表示全局默认',
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '房间可见区域0 表示全局默认',
gift_type_code VARCHAR(32) NOT NULL COMMENT '礼物类型编码',
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '业务状态',
ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 100.00 COMMENT '主播周期钻石入账比例百分比',
ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 100.00 COMMENT '房间贡献热度和主播周期钻石入账比例百分比',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台用户 ID',
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新后台用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, region_id, gift_type_code),
KEY idx_gift_diamond_ratio_region (app_code, region_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物入主播周期钻石比例配置表'`); err != nil {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物钻石和房间贡献比例配置表'`); err != nil {
return err
}
_, err := s.db.ExecContext(ctx, `

View File

@ -27,7 +27,8 @@ const (
settlementTriggerAutomatic = "automatic"
settlementTriggerManual = "manual"
// 礼物金币转主播钻石默认 1:1月底剩余钻石转美元默认不开启由后台显式配置。
// 礼物金币转主播钻石由 gift_diamond_ratio_configs 统一控制;工资政策保留 1:1 快照只用于运行表字段完整。
// 月底剩余钻石转美元默认不开启,由后台显式配置。
defaultGiftCoinToDiamondRatio = "1"
defaultResidualDiamondRate = "0"
@ -304,9 +305,9 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo
if req.EffectiveFromMS < 0 || req.EffectiveToMS < 0 || (req.EffectiveToMS > 0 && req.EffectiveToMS <= req.EffectiveFromMS) {
return model.HostAgencySalaryPolicy{}, errors.New("政策生效时间不正确")
}
// 比例字段用 decimal 字符串入库,避免 float 在金币/钻石/美元换算中产生精度噪声。
giftRatio := firstNonBlank(req.GiftCoinToDiamondRatio, defaultGiftCoinToDiamondRatio)
giftRatio, _, err := parseFixedDecimal(giftRatio, 6, false, "金币转钻石比例")
// 送礼入主播周期钻石的实际比例已经由“礼物钻石”配置在 wallet 扣礼物时写入周期账户;
// 这里不再接受工资政策单独覆盖,避免同一笔礼物在入账和结算阶段出现两套钻石口径。
giftRatio, _, err := parseFixedDecimal(defaultGiftCoinToDiamondRatio, 6, false, "金币转钻石比例")
if err != nil {
return model.HostAgencySalaryPolicy{}, err
}

View File

@ -9,7 +9,7 @@ func TestPolicyModelFromRequestNormalizesAndSortsLevels(t *testing.T) {
Status: "active",
SettlementMode: "half-month",
SettlementTriggerMode: "manual",
GiftCoinToDiamondRatio: "1",
GiftCoinToDiamondRatio: "9",
ResidualDiamondToUSDRate: "0.000001",
Levels: []levelRequest{
{Level: 2, RequiredDiamonds: 700000, HostSalaryUSD: "3", HostCoinReward: 198000, AgencySalaryUSD: "0.8"},
@ -22,6 +22,7 @@ func TestPolicyModelFromRequestNormalizesAndSortsLevels(t *testing.T) {
if item.AppCode != "lalu" || item.CreatedByAdminID != 7 || item.SettlementMode != settlementModeHalfMonth || item.SettlementTriggerMode != settlementTriggerManual {
t.Fatalf("policy fields mismatch: %+v", item)
}
// 工资政策不能单独改送礼入主播周期钻石的比例;该比例只由礼物钻石配置页控制。
if item.GiftCoinToDiamondRatio != "1.000000" || item.ResidualDiamondToUSDRate != "0.000001000000" {
t.Fatalf("ratio fields mismatch: %+v", item)
}

View File

@ -213,6 +213,39 @@ func (h *Handler) CreditCoinSellerStock(c *gin.Context) {
response.Created(c, coinSellerStockCreditFromProto(result))
}
func (h *Handler) GetCoinSellerSalaryRates(c *gin.Context) {
regionID, ok := parseInt64ID(c, "region_id")
if !ok {
return
}
items, err := h.service.GetCoinSellerSalaryRates(c.Request.Context(), regionID)
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, gin.H{"regionId": regionID, "tiers": items})
}
func (h *Handler) ReplaceCoinSellerSalaryRates(c *gin.Context) {
regionID, ok := parseInt64ID(c, "region_id")
if !ok {
return
}
var req replaceCoinSellerSalaryRatesRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "币商工资兑换比例参数不正确")
return
}
items, err := h.service.ReplaceCoinSellerSalaryRates(c.Request.Context(), adminActorID(c), regionID, req)
if err != nil {
response.BadRequest(c, err.Error())
return
}
writeHostOrgAuditLog(c, h.audit, "coin-seller-salary-rates", "coin_seller_salary_exchange_rate_tiers", regionID,
fmt.Sprintf("region_id=%d tiers=%d", regionID, len(items)))
response.OK(c, gin.H{"regionId": regionID, "tiers": items})
}
func (h *Handler) CreateAgency(c *gin.Context) {
var req createAgencyRequest
if err := c.ShouldBindJSON(&req); err != nil {

View File

@ -37,6 +37,19 @@ type CoinSellerListItem struct {
UpdatedAtMs int64 `json:"updatedAtMs"`
}
type CoinSellerSalaryRateTier struct {
RegionID int64 `json:"regionId,string"`
MinUSDMinor int64 `json:"minUsdMinor"`
MaxUSDMinor int64 `json:"maxUsdMinor"`
MinUSD string `json:"minUsd"`
MaxUSD string `json:"maxUsd"`
CoinPerUSD int64 `json:"coinPerUsd"`
Status string `json:"status"`
Enabled bool `json:"enabled"`
SortOrder int `json:"sortOrder"`
UpdatedAtMs int64 `json:"updatedAtMs"`
}
// ListBDProfiles 只读 user-service 的 BD 事实表,避免后台列表需求改动 user-service 发布物。
func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role string) ([]*userclient.BDProfile, int64, error) {
if r == nil || r.db == nil {
@ -494,6 +507,124 @@ func (r *Reader) UpdateCoinSellerContact(ctx context.Context, userID int64, cont
return err
}
func (r *Reader) ListCoinSellerSalaryRates(ctx context.Context, regionID int64) ([]CoinSellerSalaryRateTier, error) {
if r == nil || r.walletDB == nil {
return nil, fmt.Errorf("wallet mysql is not configured")
}
if regionID <= 0 {
return nil, fmt.Errorf("region_id is required")
}
if err := r.ensureCoinSellerSalaryRateTable(ctx); err != nil {
return nil, err
}
rows, err := r.walletDB.QueryContext(ctx, `
SELECT region_id, min_usd_minor, max_usd_minor, coin_per_usd, status, sort_order, updated_at_ms
FROM coin_seller_salary_exchange_rate_tiers
WHERE app_code = ? AND region_id = ?
ORDER BY sort_order ASC, min_usd_minor ASC, tier_id ASC
`, appctx.FromContext(ctx), regionID)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]CoinSellerSalaryRateTier, 0)
for rows.Next() {
var item CoinSellerSalaryRateTier
if err := rows.Scan(&item.RegionID, &item.MinUSDMinor, &item.MaxUSDMinor, &item.CoinPerUSD, &item.Status, &item.SortOrder, &item.UpdatedAtMs); err != nil {
return nil, err
}
item.Enabled = item.Status == "active"
item.MinUSD = formatUSDMinor(item.MinUSDMinor)
item.MaxUSD = formatUSDMinor(item.MaxUSDMinor)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
func (r *Reader) ReplaceCoinSellerSalaryRates(ctx context.Context, regionID int64, actorID int64, tiers []CoinSellerSalaryRateTier) ([]CoinSellerSalaryRateTier, error) {
if r == nil || r.walletDB == nil {
return nil, fmt.Errorf("wallet mysql is not configured")
}
if regionID <= 0 {
return nil, fmt.Errorf("region_id is required")
}
if err := r.ensureCoinSellerSalaryRateTable(ctx); err != nil {
return nil, err
}
tx, err := r.walletDB.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
appCode := appctx.FromContext(ctx)
nowMs := time.Now().UnixMilli()
if _, err := tx.ExecContext(ctx, `
DELETE FROM coin_seller_salary_exchange_rate_tiers
WHERE app_code = ? AND region_id = ?
`, appCode, regionID); err != nil {
return nil, err
}
for index, tier := range tiers {
status := strings.ToLower(strings.TrimSpace(tier.Status))
if status == "" {
if tier.Enabled {
status = "active"
} else {
status = "disabled"
}
}
if status != "active" && status != "disabled" {
return nil, fmt.Errorf("rate status is invalid")
}
sortOrder := tier.SortOrder
if sortOrder == 0 {
sortOrder = (index + 1) * 10
}
_, err := tx.ExecContext(ctx, `
INSERT INTO coin_seller_salary_exchange_rate_tiers (
app_code, region_id, min_usd_minor, max_usd_minor, coin_per_usd,
status, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, appCode, regionID, tier.MinUSDMinor, tier.MaxUSDMinor, tier.CoinPerUSD, status, sortOrder, actorID, actorID, nowMs, nowMs)
if err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return r.ListCoinSellerSalaryRates(ctx, regionID)
}
func (r *Reader) ensureCoinSellerSalaryRateTable(ctx context.Context) error {
if _, err := r.walletDB.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS coin_seller_salary_exchange_rate_tiers (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码用于多租户隔离',
region_id BIGINT NOT NULL COMMENT '币商所属区域 ID',
tier_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '区间 ID',
min_usd_minor BIGINT NOT NULL COMMENT '起始美元金额单位美分包含',
max_usd_minor BIGINT NOT NULL COMMENT '结束美元金额单位美分包含',
coin_per_usd BIGINT NOT NULL COMMENT ' 1 USD 工资可兑换的币商专用金币数量',
status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT '状态active/disabled',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重',
created_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建人用户 ID',
updated_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (tier_id),
KEY idx_coin_seller_salary_rates_region (app_code, region_id, status, min_usd_minor, max_usd_minor),
KEY idx_coin_seller_salary_rates_sort (app_code, region_id, status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='币商工资兑换比例区间表'`); err != nil {
return err
}
return nil
}
func (r *Reader) ensureCoinSellerContactColumn(ctx context.Context) error {
var count int
if err := r.db.QueryRowContext(ctx, `

View File

@ -55,6 +55,21 @@ type coinSellerStockCreditRequest struct {
Reason string `json:"reason"`
}
type replaceCoinSellerSalaryRatesRequest struct {
Tiers []coinSellerSalaryRateTierRequest `json:"tiers" binding:"required"`
}
type coinSellerSalaryRateTierRequest struct {
MinUSD string `json:"minUsd"`
MaxUSD string `json:"maxUsd"`
MinUSDMinor int64 `json:"minUsdMinor"`
MaxUSDMinor int64 `json:"maxUsdMinor"`
CoinPerUSD int64 `json:"coinPerUsd" binding:"required"`
Enabled *bool `json:"enabled"`
Status string `json:"status"`
SortOrder int `json:"sortOrder"`
}
type createAgencyRequest struct {
CommandID string `json:"commandId" binding:"required"`
OwnerUserID int64 `json:"ownerUserId" binding:"required"`

View File

@ -23,4 +23,6 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.POST("/admin/coin-sellers", middleware.RequirePermission("coin-seller:create"), h.CreateCoinSeller)
protected.PATCH("/admin/coin-sellers/:user_id/status", middleware.RequirePermission("coin-seller:update"), h.SetCoinSellerStatus)
protected.POST("/admin/coin-sellers/:user_id/stock-credits", middleware.RequirePermission("coin-seller:stock-credit"), h.CreditCoinSellerStock)
protected.GET("/admin/coin-seller-salary-rates/:region_id", middleware.RequirePermission("coin-seller:exchange-rate"), h.GetCoinSellerSalaryRates)
protected.PUT("/admin/coin-seller-salary-rates/:region_id", middleware.RequirePermission("coin-seller:exchange-rate"), h.ReplaceCoinSellerSalaryRates)
}

View File

@ -55,6 +55,64 @@ func (s *Service) ListCoinSellers(ctx context.Context, query listQuery) ([]*Coin
return s.reader.ListCoinSellers(ctx, query)
}
func (s *Service) GetCoinSellerSalaryRates(ctx context.Context, regionID int64) ([]CoinSellerSalaryRateTier, error) {
return s.reader.ListCoinSellerSalaryRates(ctx, regionID)
}
func (s *Service) ReplaceCoinSellerSalaryRates(ctx context.Context, actorID int64, regionID int64, req replaceCoinSellerSalaryRatesRequest) ([]CoinSellerSalaryRateTier, error) {
tiers := make([]CoinSellerSalaryRateTier, 0, len(req.Tiers))
for index, item := range req.Tiers {
minUSDMinor, err := salaryRateUSDMinor(item.MinUSDMinor, item.MinUSD)
if err != nil {
return nil, fmt.Errorf("min_usd is invalid")
}
maxUSDMinor, err := salaryRateUSDMinor(item.MaxUSDMinor, item.MaxUSD)
if err != nil {
return nil, fmt.Errorf("max_usd is invalid")
}
enabled := item.Status == "active"
if item.Enabled != nil {
enabled = *item.Enabled
}
status := strings.ToLower(strings.TrimSpace(item.Status))
if status == "" {
if enabled {
status = "active"
} else {
status = "disabled"
}
}
if status != "active" && status != "disabled" {
return nil, fmt.Errorf("status is invalid")
}
if minUSDMinor <= 0 || maxUSDMinor < minUSDMinor {
return nil, fmt.Errorf("usd range is invalid")
}
if item.CoinPerUSD <= 0 || item.CoinPerUSD%100 != 0 {
return nil, fmt.Errorf("coin_per_usd must be a positive multiple of 100")
}
sortOrder := item.SortOrder
if sortOrder == 0 {
sortOrder = (index + 1) * 10
}
tiers = append(tiers, CoinSellerSalaryRateTier{
RegionID: regionID,
MinUSDMinor: minUSDMinor,
MaxUSDMinor: maxUSDMinor,
MinUSD: formatUSDMinor(minUSDMinor),
MaxUSD: formatUSDMinor(maxUSDMinor),
CoinPerUSD: item.CoinPerUSD,
Status: status,
Enabled: status == "active",
SortOrder: sortOrder,
})
}
if err := validateCoinSellerSalaryRateTiers(tiers); err != nil {
return nil, err
}
return s.reader.ReplaceCoinSellerSalaryRates(ctx, regionID, actorID, tiers)
}
func (s *Service) CreateBDLeader(ctx context.Context, actorID int64, requestID string, req createBDLeaderRequest) (*userclient.BDProfile, error) {
targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID)
if err != nil {
@ -326,6 +384,68 @@ func parseUSDTAmountMicro(raw string) (int64, error) {
return amount, nil
}
func salaryRateUSDMinor(minor int64, raw string) (int64, error) {
if minor > 0 {
return minor, nil
}
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, fmt.Errorf("usd amount is required")
}
parts := strings.Split(raw, ".")
if len(parts) > 2 || parts[0] == "" {
return 0, fmt.Errorf("usd amount is invalid")
}
digits := parts[0]
fraction := ""
if len(parts) == 2 {
fraction = parts[1]
if len(fraction) > 2 {
return 0, fmt.Errorf("usd amount supports up to 2 decimals")
}
}
digits += fraction + strings.Repeat("0", 2-len(fraction))
var amount int64
const maxInt64 = int64(^uint64(0) >> 1)
for _, char := range digits {
if char < '0' || char > '9' {
return 0, fmt.Errorf("usd amount is invalid")
}
next := int64(char - '0')
if amount > (maxInt64-next)/10 {
return 0, fmt.Errorf("usd amount is too large")
}
amount = amount*10 + next
}
return amount, nil
}
func validateCoinSellerSalaryRateTiers(tiers []CoinSellerSalaryRateTier) error {
active := make([]CoinSellerSalaryRateTier, 0, len(tiers))
for _, tier := range tiers {
if tier.Status == "active" {
active = append(active, tier)
}
}
for i := 0; i < len(active); i++ {
for j := i + 1; j < len(active); j++ {
if active[i].MinUSDMinor <= active[j].MaxUSDMinor && active[j].MinUSDMinor <= active[i].MaxUSDMinor {
return fmt.Errorf("active usd ranges must not overlap")
}
}
}
return nil
}
func formatUSDMinor(value int64) string {
sign := ""
if value < 0 {
sign = "-"
value = -value
}
return fmt.Sprintf("%s%d.%02d", sign, value/100, value%100)
}
func normalizeListQuery(query listQuery) listQuery {
if query.Page < 1 {
query.Page = 1

View File

@ -84,6 +84,7 @@ var defaultPermissions = []model.Permission{
{Name: "币商创建", Code: "coin-seller:create", Kind: "button"},
{Name: "币商更新", Code: "coin-seller:update", Kind: "button"},
{Name: "币商进货", Code: "coin-seller:stock-credit", Kind: "button"},
{Name: "币商工资兑换比例", Code: "coin-seller:exchange-rate", Kind: "button"},
{Name: "金币流水查看", Code: "coin-ledger:view", Kind: "menu"},
{Name: "金币增减查看", Code: "coin-adjustment:view", Kind: "menu"},
{Name: "金币增减创建", Code: "coin-adjustment:create", Kind: "button"},
@ -499,7 +500,7 @@ func defaultRolePermissionCodes(code string) []string {
"host:view", "host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle",
"agency:view", "agency:create", "agency:status",
"bd:view", "bd:create", "bd:update",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate",
"coin-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "payment-bill:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"lucky-gift:view", "lucky-gift:update",
"game:view", "game:create", "game:update", "game:status", "game:delete",
@ -587,7 +588,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"country:view", "country:create", "country:update", "country:status",
"region:view", "region:create", "region:update", "region:status",
"host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate",
"coin-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "payment-bill:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"lucky-gift:view", "lucky-gift:update",
"game:view", "game:create", "game:update", "game:status", "game:delete",

View File

@ -73,6 +73,7 @@ type UserHostClient interface {
InviteBD(ctx context.Context, req *userv1.InviteBDRequest) (*userv1.InviteBDResponse, error)
ListBDLeaderBDs(ctx context.Context, req *userv1.ListBDLeaderBDsRequest) (*userv1.ListBDLeaderBDsResponse, error)
ListBDLeaderAgencies(ctx context.Context, req *userv1.ListBDLeaderAgenciesRequest) (*userv1.ListBDLeaderAgenciesResponse, error)
ListBDAgencies(ctx context.Context, req *userv1.ListBDAgenciesRequest) (*userv1.ListBDAgenciesResponse, error)
GetHostProfile(ctx context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error)
GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error)
GetCoinSellerProfile(ctx context.Context, req *userv1.GetCoinSellerProfileRequest) (*userv1.GetCoinSellerProfileResponse, error)
@ -335,6 +336,10 @@ func (c *grpcUserHostClient) ListBDLeaderAgencies(ctx context.Context, req *user
return c.client.ListBDLeaderAgencies(ctx, req)
}
func (c *grpcUserHostClient) ListBDAgencies(ctx context.Context, req *userv1.ListBDAgenciesRequest) (*userv1.ListBDAgenciesResponse, error) {
return c.client.ListBDAgencies(ctx, req)
}
func (c *grpcUserHostClient) GetHostProfile(ctx context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) {
return c.client.GetHostProfile(ctx, req)
}

View File

@ -24,6 +24,9 @@ type WalletClient interface {
GetMyVip(ctx context.Context, req *walletv1.GetMyVipRequest) (*walletv1.GetMyVipResponse, error)
PurchaseVip(ctx context.Context, req *walletv1.PurchaseVipRequest) (*walletv1.PurchaseVipResponse, error)
TransferCoinFromSeller(ctx context.Context, req *walletv1.TransferCoinFromSellerRequest) (*walletv1.TransferCoinFromSellerResponse, error)
ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, req *walletv1.ListCoinSellerSalaryExchangeRateTiersRequest) (*walletv1.ListCoinSellerSalaryExchangeRateTiersResponse, error)
ExchangeSalaryToCoin(ctx context.Context, req *walletv1.ExchangeSalaryToCoinRequest) (*walletv1.ExchangeSalaryToCoinResponse, error)
TransferSalaryToCoinSeller(ctx context.Context, req *walletv1.TransferSalaryToCoinSellerRequest) (*walletv1.TransferSalaryToCoinSellerResponse, error)
ListResources(ctx context.Context, req *walletv1.ListResourcesRequest) (*walletv1.ListResourcesResponse, error)
GetResource(ctx context.Context, req *walletv1.GetResourceRequest) (*walletv1.GetResourceResponse, error)
GetResourceGroup(ctx context.Context, req *walletv1.GetResourceGroupRequest) (*walletv1.GetResourceGroupResponse, error)
@ -108,6 +111,18 @@ func (c *grpcWalletClient) TransferCoinFromSeller(ctx context.Context, req *wall
return c.client.TransferCoinFromSeller(ctx, req)
}
func (c *grpcWalletClient) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, req *walletv1.ListCoinSellerSalaryExchangeRateTiersRequest) (*walletv1.ListCoinSellerSalaryExchangeRateTiersResponse, error) {
return c.client.ListCoinSellerSalaryExchangeRateTiers(ctx, req)
}
func (c *grpcWalletClient) ExchangeSalaryToCoin(ctx context.Context, req *walletv1.ExchangeSalaryToCoinRequest) (*walletv1.ExchangeSalaryToCoinResponse, error) {
return c.client.ExchangeSalaryToCoin(ctx, req)
}
func (c *grpcWalletClient) TransferSalaryToCoinSeller(ctx context.Context, req *walletv1.TransferSalaryToCoinSellerRequest) (*walletv1.TransferSalaryToCoinSellerResponse, error) {
return c.client.TransferSalaryToCoinSeller(ctx, req)
}
func (c *grpcWalletClient) ListResources(ctx context.Context, req *walletv1.ListResourcesRequest) (*walletv1.ListResourcesResponse, error) {
return c.client.ListResources(ctx, req)
}

View File

@ -84,6 +84,9 @@ type UserHandlers struct {
ListBDLeaderCenterAgencies http.HandlerFunc
InviteBDLeaderBD http.HandlerFunc
InviteBDLeaderAgency http.HandlerFunc
GetBDCenterOverview http.HandlerFunc
ListBDCenterAgencies http.HandlerFunc
InviteBDAgency http.HandlerFunc
CompleteMyOnboarding http.HandlerFunc
UpdateMyProfile http.HandlerFunc
ChangeMyCountry http.HandlerFunc
@ -187,6 +190,10 @@ type WalletHandlers struct {
ListWalletTransactions http.HandlerFunc
ListCoinSellers http.HandlerFunc
TransferCoinFromSeller http.HandlerFunc
GetSalaryWalletOverview http.HandlerFunc
SearchSalaryWalletSeller http.HandlerFunc
ExchangeSalaryToCoin http.HandlerFunc
TransferSalaryToSeller http.HandlerFunc
GetRedPacketConfig http.HandlerFunc
ListRoomRedPackets http.HandlerFunc
CreateRoomRedPacket http.HandlerFunc
@ -320,6 +327,9 @@ func (r routes) registerUserRoutes() {
r.profile("/bd-leader-center/agencies", http.MethodGet, h.ListBDLeaderCenterAgencies)
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/agencies", http.MethodGet, h.ListBDCenterAgencies)
r.profile("/bd/invitations/agency", http.MethodPost, h.InviteBDAgency)
r.auth("/users/me/onboarding/complete", "", h.CompleteMyOnboarding)
r.profile("/users/me/profile/update", "", h.UpdateMyProfile)
r.profile("/users/me/country/change", "", h.ChangeMyCountry)
@ -431,6 +441,10 @@ func (r routes) registerWalletRoutes() {
r.profile("/wallet/transactions", http.MethodGet, h.ListWalletTransactions)
r.profile("/wallet/coin-sellers", http.MethodGet, h.ListCoinSellers)
r.profile("/wallet/coin-seller/transfer", "", h.TransferCoinFromSeller)
r.profile("/salary-wallet/overview", http.MethodGet, h.GetSalaryWalletOverview)
r.profile("/salary-wallet/coin-sellers/search", http.MethodGet, h.SearchSalaryWalletSeller)
r.profile("/salary-wallet/exchange", http.MethodPost, h.ExchangeSalaryToCoin)
r.profile("/salary-wallet/transfer-to-coin-seller", http.MethodPost, h.TransferSalaryToSeller)
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

@ -407,6 +407,9 @@ type fakeUserHostClient struct {
lastLeaderAgencies *userv1.ListBDLeaderAgenciesRequest
leaderAgenciesResp *userv1.ListBDLeaderAgenciesResponse
leaderAgenciesErr error
lastBDAgencies *userv1.ListBDAgenciesRequest
bdAgenciesResp *userv1.ListBDAgenciesResponse
bdAgenciesErr error
last *userv1.GetCoinSellerProfileRequest
profile *userv1.CoinSellerProfile
err error
@ -480,6 +483,14 @@ type fakeWalletClient struct {
lastTransfer *walletv1.TransferCoinFromSellerRequest
transferResp *walletv1.TransferCoinFromSellerResponse
transferErr error
lastListExchangeTiers *walletv1.ListCoinSellerSalaryExchangeRateTiersRequest
listExchangeTiersResp *walletv1.ListCoinSellerSalaryExchangeRateTiersResponse
lastExchangeSalary *walletv1.ExchangeSalaryToCoinRequest
exchangeSalaryResp *walletv1.ExchangeSalaryToCoinResponse
exchangeSalaryErr error
lastTransferSalary *walletv1.TransferSalaryToCoinSellerRequest
transferSalaryResp *walletv1.TransferSalaryToCoinSellerResponse
transferSalaryErr error
lastListResources *walletv1.ListResourcesRequest
listResourcesResp *walletv1.ListResourcesResponse
lastGetResource *walletv1.GetResourceRequest
@ -1138,6 +1149,17 @@ func (f *fakeUserHostClient) ListBDLeaderAgencies(_ context.Context, req *userv1
return &userv1.ListBDLeaderAgenciesResponse{}, nil
}
func (f *fakeUserHostClient) ListBDAgencies(_ context.Context, req *userv1.ListBDAgenciesRequest) (*userv1.ListBDAgenciesResponse, error) {
f.lastBDAgencies = req
if f.bdAgenciesErr != nil {
return nil, f.bdAgenciesErr
}
if f.bdAgenciesResp != nil {
return f.bdAgenciesResp, nil
}
return &userv1.ListBDAgenciesResponse{}, nil
}
func (f *fakeUserHostClient) GetHostProfile(_ context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) {
f.lastHost = req
if f.hostErr != nil {
@ -1449,6 +1471,39 @@ func (f *fakeWalletClient) TransferCoinFromSeller(_ context.Context, req *wallet
return &walletv1.TransferCoinFromSellerResponse{}, nil
}
func (f *fakeWalletClient) ListCoinSellerSalaryExchangeRateTiers(_ context.Context, req *walletv1.ListCoinSellerSalaryExchangeRateTiersRequest) (*walletv1.ListCoinSellerSalaryExchangeRateTiersResponse, error) {
f.lastListExchangeTiers = req
if f.err != nil {
return nil, f.err
}
if f.listExchangeTiersResp != nil {
return f.listExchangeTiersResp, nil
}
return &walletv1.ListCoinSellerSalaryExchangeRateTiersResponse{}, nil
}
func (f *fakeWalletClient) ExchangeSalaryToCoin(_ context.Context, req *walletv1.ExchangeSalaryToCoinRequest) (*walletv1.ExchangeSalaryToCoinResponse, error) {
f.lastExchangeSalary = req
if f.exchangeSalaryErr != nil {
return nil, f.exchangeSalaryErr
}
if f.exchangeSalaryResp != nil {
return f.exchangeSalaryResp, nil
}
return &walletv1.ExchangeSalaryToCoinResponse{}, nil
}
func (f *fakeWalletClient) TransferSalaryToCoinSeller(_ context.Context, req *walletv1.TransferSalaryToCoinSellerRequest) (*walletv1.TransferSalaryToCoinSellerResponse, error) {
f.lastTransferSalary = req
if f.transferSalaryErr != nil {
return nil, f.transferSalaryErr
}
if f.transferSalaryResp != nil {
return f.transferSalaryResp, nil
}
return &walletv1.TransferSalaryToCoinSellerResponse{}, nil
}
func (f *fakeWalletClient) ListResources(_ context.Context, req *walletv1.ListResourcesRequest) (*walletv1.ListResourcesResponse, error) {
f.lastListResources = req
if f.err != nil {
@ -3579,6 +3634,94 @@ func TestAgencyCenterOverviewUsesOwnerAgencySalaryAndPendingApplications(t *test
}
}
func TestBDCenterOverviewUsesBDSalaryAndDirectAgencies(t *testing.T) {
hostClient := &fakeUserHostClient{
bdProfile: &userv1.BDProfile{UserId: 42, Role: "bd", Status: "active", RegionId: 1001},
bdAgenciesResp: &userv1.ListBDAgenciesResponse{Agencies: []*userv1.Agency{
{AgencyId: 7001, OwnerUserId: 99, ParentBdUserId: 42, Name: "Yumi Star Agency", Status: "active"},
}},
}
walletClient := &fakeWalletClient{balancesByUserID: map[int64]*walletv1.GetBalancesResponse{
42: {Balances: []*walletv1.AssetBalance{{AssetType: "BD_SALARY_USD", AvailableAmount: 4862080}}},
}}
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
42: {UserId: 42, DisplayUserId: "163008", Username: "BD_Jasmine", Avatar: "https://cdn.example/bd.png"},
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
handler.SetUserHostClient(hostClient)
handler.SetWalletClient(walletClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/bd-center/overview", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-bd-center-overview")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if hostClient.lastBD == nil || hostClient.lastBD.GetUserId() != 42 || hostClient.lastBDAgencies == nil || hostClient.lastBDAgencies.GetBdUserId() != 42 {
t.Fatalf("bd center requests mismatch: profile=%+v agencies=%+v", hostClient.lastBD, hostClient.lastBDAgencies)
}
if walletClient.last == nil || walletClient.last.GetUserId() != 42 || len(walletClient.last.GetAssetTypes()) != 1 || walletClient.last.GetAssetTypes()[0] != "BD_SALARY_USD" {
t.Fatalf("bd salary request mismatch: %+v", walletClient.last)
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data := response.Data.(map[string]any)
profile := data["profile"].(map[string]any)
salary := data["salary"].(map[string]any)
overview := data["overview"].(map[string]any)
if profile["display_user_id"] != "163008" || salary["asset_type"] != "BD_SALARY_USD" || salary["display_amount"] != 48620.8 || overview["total_agency"] != float64(1) {
t.Fatalf("bd center overview response mismatch: %+v", data)
}
}
func TestBDCenterInviteAgencyDirectlyForwardsInviteAgency(t *testing.T) {
hostClient := &fakeUserHostClient{
bdProfile: &userv1.BDProfile{UserId: 42, Role: "bd", Status: "active", RegionId: 1001},
inviteAgencyResp: &userv1.InviteAgencyResponse{Invitation: &userv1.RoleInvitation{
InvitationId: 9001,
CommandId: "cmd-bd-invite-agency",
InvitationType: "agency",
Status: "accepted",
InviterUserId: 42,
TargetUserId: 99,
AgencyName: "Target Agency",
}},
}
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
99: {UserId: 99, DisplayUserId: "163099", Username: "Target Agency"},
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
handler.SetUserHostClient(hostClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/bd/invitations/agency", bytes.NewReader([]byte(`{"command_id":"cmd-bd-invite-agency","target_user_id":"99"}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if hostClient.lastInviteAgency == nil || hostClient.lastInviteAgency.GetInviterUserId() != 42 || hostClient.lastInviteAgency.GetTargetUserId() != 99 || hostClient.lastInviteAgency.GetAgencyName() != "Target Agency" {
t.Fatalf("bd invite agency request mismatch: %+v", hostClient.lastInviteAgency)
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data := response.Data.(map[string]any)
invitation := data["invitation"].(map[string]any)
if invitation["status"] != "accepted" || invitation["target_user_id"] != "99" {
t.Fatalf("bd invite response mismatch: %+v", data)
}
}
func TestAgencyCenterOverviewRejectsNonOwner(t *testing.T) {
hostClient := &fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{UserId: 42, IsManager: false, AgencyId: 7001}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
@ -5260,6 +5403,63 @@ func TestListCoinTransactionsForcesCoinAndDefaultsPageSize30(t *testing.T) {
}
}
func TestListWalletTransactionsIncludesCounterpartyProfile(t *testing.T) {
walletClient := &fakeWalletClient{transactionsResp: &walletv1.ListWalletTransactionsResponse{
Total: 1,
Transactions: []*walletv1.WalletTransaction{{
EntryId: 9,
TransactionId: "wtx-seller-1",
BizType: "coin_seller_transfer",
AssetType: "COIN_SELLER_COIN",
AvailableDelta: -10_000,
AvailableAfter: 1_011,
CounterpartyUserId: 9001,
CreatedAtMs: 1_780_000_000_000,
}},
}}
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
9001: {
UserId: 9001,
DisplayUserId: "163001",
Username: "Alice",
Avatar: "https://cdn.example/alice.png",
AppCode: "lalu",
},
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
handler.SetWalletClient(walletClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/wallet/transactions?asset_type=COIN_SELLER_COIN&page_size=20", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-wallet-ledger")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if walletClient.lastTransactions == nil || walletClient.lastTransactions.GetAssetType() != "COIN_SELLER_COIN" || walletClient.lastTransactions.GetPageSize() != 20 {
t.Fatalf("wallet transaction request mismatch: %+v", walletClient.lastTransactions)
}
if profileClient.lastBatch == nil || len(profileClient.lastBatch.GetUserIds()) != 1 || profileClient.lastBatch.GetUserIds()[0] != 9001 {
t.Fatalf("counterparty profile batch mismatch: %+v", profileClient.lastBatch)
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
items, itemsOK := data["items"].([]any)
if response.Code != httpkit.CodeOK || !ok || !itemsOK || len(items) != 1 {
t.Fatalf("wallet transaction response mismatch: %+v", response)
}
first, ok := items[0].(map[string]any)
if !ok || first["counterparty_display_user_id"] != "163001" || first["counterparty_username"] != "Alice" || first["counterparty_avatar"] != "https://cdn.example/alice.png" {
t.Fatalf("counterparty profile fields mismatch: %+v", first)
}
}
func TestListRechargeProductsUsesProfileRegionAndPlatform(t *testing.T) {
walletClient := &fakeWalletClient{rechargeProductsResp: &walletv1.ListRechargeProductsResponse{
Channels: []string{"google"},
@ -5447,6 +5647,90 @@ func TestCoinSellerTransferChecksIdentityAndPropagatesWalletCommand(t *testing.T
}
}
func TestCoinSellerTransferAllowsSelfTargetAndSkipsDuplicateRegionLookup(t *testing.T) {
walletClient := &fakeWalletClient{transferResp: &walletv1.TransferCoinFromSellerResponse{
TransactionId: "wtx-coin-seller-self",
SellerBalanceAfter: 1010,
TargetBalanceAfter: 1,
Amount: 1,
}}
hostClient := &fakeUserHostClient{profile: &userv1.CoinSellerProfile{
UserId: 42,
Status: "active",
MerchantAssetType: "COIN_SELLER_COIN",
}}
identityClient := &fakeUserIdentityClient{resolveByDisplay: map[string]int64{"163000": 42}}
profileClient := &fakeUserProfileClient{regionByUserID: map[int64]int64{42: 688}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient)
handler.SetWalletClient(walletClient)
handler.SetUserHostClient(hostClient)
router := handler.Routes(auth.NewVerifier("secret"))
body := []byte(`{"command_id":"cmd-seller-self","target_display_user_id":"163000","amount":1,"reason":"self recharge"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/wallet/coin-seller/transfer", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if walletClient.lastTransfer == nil ||
walletClient.lastTransfer.GetSellerUserId() != 42 ||
walletClient.lastTransfer.GetTargetUserId() != 42 ||
walletClient.lastTransfer.GetSellerRegionId() != 688 ||
walletClient.lastTransfer.GetTargetRegionId() != 688 ||
walletClient.lastTransfer.GetAmount() != 1 {
t.Fatalf("self wallet transfer request mismatch: %+v", walletClient.lastTransfer)
}
if len(profileClient.getRequests) != 1 || profileClient.getRequests[0].GetUserId() != 42 {
t.Fatalf("self transfer should only lookup seller profile once: %+v", profileClient.getRequests)
}
}
func TestResolveDisplayUserIDIncludesProfileForH5Lookup(t *testing.T) {
identityClient := &fakeUserIdentityClient{resolveByDisplay: map[string]int64{"163000": 42}}
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
42: {
UserId: 42,
DisplayUserId: "163000",
Username: "xcxcxcxc",
Avatar: "https://cdn.example/avatar.png",
RegionId: 688,
Status: userv1.UserStatus_USER_STATUS_ACTIVE,
},
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient)
if handler.userProfileClient == nil {
t.Fatal("profile client was not attached to gateway handler")
}
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/users/by-display-user-id/163000", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if identityClient.lastResolve == nil || identityClient.lastResolve.GetDisplayUserId() != "163000" {
t.Fatalf("resolve request mismatch: %+v", identityClient.lastResolve)
}
if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 {
t.Fatalf("profile lookup mismatch: %+v", profileClient.lastGet)
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data := response.Data.(map[string]any)
profile := data["profile"].(map[string]any)
identity := data["identity"].(map[string]any)
if data["display_user_id"] != "163000" || identity["display_user_id"] != "163000" || profile["username"] != "xcxcxcxc" || profile["avatar"] != "https://cdn.example/avatar.png" {
t.Fatalf("resolve display user response mismatch: %+v", data)
}
}
func TestCoinSellerTransferRejectsCrossRegionBeforeWallet(t *testing.T) {
walletClient := &fakeWalletClient{}
hostClient := &fakeUserHostClient{profile: &userv1.CoinSellerProfile{

View File

@ -12,6 +12,7 @@ import (
const (
adminSalaryAssetType = "ADMIN_SALARY_USD"
bdSalaryAssetType = "BD_SALARY_USD"
bdStatusActive = "active"
)
@ -79,6 +80,49 @@ func (h *Handler) getBDLeaderCenterOverview(writer http.ResponseWriter, request
})
}
func (h *Handler) getBDCenterOverview(writer http.ResponseWriter, request *http.Request) {
userID := auth.UserIDFromContext(request.Context())
profile, ok := h.resolveActiveBD(writer, request, userID)
if !ok {
return
}
if h.walletClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
users, err := h.userProfiles(request, []int64{userID})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
// BD Center 的钱包只读取 BD 自己的工资资产,避免沿用 BD Leader 后台工资资产。
salary, err := h.salaryBalance(request, userID, bdSalaryAssetType)
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
agencyResp, 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
}
httpkit.WriteOK(writer, request, map[string]any{
"profile": users[userID],
"bd_profile": bdLeaderProfileFromProto(profile),
"salary": salary,
"overview": map[string]any{
"total_agency": len(agencyResp.GetAgencies()),
},
})
}
func (h *Handler) listBDLeaderCenterBDs(writer http.ResponseWriter, request *http.Request) {
userID := auth.UserIDFromContext(request.Context())
if _, ok := h.resolveActiveBDLeader(writer, request, userID); !ok {
@ -188,6 +232,57 @@ func (h *Handler) listBDLeaderCenterAgencies(writer http.ResponseWriter, request
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": len(items)})
}
func (h *Handler) listBDCenterAgencies(writer http.ResponseWriter, request *http.Request) {
userID := auth.UserIDFromContext(request.Context())
if _, ok := h.resolveActiveBD(writer, request, userID); !ok {
return
}
pageSize := queryPageSize(request, 20)
resp, err := h.userHostClient.ListBDAgencies(request.Context(), &userv1.ListBDAgenciesRequest{
Meta: httpkit.UserMeta(request, ""),
BdUserId: userID,
Status: agencyStatusActive,
PageSize: int32(pageSize),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
userIDs := make([]int64, 0, len(resp.GetAgencies())+1)
userIDs = append(userIDs, userID)
for _, agency := range resp.GetAgencies() {
userIDs = append(userIDs, agency.GetOwnerUserId())
}
users, err := h.userProfiles(request, userIDs)
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
items := make([]map[string]any, 0, len(resp.GetAgencies()))
for _, agency := range resp.GetAgencies() {
// Host count is derived from active membership rows so the H5 list reflects current agency size.
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
}
items = append(items, map[string]any{
"agency": hostAgencyFromProto(agency),
"owner": users[agency.GetOwnerUserId()],
"parent_bd": users[userID],
"host_count": len(membersResp.GetMemberships()),
"created_at_ms": agency.GetCreatedAtMs(),
})
}
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": len(items)})
}
func (h *Handler) inviteBDLeaderBD(writer http.ResponseWriter, request *http.Request) {
h.inviteBDLeaderRole(writer, request, "bd")
}
@ -251,6 +346,47 @@ func (h *Handler) inviteBDLeaderRole(writer http.ResponseWriter, request *http.R
httpkit.WriteOK(writer, request, map[string]any{"invitation": roleInvitationData(resp.GetInvitation())})
}
func (h *Handler) inviteBDAgency(writer http.ResponseWriter, request *http.Request) {
userID := auth.UserIDFromContext(request.Context())
if _, ok := h.resolveActiveBD(writer, request, userID); !ok {
return
}
var body map[string]any
if !httpkit.Decode(writer, request, &body) {
return
}
commandID := stringJSONValue(body["command_id"])
targetUserID := int64JSONValue(body["target_user_id"])
if commandID == "" || targetUserID <= 0 {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
agencyName := stringJSONValue(body["agency_name"])
if agencyName == "" {
users, err := h.userProfiles(request, []int64{targetUserID})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
target := users[targetUserID]
agencyName = firstBDLeaderNonBlank(target.Username, target.DisplayUserID, int64String(targetUserID))
}
// InviteAgency is already a direct-accept transaction in user-service, so this HTTP endpoint does not create a pending approval step.
resp, err := h.userHostClient.InviteAgency(request.Context(), &userv1.InviteAgencyRequest{
Meta: httpkit.UserMeta(request, ""),
CommandId: commandID,
InviterUserId: userID,
TargetUserId: targetUserID,
AgencyName: agencyName,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, map[string]any{"invitation": roleInvitationData(resp.GetInvitation())})
}
func (h *Handler) resolveActiveBDLeader(writer http.ResponseWriter, request *http.Request, userID int64) (*userv1.BDProfile, bool) {
if h.userHostClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
@ -272,6 +408,27 @@ func (h *Handler) resolveActiveBDLeader(writer http.ResponseWriter, request *htt
return profile, true
}
func (h *Handler) resolveActiveBD(writer http.ResponseWriter, request *http.Request, userID int64) (*userv1.BDProfile, bool) {
if h.userHostClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return nil, false
}
resp, err := h.userHostClient.GetBDProfile(request.Context(), &userv1.GetBDProfileRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: userID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return nil, false
}
profile := resp.GetBdProfile()
if profile == nil || profile.GetStatus() != bdStatusActive || (profile.GetRole() != "bd" && profile.GetRole() != "bd_leader") {
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
return nil, false
}
return profile, true
}
func bdLeaderProfileFromProto(profile *userv1.BDProfile) bdLeaderProfileData {
if profile == nil {
return bdLeaderProfileData{}

View File

@ -68,6 +68,9 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers {
ListBDLeaderCenterAgencies: h.listBDLeaderCenterAgencies,
InviteBDLeaderBD: h.inviteBDLeaderBD,
InviteBDLeaderAgency: h.inviteBDLeaderAgency,
GetBDCenterOverview: h.getBDCenterOverview,
ListBDCenterAgencies: h.listBDCenterAgencies,
InviteBDAgency: h.inviteBDAgency,
CompleteMyOnboarding: h.completeMyOnboarding,
UpdateMyProfile: h.updateMyProfile,
ChangeMyCountry: h.changeMyCountry,

View File

@ -25,6 +25,12 @@ type userIdentityData struct {
LeaseID string `json:"lease_id,omitempty"`
}
type resolveDisplayUserData struct {
userIdentityData
Identity userIdentityData `json:"identity"`
Profile userProfileData `json:"profile,omitempty"`
}
// userProfileData 是 gateway 对外返回的用户基础资料投影。
// 它不包含注册来源、三方身份或 refresh session 等服务端内部字段。
type userProfileData struct {
@ -158,7 +164,24 @@ func (h *Handler) getMyIdentity(writer http.ResponseWriter, request *http.Reques
return
}
httpkit.WriteOK(writer, request, identityData(resp.GetIdentity(), ""))
identity := resp.GetIdentity()
identityView := identityData(identity, "")
data := resolveDisplayUserData{
userIdentityData: identityView,
Identity: identityView,
}
if h.userProfileClient != nil && identity.GetUserId() > 0 {
profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: identity.GetUserId(),
})
if err != nil {
logx.Warn(request.Context(), "resolve_display_user_profile_failed", slog.Int64("user_id", identity.GetUserId()), slog.String("error", err.Error()))
} else {
data.Profile = profileData(profileResp.GetUser(), 0)
}
}
httpkit.WriteOK(writer, request, data)
}
// resolveDisplayUserID 通过当前有效展示号解析用户。
@ -182,7 +205,25 @@ func (h *Handler) resolveDisplayUserID(writer http.ResponseWriter, request *http
return
}
httpkit.WriteOK(writer, request, identityData(resp.GetIdentity(), ""))
identity := resp.GetIdentity()
identityView := identityData(identity, "")
data := resolveDisplayUserData{
userIdentityData: identityView,
Identity: identityView,
}
if h.userProfileClient != nil && identity.GetUserId() > 0 {
// H5 搜索短号后要展示头像和昵称;短号解析只负责稳定 ID资料展示必须回 user-service 用 user_id 再补齐。
profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: identity.GetUserId(),
})
if err != nil {
logx.Warn(request.Context(), "resolve_display_user_profile_failed", slog.Int64("user_id", identity.GetUserId()), slog.String("error", err.Error()))
} else {
data.Profile = profileData(profileResp.GetUser(), 0)
}
}
httpkit.WriteOK(writer, request, data)
}
// completeMyOnboarding 是注册页唯一资料提交入口。

View File

@ -50,17 +50,20 @@ type walletFeatureFlagsData struct {
}
type walletTransactionData struct {
EntryID int64 `json:"entry_id"`
TransactionID string `json:"transaction_id"`
BizType string `json:"biz_type"`
AssetType string `json:"asset_type"`
AvailableDelta int64 `json:"available_delta"`
FrozenDelta int64 `json:"frozen_delta"`
AvailableAfter int64 `json:"available_after"`
FrozenAfter int64 `json:"frozen_after"`
CounterpartyUserID int64 `json:"counterparty_user_id"`
RoomID string `json:"room_id"`
CreatedAtMS int64 `json:"created_at_ms"`
EntryID int64 `json:"entry_id"`
TransactionID string `json:"transaction_id"`
BizType string `json:"biz_type"`
AssetType string `json:"asset_type"`
AvailableDelta int64 `json:"available_delta"`
FrozenDelta int64 `json:"frozen_delta"`
AvailableAfter int64 `json:"available_after"`
FrozenAfter int64 `json:"frozen_after"`
CounterpartyUserID int64 `json:"counterparty_user_id"`
CounterpartyDisplayUserID string `json:"counterparty_display_user_id,omitempty"`
CounterpartyUsername string `json:"counterparty_username,omitempty"`
CounterpartyAvatar string `json:"counterparty_avatar,omitempty"`
RoomID string `json:"room_id"`
CreatedAtMS int64 `json:"created_at_ms"`
}
type giftWallData struct {
@ -358,13 +361,49 @@ func (h *Handler) listWalletTransactionsByAsset(writer http.ResponseWriter, requ
httpkit.WriteRPCError(writer, request, err)
return
}
profiles, err := h.walletTransactionCounterpartyProfiles(request, resp.GetTransactions())
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
items := make([]walletTransactionData, 0, len(resp.GetTransactions()))
for _, item := range resp.GetTransactions() {
items = append(items, walletTransactionFromProto(item))
items = append(items, walletTransactionFromProto(item, profiles))
}
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": resp.GetTotal(), "page": page, "page_size": pageSize})
}
func (h *Handler) walletTransactionCounterpartyProfiles(request *http.Request, transactions []*walletv1.WalletTransaction) (map[int64]*userv1.User, error) {
if h.userProfileClient == nil {
return map[int64]*userv1.User{}, nil
}
userIDs := make([]int64, 0, len(transactions))
seen := make(map[int64]struct{}, len(transactions))
for _, transaction := range transactions {
userID := transaction.GetCounterpartyUserId()
if userID <= 0 {
continue
}
if _, exists := seen[userID]; exists {
continue
}
seen[userID] = struct{}{}
userIDs = append(userIDs, userID)
}
if len(userIDs) == 0 {
return map[int64]*userv1.User{}, nil
}
// 钱包流水只保存稳定 user_id展示昵称、短号和头像必须回 user-service 批量补齐,避免 H5 把长 user_id 当短号展示。
resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
Meta: httpkit.UserMeta(request, ""),
UserIds: userIDs,
})
if err != nil {
return nil, err
}
return resp.GetUsers(), nil
}
func rechargeProductFromProto(product *walletv1.RechargeProduct) rechargeProductData {
if product == nil {
return rechargeProductData{}
@ -438,11 +477,11 @@ func walletOverviewFromProto(resp *walletv1.GetWalletOverviewResponse) walletOve
}
}
func walletTransactionFromProto(item *walletv1.WalletTransaction) walletTransactionData {
func walletTransactionFromProto(item *walletv1.WalletTransaction, profiles map[int64]*userv1.User) walletTransactionData {
if item == nil {
return walletTransactionData{}
}
return walletTransactionData{
data := walletTransactionData{
EntryID: item.GetEntryId(),
TransactionID: item.GetTransactionId(),
BizType: item.GetBizType(),
@ -455,6 +494,12 @@ func walletTransactionFromProto(item *walletv1.WalletTransaction) walletTransact
RoomID: item.GetRoomId(),
CreatedAtMS: item.GetCreatedAtMs(),
}
if profile := profiles[item.GetCounterpartyUserId()]; profile != nil {
data.CounterpartyDisplayUserID = profile.GetDisplayUserId()
data.CounterpartyUsername = profile.GetUsername()
data.CounterpartyAvatar = profile.GetAvatar()
}
return data
}
func giftWallFromProto(resp *walletv1.GetUserGiftWallResponse) giftWallData {

View File

@ -52,6 +52,10 @@ func (h *Handler) WalletHandlers() httproutes.WalletHandlers {
ListWalletTransactions: h.listWalletTransactions,
ListCoinSellers: h.listCoinSellers,
TransferCoinFromSeller: h.transferCoinFromSeller,
GetSalaryWalletOverview: h.getSalaryWalletOverview,
SearchSalaryWalletSeller: h.searchSalaryWalletSeller,
ExchangeSalaryToCoin: h.exchangeSalaryToCoin,
TransferSalaryToSeller: h.transferSalaryToCoinSeller,
GetRedPacketConfig: h.getRedPacketConfig,
ListRoomRedPackets: h.listRoomRedPackets,
CreateRoomRedPacket: h.createRoomRedPacket,

View File

@ -0,0 +1,501 @@
package walletapi
import (
"fmt"
"net/http"
"strconv"
"strings"
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"
)
const (
salaryIdentityHost = "HOST"
salaryIdentityAgency = "AGENCY"
salaryIdentityBD = "BD"
salaryIdentityBDLeader = "BD_LEADER"
salaryAssetHost = "HOST_SALARY_USD"
salaryAssetAgency = "AGENCY_SALARY_USD"
salaryAssetBD = "BD_SALARY_USD"
salaryAssetBDLeader = "ADMIN_SALARY_USD"
)
type salaryWalletIdentityContext struct {
Identity string
SalaryAssetType string
UserID int64
RegionID int64
}
type salaryWalletOverviewData struct {
Identity string `json:"identity"`
SalaryAssetType string `json:"salary_asset_type"`
RegionID int64 `json:"region_id"`
Salary salaryWalletBalanceData `json:"salary"`
ExchangeRate int64 `json:"exchange_rate"`
TransferRateTiers []salaryWalletExchangeRateTierData `json:"transfer_rate_tiers"`
}
type salaryWalletBalanceData struct {
AssetType string `json:"asset_type"`
AvailableAmount int64 `json:"available_amount"`
FrozenAmount int64 `json:"frozen_amount"`
DisplayAmount string `json:"display_amount"`
}
type salaryWalletExchangeRateTierData struct {
RegionID int64 `json:"region_id"`
MinUSDMinor int64 `json:"min_usd_minor"`
MaxUSDMinor int64 `json:"max_usd_minor"`
CoinPerUSD int64 `json:"coin_per_usd"`
Status string `json:"status"`
SortOrder int32 `json:"sort_order"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type salaryWalletAmountBody struct {
CommandID string `json:"command_id"`
CommandIDCamel string `json:"commandId"`
Identity string `json:"identity"`
AmountUSD string `json:"amount_usd"`
AmountUSDCamel string `json:"amountUsd"`
AmountUSDMinor int64 `json:"amount_usd_minor"`
AmountUSDMinorC int64 `json:"amountUsdMinor"`
Reason string `json:"reason"`
}
type salaryWalletTransferBody struct {
CommandID string `json:"command_id"`
CommandIDCamel string `json:"commandId"`
Identity string `json:"identity"`
TargetDisplayUserID string `json:"target_display_user_id"`
TargetDisplayUserIDC string `json:"targetDisplayUserId"`
AmountUSD string `json:"amount_usd"`
AmountUSDCamel string `json:"amountUsd"`
AmountUSDMinor int64 `json:"amount_usd_minor"`
AmountUSDMinorC int64 `json:"amountUsdMinor"`
Reason string `json:"reason"`
}
func (b salaryWalletAmountBody) commandID() string {
if value := strings.TrimSpace(b.CommandID); value != "" {
return value
}
return strings.TrimSpace(b.CommandIDCamel)
}
func (b salaryWalletAmountBody) amountUSDMinor() (int64, error) {
if b.AmountUSDMinor > 0 {
return b.AmountUSDMinor, nil
}
if b.AmountUSDMinorC > 0 {
return b.AmountUSDMinorC, nil
}
if value := strings.TrimSpace(b.AmountUSD); value != "" {
return parseUSDMinor(value)
}
return parseUSDMinor(b.AmountUSDCamel)
}
func (b salaryWalletTransferBody) commandID() string {
if value := strings.TrimSpace(b.CommandID); value != "" {
return value
}
return strings.TrimSpace(b.CommandIDCamel)
}
func (b salaryWalletTransferBody) targetDisplayUserID() string {
if value := strings.TrimSpace(b.TargetDisplayUserID); value != "" {
return value
}
return strings.TrimSpace(b.TargetDisplayUserIDC)
}
func (b salaryWalletTransferBody) amountUSDMinor() (int64, error) {
if b.AmountUSDMinor > 0 {
return b.AmountUSDMinor, nil
}
if b.AmountUSDMinorC > 0 {
return b.AmountUSDMinorC, nil
}
if value := strings.TrimSpace(b.AmountUSD); value != "" {
return parseUSDMinor(value)
}
return parseUSDMinor(b.AmountUSDCamel)
}
func (h *Handler) getSalaryWalletOverview(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil || h.userHostClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
identity, ok := h.resolveSalaryWalletIdentity(writer, request, request.URL.Query().Get("identity"))
if !ok {
return
}
balanceResp, err := h.walletClient.GetBalances(request.Context(), &walletv1.GetBalancesRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
UserId: identity.UserID,
AssetTypes: []string{identity.SalaryAssetType},
AppCode: appcode.FromContext(request.Context()),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
ratesResp, err := h.walletClient.ListCoinSellerSalaryExchangeRateTiers(request.Context(), &walletv1.ListCoinSellerSalaryExchangeRateTiersRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
RegionId: identity.RegionID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, salaryWalletOverviewData{
Identity: identity.Identity,
SalaryAssetType: identity.SalaryAssetType,
RegionID: identity.RegionID,
Salary: salaryBalanceFromProto(identity.SalaryAssetType, firstBalance(balanceResp.GetBalances())),
ExchangeRate: 80000,
TransferRateTiers: salaryRateTiersFromProto(ratesResp.GetTiers()),
})
}
func (h *Handler) searchSalaryWalletSeller(writer http.ResponseWriter, request *http.Request) {
if h.userHostClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
identity, ok := h.resolveSalaryWalletIdentity(writer, request, request.URL.Query().Get("identity"))
if !ok {
return
}
displayUserID := strings.TrimSpace(request.URL.Query().Get("display_user_id"))
if displayUserID == "" {
displayUserID = strings.TrimSpace(request.URL.Query().Get("displayUserId"))
}
if displayUserID == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
seller, found, ok := h.findSameRegionSalaryCoinSeller(writer, request, identity.RegionID, displayUserID)
if !ok {
return
}
if !found {
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
return
}
httpkit.WriteOK(writer, request, map[string]any{"seller": coinSellerFromProto(seller)})
}
func (h *Handler) exchangeSalaryToCoin(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil || h.userHostClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body salaryWalletAmountBody
if !httpkit.Decode(writer, request, &body) {
return
}
commandID := body.commandID()
amountUSDMinor, err := body.amountUSDMinor()
if err != nil || commandID == "" || amountUSDMinor <= 0 {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
identity, ok := h.resolveSalaryWalletIdentity(writer, request, body.Identity)
if !ok {
return
}
reason := strings.TrimSpace(body.Reason)
if reason == "" {
reason = "salary exchange"
}
resp, err := h.walletClient.ExchangeSalaryToCoin(request.Context(), &walletv1.ExchangeSalaryToCoinRequest{
CommandId: commandID,
UserId: identity.UserID,
SalaryAssetType: identity.SalaryAssetType,
SalaryUsdMinor: amountUSDMinor,
Reason: reason,
AppCode: appcode.FromContext(request.Context()),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, map[string]any{
"transaction_id": resp.GetTransactionId(),
"salary_balance_after": resp.GetSalaryBalanceAfter(),
"coin_balance_after": resp.GetCoinBalanceAfter(),
"salary_usd_minor": resp.GetSalaryUsdMinor(),
"coin_amount": resp.GetCoinAmount(),
"coin_per_usd": resp.GetCoinPerUsd(),
})
}
func (h *Handler) transferSalaryToCoinSeller(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil || h.userHostClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body salaryWalletTransferBody
if !httpkit.Decode(writer, request, &body) {
return
}
commandID := body.commandID()
displayUserID := body.targetDisplayUserID()
amountUSDMinor, err := body.amountUSDMinor()
if err != nil || commandID == "" || displayUserID == "" || amountUSDMinor <= 0 {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
identity, ok := h.resolveSalaryWalletIdentity(writer, request, body.Identity)
if !ok {
return
}
seller, found, ok := h.findSameRegionSalaryCoinSeller(writer, request, identity.RegionID, displayUserID)
if !ok {
return
}
if !found {
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
return
}
reason := strings.TrimSpace(body.Reason)
if reason == "" {
reason = "salary transfer"
}
resp, err := h.walletClient.TransferSalaryToCoinSeller(request.Context(), &walletv1.TransferSalaryToCoinSellerRequest{
CommandId: commandID,
SourceUserId: identity.UserID,
SellerUserId: seller.GetUserId(),
SalaryAssetType: identity.SalaryAssetType,
SalaryUsdMinor: amountUSDMinor,
RegionId: identity.RegionID,
Reason: reason,
AppCode: appcode.FromContext(request.Context()),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, map[string]any{
"transaction_id": resp.GetTransactionId(),
"source_salary_balance_after": resp.GetSourceSalaryBalanceAfter(),
"seller_balance_after": resp.GetSellerBalanceAfter(),
"salary_usd_minor": resp.GetSalaryUsdMinor(),
"coin_amount": resp.GetCoinAmount(),
"coin_per_usd": resp.GetCoinPerUsd(),
"rate_min_usd_minor": resp.GetRateMinUsdMinor(),
"rate_max_usd_minor": resp.GetRateMaxUsdMinor(),
})
}
func (h *Handler) resolveSalaryWalletIdentity(writer http.ResponseWriter, request *http.Request, rawIdentity string) (salaryWalletIdentityContext, bool) {
identity := normalizeSalaryWalletIdentity(rawIdentity)
if identity == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return salaryWalletIdentityContext{}, false
}
userID := auth.UserIDFromContext(request.Context())
switch identity {
case salaryIdentityHost:
resp, err := h.userHostClient.GetHostProfile(request.Context(), &userv1.GetHostProfileRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: userID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return salaryWalletIdentityContext{}, false
}
profile := resp.GetHostProfile()
if profile == nil || profile.GetUserId() != userID || profile.GetStatus() != "active" || profile.GetRegionId() <= 0 {
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
return salaryWalletIdentityContext{}, false
}
return salaryWalletIdentityContext{Identity: identity, SalaryAssetType: salaryAssetHost, UserID: userID, RegionID: profile.GetRegionId()}, true
case salaryIdentityAgency:
return h.resolveAgencySalaryIdentity(writer, request, userID)
case salaryIdentityBD, salaryIdentityBDLeader:
return h.resolveBDSalaryIdentity(writer, request, userID, identity)
default:
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return salaryWalletIdentityContext{}, false
}
}
func (h *Handler) resolveAgencySalaryIdentity(writer http.ResponseWriter, request *http.Request, userID int64) (salaryWalletIdentityContext, bool) {
roleResp, err := h.userHostClient.GetUserRoleSummary(request.Context(), &userv1.GetUserRoleSummaryRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: userID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return salaryWalletIdentityContext{}, false
}
summary := roleResp.GetSummary()
if summary == nil || summary.GetAgencyId() <= 0 || (!summary.GetIsAgency() && !summary.GetIsManager()) {
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
return salaryWalletIdentityContext{}, false
}
agencyResp, err := h.userHostClient.GetAgency(request.Context(), &userv1.GetAgencyRequest{
Meta: httpkit.UserMeta(request, ""),
AgencyId: summary.GetAgencyId(),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return salaryWalletIdentityContext{}, false
}
agency := agencyResp.GetAgency()
if agency == nil || agency.GetOwnerUserId() != userID || agency.GetStatus() != "active" || agency.GetRegionId() <= 0 {
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
return salaryWalletIdentityContext{}, false
}
return salaryWalletIdentityContext{Identity: salaryIdentityAgency, SalaryAssetType: salaryAssetAgency, UserID: userID, RegionID: agency.GetRegionId()}, true
}
func (h *Handler) resolveBDSalaryIdentity(writer http.ResponseWriter, request *http.Request, userID int64, identity string) (salaryWalletIdentityContext, bool) {
resp, err := h.userHostClient.GetBDProfile(request.Context(), &userv1.GetBDProfileRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: userID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return salaryWalletIdentityContext{}, false
}
profile := resp.GetBdProfile()
if profile == nil || profile.GetUserId() != userID || profile.GetStatus() != "active" || profile.GetRegionId() <= 0 {
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
return salaryWalletIdentityContext{}, false
}
if identity == salaryIdentityBD && profile.GetRole() != "bd" {
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
return salaryWalletIdentityContext{}, false
}
if identity == salaryIdentityBDLeader && profile.GetRole() != "bd_leader" {
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
return salaryWalletIdentityContext{}, false
}
assetType := salaryAssetBD
if identity == salaryIdentityBDLeader {
assetType = salaryAssetBDLeader
}
return salaryWalletIdentityContext{Identity: identity, SalaryAssetType: assetType, UserID: userID, RegionID: profile.GetRegionId()}, true
}
func (h *Handler) findSameRegionSalaryCoinSeller(writer http.ResponseWriter, request *http.Request, regionID int64, displayUserID string) (*userv1.CoinSellerListItem, bool, bool) {
resp, err := h.userHostClient.ListActiveCoinSellersInMyRegion(request.Context(), &userv1.ListActiveCoinSellersInMyRegionRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return nil, false, false
}
for _, seller := range resp.GetCoinSellers() {
if seller.GetRegionId() == regionID && seller.GetMerchantAssetType() == "COIN_SELLER_COIN" && seller.GetStatus() == "active" && seller.GetDisplayUserId() == displayUserID {
return seller, true, true
}
}
return nil, false, true
}
func normalizeSalaryWalletIdentity(value string) string {
value = strings.ToUpper(strings.TrimSpace(value))
value = strings.ReplaceAll(value, "-", "_")
switch value {
case salaryIdentityHost, salaryIdentityAgency, salaryIdentityBD, salaryIdentityBDLeader:
return value
default:
return ""
}
}
func firstBalance(balances []*walletv1.AssetBalance) *walletv1.AssetBalance {
if len(balances) == 0 {
return nil
}
return balances[0]
}
func salaryBalanceFromProto(assetType string, balance *walletv1.AssetBalance) salaryWalletBalanceData {
if balance == nil {
return salaryWalletBalanceData{AssetType: assetType, DisplayAmount: "0.00"}
}
return salaryWalletBalanceData{
AssetType: balance.GetAssetType(),
AvailableAmount: balance.GetAvailableAmount(),
FrozenAmount: balance.GetFrozenAmount(),
DisplayAmount: formatUSDMinor(balance.GetAvailableAmount()),
}
}
func salaryRateTiersFromProto(tiers []*walletv1.CoinSellerSalaryExchangeRateTier) []salaryWalletExchangeRateTierData {
items := make([]salaryWalletExchangeRateTierData, 0, len(tiers))
for _, tier := range tiers {
items = append(items, salaryWalletExchangeRateTierData{
RegionID: tier.GetRegionId(),
MinUSDMinor: tier.GetMinUsdMinor(),
MaxUSDMinor: tier.GetMaxUsdMinor(),
CoinPerUSD: tier.GetCoinPerUsd(),
Status: tier.GetStatus(),
SortOrder: tier.GetSortOrder(),
UpdatedAtMS: tier.GetUpdatedAtMs(),
})
}
return items
}
func parseUSDMinor(value string) (int64, error) {
value = strings.TrimSpace(value)
if value == "" || strings.HasPrefix(value, "-") {
return 0, fmt.Errorf("invalid amount")
}
parts := strings.Split(value, ".")
if len(parts) > 2 || parts[0] == "" {
return 0, fmt.Errorf("invalid amount")
}
dollars, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return 0, err
}
cents := int64(0)
if len(parts) == 2 {
fraction := parts[1]
if len(fraction) > 2 {
return 0, fmt.Errorf("invalid amount")
}
for len(fraction) < 2 {
fraction += "0"
}
cents, err = strconv.ParseInt(fraction, 10, 64)
if err != nil {
return 0, err
}
}
if dollars > (1<<63-1-cents)/100 {
return 0, fmt.Errorf("amount overflow")
}
return dollars*100 + cents, nil
}
func formatUSDMinor(value int64) string {
sign := ""
if value < 0 {
sign = "-"
value = -value
}
return fmt.Sprintf("%s%d.%02d", sign, value/100, value%100)
}

View File

@ -193,6 +193,14 @@ func (h *Handler) coinSellerTransferRegions(writer http.ResponseWriter, request
httpkit.WriteRPCError(writer, request, err)
return 0, 0, false
}
sellerRegionID := sellerResp.GetUser().GetRegionId()
if sellerUserID == targetUserID {
if sellerRegionID <= 0 {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return 0, 0, false
}
return sellerRegionID, sellerRegionID, true
}
targetResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: targetUserID,
@ -201,7 +209,6 @@ func (h *Handler) coinSellerTransferRegions(writer http.ResponseWriter, request
httpkit.WriteRPCError(writer, request, err)
return 0, 0, false
}
sellerRegionID := sellerResp.GetUser().GetRegionId()
targetRegionID := targetResp.GetUser().GetRegionId()
if sellerRegionID <= 0 || targetRegionID <= 0 {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")

View File

@ -116,6 +116,13 @@ type ListBDLeaderAgenciesCommand struct {
PageSize int
}
// ListBDAgenciesCommand 描述 BD 查询自己直属 Agency 列表的条件。
type ListBDAgenciesCommand struct {
BDUserID int64
Status string
PageSize int
}
// ProcessRoleInvitationInput 是处理角色邀请的外部输入。
type ProcessRoleInvitationInput struct {
CommandID string

View File

@ -22,6 +22,7 @@ type Repository interface {
InviteBD(ctx context.Context, command InviteBDCommand) (hostdomain.RoleInvitation, error)
ListBDLeaderBDs(ctx context.Context, command ListBDLeaderBDsCommand) ([]hostdomain.BDProfile, error)
ListBDLeaderAgencies(ctx context.Context, command ListBDLeaderAgenciesCommand) ([]hostdomain.Agency, error)
ListBDAgencies(ctx context.Context, command ListBDAgenciesCommand) ([]hostdomain.Agency, error)
ProcessRoleInvitation(ctx context.Context, command ProcessRoleInvitationCommand) (hostdomain.ProcessRoleInvitationResult, error)
CreateBDLeader(ctx context.Context, command CreateBDLeaderCommand) (hostdomain.BDProfile, error)
CreateBD(ctx context.Context, command CreateBDCommand) (hostdomain.BDProfile, error)
@ -274,6 +275,21 @@ func (s *Service) ListBDLeaderAgencies(ctx context.Context, command ListBDLeader
})
}
// ListBDAgencies 返回当前 BD 直接拓展的 Agency 列表。
func (s *Service) ListBDAgencies(ctx context.Context, command ListBDAgenciesCommand) ([]hostdomain.Agency, error) {
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "host repository is not configured")
}
if command.BDUserID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "bd_user_id is required")
}
return s.repository.ListBDAgencies(ctx, ListBDAgenciesCommand{
BDUserID: command.BDUserID,
Status: normalizeStatus(command.Status),
PageSize: normalizePageSize(command.PageSize),
})
}
// ProcessRoleInvitation 接受、拒绝或取消角色邀请。
func (s *Service) ProcessRoleInvitation(ctx context.Context, command ProcessRoleInvitationInput) (hostdomain.ProcessRoleInvitationResult, error) {
if err := s.requireWriteDependencies(); err != nil {

View File

@ -325,6 +325,23 @@ func (r *Repository) ListBDLeaderAgencies(ctx context.Context, command hostservi
return queryAgencies(ctx, r.db, clause, args...)
}
// ListBDAgencies 读取一个有效 BD 直接拓展的 Agency不包含同负责人下其他 BD 的数据。
func (r *Repository) ListBDAgencies(ctx context.Context, command hostservice.ListBDAgenciesCommand) ([]hostdomain.Agency, error) {
bd, err := queryBDProfile(ctx, r.db, "WHERE user_id = ?", command.BDUserID)
if err != nil {
return nil, err
}
if bd.Status != hostdomain.BDStatusActive {
return nil, xerr.New(xerr.PermissionDenied, "bd is not active")
}
status := command.Status
if status == "" {
status = hostdomain.AgencyStatusActive
}
clause, args := appScopedClause(ctx, "WHERE status = ? AND parent_bd_user_id = ? ORDER BY created_at_ms DESC, agency_id DESC LIMIT ?", status, command.BDUserID, command.PageSize)
return queryAgencies(ctx, r.db, clause, args...)
}
// ListAgencyMembers 读取 Agency 成员关系列表。
func (r *Repository) ListAgencyMembers(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyMembership, error) {
query := fmt.Sprintf(`SELECT %s FROM agency_memberships WHERE app_code = ? AND agency_id = ?`, agencyMembershipColumns)

View File

@ -181,6 +181,28 @@ func (s *Server) ListBDLeaderAgencies(ctx context.Context, req *userv1.ListBDLea
return resp, nil
}
// ListBDAgencies 返回当前 BD 自己拓展的 Agency 列表。
func (s *Server) ListBDAgencies(ctx context.Context, req *userv1.ListBDAgenciesRequest) (*userv1.ListBDAgenciesResponse, error) {
if s.hostSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))
}
ctx = contextWithApp(ctx, req.GetMeta())
agencies, err := s.hostSvc.ListBDAgencies(ctx, hostservice.ListBDAgenciesCommand{
BDUserID: req.GetBdUserId(),
Status: req.GetStatus(),
PageSize: int(req.GetPageSize()),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &userv1.ListBDAgenciesResponse{Agencies: make([]*userv1.Agency, 0, len(agencies))}
for _, agency := range agencies {
resp.Agencies = append(resp.Agencies, toProtoAgency(agency))
}
return resp, nil
}
// ProcessRoleInvitation 处理 Agency 或 BD 邀请。
func (s *Server) ProcessRoleInvitation(ctx context.Context, req *userv1.ProcessRoleInvitationRequest) (*userv1.ProcessRoleInvitationResponse, error) {
if s.hostSvc == nil {

View File

@ -558,6 +558,24 @@ CREATE TABLE IF NOT EXISTS coin_seller_stock_records (
KEY idx_coin_seller_stock_type_time (app_code, stock_type, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='金币商户库存记录表';
CREATE TABLE IF NOT EXISTS coin_seller_salary_exchange_rate_tiers (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
region_id BIGINT NOT NULL COMMENT '币商所属区域 ID',
tier_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '区间 ID',
min_usd_minor BIGINT NOT NULL COMMENT '起始美元金额,单位美分,包含',
max_usd_minor BIGINT NOT NULL COMMENT '结束美元金额,单位美分,包含',
coin_per_usd BIGINT NOT NULL COMMENT '每 1 USD 工资可兑换的币商专用金币数量',
status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT '状态active/disabled',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重',
created_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建人用户 ID',
updated_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (tier_id),
KEY idx_coin_seller_salary_rates_region (app_code, region_id, status, min_usd_minor, max_usd_minor),
KEY idx_coin_seller_salary_rates_sort (app_code, region_id, status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='币商工资兑换比例区间表';
CREATE TABLE IF NOT EXISTS resources (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
resource_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '资源 ID',
@ -925,17 +943,17 @@ CREATE TABLE IF NOT EXISTS wallet_diamond_exchange_rules (
CREATE TABLE IF NOT EXISTS gift_diamond_ratio_configs (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '送礼用户区域0 表示全局默认',
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '房间可见区域0 表示全局默认',
gift_type_code VARCHAR(32) NOT NULL COMMENT '礼物类型编码',
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '业务状态',
ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 100.00 COMMENT '主播周期钻石入账比例,百分比',
ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 100.00 COMMENT '房间贡献热度和主播周期钻石入账比例,百分比',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台用户 ID',
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新后台用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, region_id, gift_type_code),
KEY idx_gift_diamond_ratio_region (app_code, region_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物入主播周期钻石比例配置表';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物钻石和房间贡献比例配置表';
CREATE TABLE IF NOT EXISTS red_packet_configs (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',

View File

@ -19,6 +19,8 @@ const (
AssetBDSalaryUSD = "BD_SALARY_USD"
// AssetAdminSalaryUSD 是 Admin 工资美元钱包,只接收 Admin 政策结算工资。
AssetAdminSalaryUSD = "ADMIN_SALARY_USD"
// SalaryExchangeCoinPerUSD 是工资直接兑换普通金币的固定比例,金额以 1 USD 为单位。
SalaryExchangeCoinPerUSD int64 = 80000
// StockTypeUSDTPurchase 表示币商线下 USDT 进货后发放专用金币库存。
StockTypeUSDTPurchase = "usdt_purchase"
@ -265,6 +267,68 @@ type CoinSellerTransferReceipt struct {
RechargePolicyUSDMinorUnit int64
}
// CoinSellerSalaryExchangeRateTier 是工资转给币商时按区域和美元金额匹配的金币兑换比例。
type CoinSellerSalaryExchangeRateTier struct {
RegionID int64
MinUSDMinor int64
MaxUSDMinor int64
CoinPerUSD int64
Status string
SortOrder int
UpdatedAtMS int64
}
// SalaryExchangeCommand 是用户把某个身份工资美元钱包兑换为自己普通金币的账务命令。
type SalaryExchangeCommand struct {
AppCode string
CommandID string
UserID int64
SalaryAssetType string
SalaryUSDMinor int64
Reason string
}
// SalaryExchangeReceipt 返回工资扣减和普通金币入账后的双边余额。
type SalaryExchangeReceipt struct {
TransactionID string
UserID int64
SalaryAssetType string
SalaryBalanceAfter int64
CoinBalanceAfter int64
SalaryUSDMinor int64
CoinAmount int64
CoinPerUSD int64
CreatedAtMS int64
}
// SalaryTransferToCoinSellerCommand 是用户把某个身份工资美元钱包转给同区域币商专用金币库存的账务命令。
type SalaryTransferToCoinSellerCommand struct {
AppCode string
CommandID string
SourceUserID int64
SellerUserID int64
SalaryAssetType string
SalaryUSDMinor int64
RegionID int64
Reason string
}
// SalaryTransferToCoinSellerReceipt 返回工资扣减和币商库存入账后的双边余额与命中的区间。
type SalaryTransferToCoinSellerReceipt struct {
TransactionID string
SourceUserID int64
SellerUserID int64
SalaryAssetType string
SourceSalaryBalanceAfter int64
SellerBalanceAfter int64
SalaryUSDMinor int64
CoinAmount int64
CoinPerUSD int64
RateMinUSDMinor int64
RateMaxUSDMinor int64
CreatedAtMS int64
}
// CoinSellerStockCreditCommand 是后台给币商专用金币库存入账的最小账务命令。
type CoinSellerStockCreditCommand struct {
AppCode string
@ -824,6 +888,16 @@ func ValidAssetType(assetType string) bool {
}
}
// ValidSalaryAssetType 只允许身份工资美元钱包进入工资兑换和转币商链路,避免普通金币或币商库存被误扣。
func ValidSalaryAssetType(assetType string) bool {
switch strings.ToUpper(strings.TrimSpace(assetType)) {
case AssetHostSalaryUSD, AssetAgencySalaryUSD, AssetBDSalaryUSD, AssetAdminSalaryUSD:
return true
default:
return false
}
}
func NormalizeGiftChargeAssetType(assetType string) string {
return AssetCoin
}

View File

@ -26,6 +26,9 @@ type Repository interface {
AdminCreditAsset(ctx context.Context, command ledger.AdminCreditAssetCommand) (ledger.AssetBalance, string, error)
AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error)
TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error)
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)
CreditTaskReward(ctx context.Context, command ledger.TaskRewardCommand) (ledger.TaskRewardReceipt, error)
CreditLuckyGiftReward(ctx context.Context, command ledger.LuckyGiftRewardCommand) (ledger.LuckyGiftRewardReceipt, error)
ApplyGameCoinChange(ctx context.Context, command ledger.GameCoinChangeCommand) (ledger.GameCoinChangeReceipt, error)
@ -355,14 +358,11 @@ func (s *Service) AdminCreditCoinSellerStock(ctx context.Context, command ledger
return s.repository.AdminCreditCoinSellerStock(ctx, command)
}
// TransferCoinFromSeller 执行币商向玩家转金币;身份和区域都由 gateway/user-service 校验并传入。
// TransferCoinFromSeller 执行币商专用金币转普通金币;身份和区域都由 gateway/user-service 校验并传入。
func (s *Service) TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error) {
if command.CommandID == "" || command.SellerUserID <= 0 || command.TargetUserID <= 0 || command.Amount <= 0 {
return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.InvalidArgument, "coin seller transfer command is incomplete")
}
if command.SellerUserID == command.TargetUserID {
return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.InvalidArgument, "seller and target must be different")
}
if command.SellerRegionID <= 0 || command.TargetRegionID <= 0 {
return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.InvalidArgument, "seller and target region are required")
}
@ -382,6 +382,75 @@ func (s *Service) TransferCoinFromSeller(ctx context.Context, command ledger.Coi
return s.repository.TransferCoinFromSeller(ctx, command)
}
// ListCoinSellerSalaryExchangeRateTiers 返回指定区域的工资转币商比例区间gateway 用它给 H5 展示预计到账金币。
func (s *Service) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, appCode string, regionID int64, includeDisabled bool) ([]ledger.CoinSellerSalaryExchangeRateTier, error) {
if regionID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "region_id is required")
}
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
appCode = appcode.Normalize(appCode)
ctx = appcode.WithContext(ctx, appCode)
return s.repository.ListCoinSellerSalaryExchangeRateTiers(ctx, appCode, regionID, includeDisabled)
}
// ExchangeSalaryToCoin 校验工资兑换命令;身份归属由 gateway 校验wallet 只接受明确的工资资产和正向美分金额。
func (s *Service) ExchangeSalaryToCoin(ctx context.Context, command ledger.SalaryExchangeCommand) (ledger.SalaryExchangeReceipt, error) {
if command.CommandID == "" || command.UserID <= 0 || command.SalaryUSDMinor <= 0 {
return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.InvalidArgument, "salary exchange command is incomplete")
}
command.SalaryAssetType = strings.ToUpper(strings.TrimSpace(command.SalaryAssetType))
if !ledger.ValidSalaryAssetType(command.SalaryAssetType) {
return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.InvalidArgument, "salary_asset_type is invalid")
}
command.Reason = strings.TrimSpace(command.Reason)
if command.Reason == "" {
command.Reason = "salary exchange"
}
if len(command.CommandID) > 128 || len(command.Reason) > 512 {
return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.InvalidArgument, "salary exchange text fields are too long")
}
if s.repository == nil {
return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.ExchangeSalaryToCoin(ctx, command)
}
// TransferSalaryToCoinSeller 校验工资转币商命令;比例区间命中和双边入账由 repository 在同一事务内完成。
func (s *Service) TransferSalaryToCoinSeller(ctx context.Context, command ledger.SalaryTransferToCoinSellerCommand) (ledger.SalaryTransferToCoinSellerReceipt, error) {
if command.CommandID == "" || command.SourceUserID <= 0 || command.SellerUserID <= 0 || command.SalaryUSDMinor <= 0 {
return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "salary transfer command is incomplete")
}
if command.SourceUserID == command.SellerUserID {
return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "source and seller must be different")
}
if command.RegionID <= 0 {
return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "region_id is required")
}
command.SalaryAssetType = strings.ToUpper(strings.TrimSpace(command.SalaryAssetType))
if !ledger.ValidSalaryAssetType(command.SalaryAssetType) {
return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "salary_asset_type is invalid")
}
command.Reason = strings.TrimSpace(command.Reason)
if command.Reason == "" {
command.Reason = "salary transfer"
}
if len(command.CommandID) > 128 || len(command.Reason) > 512 {
return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "salary transfer text fields are too long")
}
if s.repository == nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.TransferSalaryToCoinSeller(ctx, command)
}
// CreditTaskReward 发放任务奖励金币;任务完成判断属于 activity-service这里只负责账本幂等入账。
func (s *Service) CreditTaskReward(ctx context.Context, command ledger.TaskRewardCommand) (ledger.TaskRewardReceipt, error) {
if command.CommandID == "" || command.TargetUserID <= 0 || command.Amount <= 0 || command.TaskID == "" || command.CycleKey == "" {

View File

@ -263,6 +263,84 @@ func TestDebitGiftCreditsHostPeriodDiamondsBySenderRegionAndGiftType(t *testing.
}
}
func TestDebitGiftAppliesRoomRegionGiftDiamondRatioToHeatValueByGiftType(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
repository.SetGiftDiamondRatio(8801, resourcedomain.GiftTypeNormal, "30.00")
repository.SetGiftDiamondRatio(8801, resourcedomain.GiftTypeLucky, "40.00")
repository.SetGiftDiamondRatio(8801, resourcedomain.GiftTypeSuperLucky, "50.00")
cases := []struct {
giftID string
giftType string
command string
senderID int64
wantHeat int64
}{
{giftID: "normal-room-ratio-gift", giftType: resourcedomain.GiftTypeNormal, command: "cmd-normal-room-ratio", senderID: 13001, wantHeat: 30},
{giftID: "lucky-room-ratio-gift", giftType: resourcedomain.GiftTypeLucky, command: "cmd-lucky-room-ratio", senderID: 13002, wantHeat: 40},
{giftID: "super-lucky-room-ratio-gift", giftType: resourcedomain.GiftTypeSuperLucky, command: "cmd-super-lucky-room-ratio", senderID: 13003, wantHeat: 50},
}
for _, tc := range cases {
repository.SetBalance(tc.senderID, 100)
repository.SetGiftPrice(tc.giftID, "v1", 100, 100, 100)
repository.SetGiftType(tc.giftID, tc.giftType)
receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
CommandID: tc.command,
RoomID: "room-ratio",
SenderUserID: tc.senderID,
SenderRegionID: 1001,
RegionID: 8801,
TargetUserID: 14001,
GiftID: tc.giftID,
GiftCount: 1,
PriceVersion: "v1",
})
if err != nil {
t.Fatalf("DebitGift %s failed: %v", tc.giftType, err)
}
if receipt.HeatValue != tc.wantHeat || receipt.GiftPointAdded != 100 || receipt.CoinSpent != 100 {
t.Fatalf("%s room contribution ratio mismatch: %+v want heat %d", tc.giftType, receipt, tc.wantHeat)
}
}
}
func TestBatchDebitGiftAppliesRoomRegionGiftDiamondRatioToEachTargetHeatValue(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(15001, 1000)
repository.SetGiftPrice("batch-room-ratio-gift", "v1", 100, 100, 100)
repository.SetGiftType("batch-room-ratio-gift", resourcedomain.GiftTypeLucky)
repository.SetGiftDiamondRatio(9901, resourcedomain.GiftTypeLucky, "25.00")
svc := walletservice.New(repository)
receipt, err := svc.BatchDebitGift(context.Background(), ledger.BatchDebitGiftCommand{
CommandID: "cmd-batch-room-ratio",
RoomID: "room-ratio",
SenderUserID: 15001,
SenderRegionID: 1001,
RegionID: 9901,
GiftID: "batch-room-ratio-gift",
GiftCount: 1,
PriceVersion: "v1",
Targets: []ledger.DebitGiftTargetCommand{
{CommandID: "cmd-batch-room-ratio:target:15002", TargetUserID: 15002},
{CommandID: "cmd-batch-room-ratio:target:15003", TargetUserID: 15003},
},
})
if err != nil {
t.Fatalf("BatchDebitGift failed: %v", err)
}
if receipt.Aggregate.HeatValue != 50 || len(receipt.Targets) != 2 {
t.Fatalf("batch aggregate room contribution ratio mismatch: %+v", receipt)
}
for _, target := range receipt.Targets {
if target.Receipt.HeatValue != 25 || target.Receipt.GiftPointAdded != 100 {
t.Fatalf("target room contribution ratio mismatch: %+v", target)
}
}
}
// TestDebitGiftRejectsHostPeriodWithoutRegion 锁定工资政策必须有主播区域才能入账。
func TestDebitGiftRejectsHostPeriodWithoutRegion(t *testing.T) {
repository := mysqltest.NewRepository(t)
@ -1553,6 +1631,111 @@ func TestTransferCoinFromSellerMovesDedicatedAssetToPlayerCoin(t *testing.T) {
}
}
// TestTransferCoinFromSellerAllowsSelfTransfer 验证币商可以把自己的专用库存转入自己的普通金币账户。
func TestTransferCoinFromSellerAllowsSelfTransfer(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
repository.SetRechargePolicy(1001, "recharge-v1", 30000, 100)
_, _, err := svc.AdminCreditAsset(context.Background(), ledger.AdminCreditAssetCommand{
CommandID: "cmd-credit-self-seller",
TargetUserID: 30003,
AssetType: ledger.AssetCoinSellerCoin,
Amount: 100000,
OperatorUserID: 90001,
Reason: "seed seller balance",
EvidenceRef: "ticket-self-seller",
})
if err != nil {
t.Fatalf("seed seller asset failed: %v", err)
}
command := ledger.CoinSellerTransferCommand{
CommandID: "cmd-seller-self-transfer",
SellerUserID: 30003,
TargetUserID: 30003,
SellerRegionID: 1001,
TargetRegionID: 1001,
Amount: 30000,
Reason: "self recharge",
}
first, err := svc.TransferCoinFromSeller(context.Background(), command)
if err != nil {
t.Fatalf("TransferCoinFromSeller self transfer failed: %v", err)
}
second, err := svc.TransferCoinFromSeller(context.Background(), command)
if err != nil {
t.Fatalf("TransferCoinFromSeller self transfer retry failed: %v", err)
}
if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.SellerBalanceAfter != 70000 || first.TargetBalanceAfter != 30000 || first.RechargeUSDMinor != 100 {
t.Fatalf("self transfer receipt mismatch: first=%+v second=%+v", first, second)
}
balances, err := svc.GetBalances(context.Background(), 30003, []string{ledger.AssetCoinSellerCoin, ledger.AssetCoin})
if err != nil {
t.Fatalf("GetBalances failed: %v", err)
}
if balanceAmount(balances, ledger.AssetCoinSellerCoin) != 70000 || balanceAmount(balances, ledger.AssetCoin) != 30000 {
t.Fatalf("self transfer balances mismatch: %+v", balances)
}
if got := repository.CountRows("wallet_transactions", "command_id = ?", command.CommandID); got != 1 {
t.Fatalf("idempotent self transfer should write one transaction, got %d", got)
}
if got := repository.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID); got != 2 {
t.Fatalf("self transfer should write two entries for two asset accounts, got %d", got)
}
if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", first.TransactionID); got != 1 {
t.Fatalf("self transfer should write one recharge record, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ?", first.TransactionID); got != 4 {
t.Fatalf("self transfer should write two balance events, one transfer event and one recharge event, got %d", got)
}
}
// TestTransferCoinFromSellerAllowsSubCentRechargeAmount 验证小额金币也必须到账,美元统计按最小货币单位向下取整。
func TestTransferCoinFromSellerAllowsSubCentRechargeAmount(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
repository.SetRechargePolicy(1001, "recharge-v1", 1000, 100)
_, _, err := svc.AdminCreditAsset(context.Background(), ledger.AdminCreditAssetCommand{
CommandID: "cmd-credit-small-seller",
TargetUserID: 30004,
AssetType: ledger.AssetCoinSellerCoin,
Amount: 11,
OperatorUserID: 90001,
Reason: "seed small seller balance",
EvidenceRef: "ticket-small-seller",
})
if err != nil {
t.Fatalf("seed seller asset failed: %v", err)
}
receipt, err := svc.TransferCoinFromSeller(context.Background(), ledger.CoinSellerTransferCommand{
CommandID: "cmd-seller-small-transfer",
SellerUserID: 30004,
TargetUserID: 30004,
SellerRegionID: 1001,
TargetRegionID: 1001,
Amount: 1,
Reason: "self recharge",
})
if err != nil {
t.Fatalf("TransferCoinFromSeller small amount failed: %v", err)
}
if receipt.SellerBalanceAfter != 10 || receipt.TargetBalanceAfter != 1 || receipt.RechargeUSDMinor != 0 {
t.Fatalf("small transfer receipt mismatch: %+v", receipt)
}
balances, err := svc.GetBalances(context.Background(), 30004, []string{ledger.AssetCoinSellerCoin, ledger.AssetCoin})
if err != nil {
t.Fatalf("GetBalances failed: %v", err)
}
if balanceAmount(balances, ledger.AssetCoinSellerCoin) != 10 || balanceAmount(balances, ledger.AssetCoin) != 1 {
t.Fatalf("small transfer balances mismatch: %+v", balances)
}
if got := repository.CountRows("wallet_recharge_records", "transaction_id = ? AND coin_amount = ? AND usd_minor_amount = ?", receipt.TransactionID, int64(1), int64(0)); got != 1 {
t.Fatalf("small transfer should write recharge record with zero USD minor, got %d", got)
}
}
// TestTransferCoinFromSellerInsufficientBalance 验证币商余额不足时不生成转账交易。
func TestTransferCoinFromSellerInsufficientBalance(t *testing.T) {
repository := mysqltest.NewRepository(t)
@ -1612,6 +1795,138 @@ func TestTransferCoinFromSellerRequiresRechargePolicy(t *testing.T) {
}
}
// TestExchangeSalaryToCoinMovesSalaryIntoPlayerCoin 验证工资兑换按固定 1 USD = 80000 COIN 扣工资并加普通金币,重复 command 只返回同一笔事实。
func TestExchangeSalaryToCoinMovesSalaryIntoPlayerCoin(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
repository.SetAssetBalance(34001, ledger.AssetAdminSalaryUSD, 2500)
command := ledger.SalaryExchangeCommand{
AppCode: appcode.Default,
CommandID: "cmd-salary-exchange",
UserID: 34001,
SalaryAssetType: ledger.AssetAdminSalaryUSD,
SalaryUSDMinor: 1000,
Reason: "salary exchange",
}
first, err := svc.ExchangeSalaryToCoin(context.Background(), command)
if err != nil {
t.Fatalf("ExchangeSalaryToCoin failed: %v", err)
}
second, err := svc.ExchangeSalaryToCoin(context.Background(), command)
if err != nil {
t.Fatalf("ExchangeSalaryToCoin retry failed: %v", err)
}
if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.SalaryBalanceAfter != 1500 || first.CoinBalanceAfter != 800000 || first.CoinAmount != 800000 || first.CoinPerUSD != ledger.SalaryExchangeCoinPerUSD {
t.Fatalf("salary exchange receipt mismatch: first=%+v second=%+v", first, second)
}
assertBalance(t, svc, 34001, ledger.AssetAdminSalaryUSD, 1500)
assertBalance(t, svc, 34001, ledger.AssetCoin, 800000)
if got := repository.CountRows("wallet_transactions", "command_id = ?", command.CommandID); got != 1 {
t.Fatalf("idempotent salary exchange should write one transaction, got %d", got)
}
if got := repository.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID); got != 2 {
t.Fatalf("salary exchange should write two entries, got %d", got)
}
if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", first.TransactionID); got != 0 {
t.Fatalf("salary exchange must not write player recharge record, got %d", got)
}
}
// TestExchangeSalaryToCoinRejectsCommandPayloadConflict 验证同 command_id 只能代表同一笔工资兑换请求。
func TestExchangeSalaryToCoinRejectsCommandPayloadConflict(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
repository.SetAssetBalance(34002, ledger.AssetHostSalaryUSD, 3000)
command := ledger.SalaryExchangeCommand{
AppCode: appcode.Default,
CommandID: "cmd-salary-exchange-conflict",
UserID: 34002,
SalaryAssetType: ledger.AssetHostSalaryUSD,
SalaryUSDMinor: 500,
Reason: "salary exchange",
}
if _, err := svc.ExchangeSalaryToCoin(context.Background(), command); err != nil {
t.Fatalf("ExchangeSalaryToCoin initial command failed: %v", err)
}
command.SalaryUSDMinor = 600
if _, err := svc.ExchangeSalaryToCoin(context.Background(), command); !xerr.IsCode(err, xerr.IdempotencyConflict) {
t.Fatalf("expected IDEMPOTENCY_CONFLICT for changed salary exchange payload, got %v", err)
}
assertBalance(t, svc, 34002, ledger.AssetHostSalaryUSD, 2500)
assertBalance(t, svc, 34002, ledger.AssetCoin, 400000)
}
// TestTransferSalaryToCoinSellerUsesRegionRateTier 验证工资转币商按命中的区域区间扣工资并加币商专用金币,不写玩家充值事实。
func TestTransferSalaryToCoinSellerUsesRegionRateTier(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
repository.SetAssetBalance(35001, ledger.AssetBDSalaryUSD, 10000)
repository.SetCoinSellerSalaryExchangeRateTier(appcode.Default, 7001, 1100, 5000, 92000)
command := ledger.SalaryTransferToCoinSellerCommand{
AppCode: appcode.Default,
CommandID: "cmd-salary-transfer-seller",
SourceUserID: 35001,
SellerUserID: 35002,
SalaryAssetType: ledger.AssetBDSalaryUSD,
SalaryUSDMinor: 4000,
RegionID: 7001,
Reason: "salary transfer",
}
first, err := svc.TransferSalaryToCoinSeller(context.Background(), command)
if err != nil {
t.Fatalf("TransferSalaryToCoinSeller failed: %v", err)
}
second, err := svc.TransferSalaryToCoinSeller(context.Background(), command)
if err != nil {
t.Fatalf("TransferSalaryToCoinSeller retry failed: %v", err)
}
if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.SourceSalaryBalanceAfter != 6000 || first.SellerBalanceAfter != 3680000 || first.CoinAmount != 3680000 || first.CoinPerUSD != 92000 || first.RateMinUSDMinor != 1100 || first.RateMaxUSDMinor != 5000 {
t.Fatalf("salary transfer receipt mismatch: first=%+v second=%+v", first, second)
}
assertBalance(t, svc, 35001, ledger.AssetBDSalaryUSD, 6000)
assertBalance(t, svc, 35002, ledger.AssetCoinSellerCoin, 3680000)
if got := repository.CountRows("wallet_transactions", "command_id = ?", command.CommandID); got != 1 {
t.Fatalf("idempotent salary transfer should write one transaction, got %d", got)
}
if got := repository.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID); got != 2 {
t.Fatalf("salary transfer should write two entries, got %d", got)
}
if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", first.TransactionID); got != 0 {
t.Fatalf("salary transfer must not write player recharge record, got %d", got)
}
}
// TestTransferSalaryToCoinSellerRequiresConfiguredRate 验证当前区域没有可命中的工资转币商比例时不会产生半成品账务。
func TestTransferSalaryToCoinSellerRequiresConfiguredRate(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
repository.SetAssetBalance(35003, ledger.AssetAgencySalaryUSD, 10000)
_, err := svc.TransferSalaryToCoinSeller(context.Background(), ledger.SalaryTransferToCoinSellerCommand{
AppCode: appcode.Default,
CommandID: "cmd-salary-transfer-no-rate",
SourceUserID: 35003,
SellerUserID: 35004,
SalaryAssetType: ledger.AssetAgencySalaryUSD,
SalaryUSDMinor: 4000,
RegionID: 8001,
Reason: "salary transfer",
})
if !xerr.IsCode(err, xerr.NotFound) {
t.Fatalf("expected NOT_FOUND when salary transfer rate is missing, got %v", err)
}
assertBalance(t, svc, 35003, ledger.AssetAgencySalaryUSD, 10000)
assertBalance(t, svc, 35004, ledger.AssetCoinSellerCoin, 0)
if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-salary-transfer-no-rate"); got != 0 {
t.Fatalf("missing salary transfer rate must not write transaction, got %d", got)
}
}
// TestAdminCreditCoinSellerStockPurchaseCreditsDedicatedBalance 验证 USDT 进货只增加币商专用库存并写专用进货记录。
func TestAdminCreditCoinSellerStockPurchaseCreditsDedicatedBalance(t *testing.T) {
repository := mysqltest.NewRepository(t)
@ -1941,6 +2256,40 @@ func TestGiftConfigSyncsConfiguredResourcePrice(t *testing.T) {
if gift.ChargeAssetType != ledger.AssetCoin || gift.CoinPrice != 77 || gift.GiftPointAmount != 77 {
t.Fatalf("gift price should be overridden by resource price: %+v", gift)
}
updatedResource, err := svc.UpdateResource(ctx, resourcedomain.ResourceCommand{
ResourceID: paidResource.ResourceID,
ResourceCode: paidResource.ResourceCode,
ResourceType: resourcedomain.TypeGift,
Name: paidResource.Name,
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
PriceType: resourcedomain.PriceTypeCoin,
CoinPrice: 88,
UsageScopes: []string{"gift"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("update priced gift resource failed: %v", err)
}
if updatedResource.CoinPrice != 88 || updatedResource.GiftPointAmount != 88 {
t.Fatalf("updated resource price mismatch: %+v", updatedResource)
}
repository.SetBalance(54001, 100)
updatedReceipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
CommandID: "cmd-price-sync-after-resource-update",
RoomID: "room-price-sync",
SenderUserID: 54001,
TargetUserID: 54002,
GiftID: "price-sync",
GiftCount: 1,
})
if err != nil {
t.Fatalf("debit gift after resource price update failed: %v", err)
}
if updatedReceipt.CoinSpent != 88 || updatedReceipt.GiftPointAdded != 88 {
t.Fatalf("gift price should follow updated resource price: %+v", updatedReceipt)
}
freeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "gift_free_sync",

View File

@ -29,6 +29,8 @@ const (
bizTypeCoinSellerTransfer = "coin_seller_transfer"
bizTypeCoinSellerStockPurchase = "coin_seller_stock_purchase"
bizTypeCoinSellerCoinCompensation = "coin_seller_coin_compensation"
bizTypeSalaryExchangeToCoin = "salary_exchange_to_coin"
bizTypeSalaryTransferToCoinSeller = "salary_transfer_to_coin_seller"
bizTypeGooglePlayRecharge = "google_play_recharge"
bizTypeGameDebit = "game_debit"
bizTypeGameCredit = "game_credit"
@ -95,6 +97,10 @@ func Open(ctx context.Context, dsn string) (*Repository, error) {
_ = db.Close()
return nil, err
}
if err := ensureCoinSellerSalaryExchangeRateSchema(ctx, db); err != nil {
_ = db.Close()
return nil, err
}
return &Repository{db: db}, nil
}
@ -103,17 +109,17 @@ func ensureGiftDiamondRatioSchema(ctx context.Context, db *sql.DB) error {
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS gift_diamond_ratio_configs (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码用于多租户隔离',
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '送礼用户区域0 表示全局默认',
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '房间可见区域0 表示全局默认',
gift_type_code VARCHAR(32) NOT NULL COMMENT '礼物类型编码',
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '业务状态',
ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 100.00 COMMENT '主播周期钻石入账比例百分比',
ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 100.00 COMMENT '房间贡献热度和主播周期钻石入账比例百分比',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台用户 ID',
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新后台用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, region_id, gift_type_code),
KEY idx_gift_diamond_ratio_region (app_code, region_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物入主播周期钻石比例配置表'`); err != nil {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物钻石和房间贡献比例配置表'`); err != nil {
return err
}
_, err := db.ExecContext(ctx, `
@ -127,6 +133,28 @@ func ensureGiftDiamondRatioSchema(ctx context.Context, db *sql.DB) error {
return err
}
func ensureCoinSellerSalaryExchangeRateSchema(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS coin_seller_salary_exchange_rate_tiers (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码用于多租户隔离',
region_id BIGINT NOT NULL COMMENT '币商所属区域 ID',
tier_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '区间 ID',
min_usd_minor BIGINT NOT NULL COMMENT '起始美元金额单位美分包含',
max_usd_minor BIGINT NOT NULL COMMENT '结束美元金额单位美分包含',
coin_per_usd BIGINT NOT NULL COMMENT ' 1 USD 工资可兑换的币商专用金币数量',
status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT '状态active/disabled',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重',
created_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建人用户 ID',
updated_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (tier_id),
KEY idx_coin_seller_salary_rates_region (app_code, region_id, status, min_usd_minor, max_usd_minor),
KEY idx_coin_seller_salary_rates_sort (app_code, region_id, status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='币商工资兑换比例区间表'`)
return err
}
// Close 释放 MySQL 连接池。
func (r *Repository) Close() error {
if r == nil || r.db == nil {
@ -194,7 +222,16 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
if err != nil {
return ledger.Receipt{}, err
}
heatValue, err := checkedMul(price.HeatValue, int64(command.GiftCount))
baseHeatValue, err := checkedMul(price.HeatValue, int64(command.GiftCount))
if err != nil {
return ledger.Receipt{}, err
}
roomContributionRatio, roomContributionRatioRegionID, err := r.resolveGiftDiamondRatio(ctx, tx, command.AppCode, command.RegionID, giftConfig.GiftTypeCode)
if err != nil {
return ledger.Receipt{}, err
}
// 房间贡献由房间 visible_region_id 命中后台礼物钻石比例room-service 只消费结算后的 heat_value不再自己查钱包配置。
heatValue, err := giftDiamondAmount(baseHeatValue, roomContributionRatio.PPM)
if err != nil {
return ledger.Receipt{}, err
}
@ -231,37 +268,40 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
transactionID := transactionID(command.AppCode, command.CommandID)
metadata := giftMetadata{
AppCode: command.AppCode,
GiftID: command.GiftID,
GiftName: giftConfig.Name,
ResourceID: giftConfig.ResourceID,
ResourceSnapshot: mustJSON(giftConfig.Resource),
GiftTypeCode: giftConfig.GiftTypeCode,
PresentationJSON: giftConfig.PresentationJSON,
SortOrder: giftConfig.SortOrder,
GiftCount: command.GiftCount,
PriceVersion: price.PriceVersion,
ChargeAssetType: price.ChargeAssetType,
ChargeAmount: chargeAmount,
CoinSpent: chargeAmount,
GiftPointAdded: giftPointAdded,
HeatValue: heatValue,
BalanceAfter: sender.AvailableAmount - chargeAmount,
BillingReceipt: billingReceiptID(command.AppCode, command.CommandID),
SenderUserID: command.SenderUserID,
SenderRegionID: command.SenderRegionID,
TargetUserID: command.TargetUserID,
TargetIsHost: command.TargetIsHost,
TargetHostRegionID: command.TargetHostRegionID,
TargetAgencyOwnerUserID: command.TargetAgencyOwnerUserID,
HostPeriodDiamondAdded: hostPeriodDiamondAdded,
GiftDiamondRatioPercent: giftDiamondRatioPercent,
GiftDiamondRatioRegionID: giftDiamondRatioRegionID,
HostPeriodCycleKey: hostPeriodCycleKey,
RoomID: command.RoomID,
CoinPrice: price.CoinPrice,
GiftPointAmount: price.GiftPointAmount,
HeatUnitValue: price.HeatValue,
AppCode: command.AppCode,
GiftID: command.GiftID,
GiftName: giftConfig.Name,
ResourceID: giftConfig.ResourceID,
ResourceSnapshot: mustJSON(giftConfig.Resource),
GiftTypeCode: giftConfig.GiftTypeCode,
PresentationJSON: giftConfig.PresentationJSON,
SortOrder: giftConfig.SortOrder,
GiftCount: command.GiftCount,
PriceVersion: price.PriceVersion,
ChargeAssetType: price.ChargeAssetType,
ChargeAmount: chargeAmount,
CoinSpent: chargeAmount,
GiftPointAdded: giftPointAdded,
HeatValue: heatValue,
BalanceAfter: sender.AvailableAmount - chargeAmount,
BillingReceipt: billingReceiptID(command.AppCode, command.CommandID),
SenderUserID: command.SenderUserID,
SenderRegionID: command.SenderRegionID,
TargetUserID: command.TargetUserID,
TargetIsHost: command.TargetIsHost,
TargetHostRegionID: command.TargetHostRegionID,
TargetAgencyOwnerUserID: command.TargetAgencyOwnerUserID,
HostPeriodDiamondAdded: hostPeriodDiamondAdded,
GiftDiamondRatioPercent: giftDiamondRatioPercent,
GiftDiamondRatioRegionID: giftDiamondRatioRegionID,
HostPeriodCycleKey: hostPeriodCycleKey,
RoomID: command.RoomID,
RoomRegionID: command.RegionID,
RoomContributionRatioPercent: roomContributionRatio.Percent,
RoomContributionRatioRegionID: roomContributionRatioRegionID,
CoinPrice: price.CoinPrice,
GiftPointAmount: price.GiftPointAmount,
HeatUnitValue: price.HeatValue,
}
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeGiftDebit, requestHash, fmt.Sprintf("%s:%s", command.GiftID, price.PriceVersion), metadata, nowMs); err != nil {
return ledger.Receipt{}, err
@ -370,7 +410,16 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
heatValue, err := checkedMul(price.HeatValue, int64(command.GiftCount))
baseHeatValue, err := checkedMul(price.HeatValue, int64(command.GiftCount))
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
roomContributionRatio, roomContributionRatioRegionID, err := r.resolveGiftDiamondRatio(ctx, tx, command.AppCode, command.RegionID, giftConfig.GiftTypeCode)
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
// 批量多目标仍然是每个目标各送一份同款礼物;每份房间贡献先按房间区域比例折算,再由聚合回执累加。
heatValue, err := giftDiamondAmount(baseHeatValue, roomContributionRatio.PPM)
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
@ -467,37 +516,40 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
transactionID := transactionID(command.AppCode, target.CommandID)
senderAfter := sender.AvailableAmount - chargeAmount
metadata := giftMetadata{
AppCode: command.AppCode,
GiftID: command.GiftID,
GiftName: giftConfig.Name,
ResourceID: giftConfig.ResourceID,
ResourceSnapshot: mustJSON(giftConfig.Resource),
GiftTypeCode: giftConfig.GiftTypeCode,
PresentationJSON: giftConfig.PresentationJSON,
SortOrder: giftConfig.SortOrder,
GiftCount: command.GiftCount,
PriceVersion: price.PriceVersion,
ChargeAssetType: price.ChargeAssetType,
ChargeAmount: chargeAmount,
CoinSpent: chargeAmount,
GiftPointAdded: giftPointAdded,
HeatValue: heatValue,
BalanceAfter: senderAfter,
BillingReceipt: billingReceiptID(command.AppCode, target.CommandID),
SenderUserID: command.SenderUserID,
SenderRegionID: command.SenderRegionID,
TargetUserID: target.TargetUserID,
TargetIsHost: target.TargetIsHost,
TargetHostRegionID: target.TargetHostRegionID,
TargetAgencyOwnerUserID: target.TargetAgencyOwnerUserID,
HostPeriodDiamondAdded: hostPeriodDiamondAdded,
GiftDiamondRatioPercent: giftDiamondRatioPercent,
GiftDiamondRatioRegionID: giftDiamondRatioRegionID,
HostPeriodCycleKey: hostPeriodCycleKey,
RoomID: command.RoomID,
CoinPrice: price.CoinPrice,
GiftPointAmount: price.GiftPointAmount,
HeatUnitValue: price.HeatValue,
AppCode: command.AppCode,
GiftID: command.GiftID,
GiftName: giftConfig.Name,
ResourceID: giftConfig.ResourceID,
ResourceSnapshot: mustJSON(giftConfig.Resource),
GiftTypeCode: giftConfig.GiftTypeCode,
PresentationJSON: giftConfig.PresentationJSON,
SortOrder: giftConfig.SortOrder,
GiftCount: command.GiftCount,
PriceVersion: price.PriceVersion,
ChargeAssetType: price.ChargeAssetType,
ChargeAmount: chargeAmount,
CoinSpent: chargeAmount,
GiftPointAdded: giftPointAdded,
HeatValue: heatValue,
BalanceAfter: senderAfter,
BillingReceipt: billingReceiptID(command.AppCode, target.CommandID),
SenderUserID: command.SenderUserID,
SenderRegionID: command.SenderRegionID,
TargetUserID: target.TargetUserID,
TargetIsHost: target.TargetIsHost,
TargetHostRegionID: target.TargetHostRegionID,
TargetAgencyOwnerUserID: target.TargetAgencyOwnerUserID,
HostPeriodDiamondAdded: hostPeriodDiamondAdded,
GiftDiamondRatioPercent: giftDiamondRatioPercent,
GiftDiamondRatioRegionID: giftDiamondRatioRegionID,
HostPeriodCycleKey: hostPeriodCycleKey,
RoomID: command.RoomID,
RoomRegionID: command.RegionID,
RoomContributionRatioPercent: roomContributionRatio.Percent,
RoomContributionRatioRegionID: roomContributionRatioRegionID,
CoinPrice: price.CoinPrice,
GiftPointAmount: price.GiftPointAmount,
HeatUnitValue: price.HeatValue,
}
if err := r.insertTransaction(ctx, tx, transactionID, target.CommandID, bizTypeGiftDebit, debitRequestHash(debitGiftCommandFromBatchTarget(command, target)), fmt.Sprintf("%s:%s", command.GiftID, price.PriceVersion), metadata, nowMs); err != nil {
return ledger.BatchGiftReceipt{}, err
@ -1249,6 +1301,259 @@ func (r *Repository) TransferCoinFromSeller(ctx context.Context, command ledger.
return receiptFromCoinSellerTransferMetadata(transactionID, metadata), nil
}
// ListCoinSellerSalaryExchangeRateTiers 返回指定区域工资转币商的兑换区间;默认只返回 active 区间。
func (r *Repository) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, appCode string, regionID int64, includeDisabled bool) ([]ledger.CoinSellerSalaryExchangeRateTier, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
ctx = contextWithCommandApp(ctx, appCode)
statusClause := "AND status = 'active'"
if includeDisabled {
statusClause = ""
}
query := fmt.Sprintf(`
SELECT region_id, min_usd_minor, max_usd_minor, coin_per_usd, status, sort_order, updated_at_ms
FROM coin_seller_salary_exchange_rate_tiers
WHERE app_code = ? AND region_id = ? %s
ORDER BY sort_order ASC, min_usd_minor ASC, tier_id ASC`, statusClause)
rows, err := r.db.QueryContext(ctx, query, appcode.FromContext(ctx), regionID)
if err != nil {
return nil, err
}
defer rows.Close()
tiers := make([]ledger.CoinSellerSalaryExchangeRateTier, 0)
for rows.Next() {
var tier ledger.CoinSellerSalaryExchangeRateTier
if err := rows.Scan(&tier.RegionID, &tier.MinUSDMinor, &tier.MaxUSDMinor, &tier.CoinPerUSD, &tier.Status, &tier.SortOrder, &tier.UpdatedAtMS); err != nil {
return nil, err
}
tiers = append(tiers, tier)
}
if err := rows.Err(); err != nil {
return nil, err
}
return tiers, nil
}
// ExchangeSalaryToCoin 在同一事务里扣工资美元钱包、给当前用户普通 COIN 入账,保证两边余额和幂等一起落库。
func (r *Repository) ExchangeSalaryToCoin(ctx context.Context, command ledger.SalaryExchangeCommand) (ledger.SalaryExchangeReceipt, error) {
if r == nil || r.db == nil {
return ledger.SalaryExchangeReceipt{}, 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.SalaryExchangeReceipt{}, err
}
defer func() { _ = tx.Rollback() }()
requestHash := salaryExchangeRequestHash(command)
if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizTypeSalaryExchangeToCoin, xerr.IdempotencyConflict); err != nil || exists {
if err != nil || !exists {
return ledger.SalaryExchangeReceipt{}, err
}
return r.receiptForSalaryExchangeTransaction(ctx, tx, txRow.TransactionID)
}
nowMs := time.Now().UnixMilli()
coinAmount, err := calculateSalaryCoinAmount(command.SalaryUSDMinor, ledger.SalaryExchangeCoinPerUSD)
if err != nil {
return ledger.SalaryExchangeReceipt{}, err
}
salaryAccount, err := r.lockAccount(ctx, tx, command.UserID, command.SalaryAssetType, false, nowMs)
if err != nil {
return ledger.SalaryExchangeReceipt{}, err
}
if salaryAccount.AvailableAmount < command.SalaryUSDMinor {
return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
}
coinAccount, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, true, nowMs)
if err != nil {
return ledger.SalaryExchangeReceipt{}, err
}
transactionID := transactionID(command.AppCode, command.CommandID)
salaryAfter := salaryAccount.AvailableAmount - command.SalaryUSDMinor
coinAfter, err := checkedAdd(coinAccount.AvailableAmount, coinAmount)
if err != nil {
return ledger.SalaryExchangeReceipt{}, err
}
metadata := salaryExchangeMetadata{
AppCode: command.AppCode,
UserID: command.UserID,
SalaryAssetType: command.SalaryAssetType,
CoinAssetType: ledger.AssetCoin,
SalaryUSDMinor: command.SalaryUSDMinor,
CoinPerUSD: ledger.SalaryExchangeCoinPerUSD,
CoinAmount: coinAmount,
SalaryBalanceAfter: salaryAfter,
CoinBalanceAfter: coinAfter,
Reason: command.Reason,
CreatedAtMS: nowMs,
}
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeSalaryExchangeToCoin, requestHash, fmt.Sprintf("salary_exchange:%d", command.UserID), metadata, nowMs); err != nil {
return ledger.SalaryExchangeReceipt{}, err
}
if err := r.applyAccountDelta(ctx, tx, salaryAccount, -command.SalaryUSDMinor, 0, nowMs); err != nil {
return ledger.SalaryExchangeReceipt{}, err
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.UserID,
AssetType: command.SalaryAssetType,
AvailableDelta: -command.SalaryUSDMinor,
FrozenDelta: 0,
AvailableAfter: salaryAfter,
FrozenAfter: salaryAccount.FrozenAmount,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.SalaryExchangeReceipt{}, err
}
if err := r.applyAccountDelta(ctx, tx, coinAccount, coinAmount, 0, nowMs); err != nil {
return ledger.SalaryExchangeReceipt{}, err
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.UserID,
AssetType: ledger.AssetCoin,
AvailableDelta: coinAmount,
FrozenDelta: 0,
AvailableAfter: coinAfter,
FrozenAfter: coinAccount.FrozenAmount,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.SalaryExchangeReceipt{}, err
}
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
balanceChangedEvent(transactionID, command.CommandID, command.UserID, command.SalaryAssetType, -command.SalaryUSDMinor, 0, salaryAfter, salaryAccount.FrozenAmount, salaryAccount.Version+1, metadata, nowMs),
balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, coinAmount, 0, coinAfter, coinAccount.FrozenAmount, coinAccount.Version+1, metadata, nowMs),
}); err != nil {
return ledger.SalaryExchangeReceipt{}, err
}
if err := tx.Commit(); err != nil {
return ledger.SalaryExchangeReceipt{}, err
}
return receiptFromSalaryExchangeMetadata(transactionID, metadata), nil
}
// TransferSalaryToCoinSeller 在同一事务里扣来源工资钱包、按当前区域配置给币商专用金币库存入账。
func (r *Repository) TransferSalaryToCoinSeller(ctx context.Context, command ledger.SalaryTransferToCoinSellerCommand) (ledger.SalaryTransferToCoinSellerReceipt, error) {
if r == nil || r.db == nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, 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.SalaryTransferToCoinSellerReceipt{}, err
}
defer func() { _ = tx.Rollback() }()
requestHash := salaryTransferToCoinSellerRequestHash(command)
if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizTypeSalaryTransferToCoinSeller, xerr.IdempotencyConflict); err != nil || exists {
if err != nil || !exists {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
return r.receiptForSalaryTransferToCoinSellerTransaction(ctx, tx, txRow.TransactionID)
}
nowMs := time.Now().UnixMilli()
rate, err := r.resolveCoinSellerSalaryExchangeRateTier(ctx, tx, command.RegionID, command.SalaryUSDMinor)
if err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
coinAmount, err := calculateSalaryCoinAmount(command.SalaryUSDMinor, rate.CoinPerUSD)
if err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
source, err := r.lockAccount(ctx, tx, command.SourceUserID, command.SalaryAssetType, false, nowMs)
if err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
if source.AvailableAmount < command.SalaryUSDMinor {
return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
}
seller, err := r.lockAccount(ctx, tx, command.SellerUserID, ledger.AssetCoinSellerCoin, true, nowMs)
if err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
transactionID := transactionID(command.AppCode, command.CommandID)
sourceAfter := source.AvailableAmount - command.SalaryUSDMinor
sellerAfter, err := checkedAdd(seller.AvailableAmount, coinAmount)
if err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
metadata := salaryTransferToCoinSellerMetadata{
AppCode: command.AppCode,
SourceUserID: command.SourceUserID,
SellerUserID: command.SellerUserID,
RegionID: command.RegionID,
SalaryAssetType: command.SalaryAssetType,
SellerAssetType: ledger.AssetCoinSellerCoin,
SalaryUSDMinor: command.SalaryUSDMinor,
CoinPerUSD: rate.CoinPerUSD,
CoinAmount: coinAmount,
RateMinUSDMinor: rate.MinUSDMinor,
RateMaxUSDMinor: rate.MaxUSDMinor,
SourceSalaryBalanceAfter: sourceAfter,
SellerBalanceAfter: sellerAfter,
Reason: command.Reason,
CreatedAtMS: nowMs,
}
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeSalaryTransferToCoinSeller, requestHash, fmt.Sprintf("salary_coin_seller:%d:%d", command.SourceUserID, command.SellerUserID), metadata, nowMs); err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
if err := r.applyAccountDelta(ctx, tx, source, -command.SalaryUSDMinor, 0, nowMs); err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.SourceUserID,
AssetType: command.SalaryAssetType,
AvailableDelta: -command.SalaryUSDMinor,
FrozenDelta: 0,
AvailableAfter: sourceAfter,
FrozenAfter: source.FrozenAmount,
CounterpartyUserID: command.SellerUserID,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
if err := r.applyAccountDelta(ctx, tx, seller, coinAmount, 0, nowMs); err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.SellerUserID,
AssetType: ledger.AssetCoinSellerCoin,
AvailableDelta: coinAmount,
FrozenDelta: 0,
AvailableAfter: sellerAfter,
FrozenAfter: seller.FrozenAmount,
CounterpartyUserID: command.SourceUserID,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
balanceChangedEvent(transactionID, command.CommandID, command.SourceUserID, command.SalaryAssetType, -command.SalaryUSDMinor, 0, sourceAfter, source.FrozenAmount, source.Version+1, metadata, nowMs),
balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, coinAmount, 0, sellerAfter, seller.FrozenAmount, seller.Version+1, metadata, nowMs),
}); err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
if err := tx.Commit(); err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
return receiptFromSalaryTransferToCoinSellerMetadata(transactionID, metadata), nil
}
type transactionRow struct {
TransactionID string
MetadataJSON string
@ -1437,13 +1742,13 @@ type giftDiamondRatioSnapshot struct {
PPM int64
}
func (r *Repository) resolveGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCode string, senderRegionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, int64, error) {
func (r *Repository) resolveGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, int64, error) {
giftTypeCode = strings.TrimSpace(giftTypeCode)
if giftTypeCode == "" {
giftTypeCode = "normal"
}
regions := []int64{senderRegionID}
if senderRegionID != 0 {
regions := []int64{regionID}
if regionID != 0 {
regions = append(regions, 0)
}
for _, regionID := range regions {
@ -1527,13 +1832,49 @@ func calculateRechargeUSDMinor(coinAmount int64, policy rechargePolicy) (int64,
if err != nil {
return 0, err
}
if numerator%policy.CoinAmount != 0 {
// 充值金额按最小货币单位入账;不能精确换算时拒绝,避免财务口径出现隐式四舍五入。
return 0, xerr.New(xerr.InvalidArgument, "coin amount does not match recharge policy")
}
// 金币必须完整到账;充值 USD 统计只能落到最小货币单位,不能表示的尾差向下取整。
return numerator / policy.CoinAmount, nil
}
func (r *Repository) resolveCoinSellerSalaryExchangeRateTier(ctx context.Context, tx *sql.Tx, regionID int64, salaryUSDMinor int64) (ledger.CoinSellerSalaryExchangeRateTier, error) {
row := tx.QueryRowContext(ctx,
`SELECT region_id, min_usd_minor, max_usd_minor, coin_per_usd, status, sort_order, updated_at_ms
FROM coin_seller_salary_exchange_rate_tiers
WHERE app_code = ? AND region_id = ? AND status = 'active'
AND min_usd_minor <= ? AND max_usd_minor >= ?
ORDER BY sort_order ASC, min_usd_minor ASC, tier_id ASC
LIMIT 1
FOR UPDATE`,
appcode.FromContext(ctx),
regionID,
salaryUSDMinor,
salaryUSDMinor,
)
var tier ledger.CoinSellerSalaryExchangeRateTier
if err := row.Scan(&tier.RegionID, &tier.MinUSDMinor, &tier.MaxUSDMinor, &tier.CoinPerUSD, &tier.Status, &tier.SortOrder, &tier.UpdatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ledger.CoinSellerSalaryExchangeRateTier{}, xerr.New(xerr.NotFound, "exchange rate not configured")
}
return ledger.CoinSellerSalaryExchangeRateTier{}, err
}
if tier.MinUSDMinor <= 0 || tier.MaxUSDMinor < tier.MinUSDMinor || tier.CoinPerUSD <= 0 || tier.CoinPerUSD%100 != 0 {
// 工资以美分入账coin_per_usd 必须能被 100 整除,避免转账金币出现隐式四舍五入。
return ledger.CoinSellerSalaryExchangeRateTier{}, xerr.New(xerr.Internal, "coin seller salary exchange rate is invalid")
}
return tier, nil
}
func calculateSalaryCoinAmount(salaryUSDMinor int64, coinPerUSD int64) (int64, error) {
numerator, err := checkedMul(salaryUSDMinor, coinPerUSD)
if err != nil {
return 0, err
}
if numerator%100 != 0 {
return 0, xerr.New(xerr.InvalidArgument, "salary amount does not match exchange rate")
}
return numerator / 100, nil
}
func (r *Repository) insertTransaction(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, bizType string, requestHash string, externalRef string, metadata any, nowMs int64) error {
metadataJSON, err := json.Marshal(metadata)
if err != nil {
@ -2194,39 +2535,42 @@ func (r *Repository) balanceAfterTransaction(ctx context.Context, tx *sql.Tx, tr
}
type giftMetadata struct {
AppCode string `json:"app_code"`
GiftID string `json:"gift_id"`
GiftName string `json:"gift_name"`
ResourceID int64 `json:"resource_id"`
ResourceSnapshot string `json:"resource_snapshot_json"`
GiftTypeCode string `json:"gift_type_code"`
PresentationJSON string `json:"presentation_json"`
SortOrder int32 `json:"sort_order"`
GiftCount int32 `json:"gift_count"`
PriceVersion string `json:"price_version"`
CoinPrice int64 `json:"coin_price"`
GiftPointAmount int64 `json:"gift_point_amount"`
HeatUnitValue int64 `json:"heat_unit_value"`
ChargeAssetType string `json:"charge_asset_type"`
ChargeAmount int64 `json:"charge_amount"`
CoinSpent int64 `json:"coin_spent"`
GiftPointAdded int64 `json:"gift_point_added"`
HeatValue int64 `json:"heat_value"`
BalanceAfter int64 `json:"balance_after"`
BillingReceipt string `json:"billing_receipt_id"`
SenderUserID int64 `json:"sender_user_id"`
SenderRegionID int64 `json:"sender_region_id"`
TargetUserID int64 `json:"target_user_id"`
TargetIsHost bool `json:"target_is_host"`
TargetHostRegionID int64 `json:"target_host_region_id"`
TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id"`
HostPeriodDiamondAdded int64 `json:"host_period_diamond_added"`
GiftDiamondRatioPercent string `json:"gift_diamond_ratio_percent"`
GiftDiamondRatioRegionID int64 `json:"gift_diamond_ratio_region_id"`
HostPeriodDiamondAfter int64 `json:"host_period_diamond_after"`
HostPeriodDiamondVersion int64 `json:"host_period_diamond_version"`
HostPeriodCycleKey string `json:"host_period_cycle_key"`
RoomID string `json:"room_id"`
AppCode string `json:"app_code"`
GiftID string `json:"gift_id"`
GiftName string `json:"gift_name"`
ResourceID int64 `json:"resource_id"`
ResourceSnapshot string `json:"resource_snapshot_json"`
GiftTypeCode string `json:"gift_type_code"`
PresentationJSON string `json:"presentation_json"`
SortOrder int32 `json:"sort_order"`
GiftCount int32 `json:"gift_count"`
PriceVersion string `json:"price_version"`
CoinPrice int64 `json:"coin_price"`
GiftPointAmount int64 `json:"gift_point_amount"`
HeatUnitValue int64 `json:"heat_unit_value"`
ChargeAssetType string `json:"charge_asset_type"`
ChargeAmount int64 `json:"charge_amount"`
CoinSpent int64 `json:"coin_spent"`
GiftPointAdded int64 `json:"gift_point_added"`
HeatValue int64 `json:"heat_value"`
BalanceAfter int64 `json:"balance_after"`
BillingReceipt string `json:"billing_receipt_id"`
SenderUserID int64 `json:"sender_user_id"`
SenderRegionID int64 `json:"sender_region_id"`
TargetUserID int64 `json:"target_user_id"`
TargetIsHost bool `json:"target_is_host"`
TargetHostRegionID int64 `json:"target_host_region_id"`
TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id"`
HostPeriodDiamondAdded int64 `json:"host_period_diamond_added"`
GiftDiamondRatioPercent string `json:"gift_diamond_ratio_percent"`
GiftDiamondRatioRegionID int64 `json:"gift_diamond_ratio_region_id"`
HostPeriodDiamondAfter int64 `json:"host_period_diamond_after"`
HostPeriodDiamondVersion int64 `json:"host_period_diamond_version"`
HostPeriodCycleKey string `json:"host_period_cycle_key"`
RoomID string `json:"room_id"`
RoomRegionID int64 `json:"room_region_id"`
RoomContributionRatioPercent string `json:"room_contribution_ratio_percent"`
RoomContributionRatioRegionID int64 `json:"room_contribution_ratio_region_id"`
}
type adminCreditMetadata struct {
@ -2304,6 +2648,38 @@ type coinSellerTransferMetadata struct {
RechargePolicyUSDMinorAmount int64 `json:"recharge_policy_usd_minor_amount"`
}
type salaryExchangeMetadata struct {
AppCode string `json:"app_code"`
UserID int64 `json:"user_id"`
SalaryAssetType string `json:"salary_asset_type"`
CoinAssetType string `json:"coin_asset_type"`
SalaryUSDMinor int64 `json:"salary_usd_minor"`
CoinPerUSD int64 `json:"coin_per_usd"`
CoinAmount int64 `json:"coin_amount"`
SalaryBalanceAfter int64 `json:"salary_balance_after"`
CoinBalanceAfter int64 `json:"coin_balance_after"`
Reason string `json:"reason"`
CreatedAtMS int64 `json:"created_at_ms"`
}
type salaryTransferToCoinSellerMetadata struct {
AppCode string `json:"app_code"`
SourceUserID int64 `json:"source_user_id"`
SellerUserID int64 `json:"seller_user_id"`
RegionID int64 `json:"region_id"`
SalaryAssetType string `json:"salary_asset_type"`
SellerAssetType string `json:"seller_asset_type"`
SalaryUSDMinor int64 `json:"salary_usd_minor"`
CoinPerUSD int64 `json:"coin_per_usd"`
CoinAmount int64 `json:"coin_amount"`
RateMinUSDMinor int64 `json:"rate_min_usd_minor"`
RateMaxUSDMinor int64 `json:"rate_max_usd_minor"`
SourceSalaryBalanceAfter int64 `json:"source_salary_balance_after"`
SellerBalanceAfter int64 `json:"seller_balance_after"`
Reason string `json:"reason"`
CreatedAtMS int64 `json:"created_at_ms"`
}
type coinSellerStockMetadata struct {
AppCode string `json:"app_code"`
SellerUserID int64 `json:"seller_user_id"`
@ -2507,6 +2883,73 @@ func receiptFromCoinSellerTransferMetadata(transactionID string, metadata coinSe
}
}
func (r *Repository) receiptForSalaryExchangeTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.SalaryExchangeReceipt, 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.SalaryExchangeReceipt{}, err
}
var metadata salaryExchangeMetadata
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
return ledger.SalaryExchangeReceipt{}, err
}
return receiptFromSalaryExchangeMetadata(transactionID, metadata), nil
}
func receiptFromSalaryExchangeMetadata(transactionID string, metadata salaryExchangeMetadata) ledger.SalaryExchangeReceipt {
return ledger.SalaryExchangeReceipt{
TransactionID: transactionID,
UserID: metadata.UserID,
SalaryAssetType: metadata.SalaryAssetType,
SalaryBalanceAfter: metadata.SalaryBalanceAfter,
CoinBalanceAfter: metadata.CoinBalanceAfter,
SalaryUSDMinor: metadata.SalaryUSDMinor,
CoinAmount: metadata.CoinAmount,
CoinPerUSD: metadata.CoinPerUSD,
CreatedAtMS: metadata.CreatedAtMS,
}
}
func (r *Repository) receiptForSalaryTransferToCoinSellerTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.SalaryTransferToCoinSellerReceipt, 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.SalaryTransferToCoinSellerReceipt{}, err
}
var metadata salaryTransferToCoinSellerMetadata
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
return receiptFromSalaryTransferToCoinSellerMetadata(transactionID, metadata), nil
}
func receiptFromSalaryTransferToCoinSellerMetadata(transactionID string, metadata salaryTransferToCoinSellerMetadata) ledger.SalaryTransferToCoinSellerReceipt {
return ledger.SalaryTransferToCoinSellerReceipt{
TransactionID: transactionID,
SourceUserID: metadata.SourceUserID,
SellerUserID: metadata.SellerUserID,
SalaryAssetType: metadata.SalaryAssetType,
SourceSalaryBalanceAfter: metadata.SourceSalaryBalanceAfter,
SellerBalanceAfter: metadata.SellerBalanceAfter,
SalaryUSDMinor: metadata.SalaryUSDMinor,
CoinAmount: metadata.CoinAmount,
CoinPerUSD: metadata.CoinPerUSD,
RateMinUSDMinor: metadata.RateMinUSDMinor,
RateMaxUSDMinor: metadata.RateMaxUSDMinor,
CreatedAtMS: metadata.CreatedAtMS,
}
}
func balanceChangedEvent(transactionID string, commandID string, userID int64, assetType string, availableDelta int64, frozenDelta int64, availableAfter int64, frozenAfter int64, balanceVersion int64, payload any, nowMs int64) walletOutboxEvent {
return walletOutboxEvent{
EventID: eventID(transactionID, "WalletBalanceChanged", userID, assetType),
@ -2940,6 +3383,28 @@ func coinSellerTransferRequestHash(command ledger.CoinSellerTransferCommand) str
))
}
func salaryExchangeRequestHash(command ledger.SalaryExchangeCommand) string {
return stableHash(fmt.Sprintf("salary_exchange|%s|%d|%s|%d|%s",
appcode.Normalize(command.AppCode),
command.UserID,
strings.ToUpper(strings.TrimSpace(command.SalaryAssetType)),
command.SalaryUSDMinor,
strings.TrimSpace(command.Reason),
))
}
func salaryTransferToCoinSellerRequestHash(command ledger.SalaryTransferToCoinSellerCommand) string {
return stableHash(fmt.Sprintf("salary_transfer_coin_seller|%s|%d|%d|%s|%d|%d|%s",
appcode.Normalize(command.AppCode),
command.SourceUserID,
command.SellerUserID,
strings.ToUpper(strings.TrimSpace(command.SalaryAssetType)),
command.SalaryUSDMinor,
command.RegionID,
strings.TrimSpace(command.Reason),
))
}
func coinSellerStockBizType(stockType string) string {
switch stockType {
case ledger.StockTypeUSDTPurchase:

View File

@ -237,9 +237,15 @@ func (r *Repository) UpdateResource(ctx context.Context, command resourcedomain.
if err != nil {
return resourcedomain.Resource{}, err
}
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
events := []walletOutboxEvent{
resourceOutboxEvent("ResourceChanged", fmt.Sprintf("resource:%d:update:%d", command.ResourceID, nowMs), 0, command.ResourceID, resource, nowMs),
}); err != nil {
}
giftEvents, err := r.syncGiftPricesForResourceTx(ctx, tx, resource, nowMs)
if err != nil {
return resourcedomain.Resource{}, err
}
events = append(events, giftEvents...)
if err := r.insertWalletOutbox(ctx, tx, events); err != nil {
return resourcedomain.Resource{}, err
}
if err := tx.Commit(); err != nil {
@ -248,6 +254,77 @@ func (r *Repository) UpdateResource(ctx context.Context, command resourcedomain.
return resource, nil
}
func (r *Repository) syncGiftPricesForResourceTx(ctx context.Context, tx *sql.Tx, resource resourcedomain.Resource, nowMs int64) ([]walletOutboxEvent, error) {
// 礼物资源的价格是后台编辑入口,实际送礼扣费读取 wallet_gift_prices。
// 因此资源价格变更后,必须在同一个事务内把已绑定礼物的 active 价格同步过去,
// 同时发 GiftConfigChanged outbox让依赖礼物配置快照的下游可以刷新缓存。
if resource.ResourceType != resourcedomain.TypeGift {
return nil, nil
}
priceType := resourcedomain.NormalizePriceType(resource.PriceType)
if priceType != resourcedomain.PriceTypeCoin && priceType != resourcedomain.PriceTypeFree {
return nil, nil
}
rows, err := tx.QueryContext(ctx, `
SELECT gift_id
FROM gift_configs
WHERE app_code = ? AND resource_id = ?
ORDER BY gift_id`,
appcode.FromContext(ctx), resource.ResourceID,
)
if err != nil {
return nil, err
}
defer rows.Close()
giftIDs := make([]string, 0)
for rows.Next() {
var giftID string
if err := rows.Scan(&giftID); err != nil {
return nil, err
}
giftIDs = append(giftIDs, giftID)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(giftIDs) == 0 {
return nil, nil
}
coinPrice := int64(0)
giftPointAmount := int64(0)
if priceType == resourcedomain.PriceTypeCoin {
coinPrice = resource.CoinPrice
giftPointAmount = resource.GiftPointAmount
}
if _, err := tx.ExecContext(ctx, `
UPDATE wallet_gift_prices gp
JOIN gift_configs gc ON gc.app_code = gp.app_code AND gc.gift_id = gp.gift_id
SET gp.charge_asset_type = ?, gp.coin_price = ?, gp.gift_point_amount = ?, gp.updated_at_ms = ?
WHERE gc.app_code = ? AND gc.resource_id = ? AND gp.status = 'active'`,
ledger.AssetCoin, coinPrice, giftPointAmount, nowMs, appcode.FromContext(ctx), resource.ResourceID,
); err != nil {
return nil, err
}
events := make([]walletOutboxEvent, 0, len(giftIDs))
for _, giftID := range giftIDs {
events = append(events, resourceOutboxEvent(
"GiftConfigChanged",
fmt.Sprintf("gift:%s:resource_price:%d", giftID, nowMs),
0,
resource.ResourceID,
map[string]any{
"gift_id": giftID,
"resource_id": resource.ResourceID,
"coin_price": coinPrice,
"gift_point_amount": giftPointAmount,
},
nowMs,
))
}
return events, nil
}
func (r *Repository) SetResourceStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.Resource, error) {
if r == nil || r.db == nil {
return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")

View File

@ -331,6 +331,26 @@ func (r *Repository) SetAssetBalance(userID int64, assetType string, balance int
}
}
// SetCoinSellerSalaryExchangeRateTier seeds an active salary transfer tier for coin seller transfer tests.
func (r *Repository) SetCoinSellerSalaryExchangeRateTier(appCode string, regionID int64, minUSDMinor int64, maxUSDMinor int64, coinPerUSD int64) {
r.t.Helper()
nowMs := time.Now().UnixMilli()
_, err := r.schema.DB.ExecContext(context.Background(), `
INSERT INTO coin_seller_salary_exchange_rate_tiers (
app_code, region_id, min_usd_minor, max_usd_minor, coin_per_usd,
status, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, 'active', 10, 0, 0, ?, ?)
ON DUPLICATE KEY UPDATE
coin_per_usd = VALUES(coin_per_usd),
status = VALUES(status),
updated_at_ms = VALUES(updated_at_ms)
`, appCode, regionID, minUSDMinor, maxUSDMinor, coinPerUSD, nowMs, nowMs)
if err != nil {
r.t.Fatalf("seed coin seller salary exchange rate tier failed: %v", err)
}
}
// SetGiftPrice overrides or inserts a gift settlement price for server-side billing tests.
func (r *Repository) SetGiftPrice(giftID string, priceVersion string, coinPrice int64, giftPointAmount int64, heatValue int64) {
r.t.Helper()
@ -410,7 +430,7 @@ func (r *Repository) SetGiftType(giftID string, giftTypeCode string) {
}
}
// SetGiftDiamondRatio configures the host-period diamond ratio for a sender region and gift type.
// SetGiftDiamondRatio configures the gift diamond ratio for a room region and gift type.
func (r *Repository) SetGiftDiamondRatio(regionID int64, giftTypeCode string, ratioPercent string) {
r.t.Helper()
nowMs := time.Now().UnixMilli()

View File

@ -862,3 +862,77 @@ func (s *Server) TransferCoinFromSeller(ctx context.Context, req *walletv1.Trans
RechargePolicyUsdMinorAmount: receipt.RechargePolicyUSDMinorUnit,
}, nil
}
// ListCoinSellerSalaryExchangeRateTiers 返回工资转币商使用的区域比例区间,调用方用它做预计到账展示。
func (s *Server) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, req *walletv1.ListCoinSellerSalaryExchangeRateTiersRequest) (*walletv1.ListCoinSellerSalaryExchangeRateTiersResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
tiers, err := s.svc.ListCoinSellerSalaryExchangeRateTiers(ctx, req.GetAppCode(), req.GetRegionId(), req.GetIncludeDisabled())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
response := &walletv1.ListCoinSellerSalaryExchangeRateTiersResponse{Tiers: make([]*walletv1.CoinSellerSalaryExchangeRateTier, 0, len(tiers))}
for _, tier := range tiers {
response.Tiers = append(response.Tiers, &walletv1.CoinSellerSalaryExchangeRateTier{
RegionId: tier.RegionID,
MinUsdMinor: tier.MinUSDMinor,
MaxUsdMinor: tier.MaxUSDMinor,
CoinPerUsd: tier.CoinPerUSD,
Status: tier.Status,
SortOrder: int32(tier.SortOrder),
UpdatedAtMs: tier.UpdatedAtMS,
})
}
return response, nil
}
// ExchangeSalaryToCoin 处理用户工资兑换普通金币,身份资产已经由 gateway 校验并注入。
func (s *Server) ExchangeSalaryToCoin(ctx context.Context, req *walletv1.ExchangeSalaryToCoinRequest) (*walletv1.ExchangeSalaryToCoinResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
receipt, err := s.svc.ExchangeSalaryToCoin(ctx, ledger.SalaryExchangeCommand{
AppCode: req.GetAppCode(),
CommandID: req.GetCommandId(),
UserID: req.GetUserId(),
SalaryAssetType: req.GetSalaryAssetType(),
SalaryUSDMinor: req.GetSalaryUsdMinor(),
Reason: req.GetReason(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &walletv1.ExchangeSalaryToCoinResponse{
TransactionId: receipt.TransactionID,
SalaryBalanceAfter: receipt.SalaryBalanceAfter,
CoinBalanceAfter: receipt.CoinBalanceAfter,
SalaryUsdMinor: receipt.SalaryUSDMinor,
CoinAmount: receipt.CoinAmount,
CoinPerUsd: receipt.CoinPerUSD,
}, nil
}
// TransferSalaryToCoinSeller 处理用户工资转同区域币商专用金币库存。
func (s *Server) TransferSalaryToCoinSeller(ctx context.Context, req *walletv1.TransferSalaryToCoinSellerRequest) (*walletv1.TransferSalaryToCoinSellerResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
receipt, err := s.svc.TransferSalaryToCoinSeller(ctx, ledger.SalaryTransferToCoinSellerCommand{
AppCode: req.GetAppCode(),
CommandID: req.GetCommandId(),
SourceUserID: req.GetSourceUserId(),
SellerUserID: req.GetSellerUserId(),
SalaryAssetType: req.GetSalaryAssetType(),
SalaryUSDMinor: req.GetSalaryUsdMinor(),
RegionID: req.GetRegionId(),
Reason: req.GetReason(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &walletv1.TransferSalaryToCoinSellerResponse{
TransactionId: receipt.TransactionID,
SourceSalaryBalanceAfter: receipt.SourceSalaryBalanceAfter,
SellerBalanceAfter: receipt.SellerBalanceAfter,
SalaryUsdMinor: receipt.SalaryUSDMinor,
CoinAmount: receipt.CoinAmount,
CoinPerUsd: receipt.CoinPerUSD,
RateMinUsdMinor: receipt.RateMinUSDMinor,
RateMaxUsdMinor: receipt.RateMaxUSDMinor,
}, nil
}