diff --git a/server/admin/internal/modules/financeorder/service.go b/server/admin/internal/modules/financeorder/service.go index 0619f026..932a990c 100644 --- a/server/admin/internal/modules/financeorder/service.go +++ b/server/admin/internal/modules/financeorder/service.go @@ -850,6 +850,8 @@ func normalizeRechargeProvider(providerCode string, chain string) (string, strin return "usdt", "TRON", nil case "usdt_bep20", "bep20", "bsc", "bnb": return "usdt", "BSC", nil + case "usdt_c2c", "c2c": + return "usdt", "C2C", nil default: return "", "", errors.New("充值方式不正确") } @@ -865,6 +867,8 @@ func normalizeRechargeChain(chain string) string { return "TRON" case "bsc", "bnb", "bep20", "usdt_bep20": return "BSC" + case "c2c", "usdt_c2c": + return "C2C" default: return "" } @@ -879,6 +883,8 @@ func receiptProviderCode(order model.CoinSellerRechargeOrder) string { return "usdt_trc20" case "BSC": return "usdt_bep20" + case "C2C": + return "usdt_c2c" default: return "usdt" } @@ -903,6 +909,8 @@ func receiptChain(order model.CoinSellerRechargeOrder) string { return "trc20" case "BSC": return "bep20" + case "C2C": + return "c2c" default: return "" } diff --git a/services/wallet-service/internal/client/binance/client.go b/services/wallet-service/internal/client/binance/client.go index 9631be8a..f06d7acb 100644 --- a/services/wallet-service/internal/client/binance/client.go +++ b/services/wallet-service/internal/client/binance/client.go @@ -79,7 +79,7 @@ func (c *Client) FindUSDTTransfer(ctx context.Context, req ports.BinanceTransfer req.Coin = strings.ToUpper(strings.TrimSpace(req.Coin)) req.Network = strings.ToUpper(strings.TrimSpace(req.Network)) req.ToAddress = strings.TrimSpace(req.ToAddress) - if req.ExternalID == "" || req.Coin == "" || req.Network == "" || req.ToAddress == "" || req.AmountMinor <= 0 { + if req.ExternalID == "" || req.Coin == "" || req.Network == "" || req.AmountMinor <= 0 { return ports.BinanceTransferRecord{}, xerr.New(xerr.InvalidArgument, "binance transfer verification request is incomplete") } serverTimeMS, err := c.serverTime(ctx) @@ -260,7 +260,8 @@ func matchDepositRecord(req ports.BinanceTransferLookupRequest, records []deposi if candidate.Status != 1 { return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance deposit status is not successful") } - if !strings.EqualFold(strings.TrimSpace(candidate.Address), strings.TrimSpace(req.ToAddress)) { + // Deposit History 属于具体链上入账,必须有预先配置的收款地址;允许空地址仅服务于随后独立校验的 Pay C2C 事实源。 + if strings.TrimSpace(req.ToAddress) == "" || !strings.EqualFold(strings.TrimSpace(candidate.Address), strings.TrimSpace(req.ToAddress)) { return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance deposit address mismatch") } amountMinor, err := parseUSDTAmountMinor(candidate.Amount) diff --git a/services/wallet-service/internal/domain/ledger/constants.go b/services/wallet-service/internal/domain/ledger/constants.go index 40f43998..8dc1c2e8 100644 --- a/services/wallet-service/internal/domain/ledger/constants.go +++ b/services/wallet-service/internal/domain/ledger/constants.go @@ -68,6 +68,8 @@ const ( PaymentProviderUSDTTRC20 = "usdt_trc20" // PaymentProviderUSDTBEP20 是用户提交 BSC BEP20 链上交易哈希的 USDT 充值渠道。 PaymentProviderUSDTBEP20 = "usdt_bep20" + // PaymentProviderUSDTC2C 是 Binance Pay C2C 收款订单;它不绑定公链,也不要求链上收款地址。 + PaymentProviderUSDTC2C = "usdt_c2c" // ThirdPartyPaymentStatusActive 表示渠道或支付方式可用于 H5 下单。 ThirdPartyPaymentStatusActive = "active" // ThirdPartyPaymentStatusDisabled 表示渠道或支付方式已被后台关闭。 diff --git a/services/wallet-service/internal/domain/ledger/recharge.go b/services/wallet-service/internal/domain/ledger/recharge.go index f4a6c3a0..a3e055f5 100644 --- a/services/wallet-service/internal/domain/ledger/recharge.go +++ b/services/wallet-service/internal/domain/ledger/recharge.go @@ -416,6 +416,8 @@ func NormalizePaymentProvider(provider string) string { return PaymentProviderUSDTTRC20 case PaymentProviderUSDTBEP20: return PaymentProviderUSDTBEP20 + case PaymentProviderUSDTC2C: + return PaymentProviderUSDTC2C case PaymentProviderGooglePlay: return PaymentProviderGooglePlay default: diff --git a/services/wallet-service/internal/service/wallet/external_recharge_receipt.go b/services/wallet-service/internal/service/wallet/external_recharge_receipt.go index 198eaff4..c6193c44 100644 --- a/services/wallet-service/internal/service/wallet/external_recharge_receipt.go +++ b/services/wallet-service/internal/service/wallet/external_recharge_receipt.go @@ -33,6 +33,9 @@ func (s *Service) VerifyCoinSellerRechargeReceipt(ctx context.Context, command l return s.verifyUSDTRechargeReceipt(ctx, command, "trc20", s.externalRecharge.USDTTRC20Enabled, s.externalRecharge.usdtTRC20AddressForApp(command.AppCode), s.tronUSDT) case ledger.PaymentProviderUSDTBEP20: return s.verifyUSDTRechargeReceipt(ctx, command, "bep20", s.externalRecharge.USDTBEP20Enabled, s.externalRecharge.usdtBEP20AddressForApp(command.AppCode), s.bscUSDT) + case ledger.PaymentProviderUSDTC2C: + // C2C 是 Binance 账户内收款事实,不属于 TRON/BSC;空地址和关闭的链上开关不影响只读查单。 + return s.verifyUSDTRechargeReceipt(ctx, command, "c2c", false, "", nil) default: return ledger.CoinSellerRechargeReceiptVerification{}, xerr.New(xerr.InvalidArgument, "receipt provider is invalid") } @@ -333,20 +336,23 @@ type receiptUSDTClient interface { } func (s *Service) verifyUSDTRechargeReceipt(ctx context.Context, command ledger.VerifyCoinSellerRechargeReceiptCommand, chain string, enabled bool, receiveAddress string, client receiptUSDTClient) (ledger.CoinSellerRechargeReceiptVerification, error) { - if !enabled || strings.TrimSpace(receiveAddress) == "" { - return ledger.CoinSellerRechargeReceiptVerification{}, xerr.New(xerr.Unavailable, "usdt "+chain+" receipt verification is not configured") - } snapshot := s.receiptSnapshot(command) snapshot.Chain = chain snapshot.CurrencyCode = ledger.PaidCurrencyUSDT snapshot.ProviderAmountMinor = command.USDMinorAmount snapshot.ProviderOrderID = command.ExternalOrderNo snapshot.ReceiveAddress = strings.TrimSpace(receiveAddress) - if !isLikelyChainTransactionHash(command.ExternalOrderNo) { - // Binance Pay C2C 订单号不属于某条公链;后台选择 TRC20 或 BEP20 只表示运营记录的支付币种。 - // 因此所有非链上哈希都必须先走 Binance 账户入账事实,不能把 BEP20 的 C2C 订单号误送给 BSC 链上客户端。 + if chain == "c2c" { + // 查单来源由运营显式选择;C2C 只读取 Binance 账户事实,绝不从 TRON/BSC 隐式降级或猜测。 return s.verifyBinanceTransferReceipt(ctx, command, snapshot, binanceNetworkForReceiptChain(chain)) } + if !enabled || snapshot.ReceiveAddress == "" { + return ledger.CoinSellerRechargeReceiptVerification{}, xerr.New(xerr.Unavailable, "usdt "+chain+" receipt verification is not configured") + } + if !isLikelyChainTransactionHash(command.ExternalOrderNo) { + // TRON/BSC 只接受标准交易哈希;非链上编号必须由运营明确改选 C2C,避免跨事实源误校验。 + return ledger.CoinSellerRechargeReceiptVerification{}, xerr.New(xerr.InvalidArgument, "usdt "+chain+" receipt requires transaction hash") + } if client == nil { return ledger.CoinSellerRechargeReceiptVerification{}, xerr.New(xerr.Unavailable, "usdt "+chain+" client is not configured") } @@ -387,7 +393,7 @@ func (s *Service) verifyBinanceTransferReceipt(ctx context.Context, command ledg snapshot.ProviderOrderID = record.TxID snapshot.ProviderAmountMinor = record.AmountMinor // Pay C2C 没有链上收款地址;只有 Deposit History 命中时才用 Binance 返回地址覆盖配置快照。 - if strings.TrimSpace(record.Address) != "" { + if snapshot.Chain != "c2c" && strings.TrimSpace(record.Address) != "" { snapshot.ReceiveAddress = record.Address } snapshot.RawJSON = strings.TrimSpace(record.RawJSON) @@ -400,6 +406,8 @@ func binanceNetworkForReceiptChain(chain string) string { switch strings.ToLower(strings.TrimSpace(chain)) { case "bep20": return "BSC" + case "c2c": + return "C2C" default: return "TRX" } 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 27836330..59f663db 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 @@ -529,7 +529,25 @@ func TestVerifyCoinSellerRechargeReceiptUSDTTRC20HashUsesTronGrid(t *testing.T) } } -func TestVerifyCoinSellerRechargeReceiptUSDTTRC20OffchainUsesBinance(t *testing.T) { +func TestVerifyCoinSellerRechargeReceiptUSDTTRC20RejectsC2COrderNumber(t *testing.T) { + svc := walletservice.New(nil) + svc.SetTronUSDTClient(&fakeTronUSDTClient{}) + svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{ + USDTTRC20Enabled: true, + USDTTRC20Address: "TPlatformReceiveAddress", + }) + _, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{ + AppCode: "lalu", + ProviderCode: ledger.PaymentProviderUSDTTRC20, + ExternalOrderNo: "440537037787594752", + USDMinorAmount: 20000, + }) + if !xerr.IsCode(err, xerr.InvalidArgument) || !strings.Contains(err.Error(), "requires transaction hash") { + t.Fatalf("TRC20 must reject C2C order number without fallback: %v", err) + } +} + +func TestVerifyCoinSellerRechargeReceiptUSDTC2CUsesBinance(t *testing.T) { tron := &fakeTronUSDTClient{err: xerr.New(xerr.Internal, "tron should not be called")} binance := &fakeBinanceClient{record: walletservice.BinanceTransferRecord{ TxID: "Off-chain transfer 388611987194", @@ -553,7 +571,7 @@ func TestVerifyCoinSellerRechargeReceiptUSDTTRC20OffchainUsesBinance(t *testing. }) receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{ AppCode: "aslan", - ProviderCode: ledger.PaymentProviderUSDTTRC20, + ProviderCode: ledger.PaymentProviderUSDTC2C, ExternalOrderNo: "388611987194", USDMinorAmount: 50000, OrderDateMS: 1783344000000, @@ -561,10 +579,10 @@ func TestVerifyCoinSellerRechargeReceiptUSDTTRC20OffchainUsesBinance(t *testing. if err != nil { t.Fatalf("VerifyCoinSellerRechargeReceipt binance offchain failed: %v", err) } - if !receipt.Verified || receipt.Status != "confirmed" || receipt.ProviderOrderID != "Off-chain transfer 388611987194" || receipt.ProviderAmountMinor != 50000 || receipt.ReceiveAddress != "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN" { + if !receipt.Verified || receipt.Status != "confirmed" || receipt.ProviderOrderID != "Off-chain transfer 388611987194" || receipt.ProviderAmountMinor != 50000 || receipt.ReceiveAddress != "" { t.Fatalf("binance offchain receipt snapshot mismatch: %+v", receipt) } - if binance.calls != 1 || binance.req.AppCode != "aslan" || binance.req.ExternalID != "388611987194" || binance.req.Network != "TRX" || binance.req.ToAddress != "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN" || binance.req.AmountMinor != 50000 || binance.req.OrderTimeMS != 1783344000000 { + if binance.calls != 1 || binance.req.AppCode != "aslan" || binance.req.ExternalID != "388611987194" || binance.req.Network != "C2C" || binance.req.ToAddress != "" || binance.req.AmountMinor != 50000 || binance.req.OrderTimeMS != 1783344000000 { t.Fatalf("binance offchain request mismatch: calls=%d req=%+v", binance.calls, binance.req) } if tron.lastTxHash != "" { @@ -572,7 +590,7 @@ func TestVerifyCoinSellerRechargeReceiptUSDTTRC20OffchainUsesBinance(t *testing. } } -func TestVerifyCoinSellerRechargeReceiptUSDTTRC20PayKeepsConfiguredAddress(t *testing.T) { +func TestVerifyCoinSellerRechargeReceiptUSDTC2CPayKeepsNoChainAddress(t *testing.T) { binance := &fakeBinanceClient{record: walletservice.BinanceTransferRecord{ Source: "pay_history", ID: "433527303245463552", @@ -593,7 +611,7 @@ func TestVerifyCoinSellerRechargeReceiptUSDTTRC20PayKeepsConfiguredAddress(t *te }) receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{ AppCode: "yumi", - ProviderCode: ledger.PaymentProviderUSDTTRC20, + ProviderCode: ledger.PaymentProviderUSDTC2C, ExternalOrderNo: "433527303245463552", USDMinorAmount: 10000, OrderDateMS: 1779713682799, @@ -604,15 +622,15 @@ func TestVerifyCoinSellerRechargeReceiptUSDTTRC20PayKeepsConfiguredAddress(t *te if !receipt.Verified || receipt.Status != "confirmed" || receipt.ProviderOrderID != "P_A223BU5M4Y171116" || receipt.ProviderAmountMinor != 10000 { t.Fatalf("Pay C2C receipt snapshot mismatch: %+v", receipt) } - if receipt.ReceiveAddress != "TYumiConfiguredChainAddress" { - t.Fatalf("Pay C2C must not erase configured chain address: %+v", receipt) + if receipt.ReceiveAddress != "" { + t.Fatalf("Pay C2C must not inherit an unrelated chain address: %+v", receipt) } if binance.req.OrderTimeMS != 1779713682799 { t.Fatalf("coin seller order time must bound Pay History query: %+v", binance.req) } } -func TestVerifyCoinSellerRechargeReceiptUSDTBEP20PayUsesBinance(t *testing.T) { +func TestVerifyCoinSellerRechargeReceiptUSDTC2CPayUsesBinance(t *testing.T) { bsc := &fakeTronUSDTClient{err: xerr.New(xerr.Internal, "bsc chain client should not be called")} binance := &fakeBinanceClient{record: walletservice.BinanceTransferRecord{ Source: "pay_history", @@ -635,7 +653,7 @@ func TestVerifyCoinSellerRechargeReceiptUSDTBEP20PayUsesBinance(t *testing.T) { }) receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{ AppCode: "yumi", - ProviderCode: ledger.PaymentProviderUSDTBEP20, + ProviderCode: ledger.PaymentProviderUSDTC2C, ExternalOrderNo: "440537037787594752", USDMinorAmount: 20000, OrderDateMS: 1784017140000, @@ -646,18 +664,49 @@ func TestVerifyCoinSellerRechargeReceiptUSDTBEP20PayUsesBinance(t *testing.T) { if !receipt.Verified || receipt.Status != "confirmed" || receipt.ProviderOrderID != "P_C2C_BEP20_RECEIPT" || receipt.ProviderAmountMinor != 20000 { t.Fatalf("BEP20 Pay C2C receipt snapshot mismatch: %+v", receipt) } - if receipt.ReceiveAddress != "0xYumiConfiguredBSCAddress" { - t.Fatalf("BEP20 Pay C2C must keep configured chain address: %+v", receipt) + if receipt.ReceiveAddress != "" { + t.Fatalf("C2C must not persist an unrelated chain address: %+v", receipt) } - if binance.calls != 1 || binance.req.Network != "BSC" || binance.req.ExternalID != "440537037787594752" || binance.req.AmountMinor != 20000 { - t.Fatalf("BEP20 Pay C2C must use Binance transfer lookup: calls=%d req=%+v", binance.calls, binance.req) + if binance.calls != 1 || binance.req.Network != "C2C" || binance.req.ExternalID != "440537037787594752" || binance.req.AmountMinor != 20000 { + t.Fatalf("C2C must use Binance transfer lookup: calls=%d req=%+v", binance.calls, binance.req) } if bsc.lastTxHash != "" { t.Fatalf("BEP20 Pay C2C order id must not call BSC chain client: %+v", bsc) } } -func TestVerifyCoinSellerRechargeReceiptUSDTTRC20OffchainMissingBinanceAccount(t *testing.T) { +func TestVerifyCoinSellerRechargeReceiptUSDTC2CDoesNotRequireChainConfig(t *testing.T) { + binance := &fakeBinanceClient{record: walletservice.BinanceTransferRecord{ + Source: "pay_history", + TxID: "P_C2C_LALU_RECEIPT", + Coin: ledger.PaidCurrencyUSDT, + Status: 1, + Amount: "200.00000000", + AmountMinor: 20000, + }} + svc := walletservice.New(nil) + svc.SetBinanceClient(binance) + // Lalu 只校验 Binance C2C 收款事实时不需要开启 BSC RPC,也不需要伪造一个链上收款地址。 + svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{}) + receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{ + AppCode: "lalu", + ProviderCode: ledger.PaymentProviderUSDTC2C, + ExternalOrderNo: "440537037787594752", + USDMinorAmount: 20000, + OrderDateMS: 1784017620000, + }) + if err != nil { + t.Fatalf("BEP20 Pay C2C must not require chain config: %v", err) + } + if !receipt.Verified || receipt.ProviderOrderID != "P_C2C_LALU_RECEIPT" { + t.Fatalf("BEP20 Pay C2C receipt mismatch without chain config: %+v", receipt) + } + if binance.calls != 1 || binance.req.ToAddress != "" || binance.req.Network != "C2C" { + t.Fatalf("BEP20 Pay C2C lookup mismatch without chain config: calls=%d req=%+v", binance.calls, binance.req) + } +} + +func TestVerifyCoinSellerRechargeReceiptUSDTC2CMissingBinanceAccount(t *testing.T) { binance := &fakeBinanceClient{err: xerr.New(xerr.Unavailable, "binance account is not configured")} svc := walletservice.New(nil) svc.SetBinanceClient(binance) @@ -667,7 +716,7 @@ func TestVerifyCoinSellerRechargeReceiptUSDTTRC20OffchainMissingBinanceAccount(t }) receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{ AppCode: "yumi", - ProviderCode: ledger.PaymentProviderUSDTTRC20, + ProviderCode: ledger.PaymentProviderUSDTC2C, ExternalOrderNo: "388611987194", USDMinorAmount: 50000, })