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
|
||||
}
|
||||
163
internal/repo/webapi/coinglass/client_test.go
Normal file
163
internal/repo/webapi/coinglass/client_test.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package coinglass
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type netHTTPDoer struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (d netHTTPDoer) Do(ctx context.Context, req httpRequest) (*httpResponse, error) {
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, req.URL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header = req.Header.Clone()
|
||||
|
||||
resp, err := d.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &httpResponse{
|
||||
StatusCode: resp.StatusCode,
|
||||
Header: resp.Header.Clone(),
|
||||
Body: body,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestClientGetPlaintext_HappyPath(t *testing.T) {
|
||||
const (
|
||||
path = "/api/index/v2/liqHeatMap"
|
||||
token = "dfa89d5d00cf4000"
|
||||
)
|
||||
|
||||
plaintext := `{"instrument":{"exName":"Binance","instrumentId":"BTCUSDT"},"liq":[[1,2,3.4]]}`
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, path, r.URL.Path)
|
||||
require.Equal(t, "true", r.URL.Query().Get("merge"))
|
||||
require.Equal(t, "Binance_BTCUSDT", r.URL.Query().Get("symbol"))
|
||||
require.NotEmpty(t, r.URL.Query().Get("data"))
|
||||
require.Equal(t, "true", r.Header.Get("Encryption"))
|
||||
require.Equal(t, defaultOrigin, r.Header.Get("Origin"))
|
||||
require.NotEmpty(t, r.Header.Get("Cache-Ts-V2"))
|
||||
|
||||
cacheTs := r.Header.Get("Cache-Ts-V2")
|
||||
_, err := strconv.ParseInt(cacheTs, 10, 64)
|
||||
require.NoError(t, err)
|
||||
|
||||
w.Header().Set("v", "0")
|
||||
w.Header().Set("user", encryptForTest(t, userKeyFromSeed(cacheTs), token))
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": "0",
|
||||
"msg": "success",
|
||||
"data": encryptForTest(t, bodyKeyFromToken(token), plaintext),
|
||||
})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := newTestClient(t, ts.URL)
|
||||
got, err := client.GetPlaintext(context.Background(), path, url.Values{
|
||||
"merge": {"true"},
|
||||
"symbol": {"Binance_BTCUSDT"},
|
||||
"interval": {"5"},
|
||||
"limit": {"288"},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, plaintext, string(got))
|
||||
}
|
||||
|
||||
func TestClientGetPlaintext_RetriesFakeSuccessOnce(t *testing.T) {
|
||||
var attempts int32
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&attempts, 1)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"success": true,
|
||||
"code": "0",
|
||||
"msg": "success",
|
||||
"data": nil,
|
||||
})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := newTestClient(t, ts.URL)
|
||||
_, err := client.GetPlaintext(context.Background(), "/api/index/v2/liqHeatMap", nil)
|
||||
|
||||
require.ErrorIs(t, err, ErrFakeSuccess)
|
||||
require.Equal(t, int32(2), atomic.LoadInt32(&attempts))
|
||||
}
|
||||
|
||||
func TestClientGetPlaintext_BusinessErrorDoesNotRetry(t *testing.T) {
|
||||
var attempts int32
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&attempts, 1)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"success": false,
|
||||
"code": "40000",
|
||||
"msg": "40000",
|
||||
})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := newTestClient(t, ts.URL)
|
||||
_, err := client.GetPlaintext(context.Background(), "/api/index/v2/liqHeatMap", nil)
|
||||
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, ErrAPIBusiness))
|
||||
require.Equal(t, int32(1), atomic.LoadInt32(&attempts))
|
||||
}
|
||||
|
||||
func TestClientGetPlaintext_MissingDecryptHeadersIsFakeSuccess(t *testing.T) {
|
||||
const token = "dfa89d5d00cf4000"
|
||||
|
||||
var attempts int32
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&attempts, 1)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": "0",
|
||||
"msg": "success",
|
||||
"data": encryptForTest(t, bodyKeyFromToken(token), `{"ok":true}`),
|
||||
})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := newTestClient(t, ts.URL)
|
||||
_, err := client.GetPlaintext(context.Background(), "/api/index/v2/liqHeatMap", nil)
|
||||
|
||||
require.ErrorIs(t, err, ErrFakeSuccess)
|
||||
require.Equal(t, int32(2), atomic.LoadInt32(&attempts))
|
||||
}
|
||||
|
||||
func newTestClient(t *testing.T, baseURL string) *Client {
|
||||
t.Helper()
|
||||
client, err := newClientWithDoer(ClientOptions{
|
||||
BaseURL: baseURL,
|
||||
TOTPSecret: "JBSWY3DPEHPK3PXP",
|
||||
FakeSuccessRetryWait: time.Nanosecond,
|
||||
}, netHTTPDoer{client: http.DefaultClient})
|
||||
require.NoError(t, err)
|
||||
client.now = func() time.Time {
|
||||
return time.Unix(1735689600, 123*int64(time.Millisecond))
|
||||
}
|
||||
return client
|
||||
}
|
||||
155
internal/repo/webapi/coinglass/crypto.go
Normal file
155
internal/repo/webapi/coinglass/crypto.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package coinglass
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/aes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrFakeSuccess — HTTP 200 + success=true + data 空。spec §4.6 / §11。
|
||||
ErrFakeSuccess = errors.New("coinglass fake-success (TLS fingerprint suspected)")
|
||||
|
||||
// ErrUnknownV — dispatcher 见到未知 v 分支(bundle 可能升级)。spec §4.3。
|
||||
ErrUnknownV = errors.New("coinglass unknown v branch")
|
||||
|
||||
// ErrDecryptFail — 双阶段解密任一步失败(base64 / aes / pkcs7 / gzip / utf8)。
|
||||
ErrDecryptFail = errors.New("coinglass decrypt pipeline failed")
|
||||
|
||||
// ErrAPIBusiness — success=false 且有明确 code/msg(params 错等编码 bug)。
|
||||
ErrAPIBusiness = errors.New("coinglass API business error")
|
||||
)
|
||||
|
||||
// aesECBDecrypt 解密 AES-ECB(stdlib 没有 ECB 模式,按 block 循环 Decrypt)。
|
||||
// 调用方保证 src 长度是 block size 的整数倍。
|
||||
func aesECBDecrypt(key, src []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: aes new cipher: %v", ErrDecryptFail, err)
|
||||
}
|
||||
bs := block.BlockSize()
|
||||
if len(src) == 0 || len(src)%bs != 0 {
|
||||
return nil, fmt.Errorf("%w: ciphertext len %d not multiple of block %d", ErrDecryptFail, len(src), bs)
|
||||
}
|
||||
dst := make([]byte, len(src))
|
||||
for i := 0; i < len(src); i += bs {
|
||||
block.Decrypt(dst[i:i+bs], src[i:i+bs])
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
// aesECBEncrypt 加密 AES-ECB(仅 data= 签名用,spec §4.5)。
|
||||
func aesECBEncrypt(key, src []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: aes new cipher: %v", ErrDecryptFail, err)
|
||||
}
|
||||
bs := block.BlockSize()
|
||||
if len(src)%bs != 0 {
|
||||
return nil, fmt.Errorf("%w: plaintext len %d not multiple of block %d", ErrDecryptFail, len(src), bs)
|
||||
}
|
||||
dst := make([]byte, len(src))
|
||||
for i := 0; i < len(src); i += bs {
|
||||
block.Encrypt(dst[i:i+bs], src[i:i+bs])
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
// pkcs7Pad / pkcs7Unpad — RFC 5652 §6.3。
|
||||
func pkcs7Pad(b []byte, blockSize int) []byte {
|
||||
pad := blockSize - len(b)%blockSize
|
||||
out := make([]byte, len(b)+pad)
|
||||
copy(out, b)
|
||||
for i := len(b); i < len(out); i++ {
|
||||
out[i] = byte(pad)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pkcs7Unpad(b []byte, blockSize int) ([]byte, error) {
|
||||
if len(b) == 0 || len(b)%blockSize != 0 {
|
||||
return nil, fmt.Errorf("%w: pkcs7 unpad bad len %d", ErrDecryptFail, len(b))
|
||||
}
|
||||
pad := int(b[len(b)-1])
|
||||
if pad == 0 || pad > blockSize {
|
||||
return nil, fmt.Errorf("%w: pkcs7 unpad bad value %d", ErrDecryptFail, pad)
|
||||
}
|
||||
for i := len(b) - pad; i < len(b); i++ {
|
||||
if int(b[i]) != pad {
|
||||
return nil, fmt.Errorf("%w: pkcs7 unpad inconsistent", ErrDecryptFail)
|
||||
}
|
||||
}
|
||||
return b[:len(b)-pad], nil
|
||||
}
|
||||
|
||||
// decryptBlob — V2 通用管线:base64dec → AES-ECB → unpad → gunzip → strip outer quotes。
|
||||
func decryptBlob(ciphertextB64 string, key []byte) (string, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(ciphertextB64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: b64dec: %v", ErrDecryptFail, err)
|
||||
}
|
||||
decrypted, err := aesECBDecrypt(key, raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
unpadded, err := pkcs7Unpad(decrypted, aes.BlockSize)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gz, err := gzip.NewReader(bytes.NewReader(unpadded))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: gzip header: %v", ErrDecryptFail, err)
|
||||
}
|
||||
defer gz.Close()
|
||||
plain, err := io.ReadAll(gz)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: gunzip: %v", ErrDecryptFail, err)
|
||||
}
|
||||
text := string(plain)
|
||||
if len(text) >= 2 && text[0] == '"' && text[len(text)-1] == '"' {
|
||||
text = text[1 : len(text)-1]
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// userKeyFromSeed — Stage 1 key 派生:base64(seed)[:16](cg.md L38)。
|
||||
func userKeyFromSeed(seed string) []byte {
|
||||
enc := base64.StdEncoding.EncodeToString([]byte(seed))
|
||||
if len(enc) < 16 {
|
||||
// 不应发生(任何非空 seed base64 后都 ≥ 4 chars,
|
||||
// 实际 seed 至少 16 字节 → enc 至少 24 chars);保险兜底。
|
||||
return []byte(enc)
|
||||
}
|
||||
return []byte(enc[:16])
|
||||
}
|
||||
|
||||
// bodyKeyFromToken — Stage 2 key 派生:token[:16] 原 ASCII(cg.md §43-50:no second base64)。
|
||||
func bodyKeyFromToken(token string) []byte {
|
||||
if len(token) < 16 {
|
||||
return []byte(token)
|
||||
}
|
||||
return []byte(token[:16])
|
||||
}
|
||||
|
||||
// DecryptUniversal 跑完整双阶段管线(spec §4.4)。
|
||||
//
|
||||
// seed 由 dispatcher.resolveSeed(v, ...) 给出
|
||||
// userHeaderB64 = response.headers["user"]
|
||||
// bodyDataB64 = body.data (envelope 外层,未解密的 ciphertext)
|
||||
//
|
||||
// 返回 plaintext JSON 字节流,可直接喂给 mapper。
|
||||
func DecryptUniversal(seed, userHeaderB64, bodyDataB64 string) ([]byte, error) {
|
||||
token, err := decryptBlob(userHeaderB64, userKeyFromSeed(seed))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plain, err := decryptBlob(bodyDataB64, bodyKeyFromToken(token))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(plain), nil
|
||||
}
|
||||
128
internal/repo/webapi/coinglass/crypto_test.go
Normal file
128
internal/repo/webapi/coinglass/crypto_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package coinglass
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/aes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// helper:把 plaintext 用 V2 通用管线反向加密,产出可喂给 decryptBlob 的 base64 ciphertext。
|
||||
// 形态:plaintext → '"plaintext"' → gzip → pkcs7 pad → AES-ECB → base64
|
||||
func encryptForTest(t *testing.T, key []byte, plaintext string) string {
|
||||
t.Helper()
|
||||
|
||||
// CoinGlass 服务器端会在 plaintext 外加引号(decryptBlob 反向 strip);
|
||||
// 测试也照样加引号 → 验证 strip 逻辑。
|
||||
quoted := []byte(`"` + plaintext + `"`)
|
||||
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
_, err := gz.Write(quoted)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, gz.Close())
|
||||
|
||||
padded := pkcs7Pad(buf.Bytes(), aes.BlockSize)
|
||||
enc, err := aesECBEncrypt(key, padded)
|
||||
require.NoError(t, err)
|
||||
return base64.StdEncoding.EncodeToString(enc)
|
||||
}
|
||||
|
||||
func TestDecryptBlob_RoundTrip(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
key []byte
|
||||
text string
|
||||
}{
|
||||
{"16-byte key (AES-128)", []byte("0123456789abcdef"), `{"hello":"world"}`},
|
||||
{"32-byte key (AES-256)", []byte("1f68efd73f8d4921acc0dead41dd39bc"), `{"foo":42,"bar":"baz"}`},
|
||||
{"empty json object", []byte("0123456789abcdef"), `{}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ct := encryptForTest(t, tc.key, tc.text)
|
||||
got, err := decryptBlob(ct, tc.key)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.text, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptBlob_BadCiphertext(t *testing.T) {
|
||||
key := []byte("0123456789abcdef")
|
||||
|
||||
_, err := decryptBlob("!!!not_base64!!!", key)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrDecryptFail)
|
||||
|
||||
short := base64.StdEncoding.EncodeToString([]byte("toosmall"))
|
||||
_, err = decryptBlob(short, key)
|
||||
require.ErrorIs(t, err, ErrDecryptFail)
|
||||
}
|
||||
|
||||
func TestPKCS7_PadUnpadRoundTrip(t *testing.T) {
|
||||
cases := [][]byte{
|
||||
[]byte(""),
|
||||
[]byte("a"),
|
||||
[]byte("0123456789abcde"), // 15 → pad 1
|
||||
[]byte("0123456789abcdef"), // 16 → pad 16 (full block)
|
||||
[]byte("0123456789abcdef0"),
|
||||
}
|
||||
for _, in := range cases {
|
||||
padded := pkcs7Pad(in, 16)
|
||||
require.Zero(t, len(padded)%16)
|
||||
out, err := pkcs7Unpad(padded, 16)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, in, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPKCS7_UnpadRejectsBadInput(t *testing.T) {
|
||||
bad := []byte("0123456789abcdef") // pad byte = 'f' (0x66 = 102 > 16)
|
||||
_, err := pkcs7Unpad(bad, 16)
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = pkcs7Unpad([]byte{}, 16)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestUserKeyFromSeed_TruncatesTo16(t *testing.T) {
|
||||
seed := "863f08689c97435b" // v=77 constant from bundle
|
||||
got := userKeyFromSeed(seed)
|
||||
require.Len(t, got, 16)
|
||||
// base64("863f08689c97435b") = "ODYzZjA4Njg5Yzk3NDM1Yg==" → first 16 = "ODYzZjA4Njg5Yzk3"
|
||||
require.Equal(t, "ODYzZjA4Njg5Yzk3", string(got))
|
||||
}
|
||||
|
||||
func TestBodyKeyFromToken_RawASCIIFirst16(t *testing.T) {
|
||||
token := "dfa89d5d00cf4000extra_chars"
|
||||
got := bodyKeyFromToken(token)
|
||||
require.Len(t, got, 16)
|
||||
require.Equal(t, "dfa89d5d00cf4000", string(got))
|
||||
}
|
||||
|
||||
func TestDecryptUniversal_TwoStage(t *testing.T) {
|
||||
seed := "863f08689c97435b" // v=77 bundle constant
|
||||
userKey := userKeyFromSeed(seed)
|
||||
token := "dfa89d5d00cf4000" // exactly 16 chars (matches cg.md L79 example)
|
||||
bodyKey := []byte(token)
|
||||
|
||||
plaintext := `{"success":true,"code":"0","msg":"","data":{"priceMin":110000.0}}`
|
||||
|
||||
userHeaderB64 := encryptForTest(t, userKey, token)
|
||||
bodyDataB64 := encryptForTest(t, bodyKey, plaintext)
|
||||
|
||||
got, err := DecryptUniversal(seed, userHeaderB64, bodyDataB64)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, plaintext, string(got))
|
||||
}
|
||||
|
||||
func TestDecryptUniversal_PropagatesDecryptFail(t *testing.T) {
|
||||
_, err := DecryptUniversal("seed", "!!!", "!!!")
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, ErrDecryptFail))
|
||||
}
|
||||
33
internal/repo/webapi/coinglass/dispatcher.go
Normal file
33
internal/repo/webapi/coinglass/dispatcher.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package coinglass
|
||||
|
||||
import "fmt"
|
||||
|
||||
// V_CONSTANTS — 来自 bundle 的硬编码 seed(cg.md "Known branches")。
|
||||
var vConstants = map[string]string{
|
||||
"55": "170b070da9654622",
|
||||
"66": "d6537d845a964081",
|
||||
"77": "863f08689c97435b",
|
||||
}
|
||||
|
||||
// resolveSeed 把 v 分支映射到 stage-1 解密的 seed 字符串(spec §4.3)。
|
||||
//
|
||||
// v="0" → request 时挂的 cache-ts-v2(毫秒 unix ts,字符串)
|
||||
// v="1" → URL path(待验证形状,cg.md 标 "shape — verify")
|
||||
// v="2" → response.headers.time
|
||||
// v="55" / "66" / "77" → bundle 常量
|
||||
//
|
||||
// 未知 v → ErrUnknownV,让 collector log + alert 等人工 review bundle。
|
||||
func resolveSeed(v, path, reqCacheTsV2, respTime string) (string, error) {
|
||||
switch v {
|
||||
case "0":
|
||||
return reqCacheTsV2, nil
|
||||
case "1":
|
||||
return path, nil
|
||||
case "2":
|
||||
return respTime, nil
|
||||
}
|
||||
if c, ok := vConstants[v]; ok {
|
||||
return c, nil
|
||||
}
|
||||
return "", fmt.Errorf("%w: v=%q (bundle may have added a new case)", ErrUnknownV, v)
|
||||
}
|
||||
44
internal/repo/webapi/coinglass/dispatcher_test.go
Normal file
44
internal/repo/webapi/coinglass/dispatcher_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package coinglass
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestResolveSeed_AllBranches(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
v string
|
||||
path string
|
||||
reqCacheTsV2 string
|
||||
respTime string
|
||||
wantSeed string
|
||||
}{
|
||||
{"v=0 → cache-ts-v2", "0", "/api/x", "1735689600000", "1735689601000", "1735689600000"},
|
||||
{"v=1 → path", "1", "/api/index/v2/liqHeatMap", "ts", "rt", "/api/index/v2/liqHeatMap"},
|
||||
{"v=2 → resp time", "2", "/api/x", "ts", "1735689601000", "1735689601000"},
|
||||
{"v=55 → bundle const", "55", "", "", "", "170b070da9654622"},
|
||||
{"v=66 → bundle const", "66", "", "", "", "d6537d845a964081"},
|
||||
{"v=77 → bundle const", "77", "", "", "", "863f08689c97435b"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := resolveSeed(tc.v, tc.path, tc.reqCacheTsV2, tc.respTime)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.wantSeed, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSeed_UnknownV(t *testing.T) {
|
||||
cases := []string{"3", "88", "99", "v0", ""}
|
||||
for _, v := range cases {
|
||||
t.Run("unknown_v="+v, func(t *testing.T) {
|
||||
_, err := resolveSeed(v, "/p", "ts", "rt")
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, ErrUnknownV), "want ErrUnknownV, got %v", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
52
internal/repo/webapi/coinglass/liq_heatmap.go
Normal file
52
internal/repo/webapi/coinglass/liq_heatmap.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package coinglass
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"cryptoHermes/internal/entity"
|
||||
)
|
||||
|
||||
const (
|
||||
liqHeatMapPath = "/api/index/v2/liqHeatMap"
|
||||
defaultLiqInterval = "5"
|
||||
defaultLiqLimit = 288
|
||||
maxLiqHeatMapLimit = 1000
|
||||
cgBinanceSymbolPrefix = "Binance_"
|
||||
)
|
||||
|
||||
func (c *Client) GetLiqHeatMap(ctx context.Context, symbol, interval string, limit int) (*entity.CGLiqHeatMap, error) {
|
||||
symbol = normalizeBinanceMarketSymbol(symbol)
|
||||
if interval == "" {
|
||||
interval = defaultLiqInterval
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = defaultLiqLimit
|
||||
}
|
||||
if limit > maxLiqHeatMapLimit {
|
||||
limit = maxLiqHeatMapLimit
|
||||
}
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("merge", "true")
|
||||
q.Set("symbol", coinglassBinanceSymbol(symbol))
|
||||
q.Set("interval", interval)
|
||||
q.Set("limit", strconv.Itoa(limit))
|
||||
|
||||
plaintext, err := c.GetPlaintext(ctx, liqHeatMapPath, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return MapLiqHeatMap(symbol, interval, c.now().UnixMilli(), plaintext)
|
||||
}
|
||||
|
||||
func coinglassBinanceSymbol(symbol string) string {
|
||||
return cgBinanceSymbolPrefix + symbol
|
||||
}
|
||||
|
||||
func normalizeBinanceMarketSymbol(symbol string) string {
|
||||
symbol = strings.ToUpper(strings.TrimSpace(symbol))
|
||||
return strings.TrimPrefix(symbol, "BINANCE_")
|
||||
}
|
||||
81
internal/repo/webapi/coinglass/mapper.go
Normal file
81
internal/repo/webapi/coinglass/mapper.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// Package coinglass — CoinGlass V2 私有 API 客户端实现。
|
||||
// spec:ai/specs/coinglass.md。
|
||||
package coinglass
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"cryptoHermes/internal/entity"
|
||||
)
|
||||
|
||||
// CGEnvelope 是所有 CoinGlass V2 endpoint 的统一外壳。
|
||||
// 解密后的 plaintext JSON 形如:{"success":true,"code":"0","msg":"","data":{...}}
|
||||
type CGEnvelope struct {
|
||||
Success bool `json:"success"`
|
||||
Code json.Number `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// MapLiqHeatMap 把解密后的 plaintext envelope 映射成 entity.CGLiqHeatMap。
|
||||
//
|
||||
// PR-1 阶段未拿到真实 schema dump,先做最薄映射:data 整体进 RawGrid;
|
||||
// 若顶层有 minPrice/maxPrice/priceMin/priceMax 字段则抽出(容忍命名漂移)。
|
||||
// 真实 schema 经 spike dump 后回填到 spec §5(mapper 同步加显式字段)。
|
||||
//
|
||||
// 数值边界(ADR-0004 D3):解 JSON number 默认 float64 是 transient,
|
||||
// 通过 formatFloat 转 string 进 entity;entity 字段全 string(G12.4 兜底)。
|
||||
func MapLiqHeatMap(symbol string, interval string, ts int64, plaintext []byte) (*entity.CGLiqHeatMap, error) {
|
||||
var env CGEnvelope
|
||||
if err := json.Unmarshal(plaintext, &env); err != nil {
|
||||
return nil, fmt.Errorf("liq_heatmap envelope: %w", err)
|
||||
}
|
||||
if !env.Success {
|
||||
return nil, fmt.Errorf("liq_heatmap business error code=%s msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
out := &entity.CGLiqHeatMap{
|
||||
Symbol: symbol,
|
||||
Interval: interval,
|
||||
Timestamp: ts,
|
||||
RawGrid: append(json.RawMessage(nil), env.Data...),
|
||||
}
|
||||
|
||||
var probe map[string]json.RawMessage
|
||||
if err := json.Unmarshal(env.Data, &probe); err == nil {
|
||||
out.PriceMin = pickFloatField(probe, "priceMin", "minPrice", "min")
|
||||
out.PriceMax = pickFloatField(probe, "priceMax", "maxPrice", "max")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pickFloatField 在 probe map 里按候选 key 顺序找第一个能解成 float64 的字段,
|
||||
// 用 formatFloat 转回 string。找不到返回 ""。
|
||||
func pickFloatField(probe map[string]json.RawMessage, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
raw, ok := probe[k]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var f float64
|
||||
if err := json.Unmarshal(raw, &f); err == nil {
|
||||
return formatFloat(f)
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// formatFloat 是 mapper 出口的唯一数值格式化点(ADR-0002 / ADR-0004 D3)。
|
||||
// NaN / Inf 统一映射成 "",下游消费方自行判空,避免 entity 出现非法字面量。
|
||||
func formatFloat(f float64) string {
|
||||
if math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||
}
|
||||
85
internal/repo/webapi/coinglass/mapper_test.go
Normal file
85
internal/repo/webapi/coinglass/mapper_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package coinglass
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMapLiqHeatMap_FloatToString(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
envelope string
|
||||
wantSymbol string
|
||||
wantMin string
|
||||
wantMax string
|
||||
wantErr bool
|
||||
probeRawKey string
|
||||
}{
|
||||
{
|
||||
name: "happy path with float number fields",
|
||||
envelope: `{"success":true,"code":"0","msg":"","data":{"priceMin":110234.5,"priceMax":118888.0,"grid":[[1,2],[3,4]]}}`,
|
||||
wantSymbol: "Binance_BTCUSDT",
|
||||
wantMin: "110234.5",
|
||||
wantMax: "118888",
|
||||
probeRawKey: "grid",
|
||||
},
|
||||
{
|
||||
name: "string-typed numbers are passed through unchanged",
|
||||
envelope: `{"success":true,"code":"0","msg":"","data":{"priceMin":"99999.99","priceMax":"100000.01"}}`,
|
||||
wantSymbol: "Binance_BTCUSDT",
|
||||
wantMin: "99999.99",
|
||||
wantMax: "100000.01",
|
||||
},
|
||||
{
|
||||
name: "alternate field names (minPrice/maxPrice)",
|
||||
envelope: `{"success":true,"code":"0","msg":"","data":{"minPrice":1000.5,"maxPrice":2000.5}}`,
|
||||
wantSymbol: "Binance_BTCUSDT",
|
||||
wantMin: "1000.5",
|
||||
wantMax: "2000.5",
|
||||
},
|
||||
{
|
||||
name: "missing min/max fields → empty strings",
|
||||
envelope: `{"success":true,"code":"0","msg":"","data":{"grid":[]}}`,
|
||||
wantSymbol: "Binance_BTCUSDT",
|
||||
wantMin: "",
|
||||
wantMax: "",
|
||||
},
|
||||
{
|
||||
name: "business error propagates",
|
||||
envelope: `{"success":false,"code":"40001","msg":"bad params","data":null}`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := MapLiqHeatMap("Binance_BTCUSDT", "5", 1735689600000, []byte(tc.envelope))
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.wantSymbol, got.Symbol)
|
||||
|
||||
require.IsType(t, "", got.PriceMin, "PriceMin must be string (G12.4 boundary)")
|
||||
require.IsType(t, "", got.PriceMax, "PriceMax must be string (G12.4 boundary)")
|
||||
|
||||
require.Equal(t, tc.wantMin, got.PriceMin)
|
||||
require.Equal(t, tc.wantMax, got.PriceMax)
|
||||
|
||||
require.NotEmpty(t, got.RawGrid, "RawGrid must carry raw data verbatim")
|
||||
require.True(t, json.Valid(got.RawGrid), "RawGrid must be valid JSON")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatFloat_RejectsNaNAndInf(t *testing.T) {
|
||||
require.Equal(t, "", formatFloat(math.NaN()))
|
||||
require.Equal(t, "", formatFloat(math.Inf(1)))
|
||||
require.Equal(t, "", formatFloat(math.Inf(-1)))
|
||||
require.Equal(t, "0.0001", formatFloat(0.0001))
|
||||
require.Equal(t, "118234.567", formatFloat(118234.567))
|
||||
}
|
||||
Reference in New Issue
Block a user