2026-05-12 14:08:46 +08:00

283 lines
8.7 KiB
Go

package binancerecharge
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const (
defaultBinanceBaseURL = "https://api.binance.com"
binanceUSDT = "USDT"
)
type HTTPBinanceVerifier struct {
baseURL string
httpClient *http.Client
}
func NewHTTPBinanceVerifier(baseURL string, timeout time.Duration) *HTTPBinanceVerifier {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
baseURL = defaultBinanceBaseURL
}
if timeout <= 0 {
timeout = 10 * time.Second
}
return &HTTPBinanceVerifier{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: timeout,
},
}
}
func (v *HTTPBinanceVerifier) Verify(ctx context.Context, credential BinanceCredential, query BinanceVerifyQuery) (VerifiedTransaction, error) {
if v == nil {
return VerifiedTransaction{}, NewAppError(http.StatusServiceUnavailable, "binance_verifier_unavailable", "binance verifier is unavailable")
}
switch query.TransferType {
case TransferTypeBinancePay:
return v.verifyPayTransaction(ctx, credential, query)
case TransferTypeChain:
return v.verifyChainDeposit(ctx, credential, query)
default:
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "invalid_transfer_type", "transferType must be BINANCE_PAY or CHAIN")
}
}
func (v *HTTPBinanceVerifier) verifyPayTransaction(ctx context.Context, credential BinanceCredential, query BinanceVerifyQuery) (VerifiedTransaction, error) {
now := time.Now()
values := url.Values{}
values.Set("startTime", fmt.Sprintf("%d", now.AddDate(0, 0, -90).UnixMilli()))
values.Set("endTime", fmt.Sprintf("%d", now.UnixMilli()))
values.Set("limit", "100")
values.Set("recvWindow", "5000")
raw, err := v.signedGet(ctx, credential, "/sapi/v1/pay/transactions", values)
if err != nil {
return VerifiedTransaction{}, err
}
var resp struct {
Code string `json:"code"`
Message string `json:"message"`
Data []json.RawMessage `json:"data"`
Success *bool `json:"success"`
}
if err := json.Unmarshal(raw, &resp); err != nil {
return VerifiedTransaction{}, NewAppError(http.StatusBadGateway, "binance_response_invalid", err.Error())
}
if resp.Success != nil && !*resp.Success {
message := strings.TrimSpace(resp.Message)
if message == "" {
message = "binance pay query failed"
}
return VerifiedTransaction{}, NewAppError(http.StatusBadGateway, "binance_query_failed", message)
}
foundBill := false
for _, item := range resp.Data {
var record map[string]any
if err := json.Unmarshal(item, &record); err != nil {
continue
}
if !binancePayRecordMatchesBill(record, query.BillNo) {
continue
}
foundBill = true
if !binancePayRecordMatchesAmount(record, query.Amount) {
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_amount_mismatch", "binance transaction amount does not match")
}
return VerifiedTransaction{
TransactionID: firstString(record, "transactionId", "orderId", "merchantTradeNo"),
OrderType: firstString(record, "orderType"),
Amount: query.Amount,
RawJSON: string(item),
}, nil
}
if foundBill {
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_amount_mismatch", "binance transaction amount does not match")
}
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_transaction_not_found", "binance transaction was not found")
}
func (v *HTTPBinanceVerifier) verifyChainDeposit(ctx context.Context, credential BinanceCredential, query BinanceVerifyQuery) (VerifiedTransaction, error) {
now := time.Now()
values := url.Values{}
values.Set("coin", binanceUSDT)
values.Set("status", "1")
values.Set("txId", query.BillNo)
values.Set("startTime", fmt.Sprintf("%d", now.AddDate(0, 0, -90).UnixMilli()))
values.Set("endTime", fmt.Sprintf("%d", now.UnixMilli()))
values.Set("limit", "1000")
values.Set("recvWindow", "5000")
raw, err := v.signedGet(ctx, credential, "/sapi/v1/capital/deposit/hisrec", values)
if err != nil {
return VerifiedTransaction{}, err
}
var rows []json.RawMessage
if err := json.Unmarshal(raw, &rows); err != nil {
return VerifiedTransaction{}, NewAppError(http.StatusBadGateway, "binance_response_invalid", err.Error())
}
foundBill := false
for _, item := range rows {
var record map[string]any
if err := json.Unmarshal(item, &record); err != nil {
continue
}
if !chainDepositRecordMatchesBill(record, query.BillNo) {
continue
}
foundBill = true
if !strings.EqualFold(firstString(record, "coin"), binanceUSDT) {
continue
}
if !chainDepositStatusSuccess(record) {
continue
}
if !decimalEqual(firstString(record, "amount"), query.Amount) {
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_amount_mismatch", "binance deposit amount does not match")
}
return VerifiedTransaction{
TransactionID: firstString(record, "txId", "id"),
OrderType: "CHAIN_DEPOSIT",
Amount: query.Amount,
RawJSON: string(item),
}, nil
}
if foundBill {
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_amount_mismatch", "binance deposit amount does not match")
}
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_transaction_not_found", "binance deposit was not found")
}
func (v *HTTPBinanceVerifier) signedGet(ctx context.Context, credential BinanceCredential, path string, values url.Values) ([]byte, error) {
apiKey := strings.TrimSpace(credential.APIKey)
apiSecret := strings.TrimSpace(credential.APISecret)
if apiKey == "" || apiSecret == "" {
return nil, NewAppError(http.StatusServiceUnavailable, "binance_credentials_missing", "binance api key and secret are required")
}
values.Set("timestamp", fmt.Sprintf("%d", time.Now().UnixMilli()))
query := values.Encode()
signature := signBinanceQuery(apiSecret, query)
endpoint := v.baseURL + path + "?" + query + "&signature=" + url.QueryEscape(signature)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, NewAppError(http.StatusBadGateway, "binance_request_failed", err.Error())
}
req.Header.Set("X-MBX-APIKEY", apiKey)
resp, err := v.httpClient.Do(req)
if err != nil {
return nil, NewAppError(http.StatusBadGateway, "binance_request_failed", err.Error())
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, NewAppError(http.StatusBadGateway, "binance_response_read_failed", err.Error())
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, NewAppError(http.StatusBadGateway, "binance_query_failed", strings.TrimSpace(string(body)))
}
return body, nil
}
func signBinanceQuery(secret string, query string) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(query))
return hex.EncodeToString(mac.Sum(nil))
}
func binancePayRecordMatchesBill(record map[string]any, billNo string) bool {
billNo = strings.TrimSpace(billNo)
for _, key := range []string{"transactionId", "orderId", "merchantTradeNo", "prepayId", "id"} {
if strings.EqualFold(firstString(record, key), billNo) {
return true
}
}
return false
}
func binancePayRecordMatchesAmount(record map[string]any, expected string) bool {
if strings.EqualFold(firstString(record, "currency"), binanceUSDT) && decimalEqual(firstString(record, "amount"), expected) {
return true
}
details, ok := record["fundsDetail"].([]any)
if !ok {
return false
}
for _, item := range details {
detail, ok := item.(map[string]any)
if !ok {
continue
}
if strings.EqualFold(firstString(detail, "currency"), binanceUSDT) && decimalEqual(firstString(detail, "amount"), expected) {
return true
}
}
return false
}
func chainDepositRecordMatchesBill(record map[string]any, billNo string) bool {
billNo = strings.TrimSpace(billNo)
for _, key := range []string{"txId", "id"} {
if strings.EqualFold(firstString(record, key), billNo) {
return true
}
}
return false
}
func chainDepositStatusSuccess(record map[string]any) bool {
switch value := record["status"].(type) {
case float64:
return int(value) == 1
case int:
return value == 1
case string:
return strings.TrimSpace(value) == "1"
default:
return false
}
}
func firstString(record map[string]any, keys ...string) string {
for _, key := range keys {
if value := stringValue(record[key]); strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func stringValue(value any) string {
switch typed := value.(type) {
case string:
return typed
case json.Number:
return typed.String()
case float64:
return trimDecimalZeros(fmt.Sprintf("%.8f", typed))
case int:
return fmt.Sprintf("%d", typed)
case int64:
return fmt.Sprintf("%d", typed)
default:
return ""
}
}