diff --git a/api/proto/wallet/v1/wallet.pb.go b/api/proto/wallet/v1/wallet.pb.go index 81c5aee6..fbcc6da8 100644 --- a/api/proto/wallet/v1/wallet.pb.go +++ b/api/proto/wallet/v1/wallet.pb.go @@ -1749,7 +1749,7 @@ func (x *AdminCreditAssetResponse) GetBalance() *AssetBalance { return nil } -// AdminCreditCoinSellerStockRequest 是后台给 active 币商专用库存入账的账务命令。 +// AdminCreditCoinSellerStockRequest 是后台调整币商专用库存的账务命令。 type AdminCreditCoinSellerStockRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache diff --git a/api/proto/wallet/v1/wallet.proto b/api/proto/wallet/v1/wallet.proto index e719a7a9..10871578 100644 --- a/api/proto/wallet/v1/wallet.proto +++ b/api/proto/wallet/v1/wallet.proto @@ -212,7 +212,7 @@ message AdminCreditAssetResponse { AssetBalance balance = 2; } -// AdminCreditCoinSellerStockRequest 是后台给 active 币商专用库存入账的账务命令。 +// AdminCreditCoinSellerStockRequest 是后台调整币商专用库存的账务命令。 message AdminCreditCoinSellerStockRequest { string command_id = 1; int64 seller_user_id = 2; diff --git a/server/admin/internal/modules/coinledger/service.go b/server/admin/internal/modules/coinledger/service.go index 13a99ad5..264565f0 100644 --- a/server/admin/internal/modules/coinledger/service.go +++ b/server/admin/internal/modules/coinledger/service.go @@ -24,6 +24,7 @@ const ( coinSellerTransferBizType = "coin_seller_transfer" coinSellerRechargeBizType = "coin_seller_recharge" coinSellerStockPurchaseBizType = "coin_seller_stock_purchase" + coinSellerStockDeductionBizType = "coin_seller_stock_deduction" coinSellerCoinCompensationBizType = "coin_seller_coin_compensation" salaryTransferToCoinSellerBizType = "salary_transfer_to_coin_seller" coinSellerLedgerTypeAdminStockCredit = "admin_stock_credit" @@ -782,6 +783,7 @@ func coinSellerLedgerBizTypes(ledgerType string) ([]string, error) { // 空类型表示“全部币商流水”,但仍只允许当前产品定义的公开币商库存口径,不能把其他内部账带出来。 return []string{ coinSellerStockPurchaseBizType, + coinSellerStockDeductionBizType, coinSellerRechargeBizType, coinSellerCoinCompensationBizType, coinSellerTransferBizType, @@ -790,7 +792,7 @@ func coinSellerLedgerBizTypes(ledgerType string) ([]string, error) { }, nil case coinSellerLedgerTypeAdminStockCredit: // 进货操作是运营库存口径,底层包含后台 USDT 进货、H5 三方币商充值、金币补偿和后台扣除;资产条件仍限定在 COIN_SELLER_COIN。 - return []string{coinSellerStockPurchaseBizType, coinSellerRechargeBizType, coinSellerCoinCompensationBizType, coinManualCreditBizType}, nil + return []string{coinSellerStockPurchaseBizType, coinSellerStockDeductionBizType, coinSellerRechargeBizType, coinSellerCoinCompensationBizType, coinManualCreditBizType}, nil case coinSellerLedgerTypeSellerTransfer: // 币商转用户只展示币商侧出账分录,收款用户资料在展示投影里补齐。 return []string{coinSellerTransferBizType}, nil @@ -804,7 +806,7 @@ func coinSellerLedgerBizTypes(ledgerType string) ([]string, error) { func coinSellerLedgerTypeForBizType(bizType string) string { switch bizType { - case coinSellerStockPurchaseBizType, coinSellerRechargeBizType, coinSellerCoinCompensationBizType: + case coinSellerStockPurchaseBizType, coinSellerStockDeductionBizType, coinSellerRechargeBizType, coinSellerCoinCompensationBizType: return coinSellerLedgerTypeAdminStockCredit case coinManualCreditBizType: return coinSellerLedgerTypeAdminStockCredit @@ -821,6 +823,8 @@ func coinSellerLedgerLabel(item coinSellerLedgerDTO) string { switch item.BizType { case coinSellerStockPurchaseBizType: return "USDT进货" + case coinSellerStockDeductionBizType: + return "USDT扣除" case coinSellerRechargeBizType: return "三方充值" case coinSellerCoinCompensationBizType: diff --git a/server/admin/internal/modules/coinledger/service_test.go b/server/admin/internal/modules/coinledger/service_test.go index 42e764fd..aeda45ee 100644 --- a/server/admin/internal/modules/coinledger/service_test.go +++ b/server/admin/internal/modules/coinledger/service_test.go @@ -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) != 8 || args[0] != "lalu" || args[1] != coinSellerAssetType || args[2] != coinSellerStockPurchaseBizType || args[3] != coinSellerRechargeBizType || args[4] != coinSellerCoinCompensationBizType || args[5] != coinManualCreditBizType || args[6] != int64(3001) || args[7] != int64(3002) { + if len(args) != 9 || args[0] != "lalu" || args[1] != coinSellerAssetType || args[2] != coinSellerStockPurchaseBizType || args[3] != coinSellerStockDeductionBizType || args[4] != coinSellerRechargeBizType || args[5] != coinSellerCoinCompensationBizType || args[6] != coinManualCreditBizType || args[7] != int64(3001) || args[8] != 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) != 8 || args[0] != "lalu" || args[1] != coinSellerAssetType || args[2] != coinSellerStockPurchaseBizType || args[3] != coinSellerRechargeBizType || args[4] != coinSellerCoinCompensationBizType || args[5] != coinSellerTransferBizType || args[6] != salaryTransferToCoinSellerBizType || args[7] != coinManualCreditBizType { + if len(args) != 9 || args[0] != "lalu" || args[1] != coinSellerAssetType || args[2] != coinSellerStockPurchaseBizType || args[3] != coinSellerStockDeductionBizType || args[4] != coinSellerRechargeBizType || args[5] != coinSellerCoinCompensationBizType || args[6] != coinSellerTransferBizType || args[7] != salaryTransferToCoinSellerBizType || args[8] != coinManualCreditBizType { t.Fatalf("args mismatch: %#v", args) } } @@ -117,6 +117,7 @@ func TestCoinSellerLedgerLabelUsesConcreteStockAndDebitType(t *testing.T) { want string }{ {name: "usdt purchase", item: coinSellerLedgerDTO{BizType: coinSellerStockPurchaseBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit}, want: "USDT进货"}, + {name: "usdt deduction", item: coinSellerLedgerDTO{BizType: coinSellerStockDeductionBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit}, want: "USDT扣除"}, {name: "third party recharge", item: coinSellerLedgerDTO{BizType: coinSellerRechargeBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit}, want: "三方充值"}, {name: "compensation", item: coinSellerLedgerDTO{BizType: coinSellerCoinCompensationBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit}, want: "金币补偿"}, {name: "debit", item: coinSellerLedgerDTO{BizType: coinManualCreditBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit, AvailableDelta: -100}, want: "金币扣除"}, diff --git a/server/admin/internal/modules/hostorg/handler.go b/server/admin/internal/modules/hostorg/handler.go index 244ef5f3..3486444b 100644 --- a/server/admin/internal/modules/hostorg/handler.go +++ b/server/admin/internal/modules/hostorg/handler.go @@ -340,9 +340,9 @@ func (h *Handler) DebitCoinSellerStock(c *gin.Context) { 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))) + writeHostOrgAuditLog(c, h.audit, "coin-seller-stock-debit", "coin_seller_stock_records", sellerUserID, + fmt.Sprintf("command_id=%s seller_user_id=%d stock_type=%s coin_amount=%d paid_currency_code=%s paid_amount_micro=%d transaction_id=%s reason=%q", + strings.TrimSpace(req.CommandID), sellerUserID, result.GetStockType(), result.GetCoinAmount(), result.GetPaidCurrencyCode(), result.GetPaidAmountMicro(), result.GetTransactionId(), strings.TrimSpace(req.Reason))) response.Created(c, coinSellerStockDebitFromProto(sellerUserID, req.CoinAmount, result)) } diff --git a/server/admin/internal/modules/hostorg/request.go b/server/admin/internal/modules/hostorg/request.go index b7116681..307172a1 100644 --- a/server/admin/internal/modules/hostorg/request.go +++ b/server/admin/internal/modules/hostorg/request.go @@ -171,10 +171,11 @@ type coinSellerStockCreditRequest struct { } 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"` + CommandID string `json:"commandId" binding:"required"` + CoinAmount int64 `json:"coinAmount" binding:"required"` + RechargeAmount string `json:"rechargeAmount" binding:"required"` + EvidenceRef string `json:"evidenceRef"` + Reason string `json:"reason" binding:"required"` } type replaceCoinSellerSalaryRatesRequest struct { diff --git a/server/admin/internal/modules/hostorg/response.go b/server/admin/internal/modules/hostorg/response.go index df08b5e6..733c3925 100644 --- a/server/admin/internal/modules/hostorg/response.go +++ b/server/admin/internal/modules/hostorg/response.go @@ -15,11 +15,16 @@ type coinSellerStockCreditResponse struct { } type coinSellerStockDebitResponse struct { - TransactionID string `json:"transactionId"` - SellerUserID int64 `json:"sellerUserId,string"` - CoinAmount int64 `json:"coinAmount"` - AvailableDelta int64 `json:"availableDelta"` - BalanceAfter int64 `json:"balanceAfter"` + TransactionID string `json:"transactionId"` + SellerUserID int64 `json:"sellerUserId,string"` + StockType string `json:"stockType"` + CoinAmount int64 `json:"coinAmount"` + AvailableDelta int64 `json:"availableDelta"` + PaidCurrencyCode string `json:"paidCurrencyCode"` + PaidAmountMicro int64 `json:"paidAmountMicro"` + CountsAsSellerRecharge bool `json:"countsAsSellerRecharge"` + BalanceAfter int64 `json:"balanceAfter"` + CreatedAtMS int64 `json:"createdAtMs"` } func coinSellerStockCreditFromProto(item *walletv1.AdminCreditCoinSellerStockResponse) coinSellerStockCreditResponse { @@ -39,19 +44,20 @@ func coinSellerStockCreditFromProto(item *walletv1.AdminCreditCoinSellerStockRes } } -func coinSellerStockDebitFromProto(sellerUserID int64, coinAmount int64, item *walletv1.AdminCreditAssetResponse) coinSellerStockDebitResponse { +func coinSellerStockDebitFromProto(sellerUserID int64, coinAmount int64, item *walletv1.AdminCreditCoinSellerStockResponse) 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, + TransactionID: item.GetTransactionId(), + SellerUserID: sellerUserID, + StockType: item.GetStockType(), + CoinAmount: item.GetCoinAmount(), + AvailableDelta: item.GetCoinAmount(), + PaidCurrencyCode: item.GetPaidCurrencyCode(), + PaidAmountMicro: item.GetPaidAmountMicro(), + CountsAsSellerRecharge: item.GetCountsAsSellerRecharge(), + BalanceAfter: item.GetBalanceAfter(), + CreatedAtMS: item.GetCreatedAtMs(), } } diff --git a/server/admin/internal/modules/hostorg/service.go b/server/admin/internal/modules/hostorg/service.go index d5940b7b..a9fe8364 100644 --- a/server/admin/internal/modules/hostorg/service.go +++ b/server/admin/internal/modules/hostorg/service.go @@ -13,9 +13,9 @@ import ( ) const ( - coinSellerStatusActive = "active" coinSellerMerchantAssetType = "COIN_SELLER_COIN" coinSellerStockTypePurchase = "usdt_purchase" + coinSellerStockTypeDeduction = "usdt_deduction" coinSellerStockTypeCompensate = "coin_compensation" paidCurrencyUSDT = "USDT" sortByDiamond = "diamond" @@ -312,7 +312,7 @@ 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) + seller, err := s.coinSellerUser(ctx, sellerUserID, requestID) if err != nil { return nil, err } @@ -334,7 +334,7 @@ func (s *Service) CreditCoinSellerStock(ctx context.Context, actorID int64, sell }) } -func (s *Service) DebitCoinSellerStock(ctx context.Context, actorID int64, sellerUserID int64, requestID string, req coinSellerStockDebitRequest) (*walletv1.AdminCreditAssetResponse, error) { +func (s *Service) DebitCoinSellerStock(ctx context.Context, actorID int64, sellerUserID int64, requestID string, req coinSellerStockDebitRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error) { if s.walletClient == nil { return nil, fmt.Errorf("wallet client is not configured") } @@ -353,24 +353,33 @@ func (s *Service) DebitCoinSellerStock(ctx context.Context, actorID int64, selle 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 { + paidAmountMicro, err := parseUSDTAmountMicro(req.RechargeAmount) + if 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), + seller, err := s.coinSellerUser(ctx, sellerUserID, requestID) + if err != nil { + return nil, err + } + // 币商扣除按 USDT 进货冲回处理:钱包事务会同时写负向库存分录、负向进货记录和统计 outbox, + // 这样后台列表、币商流水和充值聚合都使用同一条账务事实,避免只扣余额却不冲收入。 + return s.walletClient.AdminCreditCoinSellerStock(ctx, &walletv1.AdminCreditCoinSellerStockRequest{ + CommandId: commandID, + SellerUserId: sellerUserID, + SellerCountryId: seller.CountryID, + SellerRegionId: seller.RegionID, + StockType: coinSellerStockTypeDeduction, + CoinAmount: req.CoinAmount, + PaidCurrencyCode: paidCurrencyUSDT, + PaidAmountMicro: paidAmountMicro, + EvidenceRef: evidenceRef, + OperatorUserId: actorID, + Reason: reason, + AppCode: appctx.FromContext(ctx), }) } -func (s *Service) activeCoinSellerUser(ctx context.Context, sellerUserID int64, requestID string) (*userclient.User, error) { +func (s *Service) coinSellerUser(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") } @@ -382,8 +391,8 @@ func (s *Service) activeCoinSellerUser(ctx context.Context, sellerUserID int64, if err != nil { return nil, err } - if profile == nil || profile.Status != coinSellerStatusActive || profile.MerchantAssetType != coinSellerMerchantAssetType { - return nil, fmt.Errorf("target user is not an active coin seller") + if profile == nil || profile.MerchantAssetType != coinSellerMerchantAssetType { + return nil, fmt.Errorf("target user is not a coin seller") } seller, err := s.userClient.GetUser(ctx, userclient.GetUserRequest{ RequestID: requestID, diff --git a/services/statistics-service/cmd/backfill-last7/main.go b/services/statistics-service/cmd/backfill-last7/main.go index a4121b63..f3c34aca 100644 --- a/services/statistics-service/cmd/backfill-last7/main.go +++ b/services/statistics-service/cmd/backfill-last7/main.go @@ -439,7 +439,7 @@ func rebuildCoinSellerStock(ctx context.Context, db *sql.DB, state *rebuildState rows, err := db.QueryContext(ctx, ` SELECT seller_user_id, paid_amount_micro, created_at_ms FROM coin_seller_stock_records - WHERE app_code = ? AND counts_as_seller_recharge = 1 AND paid_amount_micro > 0 AND created_at_ms >= ? AND created_at_ms < ?`, + WHERE app_code = ? AND counts_as_seller_recharge = 1 AND paid_amount_micro <> 0 AND created_at_ms >= ? AND created_at_ms < ?`, app, startMS, endMS) if err != nil { return err diff --git a/services/statistics-service/internal/app/app.go b/services/statistics-service/internal/app/app.go index cc144814..426e2789 100644 --- a/services/statistics-service/internal/app/app.go +++ b/services/statistics-service/internal/app/app.go @@ -287,7 +287,7 @@ func coinSellerStockEvent(body []byte) (mysqlstorage.CoinSellerStockEvent, bool, return mysqlstorage.CoinSellerStockEvent{}, false, nil } usdMinor := amountMicroToUSDMinor(mysqlstorage.Int64(payload, "paid_amount_micro")) - if usdMinor <= 0 { + if usdMinor == 0 { return mysqlstorage.CoinSellerStockEvent{}, false, nil } return mysqlstorage.CoinSellerStockEvent{ @@ -336,7 +336,7 @@ func isCoinSellerRechargeType(value string) bool { } func amountMicroToUSDMinor(amountMicro int64) int64 { - if amountMicro <= 0 { + if amountMicro == 0 { return 0 } return amountMicro / 10_000 diff --git a/services/statistics-service/internal/app/local_flow_test.go b/services/statistics-service/internal/app/local_flow_test.go index a9450441..e8b18dc3 100644 --- a/services/statistics-service/internal/app/local_flow_test.go +++ b/services/statistics-service/internal/app/local_flow_test.go @@ -88,6 +88,31 @@ func TestLocalStatisticsDataScreenFlow(t *testing.T) { if err := repo.ConsumeCoinSellerStock(ctx, stock); err != nil { t.Fatalf("consume coin seller stock failed: %v", err) } + coinSellerStockDeduction := walletMessage(t, walletmq.WalletOutboxMessage{ + AppCode: appCode, + EventID: "WalletCoinSellerStockPurchased:deduction:1", + EventType: "WalletCoinSellerStockPurchased", + TransactionID: "wallet_tx_coin_seller_stock_deduction_1", + CommandID: "coin_seller_stock_deduction_1", + UserID: userID + 10, + PayloadJSON: mustJSON(t, map[string]any{ + "seller_user_id": userID + 10, + "country_id": countryID, + "region_id": regionID, + "coin_amount": int64(-50_000), + "paid_amount_micro": int64(-500_000), + "counts_as_seller_recharge": true, + "stock_type": "usdt_deduction", + }), + OccurredAtMS: occurredAt, + }) + stockDeduction, ok, err := coinSellerStockEvent(coinSellerStockDeduction) + if err != nil || !ok { + t.Fatalf("decode coin seller stock deduction failed: ok=%v err=%v", ok, err) + } + if err := repo.ConsumeCoinSellerStock(ctx, stockDeduction); err != nil { + t.Fatalf("consume coin seller stock deduction failed: %v", err) + } coinSellerRecharge := walletMessage(t, walletmq.WalletOutboxMessage{ AppCode: appCode, @@ -206,7 +231,7 @@ func TestLocalStatisticsDataScreenFlow(t *testing.T) { if err != nil { t.Fatalf("query overview failed: %v", err) } - if overview.GoogleRechargeUSDMinor != 150 || overview.CoinSellerRechargeUSDMinor != 400 || overview.CoinSellerTransferCoin != 160_000 || overview.RechargeUSDMinor != 550 || overview.NewUserRechargeUSDMinor != 150 || overview.PaidUsers != 2 { + if overview.GoogleRechargeUSDMinor != 150 || overview.CoinSellerRechargeUSDMinor != 350 || overview.CoinSellerTransferCoin != 160_000 || overview.RechargeUSDMinor != 500 || overview.NewUserRechargeUSDMinor != 150 || overview.PaidUsers != 2 { t.Fatalf("google recharge metrics mismatch: %+v", overview) } if overview.LuckyGiftTurnover != 1_250 || overview.LuckyGiftPayout != 300 || overview.LuckyGiftProfit != 950 || overview.LuckyGiftPayers != 1 { diff --git a/services/wallet-service/internal/domain/ledger/coin_seller.go b/services/wallet-service/internal/domain/ledger/coin_seller.go index c7bf8887..39b4469b 100644 --- a/services/wallet-service/internal/domain/ledger/coin_seller.go +++ b/services/wallet-service/internal/domain/ledger/coin_seller.go @@ -130,6 +130,8 @@ func NormalizeCoinSellerStockType(stockType string) string { switch strings.ToLower(strings.TrimSpace(stockType)) { case StockTypeUSDTPurchase: return StockTypeUSDTPurchase + case StockTypeUSDTDeduction: + return StockTypeUSDTDeduction case StockTypeCoinCompensation: return StockTypeCoinCompensation default: diff --git a/services/wallet-service/internal/domain/ledger/constants.go b/services/wallet-service/internal/domain/ledger/constants.go index 41597c65..96e30d6c 100644 --- a/services/wallet-service/internal/domain/ledger/constants.go +++ b/services/wallet-service/internal/domain/ledger/constants.go @@ -26,6 +26,8 @@ const ( // StockTypeUSDTPurchase 表示币商线下 USDT 进货后发放专用金币库存。 StockTypeUSDTPurchase = "usdt_purchase" + // StockTypeUSDTDeduction 表示运营按 USDT 入金口径冲回币商专用金币库存和充值金额。 + StockTypeUSDTDeduction = "usdt_deduction" // StockTypeCoinCompensation 表示平台人工补偿币商专用金币库存,不计入进货金额。 StockTypeCoinCompensation = "coin_compensation" // PaidCurrencyUSDT 是首版币商进货支持的唯一线下付款币种。 diff --git a/services/wallet-service/internal/service/wallet/coin_seller.go b/services/wallet-service/internal/service/wallet/coin_seller.go index 27a0bdff..d826dd38 100644 --- a/services/wallet-service/internal/service/wallet/coin_seller.go +++ b/services/wallet-service/internal/service/wallet/coin_seller.go @@ -8,7 +8,7 @@ import ( "strings" ) -// AdminCreditCoinSellerStock 只给 COIN_SELLER_COIN 库存入账;USDT 进货和金币补偿使用不同账务类型。 +// AdminCreditCoinSellerStock 只调整 COIN_SELLER_COIN 库存;USDT 进货、USDT 冲回和金币补偿使用不同账务类型。 func (s *Service) AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error) { if command.CommandID == "" || command.SellerUserID <= 0 || command.OperatorUserID <= 0 { return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.InvalidArgument, "coin seller stock command is incomplete") @@ -34,12 +34,12 @@ func (s *Service) AdminCreditCoinSellerStock(ctx context.Context, command ledger return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.InvalidArgument, "coin seller stock text fields are too long") } switch command.StockType { - case ledger.StockTypeUSDTPurchase: + case ledger.StockTypeUSDTPurchase, ledger.StockTypeUSDTDeduction: if command.PaidCurrencyCode == "" { command.PaidCurrencyCode = ledger.PaidCurrencyUSDT } if command.PaidCurrencyCode != ledger.PaidCurrencyUSDT || command.PaidAmountMicro <= 0 { - return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockAmountInvalid, "usdt purchase requires paid amount") + return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockAmountInvalid, "usdt stock adjustment requires paid amount") } case ledger.StockTypeCoinCompensation: if command.PaidCurrencyCode != "" || command.PaidAmountMicro != 0 || command.PaymentRef != "" { diff --git a/services/wallet-service/internal/service/wallet/external_recharge_order.go b/services/wallet-service/internal/service/wallet/external_recharge_order.go index 74cbdd11..521d671a 100644 --- a/services/wallet-service/internal/service/wallet/external_recharge_order.go +++ b/services/wallet-service/internal/service/wallet/external_recharge_order.go @@ -60,7 +60,7 @@ func (s *Service) createMifaPayRechargeOrder(ctx context.Context, command ledger if err := validateMifaPayMethodForOrder(method, command); err != nil { return ledger.ExternalRechargeOrder{}, err } - providerAmountMinor, err := calculateProviderAmountMinor(amountMicroToUSDMinor(product.AmountMicro), method.USDToCurrencyRate) + providerAmountMinor, err := calculateProviderAmountMinor(amountMicroToUSDMinor(product.AmountMicro), method.USDToCurrencyRate, method.CurrencyCode) if err != nil { return ledger.ExternalRechargeOrder{}, err } @@ -111,7 +111,7 @@ func (s *Service) createV5PayRechargeOrder(ctx context.Context, command ledger.C if err := validateV5PayMethodForOrder(method, command); err != nil { return ledger.ExternalRechargeOrder{}, err } - providerAmountMinor, err := calculateProviderAmountMinor(amountMicroToUSDMinor(product.AmountMicro), method.USDToCurrencyRate) + providerAmountMinor, err := calculateProviderAmountMinor(amountMicroToUSDMinor(product.AmountMicro), method.USDToCurrencyRate, method.CurrencyCode) if err != nil { return ledger.ExternalRechargeOrder{}, err } diff --git a/services/wallet-service/internal/service/wallet/external_recharge_validate.go b/services/wallet-service/internal/service/wallet/external_recharge_validate.go index 195cc697..7e31372d 100644 --- a/services/wallet-service/internal/service/wallet/external_recharge_validate.go +++ b/services/wallet-service/internal/service/wallet/external_recharge_validate.go @@ -116,7 +116,7 @@ func validateMifaPayMethodForOrder(method ledger.ThirdPartyPaymentMethod, comman if method.CountryCode != "GLOBAL" && !strings.EqualFold(method.CountryCode, command.TargetCountryCode) { return xerr.New(xerr.Conflict, "mifapay method country does not match") } - if _, err := calculateProviderAmountMinor(100, method.USDToCurrencyRate); err != nil { + if _, err := calculateProviderAmountMinor(100, method.USDToCurrencyRate, method.CurrencyCode); err != nil { return err } return nil @@ -132,13 +132,13 @@ func validateV5PayMethodForOrder(method ledger.ThirdPartyPaymentMethod, command if strings.TrimSpace(method.PayType) == "" { return xerr.New(xerr.InvalidArgument, "v5pay product type is required") } - if _, err := calculateProviderAmountMinor(100, method.USDToCurrencyRate); err != nil { + if _, err := calculateProviderAmountMinor(100, method.USDToCurrencyRate, method.CurrencyCode); err != nil { return err } return nil } -func calculateProviderAmountMinor(usdMinor int64, rate string) (int64, error) { +func calculateProviderAmountMinor(usdMinor int64, rate string, currencyCode string) (int64, error) { if usdMinor <= 0 { return 0, xerr.New(xerr.InvalidArgument, "usd amount is invalid") } @@ -146,9 +146,23 @@ func calculateProviderAmountMinor(usdMinor int64, rate string) (int64, error) { if err != nil || value <= 0 { return 0, xerr.New(xerr.InvalidArgument, "payment exchange rate is invalid") } + if providerCurrencyScale(currencyCode) == 0 { + // MiFaPay still receives a fixed two-decimal integer amount, but zero-decimal currencies + // such as IDR must represent a whole major-unit amount after the provider divides by 100. + return int64(math.Round(float64(usdMinor)/100*value)) * 100, nil + } return int64(math.Round(float64(usdMinor) * value)), nil } +func providerCurrencyScale(currencyCode string) int { + switch strings.ToUpper(strings.TrimSpace(currencyCode)) { + case "BIF", "CLP", "DJF", "GNF", "IDR", "JPY", "KMF", "KRW", "MGA", "PYG", "RWF", "UGX", "VND", "VUV", "XAF", "XOF", "XPF": + return 0 + default: + return 2 + } +} + func amountMicroToUSDMinor(amountMicro int64) int64 { if amountMicro <= 0 { return 0 diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index 340e23db..3a3921f0 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -2452,6 +2452,37 @@ func TestH5MifaPayOrderRejectsPaymentMethodFromAnotherCountry(t *testing.T) { } } +// TestH5MifaPayOrderRoundsZeroDecimalCurrency 锁定 IDR 等零小数币种的三方下单金额: +// MiFaPay 按 fixed minor 接收 amount,但会校验 IDR 主单位不能带小数,因此本地要把主单位先归整。 +func TestH5MifaPayOrderRoundsZeroDecimalCurrency(t *testing.T) { + repository := mysqltest.NewRepository(t) + svc := walletservice.New(repository) + mifaPay := &fakeMifaPayClient{} + svc.SetMifaPayClient(mifaPay) + svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{MifaPayNotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify"}) + product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceNormal, 7201, 300_000_000, 28_200_000) + idBankMethodID := repository.ThirdPartyPaymentMethodID("ID", "BankTransfer", "BCA") + repository.SetThirdPartyPaymentRate(idBankMethodID, "17706.65883333") + + order, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{ + AppCode: appcode.Default, + CommandID: "cmd-h5-mifapay-idr-integer", + TargetUserID: 42002, + TargetRegionID: 7201, + TargetCountryCode: "ID", + AudienceType: ledger.RechargeAudienceNormal, + ProductID: product.ProductID, + ProviderCode: ledger.PaymentProviderMifaPay, + PaymentMethodID: idBankMethodID, + }) + if err != nil { + t.Fatalf("CreateH5RechargeOrder IDR failed: %v", err) + } + if order.ProviderAmountMinor != 531_199_800 || mifaPay.createReq.ProviderAmountMinor != 531_199_800 { + t.Fatalf("IDR amount must be rounded to whole major units before fixed-minor submit: order=%+v req=%+v", order, mifaPay.createReq) + } +} + // TestH5MifaPayOrderRejectsUpstreamFailureAsUnavailable 锁定 MiFaPay 网关拒单的边界:本地订单要落失败便于排障, // 对外错误必须保持上游依赖失败语义,不能伪装成商品、幂等或状态冲突。 func TestH5MifaPayOrderRejectsUpstreamFailureAsUnavailable(t *testing.T) { @@ -2952,6 +2983,58 @@ func TestAdminCreditCoinSellerStockPurchaseCreditsDedicatedBalance(t *testing.T) } } +// TestAdminCreditCoinSellerStockDeductionDebitsDedicatedBalanceAndRechargeAmount 验证 USDT 扣除会同时冲回库存和币商充值金额。 +func TestAdminCreditCoinSellerStockDeductionDebitsDedicatedBalanceAndRechargeAmount(t *testing.T) { + repository := mysqltest.NewRepository(t) + svc := walletservice.New(repository) + credit := ledger.CoinSellerStockCreditCommand{ + CommandID: "cmd-stock-usdt-deduct-seed", + SellerUserID: 33011, + SellerCountryID: 63, + SellerRegionID: 7, + StockType: ledger.StockTypeUSDTPurchase, + CoinAmount: 8000000, + PaidAmountMicro: 100000000, + OperatorUserID: 90001, + } + if _, err := svc.AdminCreditCoinSellerStock(context.Background(), credit); err != nil { + t.Fatalf("seed stock purchase failed: %v", err) + } + + deduction := ledger.CoinSellerStockCreditCommand{ + CommandID: "cmd-stock-usdt-deduct", + SellerUserID: 33011, + SellerCountryID: 63, + SellerRegionID: 7, + StockType: ledger.StockTypeUSDTDeduction, + CoinAmount: 3000000, + PaidAmountMicro: 37500000, + OperatorUserID: 90002, + Reason: "reverse bad stock", + } + first, err := svc.AdminCreditCoinSellerStock(context.Background(), deduction) + if err != nil { + t.Fatalf("AdminCreditCoinSellerStock deduction failed: %v", err) + } + second, err := svc.AdminCreditCoinSellerStock(context.Background(), deduction) + if err != nil { + t.Fatalf("AdminCreditCoinSellerStock deduction retry failed: %v", err) + } + if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.BalanceAfter != 5000000 || first.CoinAmount != -3000000 || first.PaidAmountMicro != -37500000 || !first.CountsAsSellerRecharge { + t.Fatalf("stock deduction receipt mismatch: first=%+v second=%+v", first, second) + } + assertBalance(t, svc, 33011, ledger.AssetCoinSellerCoin, 5000000) + if got := repository.CountRows("coin_seller_stock_records", "transaction_id = ? AND stock_type = ? AND counts_as_seller_recharge = TRUE AND coin_amount = ? AND paid_amount_micro = ?", first.TransactionID, ledger.StockTypeUSDTDeduction, int64(-3000000), int64(-37500000)); got != 1 { + t.Fatalf("stock deduction should write one negative seller recharge record, got %d", got) + } + if got := repository.CountRows("wallet_entries", "transaction_id = ? AND available_delta = ?", first.TransactionID, int64(-3000000)); got != 1 { + t.Fatalf("stock deduction should write one negative wallet entry, got %d", got) + } + if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND available_delta = ? AND JSON_EXTRACT(payload_json, '$.paid_amount_micro') = ?", first.TransactionID, "WalletCoinSellerStockPurchased", int64(-3000000), int64(-37500000)); got != 1 { + t.Fatalf("stock deduction should emit negative seller stock outbox event, got %d", got) + } +} + // TestAdminCreditCoinSellerStockCompensationRejectsPaidAmount 锁定补偿不能携带充值金额的边界。 func TestAdminCreditCoinSellerStockCompensationRejectsPaidAmount(t *testing.T) { repository := mysqltest.NewRepository(t) diff --git a/services/wallet-service/internal/storage/mysql/coin_seller_ledger.go b/services/wallet-service/internal/storage/mysql/coin_seller_ledger.go index f8c6bb74..afb3b358 100644 --- a/services/wallet-service/internal/storage/mysql/coin_seller_ledger.go +++ b/services/wallet-service/internal/storage/mysql/coin_seller_ledger.go @@ -14,7 +14,7 @@ import ( "hyapp/services/wallet-service/internal/domain/ledger" ) -// AdminCreditCoinSellerStock 在同一事务里给币商 COIN_SELLER_COIN 库存入账,并记录专用进货明细。 +// AdminCreditCoinSellerStock 在同一事务里调整币商 COIN_SELLER_COIN 库存,并记录专用进货/冲回明细。 func (r *Repository) AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error) { if r == nil || r.db == nil { return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") @@ -49,26 +49,32 @@ func (r *Repository) AdminCreditCoinSellerStock(ctx context.Context, command led } nowMs := time.Now().UnixMilli() - account, err := r.lockAccount(ctx, tx, command.SellerUserID, ledger.AssetCoinSellerCoin, true, nowMs) + availableDelta := coinSellerStockAvailableDelta(command) + account, err := r.lockAccount(ctx, tx, command.SellerUserID, ledger.AssetCoinSellerCoin, availableDelta > 0, nowMs) if err != nil { return ledger.CoinSellerStockCreditReceipt{}, err } - balanceAfter, err := checkedAdd(account.AvailableAmount, command.CoinAmount) + if availableDelta < 0 && account.AvailableAmount < command.CoinAmount { + return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") + } + balanceAfter, err := checkedAddDelta(account.AvailableAmount, availableDelta) if err != nil { return ledger.CoinSellerStockCreditReceipt{}, err } transactionID := transactionID(command.AppCode, command.CommandID) - countsAsSellerRecharge := command.StockType == ledger.StockTypeUSDTPurchase + countsAsSellerRecharge := command.StockType == ledger.StockTypeUSDTPurchase || command.StockType == ledger.StockTypeUSDTDeduction + recordCoinAmount := availableDelta + recordPaidAmountMicro := coinSellerStockPaidAmountMicro(command) metadata := coinSellerStockMetadata{ AppCode: command.AppCode, SellerUserID: command.SellerUserID, SellerCountryID: command.SellerCountryID, SellerRegionID: command.SellerRegionID, StockType: command.StockType, - CoinAmount: command.CoinAmount, + CoinAmount: recordCoinAmount, PaidCurrencyCode: command.PaidCurrencyCode, - PaidAmountMicro: command.PaidAmountMicro, + PaidAmountMicro: recordPaidAmountMicro, PaymentRef: command.PaymentRef, EvidenceRef: command.EvidenceRef, OperatorUserID: command.OperatorUserID, @@ -85,14 +91,14 @@ func (r *Repository) AdminCreditCoinSellerStock(ctx context.Context, command led if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizType, requestHash, externalRef, metadata, nowMs); err != nil { return ledger.CoinSellerStockCreditReceipt{}, err } - if err := r.applyAccountDelta(ctx, tx, account, command.CoinAmount, 0, nowMs); err != nil { + if err := r.applyAccountDelta(ctx, tx, account, availableDelta, 0, nowMs); err != nil { return ledger.CoinSellerStockCreditReceipt{}, err } if err := r.insertEntry(ctx, tx, walletEntry{ TransactionID: transactionID, UserID: command.SellerUserID, AssetType: ledger.AssetCoinSellerCoin, - AvailableDelta: command.CoinAmount, + AvailableDelta: availableDelta, FrozenDelta: 0, AvailableAfter: balanceAfter, FrozenAfter: account.FrozenAmount, @@ -104,7 +110,7 @@ func (r *Repository) AdminCreditCoinSellerStock(ctx context.Context, command led return ledger.CoinSellerStockCreditReceipt{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, command.CoinAmount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), + balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, availableDelta, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), coinSellerStockCreditedEvent(transactionID, command.CommandID, metadata, nowMs), }); err != nil { return ledger.CoinSellerStockCreditReceipt{}, err @@ -669,9 +675,25 @@ func coinSellerStockBizType(stockType string) string { switch stockType { case ledger.StockTypeUSDTPurchase: return bizTypeCoinSellerStockPurchase + case ledger.StockTypeUSDTDeduction: + return bizTypeCoinSellerStockDeduction case ledger.StockTypeCoinCompensation: return bizTypeCoinSellerCoinCompensation default: return "" } } + +func coinSellerStockAvailableDelta(command ledger.CoinSellerStockCreditCommand) int64 { + if command.StockType == ledger.StockTypeUSDTDeduction { + return -command.CoinAmount + } + return command.CoinAmount +} + +func coinSellerStockPaidAmountMicro(command ledger.CoinSellerStockCreditCommand) int64 { + if command.StockType == ledger.StockTypeUSDTDeduction { + return -command.PaidAmountMicro + } + return command.PaidAmountMicro +} diff --git a/services/wallet-service/internal/storage/mysql/constants.go b/services/wallet-service/internal/storage/mysql/constants.go index 36cc9153..ca577d0f 100644 --- a/services/wallet-service/internal/storage/mysql/constants.go +++ b/services/wallet-service/internal/storage/mysql/constants.go @@ -13,6 +13,7 @@ const ( bizTypeAgencyOpeningReward = "agency_opening_reward" bizTypeCoinSellerTransfer = "coin_seller_transfer" bizTypeCoinSellerStockPurchase = "coin_seller_stock_purchase" + bizTypeCoinSellerStockDeduction = "coin_seller_stock_deduction" bizTypeCoinSellerCoinCompensation = "coin_seller_coin_compensation" bizTypeSalaryExchangeToCoin = "salary_exchange_to_coin" bizTypeSalaryTransferToCoinSeller = "salary_transfer_to_coin_seller" diff --git a/services/wallet-service/internal/storage/mysql/outbox.go b/services/wallet-service/internal/storage/mysql/outbox.go index f042180b..999b6f9a 100644 --- a/services/wallet-service/internal/storage/mysql/outbox.go +++ b/services/wallet-service/internal/storage/mysql/outbox.go @@ -608,7 +608,7 @@ func agencyOpeningRewardCreditedEvent(transactionID string, commandID string, me func coinSellerStockCreditedEvent(transactionID string, commandID string, metadata coinSellerStockMetadata, nowMs int64) walletOutboxEvent { eventType := "WalletCoinSellerCoinCompensated" - if metadata.StockType == ledger.StockTypeUSDTPurchase { + if metadata.StockType == ledger.StockTypeUSDTPurchase || metadata.StockType == ledger.StockTypeUSDTDeduction { eventType = "WalletCoinSellerStockPurchased" } return walletOutboxEvent{ diff --git a/services/wallet-service/internal/storage/mysql/utils.go b/services/wallet-service/internal/storage/mysql/utils.go index 9ea354e0..e69cf32b 100644 --- a/services/wallet-service/internal/storage/mysql/utils.go +++ b/services/wallet-service/internal/storage/mysql/utils.go @@ -49,6 +49,13 @@ func checkedAdd(left int64, right int64) (int64, error) { return left + right, nil } +func checkedAddDelta(left int64, right int64) (int64, error) { + if (right > 0 && left > math.MaxInt64-right) || (right < 0 && left < math.MinInt64-right) { + return 0, xerr.New(xerr.CoinSellerStockAmountInvalid, "coin amount overflow") + } + return left + right, nil +} + func stableHash(value string) string { sum := sha256.Sum256([]byte(value)) return hex.EncodeToString(sum[:]) diff --git a/services/wallet-service/internal/testutil/mysqltest/mysqltest.go b/services/wallet-service/internal/testutil/mysqltest/mysqltest.go index 0d19b2fe..21844eb9 100644 --- a/services/wallet-service/internal/testutil/mysqltest/mysqltest.go +++ b/services/wallet-service/internal/testutil/mysqltest/mysqltest.go @@ -410,6 +410,20 @@ func (r *Repository) ThirdPartyProviderPaymentMethodID(providerCode string, coun return methodID } +// SetThirdPartyPaymentRate updates the production payment method row so service tests can lock provider amount semantics. +func (r *Repository) SetThirdPartyPaymentRate(methodID int64, rate string) { + r.t.Helper() + + if _, err := r.schema.DB.ExecContext(context.Background(), ` + UPDATE third_party_payment_methods + SET usd_to_currency_rate = ?, updated_at_ms = ? + WHERE method_id = ?`, + strings.TrimSpace(rate), time.Now().UnixMilli(), methodID, + ); err != nil { + r.t.Fatalf("set third party payment rate 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()