Add salary wallet exchange flows
This commit is contained in:
parent
0ad227187f
commit
d79c877ec0
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
|
||||
@ -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
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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, `
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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, `
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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{}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 是注册页唯一资料提交入口。
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 '应用编码,用于多租户隔离',
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user