增加显式C2C充值校验渠道

This commit is contained in:
zhx 2026-07-14 16:33:15 +08:00
parent 7609509f7f
commit 3210db25c4
6 changed files with 95 additions and 25 deletions

View File

@ -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 ""
}

View File

@ -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)

View File

@ -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 表示渠道或支付方式已被后台关闭。

View File

@ -416,6 +416,8 @@ func NormalizePaymentProvider(provider string) string {
return PaymentProviderUSDTTRC20
case PaymentProviderUSDTBEP20:
return PaymentProviderUSDTBEP20
case PaymentProviderUSDTC2C:
return PaymentProviderUSDTC2C
case PaymentProviderGooglePlay:
return PaymentProviderGooglePlay
default:

View File

@ -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"
}

View File

@ -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,
})