币商转账
This commit is contained in:
parent
da1c5e7c30
commit
7744232add
@ -43,6 +43,11 @@ type coinSellerLedgerDTO struct {
|
||||
Direction string `json:"direction"`
|
||||
AvailableDelta int64 `json:"availableDelta"`
|
||||
SellerBalanceAfter int64 `json:"sellerBalanceAfter"`
|
||||
StockType string `json:"stockType,omitempty"`
|
||||
PaidCurrencyCode string `json:"paidCurrencyCode,omitempty"`
|
||||
PaidAmountMicro int64 `json:"paidAmountMicro,omitempty"`
|
||||
TransferUSDMinor int64 `json:"transferUsdMinor,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
CounterpartyUserID string `json:"counterpartyUserId"`
|
||||
OperatorUserID string `json:"operatorUserId"`
|
||||
Operator coinAdjustmentOperatorDTO `json:"operator"`
|
||||
|
||||
@ -167,7 +167,7 @@ func (s *Service) ExportCoinSellerLedger(ctx context.Context, appCode string, qu
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
writer := csv.NewWriter(&buf)
|
||||
_ = writer.Write([]string{"币商名称", "币商短ID", "币商长ID", "流水类型", "转账金额", "收款人名称", "收款人短ID", "收款人长ID", "操作人", "操作人ID", "币商余额", "转账时间", "交易ID", "命令ID"})
|
||||
_ = writer.Write([]string{"币商名称", "币商短ID", "币商长ID", "流水类型", "金币变动", "转账USD金额", "USDT数量", "收款人名称", "收款人短ID", "收款人长ID", "操作人", "操作人ID", "币商余额", "转账时间", "交易ID", "命令ID"})
|
||||
for _, item := range items {
|
||||
operatorName, operatorID := coinSellerLedgerOperatorExportFields(item)
|
||||
_ = writer.Write([]string{
|
||||
@ -176,6 +176,8 @@ func (s *Service) ExportCoinSellerLedger(ctx context.Context, appCode string, qu
|
||||
item.Seller.UserID,
|
||||
coinSellerLedgerLabel(item),
|
||||
strconv.FormatInt(signedCoinSellerLedgerAmount(item), 10),
|
||||
formatUSDMinorForCSV(item.TransferUSDMinor),
|
||||
formatUSDTMicroForCSV(item.PaidAmountMicro),
|
||||
item.Receiver.Username,
|
||||
item.Receiver.DisplayUserID,
|
||||
item.Receiver.UserID,
|
||||
@ -276,6 +278,11 @@ func (s *Service) listCoinSellerLedger(ctx context.Context, appCode string, quer
|
||||
return nil, 0, err
|
||||
}
|
||||
operatorUserID := metadataInt64(metadata, "operator_user_id", "operatorUserId")
|
||||
item.StockType = firstNonEmpty(metadataString(metadata, "stock_type"), metadataString(metadata, "stockType"))
|
||||
item.PaidCurrencyCode = firstNonEmpty(metadataString(metadata, "paid_currency_code"), metadataString(metadata, "paidCurrencyCode"))
|
||||
item.PaidAmountMicro = metadataInt64(metadata, "paid_amount_micro", "paidAmountMicro")
|
||||
item.TransferUSDMinor = metadataInt64(metadata, "salary_usd_minor", "salaryUsdMinor", "transfer_usd_minor", "transferUsdMinor")
|
||||
item.Reason = metadataString(metadata, "reason")
|
||||
// 收款人按产品口径取实际收款人:币商转用户优先取 metadata.target_user_id,老数据没有 metadata 时再用 counterparty_user_id;
|
||||
// 后台入账和工资转币商的实际收款人都是币商本人,所以不能被 counterparty 字段误导成付款方或操作方。
|
||||
receiverUserID := coinSellerLedgerReceiverUserID(item.BizType, sellerUserID, counterpartyUserID, metadata)
|
||||
@ -771,16 +778,17 @@ func normalizeCoinSellerLedgerQuery(query coinSellerLedgerQuery) coinSellerLedge
|
||||
func coinSellerLedgerBizTypes(ledgerType string) ([]string, error) {
|
||||
switch strings.TrimSpace(ledgerType) {
|
||||
case "":
|
||||
// 空类型表示“全部币商流水”,但仍只允许当前产品定义的三个类型口径,不能把其他币商库存内部账带出来。
|
||||
// 空类型表示“全部币商流水”,但仍只允许当前产品定义的公开币商库存口径,不能把其他内部账带出来。
|
||||
return []string{
|
||||
coinSellerStockPurchaseBizType,
|
||||
coinSellerCoinCompensationBizType,
|
||||
coinSellerTransferBizType,
|
||||
salaryTransferToCoinSellerBizType,
|
||||
coinManualCreditBizType,
|
||||
}, nil
|
||||
case coinSellerLedgerTypeAdminStockCredit:
|
||||
// 后台入账是运营口径,底层包含正常进货和金币补偿两种账务 biz_type。
|
||||
return []string{coinSellerStockPurchaseBizType, coinSellerCoinCompensationBizType}, nil
|
||||
// 后台操作是运营口径,底层包含 USDT 进货、金币补偿和后台扣除;资产条件仍限定在 COIN_SELLER_COIN。
|
||||
return []string{coinSellerStockPurchaseBizType, coinSellerCoinCompensationBizType, coinManualCreditBizType}, nil
|
||||
case coinSellerLedgerTypeSellerTransfer:
|
||||
// 币商转用户只展示币商侧出账分录,收款用户资料在展示投影里补齐。
|
||||
return []string{coinSellerTransferBizType}, nil
|
||||
@ -796,6 +804,8 @@ func coinSellerLedgerTypeForBizType(bizType string) string {
|
||||
switch bizType {
|
||||
case coinSellerStockPurchaseBizType, coinSellerCoinCompensationBizType:
|
||||
return coinSellerLedgerTypeAdminStockCredit
|
||||
case coinManualCreditBizType:
|
||||
return coinSellerLedgerTypeAdminStockCredit
|
||||
case coinSellerTransferBizType:
|
||||
return coinSellerLedgerTypeSellerTransfer
|
||||
case salaryTransferToCoinSellerBizType:
|
||||
@ -806,26 +816,30 @@ func coinSellerLedgerTypeForBizType(bizType string) string {
|
||||
}
|
||||
|
||||
func coinSellerLedgerLabel(item coinSellerLedgerDTO) string {
|
||||
switch item.BizType {
|
||||
case coinSellerStockPurchaseBizType:
|
||||
return "USDT进货"
|
||||
case coinSellerCoinCompensationBizType:
|
||||
return "金币补偿"
|
||||
case coinManualCreditBizType:
|
||||
if item.AvailableDelta < 0 {
|
||||
return "金币扣除"
|
||||
}
|
||||
return "金币增加"
|
||||
case coinSellerTransferBizType:
|
||||
return "币商转用户"
|
||||
case salaryTransferToCoinSellerBizType:
|
||||
return "工资转币商"
|
||||
}
|
||||
switch item.LedgerType {
|
||||
case coinSellerLedgerTypeAdminStockCredit:
|
||||
return "后台入账"
|
||||
return "后台操作"
|
||||
case coinSellerLedgerTypeSellerTransfer:
|
||||
return "币商转用户"
|
||||
case coinSellerLedgerTypeSalaryTransferIncome:
|
||||
return "工资转币商"
|
||||
default:
|
||||
switch item.BizType {
|
||||
case coinSellerStockPurchaseBizType:
|
||||
return "币商进货"
|
||||
case coinSellerCoinCompensationBizType:
|
||||
return "金币补偿"
|
||||
case coinSellerTransferBizType:
|
||||
return "币商转用户"
|
||||
case salaryTransferToCoinSellerBizType:
|
||||
return "工资转币商"
|
||||
default:
|
||||
return firstNonEmpty(item.LedgerType, item.BizType, "-")
|
||||
}
|
||||
return firstNonEmpty(item.LedgerType, item.BizType, "-")
|
||||
}
|
||||
}
|
||||
|
||||
@ -836,6 +850,21 @@ func signedCoinSellerLedgerAmount(item coinSellerLedgerDTO) int64 {
|
||||
return absInt64(item.Amount)
|
||||
}
|
||||
|
||||
func formatUSDMinorForCSV(value int64) string {
|
||||
if value <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatFloat(float64(value)/100, 'f', 2, 64)
|
||||
}
|
||||
|
||||
func formatUSDTMicroForCSV(value int64) string {
|
||||
if value <= 0 {
|
||||
return ""
|
||||
}
|
||||
formatted := strconv.FormatFloat(float64(value)/1_000_000, 'f', 6, 64)
|
||||
return strings.TrimRight(strings.TrimRight(formatted, "0"), ".")
|
||||
}
|
||||
|
||||
func coinSellerLedgerOperatorExportFields(item coinSellerLedgerDTO) (string, string) {
|
||||
if item.LedgerType != coinSellerLedgerTypeAdminStockCredit {
|
||||
return "", ""
|
||||
|
||||
@ -51,10 +51,10 @@ func TestCoinSellerLedgerWhereMapsAdminStockCredit(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("coin seller ledger where failed: %v", err)
|
||||
}
|
||||
if want := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type IN (?,?) AND e.user_id IN (?,?)"; where != want {
|
||||
if want := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type IN (?,?,?) AND e.user_id IN (?,?)"; where != want {
|
||||
t.Fatalf("where mismatch:\nwant %s\n got %s", want, where)
|
||||
}
|
||||
if len(args) != 6 || args[0] != "lalu" || args[1] != coinSellerAssetType || args[2] != coinSellerStockPurchaseBizType || args[3] != coinSellerCoinCompensationBizType || args[4] != int64(3001) || args[5] != int64(3002) {
|
||||
if len(args) != 7 || args[0] != "lalu" || args[1] != coinSellerAssetType || args[2] != coinSellerStockPurchaseBizType || args[3] != coinSellerCoinCompensationBizType || args[4] != coinManualCreditBizType || args[5] != int64(3001) || args[6] != int64(3002) {
|
||||
t.Fatalf("args mismatch: %#v", args)
|
||||
}
|
||||
}
|
||||
@ -64,10 +64,10 @@ func TestCoinSellerLedgerWhereEmptyTypeUsesAllPublicTypes(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("coin seller ledger where failed: %v", err)
|
||||
}
|
||||
if want := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type IN (?,?,?,?)"; where != want {
|
||||
if want := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type IN (?,?,?,?,?)"; where != want {
|
||||
t.Fatalf("where mismatch:\nwant %s\n got %s", want, where)
|
||||
}
|
||||
if len(args) != 6 || args[0] != "lalu" || args[1] != coinSellerAssetType || args[2] != coinSellerStockPurchaseBizType || args[3] != coinSellerCoinCompensationBizType || args[4] != coinSellerTransferBizType || args[5] != salaryTransferToCoinSellerBizType {
|
||||
if len(args) != 7 || args[0] != "lalu" || args[1] != coinSellerAssetType || args[2] != coinSellerStockPurchaseBizType || args[3] != coinSellerCoinCompensationBizType || args[4] != coinSellerTransferBizType || args[5] != salaryTransferToCoinSellerBizType || args[6] != coinManualCreditBizType {
|
||||
t.Fatalf("args mismatch: %#v", args)
|
||||
}
|
||||
}
|
||||
@ -110,6 +110,33 @@ func TestCoinSellerLedgerOperatorExportFieldsOnlyForAdminStock(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoinSellerLedgerLabelUsesConcreteStockAndDebitType(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
item coinSellerLedgerDTO
|
||||
want string
|
||||
}{
|
||||
{name: "usdt purchase", item: coinSellerLedgerDTO{BizType: coinSellerStockPurchaseBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit}, want: "USDT进货"},
|
||||
{name: "compensation", item: coinSellerLedgerDTO{BizType: coinSellerCoinCompensationBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit}, want: "金币补偿"},
|
||||
{name: "debit", item: coinSellerLedgerDTO{BizType: coinManualCreditBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit, AvailableDelta: -100}, want: "金币扣除"},
|
||||
{name: "manual increase", item: coinSellerLedgerDTO{BizType: coinManualCreditBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit, AvailableDelta: 100}, want: "金币增加"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := coinSellerLedgerLabel(tc.item); got != tc.want {
|
||||
t.Fatalf("%s label mismatch: got %q want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoinSellerLedgerCSVAmountFormatters(t *testing.T) {
|
||||
if got := formatUSDMinorForCSV(50); got != "0.50" {
|
||||
t.Fatalf("usd minor format mismatch: %q", got)
|
||||
}
|
||||
if got := formatUSDTMicroForCSV(1_230_000); got != "1.23" {
|
||||
t.Fatalf("usdt micro format mismatch: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCoinSellerLedgerQueryCapsPageSize(t *testing.T) {
|
||||
query := normalizeCoinSellerLedgerQuery(coinSellerLedgerQuery{Page: -1, PageSize: 1000, SellerKeyword: " 164425 ", LedgerType: " seller_transfer "})
|
||||
if query.Page != 1 || query.PageSize != 100 || query.SellerKeyword != "164425" || query.LedgerType != coinSellerLedgerTypeSellerTransfer {
|
||||
|
||||
@ -276,6 +276,27 @@ func (h *Handler) CreditCoinSellerStock(c *gin.Context) {
|
||||
response.Created(c, coinSellerStockCreditFromProto(result))
|
||||
}
|
||||
|
||||
func (h *Handler) DebitCoinSellerStock(c *gin.Context) {
|
||||
sellerUserID, ok := parseInt64ID(c, "user_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req coinSellerStockDebitRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "币商扣除参数不正确")
|
||||
return
|
||||
}
|
||||
result, err := h.service.DebitCoinSellerStock(c.Request.Context(), adminActorID(c), sellerUserID, middleware.CurrentRequestID(c), req)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
writeHostOrgAuditLog(c, h.audit, "coin-seller-stock-debit", "wallet_entries", sellerUserID,
|
||||
fmt.Sprintf("command_id=%s seller_user_id=%d coin_amount=%d transaction_id=%s reason=%q",
|
||||
strings.TrimSpace(req.CommandID), sellerUserID, req.CoinAmount, result.GetTransactionId(), strings.TrimSpace(req.Reason)))
|
||||
response.Created(c, coinSellerStockDebitFromProto(sellerUserID, req.CoinAmount, result))
|
||||
}
|
||||
|
||||
func (h *Handler) GetCoinSellerSalaryRates(c *gin.Context) {
|
||||
regionID, ok := parseInt64ID(c, "region_id")
|
||||
if !ok {
|
||||
|
||||
@ -103,6 +103,13 @@ type coinSellerStockCreditRequest struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type coinSellerStockDebitRequest struct {
|
||||
CommandID string `json:"commandId" binding:"required"`
|
||||
CoinAmount int64 `json:"coinAmount" binding:"required"`
|
||||
EvidenceRef string `json:"evidenceRef"`
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
}
|
||||
|
||||
type replaceCoinSellerSalaryRatesRequest struct {
|
||||
Tiers []coinSellerSalaryRateTierRequest `json:"tiers" binding:"required"`
|
||||
}
|
||||
|
||||
@ -14,6 +14,14 @@ type coinSellerStockCreditResponse struct {
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
}
|
||||
|
||||
type coinSellerStockDebitResponse struct {
|
||||
TransactionID string `json:"transactionId"`
|
||||
SellerUserID int64 `json:"sellerUserId,string"`
|
||||
CoinAmount int64 `json:"coinAmount"`
|
||||
AvailableDelta int64 `json:"availableDelta"`
|
||||
BalanceAfter int64 `json:"balanceAfter"`
|
||||
}
|
||||
|
||||
func coinSellerStockCreditFromProto(item *walletv1.AdminCreditCoinSellerStockResponse) coinSellerStockCreditResponse {
|
||||
if item == nil {
|
||||
return coinSellerStockCreditResponse{}
|
||||
@ -30,3 +38,20 @@ func coinSellerStockCreditFromProto(item *walletv1.AdminCreditCoinSellerStockRes
|
||||
CreatedAtMS: item.GetCreatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func coinSellerStockDebitFromProto(sellerUserID int64, coinAmount int64, item *walletv1.AdminCreditAssetResponse) coinSellerStockDebitResponse {
|
||||
if item == nil {
|
||||
return coinSellerStockDebitResponse{SellerUserID: sellerUserID, CoinAmount: coinAmount, AvailableDelta: -coinAmount}
|
||||
}
|
||||
balanceAfter := int64(0)
|
||||
if item.GetBalance() != nil {
|
||||
balanceAfter = item.GetBalance().GetAvailableAmount()
|
||||
}
|
||||
return coinSellerStockDebitResponse{
|
||||
TransactionID: item.GetTransactionId(),
|
||||
SellerUserID: sellerUserID,
|
||||
CoinAmount: coinAmount,
|
||||
AvailableDelta: -coinAmount,
|
||||
BalanceAfter: balanceAfter,
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,6 +27,7 @@ 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.POST("/admin/coin-sellers/:user_id/stock-debits", middleware.RequirePermission("coin-seller:stock-credit"), h.DebitCoinSellerStock)
|
||||
protected.GET("/admin/coin-seller-salary-rates/:region_id", middleware.RequirePermission("coin-seller:exchange-rate"), h.GetCoinSellerSalaryRates)
|
||||
protected.PUT("/admin/coin-seller-salary-rates/:region_id", middleware.RequirePermission("coin-seller:exchange-rate"), h.ReplaceCoinSellerSalaryRates)
|
||||
}
|
||||
|
||||
@ -293,6 +293,68 @@ func (s *Service) CreditCoinSellerStock(ctx context.Context, actorID int64, sell
|
||||
return nil, fmt.Errorf("stock type is invalid")
|
||||
}
|
||||
|
||||
seller, err := s.activeCoinSellerUser(ctx, sellerUserID, requestID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.walletClient.AdminCreditCoinSellerStock(ctx, &walletv1.AdminCreditCoinSellerStockRequest{
|
||||
CommandId: commandID,
|
||||
SellerUserId: sellerUserID,
|
||||
SellerCountryId: seller.CountryID,
|
||||
SellerRegionId: seller.RegionID,
|
||||
StockType: stockType,
|
||||
CoinAmount: req.CoinAmount,
|
||||
PaidCurrencyCode: paidCurrencyCode,
|
||||
PaidAmountMicro: paidAmountMicro,
|
||||
PaymentRef: paymentRef,
|
||||
EvidenceRef: evidenceRef,
|
||||
OperatorUserId: actorID,
|
||||
Reason: reason,
|
||||
AppCode: appctx.FromContext(ctx),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) DebitCoinSellerStock(ctx context.Context, actorID int64, sellerUserID int64, requestID string, req coinSellerStockDebitRequest) (*walletv1.AdminCreditAssetResponse, error) {
|
||||
if s.walletClient == nil {
|
||||
return nil, fmt.Errorf("wallet client is not configured")
|
||||
}
|
||||
if sellerUserID <= 0 || actorID <= 0 {
|
||||
return nil, fmt.Errorf("seller_user_id and operator_user_id are required")
|
||||
}
|
||||
commandID := strings.TrimSpace(req.CommandID)
|
||||
reason := strings.TrimSpace(req.Reason)
|
||||
evidenceRef := strings.TrimSpace(req.EvidenceRef)
|
||||
if commandID == "" || req.CoinAmount <= 0 {
|
||||
return nil, fmt.Errorf("command_id and coin_amount are required")
|
||||
}
|
||||
if reason == "" {
|
||||
return nil, fmt.Errorf("reason is required")
|
||||
}
|
||||
if len(reason) > 512 || len(commandID) > 128 || len(evidenceRef) > 256 {
|
||||
return nil, fmt.Errorf("coin seller debit text fields are too long")
|
||||
}
|
||||
if _, err := s.activeCoinSellerUser(ctx, sellerUserID, requestID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 币商扣除是运营对币商专用库存的负向调账:只扣 COIN_SELLER_COIN,不写普通 COIN,也不写进货记录。
|
||||
// wallet-service 的 AdminCreditAsset 负责余额不足校验、幂等冲突检测、wallet_entries 和 outbox 原子落账。
|
||||
return s.walletClient.AdminCreditAsset(ctx, &walletv1.AdminCreditAssetRequest{
|
||||
CommandId: commandID,
|
||||
TargetUserId: sellerUserID,
|
||||
AssetType: coinSellerMerchantAssetType,
|
||||
Amount: -req.CoinAmount,
|
||||
OperatorUserId: actorID,
|
||||
Reason: reason,
|
||||
EvidenceRef: evidenceRef,
|
||||
AppCode: appctx.FromContext(ctx),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) activeCoinSellerUser(ctx context.Context, sellerUserID int64, requestID string) (*userclient.User, error) {
|
||||
if s == nil || s.userClient == nil {
|
||||
return nil, fmt.Errorf("user client is not configured")
|
||||
}
|
||||
profile, err := s.userClient.GetCoinSellerProfile(ctx, userclient.GetCoinSellerProfileRequest{
|
||||
RequestID: requestID,
|
||||
Caller: "hyapp-admin-server",
|
||||
@ -315,22 +377,7 @@ func (s *Service) CreditCoinSellerStock(ctx context.Context, actorID int64, sell
|
||||
if seller == nil || seller.CountryID <= 0 || seller.RegionID <= 0 {
|
||||
return nil, fmt.Errorf("coin seller country and region are required")
|
||||
}
|
||||
|
||||
return s.walletClient.AdminCreditCoinSellerStock(ctx, &walletv1.AdminCreditCoinSellerStockRequest{
|
||||
CommandId: commandID,
|
||||
SellerUserId: sellerUserID,
|
||||
SellerCountryId: seller.CountryID,
|
||||
SellerRegionId: seller.RegionID,
|
||||
StockType: stockType,
|
||||
CoinAmount: req.CoinAmount,
|
||||
PaidCurrencyCode: paidCurrencyCode,
|
||||
PaidAmountMicro: paidAmountMicro,
|
||||
PaymentRef: paymentRef,
|
||||
EvidenceRef: evidenceRef,
|
||||
OperatorUserId: actorID,
|
||||
Reason: reason,
|
||||
AppCode: appctx.FromContext(ctx),
|
||||
})
|
||||
return seller, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateAgency(ctx context.Context, actorID int64, requestID string, req createAgencyRequest) (*userclient.CreateAgencyResult, error) {
|
||||
|
||||
@ -232,6 +232,7 @@ type WalletHandlers struct {
|
||||
ListCoinSellers http.HandlerFunc
|
||||
TransferCoinFromSeller http.HandlerFunc
|
||||
GetSalaryWalletOverview http.HandlerFunc
|
||||
GetSalaryWalletHistory http.HandlerFunc
|
||||
SearchSalaryWalletSeller http.HandlerFunc
|
||||
ExchangeSalaryToCoin http.HandlerFunc
|
||||
TransferSalaryToSeller http.HandlerFunc
|
||||
@ -555,6 +556,7 @@ func (r routes) registerWalletRoutes() {
|
||||
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/history", http.MethodGet, h.GetSalaryWalletHistory)
|
||||
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)
|
||||
|
||||
@ -4099,6 +4099,247 @@ func TestListCoinSellersUsesAuthenticatedUserRegion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSalaryWalletSearchAllowsSelfCoinSeller(t *testing.T) {
|
||||
hostClient := &fakeUserHostClient{
|
||||
hostProfile: &userv1.HostProfile{UserId: 42, Status: "active", RegionId: 30},
|
||||
listCoinSellersResp: &userv1.ListActiveCoinSellersInMyRegionResponse{CoinSellers: []*userv1.CoinSellerListItem{
|
||||
{
|
||||
UserId: 42,
|
||||
DisplayUserId: "163006",
|
||||
Username: "self seller",
|
||||
RegionId: 30,
|
||||
Status: "active",
|
||||
MerchantAssetType: "COIN_SELLER_COIN",
|
||||
},
|
||||
}},
|
||||
}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||
handler.SetUserHostClient(hostClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/salary-wallet/coin-sellers/search?identity=HOST&display_user_id=163006", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-salary-search-self")
|
||||
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.lastListCoinSellers == nil || hostClient.lastListCoinSellers.GetUserId() != 42 {
|
||||
t.Fatalf("salary wallet seller search request mismatch: %+v", hostClient.lastListCoinSellers)
|
||||
}
|
||||
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)
|
||||
seller := data["seller"].(map[string]any)
|
||||
if seller["user_id"] != "42" || seller["display_user_id"] != "163006" || seller["merchant_asset_type"] != "COIN_SELLER_COIN" {
|
||||
t.Fatalf("self salary seller search response mismatch: %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSalaryWalletTransferMapsEachIdentityToSalaryAssetAndAllowsSelfSeller(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
identity string
|
||||
assetType string
|
||||
configure func(*fakeUserHostClient)
|
||||
assertCalls func(*testing.T, *fakeUserHostClient)
|
||||
}{
|
||||
{
|
||||
name: "host",
|
||||
identity: "HOST",
|
||||
assetType: "HOST_SALARY_USD",
|
||||
configure: func(hostClient *fakeUserHostClient) {
|
||||
hostClient.hostProfile = &userv1.HostProfile{UserId: 42, Status: "active", RegionId: 30}
|
||||
},
|
||||
assertCalls: func(t *testing.T, hostClient *fakeUserHostClient) {
|
||||
t.Helper()
|
||||
if hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 42 {
|
||||
t.Fatalf("host identity request mismatch: %+v", hostClient.lastHost)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "agency",
|
||||
identity: "AGENCY",
|
||||
assetType: "AGENCY_SALARY_USD",
|
||||
configure: func(hostClient *fakeUserHostClient) {
|
||||
hostClient.roleSummary = &userv1.UserRoleSummary{UserId: 42, IsAgency: true, AgencyId: 7001}
|
||||
hostClient.getAgencyResp = &userv1.GetAgencyResponse{Agency: &userv1.Agency{AgencyId: 7001, OwnerUserId: 42, Status: "active", RegionId: 30}}
|
||||
},
|
||||
assertCalls: func(t *testing.T, hostClient *fakeUserHostClient) {
|
||||
t.Helper()
|
||||
if hostClient.lastRoleSummary == nil || hostClient.lastRoleSummary.GetUserId() != 42 || hostClient.lastGetAgency == nil || hostClient.lastGetAgency.GetAgencyId() != 7001 {
|
||||
t.Fatalf("agency identity requests mismatch: role=%+v agency=%+v", hostClient.lastRoleSummary, hostClient.lastGetAgency)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bd",
|
||||
identity: "BD",
|
||||
assetType: "BD_SALARY_USD",
|
||||
configure: func(hostClient *fakeUserHostClient) {
|
||||
hostClient.bdProfile = &userv1.BDProfile{UserId: 42, Role: "bd", Status: "active", RegionId: 30}
|
||||
},
|
||||
assertCalls: func(t *testing.T, hostClient *fakeUserHostClient) {
|
||||
t.Helper()
|
||||
if hostClient.lastBD == nil || hostClient.lastBD.GetUserId() != 42 || hostClient.lastBD.GetRole() != "bd" {
|
||||
t.Fatalf("bd identity request mismatch: %+v", hostClient.lastBD)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bd-leader-admin",
|
||||
identity: "BD_LEADER",
|
||||
assetType: "ADMIN_SALARY_USD",
|
||||
configure: func(hostClient *fakeUserHostClient) {
|
||||
hostClient.bdProfile = &userv1.BDProfile{UserId: 42, Role: "bd_leader", Status: "active", RegionId: 30}
|
||||
},
|
||||
assertCalls: func(t *testing.T, hostClient *fakeUserHostClient) {
|
||||
t.Helper()
|
||||
if hostClient.lastBD == nil || hostClient.lastBD.GetUserId() != 42 || hostClient.lastBD.GetRole() != "bd_leader" {
|
||||
t.Fatalf("bd leader identity request mismatch: %+v", hostClient.lastBD)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
walletClient := &fakeWalletClient{transferSalaryResp: &walletv1.TransferSalaryToCoinSellerResponse{
|
||||
TransactionId: "wtx-salary-" + test.name,
|
||||
SourceSalaryBalanceAfter: 950,
|
||||
SellerBalanceAfter: 40500,
|
||||
SalaryUsdMinor: 50,
|
||||
CoinAmount: 40500,
|
||||
CoinPerUsd: 81000,
|
||||
RateMinUsdMinor: 10,
|
||||
RateMaxUsdMinor: 10000,
|
||||
}}
|
||||
hostClient := &fakeUserHostClient{listCoinSellersResp: &userv1.ListActiveCoinSellersInMyRegionResponse{CoinSellers: []*userv1.CoinSellerListItem{
|
||||
{
|
||||
UserId: 42,
|
||||
DisplayUserId: "163006",
|
||||
Username: "self seller",
|
||||
RegionId: 30,
|
||||
Status: "active",
|
||||
MerchantAssetType: "COIN_SELLER_COIN",
|
||||
},
|
||||
}}}
|
||||
test.configure(hostClient)
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||
handler.SetWalletClient(walletClient)
|
||||
handler.SetUserHostClient(hostClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
body := []byte(`{"command_id":"cmd-salary-self-` + test.name + `","identity":"` + test.identity + `","target_display_user_id":"163006","amount_usd":"0.50","reason":"salary transfer"}`)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/salary-wallet/transfer-to-coin-seller", bytes.NewReader(body))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-salary-transfer-self-"+test.name)
|
||||
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())
|
||||
}
|
||||
test.assertCalls(t, hostClient)
|
||||
if hostClient.lastListCoinSellers == nil || hostClient.lastListCoinSellers.GetUserId() != 42 {
|
||||
t.Fatalf("coin seller search request mismatch: %+v", hostClient.lastListCoinSellers)
|
||||
}
|
||||
if walletClient.lastTransferSalary == nil ||
|
||||
walletClient.lastTransferSalary.GetSourceUserId() != 42 ||
|
||||
walletClient.lastTransferSalary.GetSellerUserId() != 42 ||
|
||||
walletClient.lastTransferSalary.GetSalaryAssetType() != test.assetType ||
|
||||
walletClient.lastTransferSalary.GetSalaryUsdMinor() != 50 ||
|
||||
walletClient.lastTransferSalary.GetRegionId() != 30 {
|
||||
t.Fatalf("salary transfer wallet command mismatch: %+v", walletClient.lastTransferSalary)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSalaryWalletHistoryMapsIdentityToSalaryAsset(t *testing.T) {
|
||||
walletClient := &fakeWalletClient{transactionsResp: &walletv1.ListWalletTransactionsResponse{
|
||||
Total: 2,
|
||||
Transactions: []*walletv1.WalletTransaction{
|
||||
{
|
||||
EntryId: 31,
|
||||
TransactionId: "wtx-admin-salary-in",
|
||||
BizType: "host_salary_settlement",
|
||||
AssetType: "ADMIN_SALARY_USD",
|
||||
AvailableDelta: 1100,
|
||||
AvailableAfter: 1100,
|
||||
CreatedAtMs: 1_781_000_000_000,
|
||||
},
|
||||
{
|
||||
EntryId: 32,
|
||||
TransactionId: "wtx-admin-salary-out",
|
||||
BizType: "salary_transfer_to_coin_seller",
|
||||
AssetType: "ADMIN_SALARY_USD",
|
||||
AvailableDelta: -500,
|
||||
AvailableAfter: 600,
|
||||
CounterpartyUserId: 9001,
|
||||
CreatedAtMs: 1_781_000_060_000,
|
||||
},
|
||||
},
|
||||
}}
|
||||
hostClient := &fakeUserHostClient{
|
||||
bdProfile: &userv1.BDProfile{UserId: 42, Role: "bd_leader", Status: "active", RegionId: 30},
|
||||
}
|
||||
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
|
||||
9001: {
|
||||
UserId: 9001,
|
||||
DisplayUserId: "163001",
|
||||
Username: "Seller One",
|
||||
Avatar: "https://cdn.example/seller.png",
|
||||
AppCode: "lalu",
|
||||
},
|
||||
}}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
|
||||
handler.SetWalletClient(walletClient)
|
||||
handler.SetUserHostClient(hostClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/salary-wallet/history?identity=BD_LEADER&page=2&page_size=10", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-salary-history")
|
||||
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.GetRole() != "bd_leader" || hostClient.lastBD.GetUserId() != 42 {
|
||||
t.Fatalf("bd leader identity request mismatch: %+v", hostClient.lastBD)
|
||||
}
|
||||
if walletClient.lastTransactions == nil ||
|
||||
walletClient.lastTransactions.GetUserId() != 42 ||
|
||||
walletClient.lastTransactions.GetAssetType() != "ADMIN_SALARY_USD" ||
|
||||
walletClient.lastTransactions.GetPage() != 2 ||
|
||||
walletClient.lastTransactions.GetPageSize() != 10 {
|
||||
t.Fatalf("salary wallet history request mismatch: %+v", walletClient.lastTransactions)
|
||||
}
|
||||
if profileClient.lastBatch == nil || len(profileClient.lastBatch.GetUserIds()) != 1 || profileClient.lastBatch.GetUserIds()[0] != 9001 {
|
||||
t.Fatalf("salary history counterparty profile request 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 || data["identity"] != "BD_LEADER" || data["salary_asset_type"] != "ADMIN_SALARY_USD" || len(items) != 2 {
|
||||
t.Fatalf("salary wallet history response mismatch: %+v", response)
|
||||
}
|
||||
second := items[1].(map[string]any)
|
||||
if second["available_delta"] != float64(-500) || second["available_after"] != float64(600) || second["counterparty_display_user_id"] != "163001" {
|
||||
t.Fatalf("salary wallet history item mismatch: %+v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyToHostAgencyPropagatesClientCommandID(t *testing.T) {
|
||||
hostClient := &fakeUserHostClient{applyAgencyResp: &userv1.ApplyToAgencyResponse{Application: &userv1.AgencyApplication{
|
||||
ApplicationId: 8001,
|
||||
|
||||
@ -59,6 +59,7 @@ func (h *Handler) WalletHandlers() httproutes.WalletHandlers {
|
||||
ListCoinSellers: h.listCoinSellers,
|
||||
TransferCoinFromSeller: h.transferCoinFromSeller,
|
||||
GetSalaryWalletOverview: h.getSalaryWalletOverview,
|
||||
GetSalaryWalletHistory: h.getSalaryWalletHistory,
|
||||
SearchSalaryWalletSeller: h.searchSalaryWalletSeller,
|
||||
ExchangeSalaryToCoin: h.exchangeSalaryToCoin,
|
||||
TransferSalaryToSeller: h.transferSalaryToCoinSeller,
|
||||
|
||||
@ -169,6 +169,58 @@ func (h *Handler) getSalaryWalletOverview(writer http.ResponseWriter, request *h
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) getSalaryWalletHistory(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
|
||||
}
|
||||
page, ok := httpkit.PositiveInt32Query(request, "page", 1)
|
||||
if !ok {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20)
|
||||
if !ok {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
// H5 工资历史不能让前端自己传 asset_type;先用身份权限解析出对应工资钱包资产,
|
||||
// 再查询当前用户的该资产流水,确保主播/Agency/BD/Admin 只看到自己身份的钱包收支。
|
||||
resp, err := h.walletClient.ListWalletTransactions(request.Context(), &walletv1.ListWalletTransactionsRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
UserId: identity.UserID,
|
||||
AssetType: identity.SalaryAssetType,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
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, profiles))
|
||||
}
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"identity": identity.Identity,
|
||||
"salary_asset_type": identity.SalaryAssetType,
|
||||
"items": items,
|
||||
"total": resp.GetTotal(),
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
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")
|
||||
@ -423,6 +475,8 @@ func normalizeSalaryWalletIdentity(value string) string {
|
||||
switch value {
|
||||
case salaryIdentityHost, salaryIdentityAgency, salaryIdentityBD, salaryIdentityBDLeader:
|
||||
return value
|
||||
case "ADMIN":
|
||||
return salaryIdentityBDLeader
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -461,9 +461,6 @@ func (s *Service) TransferSalaryToCoinSeller(ctx context.Context, command ledger
|
||||
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")
|
||||
}
|
||||
|
||||
@ -1975,6 +1975,8 @@ func TestTransferSalaryToCoinSellerUsesRegionRateTier(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := walletservice.New(repository)
|
||||
repository.SetAssetBalance(35001, ledger.AssetBDSalaryUSD, 10000)
|
||||
repository.SetAssetBalance(35001, ledger.AssetCoin, 1234)
|
||||
repository.SetAssetBalance(35001, ledger.AssetCoinSellerCoin, 0)
|
||||
repository.SetCoinSellerSalaryExchangeRateTier(appcode.Default, 7001, 1100, 5000, 92000)
|
||||
|
||||
command := ledger.SalaryTransferToCoinSellerCommand{
|
||||
@ -2001,9 +2003,17 @@ func TestTransferSalaryToCoinSellerUsesRegionRateTier(t *testing.T) {
|
||||
|
||||
assertBalance(t, svc, 35001, ledger.AssetBDSalaryUSD, 6000)
|
||||
assertBalance(t, svc, 35002, ledger.AssetCoinSellerCoin, 3680000)
|
||||
assertBalance(t, svc, 35001, ledger.AssetCoin, 1234)
|
||||
assertBalance(t, svc, 35001, ledger.AssetCoinSellerCoin, 0)
|
||||
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 = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", first.TransactionID, int64(35001), ledger.AssetCoinSellerCoin, int64(3680000)); got != 0 {
|
||||
t.Fatalf("salary transfer must not credit sender coin seller wallet, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", first.TransactionID, int64(35002), ledger.AssetCoinSellerCoin, int64(3680000)); got != 1 {
|
||||
t.Fatalf("salary transfer must credit target coin seller wallet once, 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)
|
||||
}
|
||||
@ -2012,6 +2022,108 @@ func TestTransferSalaryToCoinSellerUsesRegionRateTier(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTransferHostSalaryToCoinSellerCreditsTargetSellerOnly 验证别人主播把工资转给币商时,
|
||||
// 只扣主播工资并给目标币商的币商专用金币入账,主播自己的普通金币和币商金币都不能增加。
|
||||
func TestTransferHostSalaryToCoinSellerCreditsTargetSellerOnly(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := walletservice.New(repository)
|
||||
repository.SetAssetBalance(35011, ledger.AssetHostSalaryUSD, 10000)
|
||||
repository.SetAssetBalance(35011, ledger.AssetCoin, 1234)
|
||||
repository.SetAssetBalance(35011, ledger.AssetCoinSellerCoin, 0)
|
||||
repository.SetAssetBalance(35012, ledger.AssetCoinSellerCoin, 1000)
|
||||
repository.SetCoinSellerSalaryExchangeRateTier(appcode.Default, 7002, 10, 10000, 81000)
|
||||
|
||||
receipt, err := svc.TransferSalaryToCoinSeller(context.Background(), ledger.SalaryTransferToCoinSellerCommand{
|
||||
AppCode: appcode.Default,
|
||||
CommandID: "cmd-host-salary-transfer-target-seller",
|
||||
SourceUserID: 35011,
|
||||
SellerUserID: 35012,
|
||||
SalaryAssetType: ledger.AssetHostSalaryUSD,
|
||||
SalaryUSDMinor: 50,
|
||||
RegionID: 7002,
|
||||
Reason: "host salary transfer to seller",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("TransferSalaryToCoinSeller host to target seller failed: %v", err)
|
||||
}
|
||||
if receipt.SourceSalaryBalanceAfter != 9950 || receipt.SellerBalanceAfter != 41500 || receipt.CoinAmount != 40500 {
|
||||
t.Fatalf("host salary transfer receipt mismatch: %+v", receipt)
|
||||
}
|
||||
|
||||
assertBalance(t, svc, 35011, ledger.AssetHostSalaryUSD, 9950)
|
||||
assertBalance(t, svc, 35011, ledger.AssetCoin, 1234)
|
||||
assertBalance(t, svc, 35011, ledger.AssetCoinSellerCoin, 0)
|
||||
assertBalance(t, svc, 35012, ledger.AssetCoinSellerCoin, 41500)
|
||||
if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", receipt.TransactionID, int64(35011), ledger.AssetCoin, int64(40500)); got != 0 {
|
||||
t.Fatalf("host salary transfer must not credit host player coin wallet, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", receipt.TransactionID, int64(35011), ledger.AssetCoinSellerCoin, int64(40500)); got != 0 {
|
||||
t.Fatalf("host salary transfer must not credit host coin seller wallet, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", receipt.TransactionID, int64(35012), ledger.AssetCoinSellerCoin, int64(40500)); got != 1 {
|
||||
t.Fatalf("host salary transfer must credit target seller coin seller wallet once, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTransferSalaryToCoinSellerAllowsSelfSellerForAllSalaryAssets 验证同一用户同时具备主播/代理/BD/Admin 和币商身份时,
|
||||
// 工资转币商允许 source_user_id == seller_user_id;账务只扣对应身份工资钱包并增加币商专用金币,不增加玩家普通金币。
|
||||
func TestTransferSalaryToCoinSellerAllowsSelfSellerForAllSalaryAssets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
assetType string
|
||||
userID int64
|
||||
regionID int64
|
||||
}{
|
||||
{name: "host", assetType: ledger.AssetHostSalaryUSD, userID: 35101, regionID: 7101},
|
||||
{name: "agency", assetType: ledger.AssetAgencySalaryUSD, userID: 35102, regionID: 7102},
|
||||
{name: "bd", assetType: ledger.AssetBDSalaryUSD, userID: 35103, regionID: 7103},
|
||||
{name: "admin", assetType: ledger.AssetAdminSalaryUSD, userID: 35104, regionID: 7104},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := walletservice.New(repository)
|
||||
repository.SetAssetBalance(test.userID, test.assetType, 10000)
|
||||
repository.SetAssetBalance(test.userID, ledger.AssetCoin, 1234)
|
||||
repository.SetCoinSellerSalaryExchangeRateTier(appcode.Default, test.regionID, 10, 10000, 81000)
|
||||
|
||||
receipt, err := svc.TransferSalaryToCoinSeller(context.Background(), ledger.SalaryTransferToCoinSellerCommand{
|
||||
AppCode: appcode.Default,
|
||||
CommandID: "cmd-salary-self-" + test.name,
|
||||
SourceUserID: test.userID,
|
||||
SellerUserID: test.userID,
|
||||
SalaryAssetType: test.assetType,
|
||||
SalaryUSDMinor: 50,
|
||||
RegionID: test.regionID,
|
||||
Reason: "salary transfer to own coin seller wallet",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("TransferSalaryToCoinSeller self %s failed: %v", test.name, err)
|
||||
}
|
||||
if receipt.SourceSalaryBalanceAfter != 9950 || receipt.SellerBalanceAfter != 40500 || receipt.CoinAmount != 40500 || receipt.CoinPerUSD != 81000 || receipt.RateMinUSDMinor != 10 || receipt.RateMaxUSDMinor != 10000 {
|
||||
t.Fatalf("self salary transfer receipt mismatch: %+v", receipt)
|
||||
}
|
||||
|
||||
assertBalance(t, svc, test.userID, test.assetType, 9950)
|
||||
assertBalance(t, svc, test.userID, ledger.AssetCoinSellerCoin, 40500)
|
||||
assertBalance(t, svc, test.userID, ledger.AssetCoin, 1234)
|
||||
if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", receipt.TransactionID, test.userID, test.assetType, int64(-50)); got != 1 {
|
||||
t.Fatalf("self transfer must debit salary asset once, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", receipt.TransactionID, test.userID, ledger.AssetCoinSellerCoin, int64(40500)); got != 1 {
|
||||
t.Fatalf("self transfer must credit coin seller asset once, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ?", receipt.TransactionID, test.userID, ledger.AssetCoin); got != 0 {
|
||||
t.Fatalf("self transfer must not credit player coin wallet, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", receipt.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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user