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 ) // 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), } } // FindUSDTDeposit 查询 Binance deposit history,并按后台订单输入校验币种、网络、状态、地址和金额。 func (c *Client) FindUSDTDeposit(ctx context.Context, req ports.BinanceDepositLookupRequest) (ports.BinanceDepositRecord, error) { if c == nil || c.apiBaseURL == "" { return ports.BinanceDepositRecord{}, xerr.New(xerr.Unavailable, "binance client is not configured") } account, ok := c.accountForApp(req.AppCode) if !ok { return ports.BinanceDepositRecord{}, 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.ToAddress == "" || req.AmountMinor <= 0 { return ports.BinanceDepositRecord{}, xerr.New(xerr.InvalidArgument, "binance deposit verification request is incomplete") } serverTimeMS, err := c.serverTime(ctx) if err != nil { return ports.BinanceDepositRecord{}, err } // Binance 的 deposit history 查询窗口有限;后台凭证校验只做近期有界查询,避免一次手工校验变成无界扫描。 records, err := c.depositHistory(ctx, account, req.Coin, serverTimeMS-int64(defaultDepositLookback/time.Millisecond), serverTimeMS) if err != nil { return ports.BinanceDepositRecord{}, err } return matchDepositRecord(req, records) } 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 } 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.BinanceDepositLookupRequest, records []depositRecord) (ports.BinanceDepositRecord, error) { var candidate *depositRecord for index := range records { if depositTxIDMatches(records[index].TxID, req.ExternalID) { candidate = &records[index] break } } if candidate == nil { return ports.BinanceDepositRecord{}, xerr.New(xerr.NotFound, "binance deposit not found") } if !strings.EqualFold(candidate.Coin, req.Coin) { return ports.BinanceDepositRecord{}, xerr.New(xerr.Conflict, "binance deposit coin mismatch") } if !strings.EqualFold(candidate.Network, req.Network) { return ports.BinanceDepositRecord{}, xerr.New(xerr.Conflict, "binance deposit network mismatch") } if candidate.Status != 1 { return ports.BinanceDepositRecord{}, xerr.New(xerr.Conflict, "binance deposit status is not successful") } if !strings.EqualFold(strings.TrimSpace(candidate.Address), strings.TrimSpace(req.ToAddress)) { return ports.BinanceDepositRecord{}, xerr.New(xerr.Conflict, "binance deposit address mismatch") } amountMinor, err := parseUSDTAmountMinor(candidate.Amount) if err != nil { return ports.BinanceDepositRecord{}, err } if amountMinor != req.AmountMinor { return ports.BinanceDepositRecord{}, xerr.New(xerr.Conflict, "binance deposit amount mismatch") } return ports.BinanceDepositRecord{ 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 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"` } 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)) }