修正币安C2C订单查询接口

This commit is contained in:
zhx 2026-07-14 17:30:11 +08:00
parent 1fbbe2758f
commit d66799b312
2 changed files with 138 additions and 0 deletions

View File

@ -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 表示账户收到 assetSELL 则是付出 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

View File

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