feat: 接入 CoinGlass 清算热力图隔离链路
Some checks failed
ci / build + vet + guard + race (push) Has been cancelled

This commit is contained in:
dela
2026-05-25 16:57:22 +08:00
parent e5fb54cb72
commit 1b61541ff5
32 changed files with 2550 additions and 70 deletions

View File

@@ -0,0 +1,321 @@
package coinglass
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/enetx/g"
"github.com/enetx/surf"
"github.com/pquerna/otp/totp"
)
const (
defaultBaseURL = "https://capi.coinglass.com"
defaultOrigin = "https://www.coinglass.com"
defaultDataAESKey = "1f68efd73f8d4921acc0dead41dd39bc"
defaultRequestTimeout = 15 * time.Second
defaultFakeSuccessRetry = 1
defaultFakeSuccessRetryWait = 2 * time.Second
)
type Client struct {
baseURL string
totpSecret string
dataAESKey []byte
fakeSuccessRetry int
fakeSuccessRetryWait time.Duration
doer requestDoer
now func() time.Time
}
type ClientOptions struct {
BaseURL string
TOTPSecret string
DataAESKey string
Timeout time.Duration
FakeSuccessRetry int
FakeSuccessRetryWait time.Duration
}
type requestDoer interface {
Do(context.Context, httpRequest) (*httpResponse, error)
}
type httpRequest struct {
URL string
Header http.Header
}
type httpResponse struct {
StatusCode int
Header http.Header
Body []byte
}
type transportEnvelope struct {
Success *bool `json:"success"`
Code json.RawMessage `json:"code"`
Msg string `json:"msg"`
Data *string `json:"data"`
}
func NewClient(opts ClientOptions) (*Client, error) {
return newClientWithDoer(opts, newSurfDoer(opts.Timeout))
}
func newClientWithDoer(opts ClientOptions, doer requestDoer) (*Client, error) {
if doer == nil {
return nil, fmt.Errorf("coinglass client: nil doer")
}
if opts.BaseURL == "" {
opts.BaseURL = defaultBaseURL
}
if opts.TOTPSecret == "" {
return nil, fmt.Errorf("coinglass client: totp secret is required")
}
if opts.DataAESKey == "" {
opts.DataAESKey = defaultDataAESKey
}
if opts.FakeSuccessRetry == 0 {
opts.FakeSuccessRetry = defaultFakeSuccessRetry
}
if opts.FakeSuccessRetryWait == 0 {
opts.FakeSuccessRetryWait = defaultFakeSuccessRetryWait
}
if _, err := aesECBEncrypt([]byte(opts.DataAESKey), pkcs7Pad([]byte("probe"), 16)); err != nil {
return nil, fmt.Errorf("coinglass client: bad data aes key: %w", err)
}
return &Client{
baseURL: strings.TrimRight(opts.BaseURL, "/"),
totpSecret: opts.TOTPSecret,
dataAESKey: []byte(opts.DataAESKey),
fakeSuccessRetry: opts.FakeSuccessRetry,
fakeSuccessRetryWait: opts.FakeSuccessRetryWait,
doer: doer,
now: time.Now,
}, nil
}
// GetPlaintext dispatches one CoinGlass V2 GET request and returns decrypted plaintext bytes.
func (c *Client) GetPlaintext(ctx context.Context, path string, query url.Values) ([]byte, error) {
var lastFake error
for attempt := 0; attempt <= c.fakeSuccessRetry; attempt++ {
plain, err := c.getPlaintextOnce(ctx, path, query)
if err == nil {
return plain, nil
}
if !errors.Is(err, ErrFakeSuccess) {
return nil, err
}
lastFake = err
if attempt == c.fakeSuccessRetry {
break
}
if err := sleepContext(ctx, c.fakeSuccessRetryWait); err != nil {
return nil, err
}
}
return nil, lastFake
}
func (c *Client) getPlaintextOnce(ctx context.Context, path string, query url.Values) ([]byte, error) {
now := c.now()
cacheTsV2 := fmt.Sprintf("%d", now.UnixMilli())
q := cloneValues(query)
dataParam, err := generateDataParam(c.totpSecret, c.dataAESKey, now)
if err != nil {
return nil, err
}
q.Set("data", dataParam)
resp, err := c.doer.Do(ctx, httpRequest{
URL: c.buildURL(path, q),
Header: baseHeaders(cacheTsV2),
})
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("coinglass %s status=%d: %s", path, resp.StatusCode, bodySnippet(resp.Body))
}
env, err := parseTransportEnvelope(resp.Body)
if err != nil {
return nil, fmt.Errorf("coinglass %s envelope: %w", path, err)
}
if env.Success != nil && !*env.Success {
return nil, fmt.Errorf("%w: code=%s msg=%q", ErrAPIBusiness, envelopeCode(env.Code), env.Msg)
}
if env.Data == nil || *env.Data == "" {
return nil, fmt.Errorf("%w: empty data code=%s msg=%q", ErrFakeSuccess, envelopeCode(env.Code), env.Msg)
}
v := firstHeader(resp.Header, "v")
user := firstHeader(resp.Header, "user")
respTime := firstHeader(resp.Header, "time")
if v == "" || user == "" {
return nil, fmt.Errorf("%w: missing v/user response headers", ErrFakeSuccess)
}
seed, err := resolveSeed(v, path, cacheTsV2, respTime)
if err != nil {
return nil, err
}
plain, err := DecryptUniversal(seed, user, *env.Data)
if err != nil {
return nil, err
}
return plain, nil
}
func (c *Client) buildURL(path string, query url.Values) string {
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
if len(query) == 0 {
return c.baseURL + path
}
return c.baseURL + path + "?" + query.Encode()
}
func generateDataParam(secret string, key []byte, now time.Time) (string, error) {
code, err := totp.GenerateCode(secret, now)
if err != nil {
return "", fmt.Errorf("coinglass data param totp: %w", err)
}
plain := []byte(fmt.Sprintf("%d,%s", now.Unix(), code))
enc, err := aesECBEncrypt(key, pkcs7Pad(plain, 16))
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(enc), nil
}
func baseHeaders(cacheTsV2 string) http.Header {
h := http.Header{}
h.Set("Accept", "application/json")
h.Set("Accept-Language", "en-US,en;q=0.9")
h.Set("Language", "zh")
h.Set("Encryption", "true")
h.Set("Origin", defaultOrigin)
h.Set("Referer", defaultOrigin+"/")
h.Set("Cache-Ts-V2", cacheTsV2)
h.Set("Sec-Fetch-Dest", "empty")
h.Set("Sec-Fetch-Mode", "cors")
h.Set("Sec-Fetch-Site", "same-site")
return h
}
func parseTransportEnvelope(body []byte) (*transportEnvelope, error) {
var env transportEnvelope
if err := json.Unmarshal(body, &env); err != nil {
return nil, err
}
return &env, nil
}
func cloneValues(in url.Values) url.Values {
out := make(url.Values, len(in))
for k, vs := range in {
out[k] = append([]string(nil), vs...)
}
return out
}
func sleepContext(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func firstHeader(h http.Header, key string) string {
if v := h.Get(key); v != "" {
return v
}
return h.Get(http.CanonicalHeaderKey(key))
}
func envelopeCode(raw json.RawMessage) string {
raw = json.RawMessage(strings.TrimSpace(string(raw)))
if len(raw) == 0 || string(raw) == "null" {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
return string(raw)
}
func bodySnippet(b []byte) string {
const max = 200
if len(b) <= max {
return string(b)
}
return string(b[:max])
}
type surfDoer struct {
client *surf.Client
}
func newSurfDoer(timeout time.Duration) *surfDoer {
if timeout == 0 {
timeout = defaultRequestTimeout
}
return &surfDoer{
client: surf.NewClient().Builder().
Impersonate().FireFox().
Timeout(timeout).
Build(),
}
}
func (d *surfDoer) Do(ctx context.Context, req httpRequest) (*httpResponse, error) {
result := d.client.Get(g.String(req.URL)).
WithContext(ctx).
SetHeaders(singleHeaderValues(req.Header)).
Do()
if result.IsErr() {
return nil, result.Err()
}
resp := result.Ok()
header := http.Header{}
for _, key := range []string{"v", "user", "time"} {
if value := resp.Headers.Get(g.String(key)).Std(); value != "" {
header.Set(key, value)
}
}
return &httpResponse{
StatusCode: int(resp.StatusCode),
Header: header,
Body: []byte(resp.Body.Bytes()),
}, nil
}
func singleHeaderValues(h http.Header) map[string]string {
out := make(map[string]string, len(h))
for k, vs := range h {
if len(vs) > 0 {
out[k] = vs[0]
}
}
return out
}