feat: 接入 CoinGlass 清算热力图隔离链路
Some checks failed
ci / build + vet + guard + race (push) Has been cancelled
Some checks failed
ci / build + vet + guard + race (push) Has been cancelled
This commit is contained in:
321
internal/repo/webapi/coinglass/client.go
Normal file
321
internal/repo/webapi/coinglass/client.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user