package binance import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "net/http" "net/url" "strconv" "strings" "time" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/config" "hyapp/services/wallet-service/internal/service/wallet/ports" ) const ( defaultDepositLookback = 90 * 24 * time.Hour depositHistoryLimit = 1000 defaultPayLookback = 90 * 24 * time.Hour payOrderTimeRadius = time.Hour payHistoryLimit = 100 binancePaySuccessCode = "000000" ) // Client 是 Binance 只读查单适配器;它只使用 signed read API,不保存、不生成任何链上私钥。 type Client struct { httpClient *http.Client apiBaseURL string recvWindow int64 accounts map[string]account } type account struct { apiKey string apiSecretKey string } // New 从 wallet-service 配置创建 Binance client;具体 App 是否有账号在请求时按 appCode 决定。 func New(cfg config.BinanceConfig) *Client { timeout := cfg.HTTPTimeout if timeout <= 0 { timeout = 10 * time.Second } return &Client{ httpClient: &http.Client{Timeout: timeout}, apiBaseURL: strings.TrimRight(strings.TrimSpace(firstNonEmpty(cfg.APIBaseURL, "https://api.binance.com")), "/"), recvWindow: firstPositiveInt64(cfg.RecvWindow, 10000), accounts: map[string]account{ "lalu": newAccount(cfg.Lalu), "aslan": newAccount(cfg.Aslan), "yumi": newAccount(cfg.Yumi), }, } } func newAccount(cfg config.BinanceAccountConfig) account { return account{ apiKey: strings.TrimSpace(cfg.APIKey), apiSecretKey: strings.TrimSpace(cfg.APISecretKey), } } // FindUSDTTransfer 先查链上 Deposit History,再在未命中时查 Funding Account 的 Pay C2C History。 // 两个事实源的成功语义不同:链上入金必须校验网络、地址和状态;Pay C2C 必须校验正金额,证明是当前 API Key 账户收款而不是付款。 func (c *Client) FindUSDTTransfer(ctx context.Context, req ports.BinanceTransferLookupRequest) (ports.BinanceTransferRecord, error) { if c == nil || c.apiBaseURL == "" { return ports.BinanceTransferRecord{}, xerr.New(xerr.Unavailable, "binance client is not configured") } account, ok := c.accountForApp(req.AppCode) if !ok { return ports.BinanceTransferRecord{}, xerr.New(xerr.Unavailable, "binance account is not configured") } req.ExternalID = strings.TrimSpace(req.ExternalID) 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.AmountMinor <= 0 { return ports.BinanceTransferRecord{}, xerr.New(xerr.InvalidArgument, "binance transfer verification request is incomplete") } serverTimeMS, err := c.serverTime(ctx) if err != nil { return ports.BinanceTransferRecord{}, err } // Binance 的 deposit history 查询窗口有限;后台凭证校验只做近期有界查询,避免一次手工校验变成无界扫描。 records, err := c.depositHistory(ctx, account, req.Coin, serverTimeMS-int64(defaultDepositLookback/time.Millisecond), serverTimeMS) if err != nil { return ports.BinanceTransferRecord{}, err } matched, err := matchDepositRecord(req, records) if err == nil { return matched, nil } if !xerr.IsCode(err, xerr.NotFound) { // 同一编号已经命中链上事实但字段冲突时禁止切换事实源,避免用 Pay 记录绕过地址、网络或状态校验。 return ports.BinanceTransferRecord{}, err } startTimeMS, endTimeMS := payHistoryRange(req.OrderTimeMS, serverTimeMS) payRecords, err := c.payHistory(ctx, account, startTimeMS, endTimeMS, serverTimeMS) if err != nil { return ports.BinanceTransferRecord{}, err } return matchPayRecord(req, payRecords) } 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) == "" { return account{}, false } return value, true } func (c *Client) serverTime(ctx context.Context) (int64, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.apiBaseURL+"/api/v3/time", nil) if err != nil { return 0, err } body, err := c.do(req) if err != nil { return 0, err } var parsed struct { ServerTime int64 `json:"serverTime"` } if err := json.Unmarshal(body, &parsed); err != nil { return 0, err } if parsed.ServerTime <= 0 { return 0, xerr.New(xerr.Unavailable, "binance server time is invalid") } return parsed.ServerTime, nil } func (c *Client) depositHistory(ctx context.Context, account account, coin string, startTimeMS int64, endTimeMS int64) ([]depositRecord, error) { query := url.Values{} query.Set("coin", strings.ToUpper(strings.TrimSpace(coin))) query.Set("startTime", strconv.FormatInt(startTimeMS, 10)) query.Set("endTime", strconv.FormatInt(endTimeMS, 10)) query.Set("limit", strconv.Itoa(depositHistoryLimit)) body, err := c.signedGET(ctx, account, "/sapi/v1/capital/deposit/hisrec", query, endTimeMS) if err != nil { return nil, err } var raws []json.RawMessage if err := json.Unmarshal(body, &raws); err != nil { return nil, err } records := make([]depositRecord, 0, len(raws)) for _, raw := range raws { var wire depositRecordWire if err := json.Unmarshal(raw, &wire); err != nil { return nil, err } record := wire.toRecord() record.RawJSON = string(raw) records = append(records, record) } return records, nil } // payHistory 查询 Binance Pay 用户账单。接口单次最多返回 100 条,调用方提供订单时间时使用窄窗口降低高频账户漏单概率。 func (c *Client) payHistory(ctx context.Context, account account, startTimeMS int64, endTimeMS int64, serverTimeMS int64) ([]payRecord, error) { query := url.Values{} query.Set("startTime", strconv.FormatInt(startTimeMS, 10)) query.Set("endTime", strconv.FormatInt(endTimeMS, 10)) query.Set("limit", strconv.Itoa(payHistoryLimit)) // 查询区间可以是历史日期,但 signed API 的 timestamp 必须始终使用当前 Binance server time,否则旧订单会被 -1021 拒绝。 body, err := c.signedGET(ctx, account, "/sapi/v1/pay/transactions", query, serverTimeMS) if err != nil { return nil, err } var response payHistoryResponse if err := json.Unmarshal(body, &response); err != nil { return nil, err } if strings.TrimSpace(response.Code) != binancePaySuccessCode || !response.Success { return nil, xerr.New(xerr.Unavailable, "binance pay history returned non-success") } records := make([]payRecord, 0, len(response.Data)) for _, raw := range response.Data { var wire payRecordWire if err := json.Unmarshal(raw, &wire); err != nil { return nil, err } record := wire.toRecord() // Pay 回包含付款人与收款人的账户资料;持久审计只保留完成资金校验所需字段,避免把无关 PII 写入充值凭证。 record.RawJSON = safePayRecordJSON(record) records = append(records, record) } return records, nil } func payHistoryRange(orderTimeMS int64, serverTimeMS int64) (int64, int64) { lookbackMS := int64(defaultPayLookback / time.Millisecond) if orderTimeMS <= 0 { return maxInt64(0, serverTimeMS-lookbackMS), serverTimeMS } radiusMS := int64(payOrderTimeRadius / time.Millisecond) startTimeMS := maxInt64(0, orderTimeMS-radiusMS) endTimeMS := minInt64(serverTimeMS, orderTimeMS+radiusMS) if endTimeMS <= startTimeMS { // 未来订单时间不应发生;这里返回最小合法区间,让 Binance 给出空结果而不是发出反向时间范围。 endTimeMS = startTimeMS + 1 } return startTimeMS, endTimeMS } func (c *Client) signedGET(ctx context.Context, account account, path string, values url.Values, timestampMS int64) ([]byte, error) { values.Set("recvWindow", strconv.FormatInt(c.recvWindow, 10)) values.Set("timestamp", strconv.FormatInt(timestampMS, 10)) queryString := values.Encode() signature := signQueryString(queryString, account.apiSecretKey) endpoint := c.apiBaseURL + path + "?" + queryString + "&signature=" + signature req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } req.Header.Set("X-MBX-APIKEY", account.apiKey) return c.do(req) } func (c *Client) do(req *http.Request) ([]byte, error) { resp, err := c.httpClient.Do(req) if err != nil { return nil, xerr.New(xerr.Unavailable, "binance request failed") } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) if err != nil { return nil, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, xerr.New(xerr.Unavailable, "binance returned non-success") } return body, nil } func matchDepositRecord(req ports.BinanceTransferLookupRequest, records []depositRecord) (ports.BinanceTransferRecord, error) { var candidate *depositRecord for index := range records { if depositTxIDMatches(records[index].TxID, req.ExternalID) { candidate = &records[index] break } } if candidate == nil { return ports.BinanceTransferRecord{}, xerr.New(xerr.NotFound, "binance deposit not found") } if !strings.EqualFold(candidate.Coin, req.Coin) { return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance deposit coin mismatch") } if !strings.EqualFold(candidate.Network, req.Network) { return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance deposit network mismatch") } if candidate.Status != 1 { return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance deposit status is not successful") } // 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) if err != nil { return ports.BinanceTransferRecord{}, err } if amountMinor != req.AmountMinor { return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance deposit amount mismatch") } return ports.BinanceTransferRecord{ Source: "deposit_history", ID: candidate.ID, TxID: candidate.TxID, Coin: candidate.Coin, Network: candidate.Network, Status: candidate.Status, Address: candidate.Address, Amount: candidate.Amount, AmountMinor: amountMinor, TransferType: candidate.TransferType, InsertTimeMS: candidate.InsertTimeMS, CompleteTimeMS: candidate.CompleteTimeMS, ConfirmTimes: candidate.ConfirmTimes, UnlockConfirm: candidate.UnlockConfirm, WalletType: candidate.WalletType, RawJSON: strings.TrimSpace(candidate.RawJSON), }, nil } func matchPayRecord(req ports.BinanceTransferLookupRequest, records []payRecord) (ports.BinanceTransferRecord, error) { var candidate *payRecord for index := range records { if payExternalIDMatches(records[index], req.ExternalID) { candidate = &records[index] break } } if candidate == nil { return ports.BinanceTransferRecord{}, xerr.New(xerr.NotFound, "binance pay transfer not found") } if !strings.EqualFold(candidate.OrderType, "C2C") { return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance pay order type is not c2c") } if !strings.EqualFold(candidate.Currency, req.Coin) { return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance pay currency mismatch") } // Binance Pay 官方语义中正数是当前 API Key 账户收入、负数是支出;只允许正数,防止付款截图被当成平台收款。 if strings.HasPrefix(strings.TrimSpace(candidate.Amount), "-") { return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance pay transfer is not income") } amountMinor, err := parseUSDTAmountMinor(candidate.Amount) if err != nil { return ports.BinanceTransferRecord{}, err } if amountMinor <= 0 { return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance pay transfer is not income") } if amountMinor != req.AmountMinor { return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance pay amount mismatch") } return ports.BinanceTransferRecord{ Source: "pay_history", ID: candidate.OrderID, TxID: firstNonEmpty(candidate.TransactionID, candidate.OrderID), Coin: candidate.Currency, Status: 1, Amount: candidate.Amount, AmountMinor: amountMinor, InsertTimeMS: candidate.TransactionTimeMS, CompleteTimeMS: candidate.TransactionTimeMS, WalletType: candidate.WalletType, RawJSON: strings.TrimSpace(candidate.RawJSON), }, nil } func payExternalIDMatches(record payRecord, externalID string) bool { externalID = strings.TrimSpace(externalID) if externalID == "" { return false } return strings.EqualFold(strings.TrimSpace(record.OrderID), externalID) || strings.EqualFold(strings.TrimSpace(record.TransactionID), externalID) } func depositTxIDMatches(txID string, externalID string) bool { normalizedExternalID := normalizeOffchainExternalID(externalID) if normalizedExternalID == "" { return false } normalizedTxID := strings.ToLower(strings.TrimSpace(txID)) return strings.Contains(normalizedTxID, normalizedExternalID) || normalizeOffchainExternalID(txID) == normalizedExternalID } func normalizeOffchainExternalID(value string) string { value = strings.ToLower(strings.TrimSpace(value)) value = strings.TrimPrefix(value, "off-chain transfer") value = strings.TrimSpace(strings.Trim(value, ":#")) return value } func parseUSDTAmountMinor(value string) (int64, error) { value = strings.TrimSpace(value) if value == "" || strings.HasPrefix(value, "-") { return 0, xerr.New(xerr.InvalidArgument, "binance deposit amount is invalid") } parts := strings.Split(value, ".") if len(parts) > 2 { return 0, xerr.New(xerr.InvalidArgument, "binance deposit amount is invalid") } whole, err := strconv.ParseInt(firstNonEmpty(parts[0], "0"), 10, 64) if err != nil { return 0, xerr.New(xerr.InvalidArgument, "binance deposit amount is invalid") } frac := "" if len(parts) == 2 { frac = parts[1] } if len(frac) > 2 && strings.Trim(frac[2:], "0") != "" { return 0, xerr.New(xerr.Conflict, "binance deposit amount has sub-cent precision") } if len(frac) > 2 { frac = frac[:2] } for len(frac) < 2 { frac += "0" } minorFrac, err := strconv.ParseInt(firstNonEmpty(frac, "0"), 10, 64) if err != nil { return 0, xerr.New(xerr.InvalidArgument, "binance deposit amount is invalid") } return whole*100 + minorFrac, nil } func signQueryString(queryString string, secret string) string { mac := hmac.New(sha256.New, []byte(secret)) _, _ = mac.Write([]byte(queryString)) return hex.EncodeToString(mac.Sum(nil)) } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" } func firstPositiveInt64(values ...int64) int64 { for _, value := range values { if value > 0 { return value } } return 0 } type depositRecord struct { ID string Amount string Coin string Network string Status int64 Address string TxID string TransferType int64 InsertTimeMS int64 CompleteTimeMS int64 ConfirmTimes string UnlockConfirm int64 WalletType int64 RawJSON string } type depositRecordWire struct { ID json.RawMessage `json:"id"` Amount string `json:"amount"` Coin string `json:"coin"` Network string `json:"network"` Status int64 `json:"status"` Address string `json:"address"` TxID string `json:"txId"` TransferType int64 `json:"transferType"` InsertTimeMS int64 `json:"insertTime"` CompleteTimeMS int64 `json:"completeTime"` ConfirmTimes string `json:"confirmTimes"` UnlockConfirm int64 `json:"unlockConfirm"` WalletType int64 `json:"walletType"` } type payHistoryResponse struct { Code string `json:"code"` Message string `json:"message"` Success bool `json:"success"` Data []json.RawMessage `json:"data"` } type payRecord struct { OrderType string OrderID string TransactionID string TransactionTimeMS int64 Amount string Currency string WalletType int64 RawJSON string } type payRecordWire struct { OrderType string `json:"orderType"` OrderID json.RawMessage `json:"orderId"` TransactionID string `json:"transactionId"` TransactionTimeMS int64 `json:"transactionTime"` Amount string `json:"amount"` Currency string `json:"currency"` WalletType int64 `json:"walletType"` } func (wire payRecordWire) toRecord() payRecord { return payRecord{ OrderType: strings.ToUpper(strings.TrimSpace(wire.OrderType)), OrderID: rawScalarString(wire.OrderID), TransactionID: strings.TrimSpace(wire.TransactionID), TransactionTimeMS: wire.TransactionTimeMS, Amount: strings.TrimSpace(wire.Amount), Currency: strings.ToUpper(strings.TrimSpace(wire.Currency)), WalletType: wire.WalletType, } } func safePayRecordJSON(record payRecord) string { payload := struct { OrderType string `json:"orderType"` OrderID string `json:"orderId,omitempty"` TransactionID string `json:"transactionId"` TransactionTimeMS int64 `json:"transactionTime"` Amount string `json:"amount"` Currency string `json:"currency"` WalletType int64 `json:"walletType"` }{ OrderType: record.OrderType, OrderID: record.OrderID, TransactionID: record.TransactionID, TransactionTimeMS: record.TransactionTimeMS, Amount: record.Amount, Currency: record.Currency, WalletType: record.WalletType, } body, err := json.Marshal(payload) if err != nil { return "" } return string(body) } func (wire depositRecordWire) toRecord() depositRecord { return depositRecord{ ID: rawScalarString(wire.ID), Amount: strings.TrimSpace(wire.Amount), Coin: strings.ToUpper(strings.TrimSpace(wire.Coin)), Network: strings.ToUpper(strings.TrimSpace(wire.Network)), Status: wire.Status, Address: strings.TrimSpace(wire.Address), TxID: strings.TrimSpace(wire.TxID), TransferType: wire.TransferType, InsertTimeMS: wire.InsertTimeMS, CompleteTimeMS: wire.CompleteTimeMS, ConfirmTimes: strings.TrimSpace(wire.ConfirmTimes), UnlockConfirm: wire.UnlockConfirm, WalletType: wire.WalletType, } } func rawScalarString(raw json.RawMessage) string { if len(raw) == 0 || string(raw) == "null" { return "" } var text string if err := json.Unmarshal(raw, &text); err == nil { return strings.TrimSpace(text) } return strings.TrimSpace(string(raw)) } func minInt64(left int64, right int64) int64 { if left < right { return left } return right } func maxInt64(left int64, right int64) int64 { if left > right { return left } return right }