diff --git a/services/wallet-service/internal/client/binance/client.go b/services/wallet-service/internal/client/binance/client.go index 79dd6d9c..df578f09 100644 --- a/services/wallet-service/internal/client/binance/client.go +++ b/services/wallet-service/internal/client/binance/client.go @@ -89,13 +89,15 @@ func (c *Client) FindUSDTTransfer(ctx context.Context, req ports.BinanceTransfer return ports.BinanceTransferRecord{}, err } if req.Network == "C2C" { - // C2C/P2P 与 Binance Pay 是两套独立账单;显式 C2C 只查法币交易历史,禁止再回退到 Pay 或链上入金。 - startTimeMS, endTimeMS := payHistoryRange(req.OrderTimeMS, serverTimeMS) - records, err := c.c2cHistory(ctx, account, startTimeMS, endTimeMS, serverTimeMS) + // 后台的 C2C 对应 Binance Pay 账单中的 orderType=C2C,而不是法币 P2P Trade History。 + // 显式 C2C 只查 Pay,且允许收款币种为 USDT 或 USDC;不能再回退到链上或 P2P 历史。 + // 运营录单时间可能是补单操作时间而非 Binance 成交时间;显式订单号查验使用有界 90 天窗口,避免错误窄窗漏单。 + startTimeMS, endTimeMS := maxInt64(0, serverTimeMS-int64(defaultPayLookback/time.Millisecond)), serverTimeMS + records, err := c.payHistory(ctx, account, startTimeMS, endTimeMS, serverTimeMS) if err != nil { return ports.BinanceTransferRecord{}, err } - return matchC2CRecord(req, records) + return matchExplicitC2CPayRecord(req, records) } // Binance 的 deposit history 查询窗口有限;后台凭证校验只做近期有界查询,避免一次手工校验变成无界扫描。 records, err := c.depositHistory(ctx, account, req.Coin, serverTimeMS-int64(defaultDepositLookback/time.Millisecond), serverTimeMS) @@ -118,6 +120,32 @@ func (c *Client) FindUSDTTransfer(ctx context.Context, req ports.BinanceTransfer return matchPayRecord(req, payRecords) } +func matchExplicitC2CPayRecord(req ports.BinanceTransferLookupRequest, records []payRecord) (ports.BinanceTransferRecord, error) { + for _, record := range records { + if !payExternalIDMatches(record, req.ExternalID) { + continue + } + if record.OrderType != "C2C" { + return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance pay order type is not c2c") + } + if record.Currency != "USDT" && record.Currency != "USDC" { + return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance pay c2c currency is not supported") + } + if strings.HasPrefix(strings.TrimSpace(record.Amount), "-") { + return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance pay transfer is not income") + } + amountMinor, err := parseUSDTAmountMinor(record.Amount) + if err != nil { + return ports.BinanceTransferRecord{}, err + } + if amountMinor <= 0 || amountMinor != req.AmountMinor { + return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance pay amount mismatch") + } + return ports.BinanceTransferRecord{Source: "pay_history", ID: record.OrderID, TxID: firstNonEmpty(record.TransactionID, record.OrderID), Coin: record.Currency, Status: 1, Amount: record.Amount, AmountMinor: amountMinor, InsertTimeMS: record.TransactionTimeMS, CompleteTimeMS: record.TransactionTimeMS, WalletType: record.WalletType, RawJSON: strings.TrimSpace(record.RawJSON)}, nil + } + return ports.BinanceTransferRecord{}, xerr.New(xerr.NotFound, "binance pay c2c transfer not found") +} + // c2cHistory 查询当前 API Key 账户作为买方完成的 C2C 订单;BUY 表示账户收到 asset,SELL 则是付出 asset。 func (c *Client) c2cHistory(ctx context.Context, account account, startTimeMS int64, endTimeMS int64, serverTimeMS int64) ([]c2cRecord, error) { query := url.Values{} diff --git a/services/wallet-service/internal/client/binance/client_test.go b/services/wallet-service/internal/client/binance/client_test.go index 868ffd34..5570422c 100644 --- a/services/wallet-service/internal/client/binance/client_test.go +++ b/services/wallet-service/internal/client/binance/client_test.go @@ -209,14 +209,14 @@ func TestFindUSDTTransferFallsBackToIncomingPayC2C(t *testing.T) { } } -func TestFindUSDTTransferC2CUsesFiatTradeHistoryOnly(t *testing.T) { +func TestFindUSDTTransferC2CUsesPayAndAcceptsUSDC(t *testing.T) { const orderID = "440537037787594752" var sawC2C bool server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v3/time": _, _ = w.Write([]byte(`{"serverTime":1784018000000}`)) - case "/sapi/v1/c2c/orderMatch/listUserOrderHistory": + case "/sapi/v1/pay/transactions": sawC2C = true values := r.URL.Query() signature := values.Get("signature") @@ -224,10 +224,10 @@ func TestFindUSDTTransferC2CUsesFiatTradeHistoryOnly(t *testing.T) { if signature != signQueryString(values.Encode(), "secret-lalu") { t.Fatalf("C2C signature mismatch: %s", values.Encode()) } - if values.Get("tradeType") != "BUY" || values.Get("page") != "1" || values.Get("rows") != "100" { + if values.Get("limit") != "100" { t.Fatalf("C2C query mismatch: %s", values.Encode()) } - _, _ = w.Write([]byte(`{"code":"000000","success":true,"data":[{"orderNumber":"440537037787594752","tradeType":"BUY","asset":"USDT","amount":"200.00000000","orderStatus":"COMPLETED","createTime":1784017560000}]}`)) + _, _ = w.Write([]byte(`{"code":"000000","success":true,"data":[{"orderType":"C2C","orderId":"440537037787594752","transactionId":"P_A22V8RE4HQS71114","transactionTime":1782977844437,"amount":"200","currency":"USDC","walletType":1}]}`)) default: t.Fatalf("C2C lookup must not call another history endpoint: %s", r.URL.Path) } @@ -238,7 +238,7 @@ func TestFindUSDTTransferC2CUsesFiatTradeHistoryOnly(t *testing.T) { if err != nil { t.Fatalf("C2C lookup failed: %v", err) } - if !sawC2C || record.Source != "c2c_history" || record.ID != orderID || record.AmountMinor != 20000 { + if !sawC2C || record.Source != "pay_history" || record.ID != orderID || record.Coin != "USDC" || record.AmountMinor != 20000 { t.Fatalf("C2C record mismatch: %+v", record) } } 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 ada294b5..4db689d5 100644 --- a/services/wallet-service/internal/service/wallet/external_recharge_receipt.go +++ b/services/wallet-service/internal/service/wallet/external_recharge_receipt.go @@ -392,6 +392,10 @@ func (s *Service) verifyBinanceTransferReceipt(ctx context.Context, command ledg snapshot.Status = "confirmed" snapshot.ProviderOrderID = record.TxID snapshot.ProviderAmountMinor = record.AmountMinor + if snapshot.Chain == "c2c" && strings.TrimSpace(record.Coin) != "" { + // C2C Pay 实际收款可能是 USDT 或 USDC;凭证必须保存 Binance 返回币种,不能继续伪装成表单默认 USDT。 + snapshot.CurrencyCode = strings.ToUpper(strings.TrimSpace(record.Coin)) + } // Pay C2C 没有链上收款地址;只有 Deposit History 命中时才用 Binance 返回地址覆盖配置快照。 if snapshot.Chain != "c2c" && strings.TrimSpace(record.Address) != "" { snapshot.ReceiveAddress = record.Address