2026-07-07 18:05:02 +08:00

361 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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))
}