diff --git a/services/wallet-service/internal/client/binance/client_test.go b/services/wallet-service/internal/client/binance/client_test.go index e2c0c802..354048e9 100644 --- a/services/wallet-service/internal/client/binance/client_test.go +++ b/services/wallet-service/internal/client/binance/client_test.go @@ -148,6 +148,20 @@ func TestFindUSDTDepositMissingAccountReturnsConfiguredError(t *testing.T) { } } +func TestAccountForAppSupportsAllProductionApps(t *testing.T) { + client := New(config.BinanceConfig{ + Lalu: config.BinanceAccountConfig{APIKey: "api-lalu", APISecretKey: "secret-lalu"}, + Yumi: config.BinanceAccountConfig{APIKey: "api-yumi", APISecretKey: "secret-yumi"}, + Aslan: config.BinanceAccountConfig{APIKey: "api-aslan", APISecretKey: "secret-aslan"}, + }) + for _, appCode := range []string{"lalu", "yumi", "aslan"} { + account, ok := client.accountForApp(appCode) + if !ok || account.apiKey != "api-"+appCode || account.apiSecretKey != "secret-"+appCode { + t.Fatalf("Binance account mapping mismatch for %s: ok=%v account=%+v", appCode, ok, account) + } + } +} + func TestDepositTxIDMatchesOffchainVariants(t *testing.T) { if !depositTxIDMatches("Off-chain transfer 388611987194", "388611987194") { t.Fatalf("plain off-chain id should match Binance txId") 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 01841c0b..bfad3c80 100644 --- a/services/wallet-service/internal/service/wallet/external_recharge_order.go +++ b/services/wallet-service/internal/service/wallet/external_recharge_order.go @@ -6,6 +6,7 @@ import ( "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/domain/ledger" + "hyapp/services/wallet-service/internal/service/wallet/ports" "strings" ) @@ -178,7 +179,7 @@ func providerPayloadJSON(err error) string { return "" } -// SubmitH5RechargeTx 把用户提交的 tx_hash 交给 TRON 适配器校验,通过后立即入账。 +// SubmitH5RechargeTx 校验用户提交的 TRON 链上哈希或 Binance 站内转账号,通过后立即入账。 func (s *Service) SubmitH5RechargeTx(ctx context.Context, command ledger.SubmitExternalRechargeTxCommand) (ledger.ExternalRechargeOrder, error) { command.AppCode = appcode.Normalize(command.AppCode) command.OrderID = strings.TrimSpace(command.OrderID) @@ -200,18 +201,43 @@ func (s *Service) SubmitH5RechargeTx(ctx context.Context, command ledger.SubmitE if order.ProviderCode != ledger.PaymentProviderUSDTTRC20 || order.Status != ledger.ExternalRechargeStatusPending { return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Conflict, "external recharge order is not pending usdt order") } - if s.tronUSDT == nil { - return s.repository.AttachExternalRechargeTx(ctx, command, "") - } - rawJSON, err := s.tronUSDT.VerifyUSDTTransfer(ctx, command.TxHash, order.ReceiveAddress, order.USDMinorAmount) + providerOrderID, rawJSON, err := s.verifyH5USDTTransfer(ctx, command, order) if err != nil { + // 校验失败仍保留用户提交的编号和上游快照,便于后续重试与财务复核;这一步不会改变账务。 _, _ = s.repository.AttachExternalRechargeTx(ctx, command, rawJSON) return ledger.ExternalRechargeOrder{}, err } if _, err := s.repository.AttachExternalRechargeTx(ctx, command, rawJSON); err != nil { return ledger.ExternalRechargeOrder{}, err } - return s.repository.CreditExternalRechargeOrder(ctx, command.AppCode, command.OrderID, "", command.TxHash, rawJSON) + return s.repository.CreditExternalRechargeOrder(ctx, command.AppCode, command.OrderID, providerOrderID, command.TxHash, rawJSON) +} + +// verifyH5USDTTransfer 只根据凭证形态选择事实源:64 位十六进制哈希查 TRON,其他编号查当前 App 的 Binance 入金记录。 +// Binance 路径不信任付款方截图;FindUSDTDeposit 会同时核对币种、网络、收款地址、金额和成功状态。 +func (s *Service) verifyH5USDTTransfer(ctx context.Context, command ledger.SubmitExternalRechargeTxCommand, order ledger.ExternalRechargeOrder) (string, string, error) { + if isLikelyChainTransactionHash(command.TxHash) { + if s.tronUSDT == nil { + return "", "", xerr.New(xerr.Unavailable, "tron grid client is not configured") + } + rawJSON, err := s.tronUSDT.VerifyUSDTTransfer(ctx, command.TxHash, order.ReceiveAddress, order.USDMinorAmount) + return "", rawJSON, err + } + if s.binance == nil { + return "", "", xerr.New(xerr.Unavailable, "binance client is not configured") + } + record, err := s.binance.FindUSDTDeposit(ctx, ports.BinanceDepositLookupRequest{ + AppCode: command.AppCode, + ExternalID: command.TxHash, + Coin: ledger.PaidCurrencyUSDT, + Network: "TRX", + ToAddress: order.ReceiveAddress, + AmountMinor: order.USDMinorAmount, + }) + if err != nil { + return "", "", err + } + return record.TxID, strings.TrimSpace(record.RawJSON), nil } func (s *Service) GetH5RechargeOrder(ctx context.Context, appCode string, orderID string, targetUserID int64) (ledger.ExternalRechargeOrder, error) { diff --git a/services/wallet-service/internal/service/wallet/external_recharge_receipt_test.go b/services/wallet-service/internal/service/wallet/external_recharge_receipt_test.go index 21bfa2f6..0c323d53 100644 --- a/services/wallet-service/internal/service/wallet/external_recharge_receipt_test.go +++ b/services/wallet-service/internal/service/wallet/external_recharge_receipt_test.go @@ -592,6 +592,58 @@ func TestVerifyCoinSellerRechargeReceiptUSDTTRC20OffchainMissingBinanceAccount(t } } +func TestSubmitH5RechargeTxBinanceOffchainUsesAppScopedAccount(t *testing.T) { + for index, appCode := range []string{"lalu", "yumi", "aslan"} { + t.Run(appCode, func(t *testing.T) { + order := ledger.ExternalRechargeOrder{ + OrderID: "order-" + appCode, + AppCode: appCode, + TargetUserID: int64(81000 + index), + USDMinorAmount: 30000, + ProviderCode: ledger.PaymentProviderUSDTTRC20, + ReceiveAddress: "TReceiveAddress-" + appCode, + Status: ledger.ExternalRechargeStatusPending, + } + repository := &fakeH5USDTRepository{order: order} + binance := &fakeBinanceClient{record: walletservice.BinanceDepositRecord{ + TxID: "Off-chain transfer 390597460031", + AmountMinor: 30000, + Address: order.ReceiveAddress, + RawJSON: `{"txId":"Off-chain transfer 390597460031","status":1}`, + }} + tron := &fakeTronUSDTClient{err: xerr.New(xerr.Internal, "tron should not be called")} + svc := walletservice.New(repository) + svc.SetBinanceClient(binance) + svc.SetTronUSDTClient(tron) + + credited, err := svc.SubmitH5RechargeTx(context.Background(), ledger.SubmitExternalRechargeTxCommand{ + AppCode: appCode, + OrderID: order.OrderID, + TargetUserID: order.TargetUserID, + TxHash: "390597460031", + }) + if err != nil { + t.Fatalf("SubmitH5RechargeTx Binance off-chain failed: %v", err) + } + if credited.Status != ledger.ExternalRechargeStatusCredited { + t.Fatalf("Binance off-chain order was not credited: %+v", credited) + } + if binance.calls != 1 || binance.req.AppCode != appCode || binance.req.ExternalID != "390597460031" || binance.req.Coin != ledger.PaidCurrencyUSDT || binance.req.Network != "TRX" || binance.req.ToAddress != order.ReceiveAddress || binance.req.AmountMinor != 30000 { + t.Fatalf("Binance app-scoped lookup mismatch: calls=%d req=%+v", binance.calls, binance.req) + } + if tron.lastTxHash != "" { + t.Fatalf("Binance off-chain transfer must not query TronGrid: %+v", tron) + } + if repository.attachCommand.TxHash != "390597460031" || repository.attachPayload != binance.record.RawJSON { + t.Fatalf("submitted Binance evidence was not attached: command=%+v payload=%s", repository.attachCommand, repository.attachPayload) + } + if repository.creditProviderOrderID != binance.record.TxID || repository.creditTxHash != "390597460031" || repository.creditPayload != binance.record.RawJSON { + t.Fatalf("Binance credit evidence mismatch: provider_order_id=%s tx_hash=%s payload=%s", repository.creditProviderOrderID, repository.creditTxHash, repository.creditPayload) + } + }) + } +} + type fakeReceiptRepository struct { walletservice.Repository channels []ledger.ThirdPartyPaymentChannel @@ -600,6 +652,47 @@ type fakeReceiptRepository struct { querySeen bool } +type fakeH5USDTRepository struct { + walletservice.Repository + order ledger.ExternalRechargeOrder + attachCommand ledger.SubmitExternalRechargeTxCommand + attachPayload string + creditProviderOrderID string + creditTxHash string + creditPayload string +} + +func (r *fakeH5USDTRepository) GetExternalRechargeOrder(_ context.Context, appCode string, orderID string) (ledger.ExternalRechargeOrder, error) { + if appCode != r.order.AppCode || orderID != r.order.OrderID { + return ledger.ExternalRechargeOrder{}, xerr.New(xerr.NotFound, "external recharge order not found") + } + return r.order, nil +} + +func (r *fakeH5USDTRepository) AttachExternalRechargeTx(_ context.Context, command ledger.SubmitExternalRechargeTxCommand, payloadJSON string) (ledger.ExternalRechargeOrder, error) { + // 测试仓储保留入账前的凭证快照,用于断言上游查单结果没有在 service 层丢失。 + r.attachCommand = command + r.attachPayload = payloadJSON + r.order.TxHash = command.TxHash + r.order.ProviderPayloadJSON = payloadJSON + return r.order, nil +} + +func (r *fakeH5USDTRepository) CreditExternalRechargeOrder(_ context.Context, appCode string, orderID string, providerOrderID string, txHash string, payloadJSON string) (ledger.ExternalRechargeOrder, error) { + if appCode != r.order.AppCode || orderID != r.order.OrderID { + return ledger.ExternalRechargeOrder{}, xerr.New(xerr.NotFound, "external recharge order not found") + } + // 只有校验通过才会进入此方法;真实 MySQL 仓储仍负责唯一凭证和原子入账约束。 + r.creditProviderOrderID = providerOrderID + r.creditTxHash = txHash + r.creditPayload = payloadJSON + r.order.ProviderOrderID = providerOrderID + r.order.TxHash = txHash + r.order.ProviderPayloadJSON = payloadJSON + r.order.Status = ledger.ExternalRechargeStatusCredited + return r.order, nil +} + func (r *fakeReceiptRepository) ListThirdPartyPaymentChannels(_ context.Context, query ledger.ListThirdPartyPaymentChannelsQuery) ([]ledger.ThirdPartyPaymentChannel, error) { r.query = query r.querySeen = true diff --git a/services/wallet-service/internal/service/wallet/ports/clients.go b/services/wallet-service/internal/service/wallet/ports/clients.go index 29fd3ef3..67b110ee 100644 --- a/services/wallet-service/internal/service/wallet/ports/clients.go +++ b/services/wallet-service/internal/service/wallet/ports/clients.go @@ -147,7 +147,7 @@ type V5PayProductTypesResponse struct { RawJSON string } -// BinanceDepositLookupRequest 是后台 USDT 币商充值使用的 Binance 入金查单条件。 +// BinanceDepositLookupRequest 是 H5 充值和后台 USDT 币商充值共用的 Binance 入金查单条件。 type BinanceDepositLookupRequest struct { AppCode string ExternalID string @@ -201,7 +201,7 @@ type BSCUSDTClient interface { VerifyUSDTTransfer(ctx context.Context, txHash string, toAddress string, amountMinor int64) (string, error) } -// BinanceClient 隔离 Binance signed API;service 只关心入金记录是否匹配后台订单。 +// BinanceClient 隔离 Binance signed API;service 只关心入金记录是否匹配当前 App 订单。 type BinanceClient interface { FindUSDTDeposit(ctx context.Context, req BinanceDepositLookupRequest) (BinanceDepositRecord, error) } diff --git a/services/wallet-service/internal/service/wallet/service.go b/services/wallet-service/internal/service/wallet/service.go index 4a137f50..d5e2cf0e 100644 --- a/services/wallet-service/internal/service/wallet/service.go +++ b/services/wallet-service/internal/service/wallet/service.go @@ -63,7 +63,7 @@ func (s *Service) SetBSCUSDTClient(client ports.BSCUSDTClient) { s.bscUSDT = client } -// SetBinanceClient 注入 Binance 入金只读查询客户端;后台币商充值可用 off-chain 单号自动查账。 +// SetBinanceClient 注入 Binance 入金只读查询客户端;H5 充值和后台币商充值共用按 App 隔离的 off-chain 查账。 func (s *Service) SetBinanceClient(client ports.BinanceClient) { s.binance = client } diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index ff1bf806..1000a0c7 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -3568,7 +3568,7 @@ func TestH5USDTRechargeCreditsNormalUserCoinWallet(t *testing.T) { AppCode: appcode.Default, OrderID: order.OrderID, TargetUserID: 43001, - TxHash: "tx-normal-usdt", + TxHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", }) if err != nil { t.Fatalf("SubmitH5RechargeTx normal failed: %v", err) @@ -3576,7 +3576,7 @@ func TestH5USDTRechargeCreditsNormalUserCoinWallet(t *testing.T) { if credited.Status != ledger.ExternalRechargeStatusCredited || credited.TransactionID == "" { t.Fatalf("normal usdt credited order mismatch: %+v", credited) } - if tron.lastTxHash != "tx-normal-usdt" || tron.lastToAddress != "TPlatformReceiveAddress" || tron.lastAmountMinor != 150 { + if tron.lastTxHash != "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" || tron.lastToAddress != "TPlatformReceiveAddress" || tron.lastAmountMinor != 150 { t.Fatalf("tron verification input mismatch: %+v", tron) } assertBalance(t, svc, 43001, ledger.AssetCoin, 120000) @@ -3620,7 +3620,7 @@ func TestH5USDTRechargeCreditsCoinSellerWalletOnly(t *testing.T) { AppCode: appcode.Default, OrderID: order.OrderID, TargetUserID: 44001, - TxHash: "tx-seller-usdt", + TxHash: "1123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", }) if err != nil { t.Fatalf("SubmitH5RechargeTx seller failed: %v", err)