修正币安C2C订单查询接口
This commit is contained in:
parent
1fbbe2758f
commit
d66799b312
@ -25,6 +25,8 @@ const (
|
|||||||
payOrderTimeRadius = time.Hour
|
payOrderTimeRadius = time.Hour
|
||||||
payHistoryLimit = 100
|
payHistoryLimit = 100
|
||||||
binancePaySuccessCode = "000000"
|
binancePaySuccessCode = "000000"
|
||||||
|
c2cTradeTypeBuy = "BUY"
|
||||||
|
c2cOrderStatusComplete = "COMPLETED"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client 是 Binance 只读查单适配器;它只使用 signed read API,不保存、不生成任何链上私钥。
|
// Client 是 Binance 只读查单适配器;它只使用 signed read API,不保存、不生成任何链上私钥。
|
||||||
@ -86,6 +88,15 @@ func (c *Client) FindUSDTTransfer(ctx context.Context, req ports.BinanceTransfer
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ports.BinanceTransferRecord{}, err
|
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 查询窗口有限;后台凭证校验只做近期有界查询,避免一次手工校验变成无界扫描。
|
// Binance 的 deposit history 查询窗口有限;后台凭证校验只做近期有界查询,避免一次手工校验变成无界扫描。
|
||||||
records, err := c.depositHistory(ctx, account, req.Coin, serverTimeMS-int64(defaultDepositLookback/time.Millisecond), serverTimeMS)
|
records, err := c.depositHistory(ctx, account, req.Coin, serverTimeMS-int64(defaultDepositLookback/time.Millisecond), serverTimeMS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -107,6 +118,68 @@ func (c *Client) FindUSDTTransfer(ctx context.Context, req ports.BinanceTransfer
|
|||||||
return matchPayRecord(req, payRecords)
|
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) {
|
func (c *Client) accountForApp(appCode string) (account, bool) {
|
||||||
value, ok := c.accounts[strings.ToLower(strings.TrimSpace(appCode))]
|
value, ok := c.accounts[strings.ToLower(strings.TrimSpace(appCode))]
|
||||||
if !ok || strings.TrimSpace(value.apiKey) == "" || strings.TrimSpace(value.apiSecretKey) == "" {
|
if !ok || strings.TrimSpace(value.apiKey) == "" || strings.TrimSpace(value.apiSecretKey) == "" {
|
||||||
@ -459,6 +532,37 @@ type payHistoryResponse struct {
|
|||||||
Data []json.RawMessage `json:"data"`
|
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 {
|
type payRecord struct {
|
||||||
OrderType string
|
OrderType string
|
||||||
OrderID string
|
OrderID string
|
||||||
|
|||||||
@ -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) {
|
func TestFindUSDTTransferDoesNotBypassDepositConflictWithPay(t *testing.T) {
|
||||||
var payCalls int
|
var payCalls int
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user