diff --git a/services/wallet-service/internal/client/binance/client.go b/services/wallet-service/internal/client/binance/client.go index f06d7acb..79dd6d9c 100644 --- a/services/wallet-service/internal/client/binance/client.go +++ b/services/wallet-service/internal/client/binance/client.go @@ -25,6 +25,8 @@ const ( payOrderTimeRadius = time.Hour payHistoryLimit = 100 binancePaySuccessCode = "000000" + c2cTradeTypeBuy = "BUY" + c2cOrderStatusComplete = "COMPLETED" ) // Client 是 Binance 只读查单适配器;它只使用 signed read API,不保存、不生成任何链上私钥。 @@ -86,6 +88,15 @@ func (c *Client) FindUSDTTransfer(ctx context.Context, req ports.BinanceTransfer if err != nil { 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) + if err != nil { + return ports.BinanceTransferRecord{}, err + } + return matchC2CRecord(req, records) + } // Binance 的 deposit history 查询窗口有限;后台凭证校验只做近期有界查询,避免一次手工校验变成无界扫描。 records, err := c.depositHistory(ctx, account, req.Coin, serverTimeMS-int64(defaultDepositLookback/time.Millisecond), serverTimeMS) if err != nil { @@ -107,6 +118,68 @@ func (c *Client) FindUSDTTransfer(ctx context.Context, req ports.BinanceTransfer return matchPayRecord(req, payRecords) } +// 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{} + query.Set("tradeType", c2cTradeTypeBuy) + query.Set("startTimestamp", strconv.FormatInt(startTimeMS, 10)) + query.Set("endTimestamp", strconv.FormatInt(endTimeMS, 10)) + query.Set("page", "1") + query.Set("rows", strconv.Itoa(payHistoryLimit)) + body, err := c.signedGET(ctx, account, "/sapi/v1/c2c/orderMatch/listUserOrderHistory", query, serverTimeMS) + if err != nil { + return nil, err + } + var response c2cHistoryResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, err + } + if !strings.EqualFold(strings.TrimSpace(response.Code), "000000") || !response.Success { + return nil, xerr.New(xerr.Unavailable, "binance c2c history returned non-success") + } + records := make([]c2cRecord, 0, len(response.Data)) + for _, raw := range response.Data { + var record c2cRecord + if err := json.Unmarshal(raw, &record); err != nil { + return nil, err + } + record.OrderNumber = strings.TrimSpace(record.OrderNumber) + record.TradeType = strings.ToUpper(strings.TrimSpace(record.TradeType)) + record.Asset = strings.ToUpper(strings.TrimSpace(record.Asset)) + record.Amount = strings.TrimSpace(record.Amount) + record.OrderStatus = strings.ToUpper(strings.TrimSpace(record.OrderStatus)) + record.RawJSON = safeC2CRecordJSON(record) + records = append(records, record) + } + return records, nil +} + +func matchC2CRecord(req ports.BinanceTransferLookupRequest, records []c2cRecord) (ports.BinanceTransferRecord, error) { + for _, record := range records { + if !strings.EqualFold(record.OrderNumber, req.ExternalID) { + continue + } + if record.TradeType != c2cTradeTypeBuy { + return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance c2c trade is not income") + } + if !strings.EqualFold(record.Asset, req.Coin) { + return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance c2c asset mismatch") + } + if record.OrderStatus != c2cOrderStatusComplete { + return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance c2c order is not completed") + } + amountMinor, err := parseUSDTAmountMinor(record.Amount) + if err != nil { + return ports.BinanceTransferRecord{}, err + } + if amountMinor != req.AmountMinor { + return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance c2c amount mismatch") + } + return ports.BinanceTransferRecord{Source: "c2c_history", ID: record.OrderNumber, TxID: record.OrderNumber, Coin: record.Asset, Status: 1, Amount: record.Amount, AmountMinor: amountMinor, InsertTimeMS: record.CreateTimeMS, CompleteTimeMS: record.CreateTimeMS, RawJSON: record.RawJSON}, nil + } + return ports.BinanceTransferRecord{}, xerr.New(xerr.NotFound, "binance c2c transfer not found") +} + func (c *Client) accountForApp(appCode string) (account, bool) { value, ok := c.accounts[strings.ToLower(strings.TrimSpace(appCode))] if !ok || strings.TrimSpace(value.apiKey) == "" || strings.TrimSpace(value.apiSecretKey) == "" { @@ -459,6 +532,37 @@ type payHistoryResponse struct { Data []json.RawMessage `json:"data"` } +type c2cHistoryResponse struct { + Code string `json:"code"` + Success bool `json:"success"` + Data []json.RawMessage `json:"data"` +} + +type c2cRecord struct { + OrderNumber string `json:"orderNumber"` + TradeType string `json:"tradeType"` + Asset string `json:"asset"` + Amount string `json:"amount"` + OrderStatus string `json:"orderStatus"` + CreateTimeMS int64 `json:"createTime"` + RawJSON string `json:"-"` +} + +func safeC2CRecordJSON(record c2cRecord) string { + body, err := json.Marshal(struct { + OrderNumber string `json:"orderNumber"` + TradeType string `json:"tradeType"` + Asset string `json:"asset"` + Amount string `json:"amount"` + OrderStatus string `json:"orderStatus"` + CreateTimeMS int64 `json:"createTime"` + }{record.OrderNumber, record.TradeType, record.Asset, record.Amount, record.OrderStatus, record.CreateTimeMS}) + if err != nil { + return "" + } + return string(body) +} + type payRecord struct { OrderType string OrderID string diff --git a/services/wallet-service/internal/client/binance/client_test.go b/services/wallet-service/internal/client/binance/client_test.go index 32efeeda..868ffd34 100644 --- a/services/wallet-service/internal/client/binance/client_test.go +++ b/services/wallet-service/internal/client/binance/client_test.go @@ -209,6 +209,40 @@ func TestFindUSDTTransferFallsBackToIncomingPayC2C(t *testing.T) { } } +func TestFindUSDTTransferC2CUsesFiatTradeHistoryOnly(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": + sawC2C = true + values := r.URL.Query() + signature := values.Get("signature") + values.Del("signature") + 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" { + 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}]}`)) + default: + t.Fatalf("C2C lookup must not call another history endpoint: %s", r.URL.Path) + } + })) + defer server.Close() + client := New(config.BinanceConfig{Enabled: true, APIBaseURL: server.URL, RecvWindow: 10000, HTTPTimeout: time.Second, Lalu: config.BinanceAccountConfig{APIKey: "api-lalu", APISecretKey: "secret-lalu"}}) + record, err := client.FindUSDTTransfer(context.Background(), ports.BinanceTransferLookupRequest{AppCode: "lalu", ExternalID: orderID, Coin: "USDT", Network: "C2C", AmountMinor: 20000, OrderTimeMS: 1784017560000}) + if err != nil { + t.Fatalf("C2C lookup failed: %v", err) + } + if !sawC2C || record.Source != "c2c_history" || record.ID != orderID || record.AmountMinor != 20000 { + t.Fatalf("C2C record mismatch: %+v", record) + } +} + func TestFindUSDTTransferDoesNotBypassDepositConflictWithPay(t *testing.T) { var payCalls int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {