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
}

View 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
}

View 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/msgparams 错等编码 bug
ErrAPIBusiness = errors.New("coinglass API business error")
)
// aesECBDecrypt 解密 AES-ECBstdlib 没有 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] 原 ASCIIcg.md §43-50no 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
}

View 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))
}

View File

@@ -0,0 +1,33 @@
package coinglass
import "fmt"
// V_CONSTANTS — 来自 bundle 的硬编码 seedcg.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)
}

View 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)
})
}
}

View 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_")
}

View File

@@ -0,0 +1,81 @@
// Package coinglass — CoinGlass V2 私有 API 客户端实现。
// specai/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 §5mapper 同步加显式字段)。
//
// 数值边界ADR-0004 D3解 JSON number 默认 float64 是 transient
// 通过 formatFloat 转 string 进 entityentity 字段全 stringG12.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)
}

View 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))
}