2026-07-14 16:33:15 +08:00

562 lines
19 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
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
}