diff --git a/internal/repo/webapi/coinglass/mapper.go b/internal/repo/webapi/coinglass/mapper.go index 8f00aa2..52842f7 100644 --- a/internal/repo/webapi/coinglass/mapper.go +++ b/internal/repo/webapi/coinglass/mapper.go @@ -11,46 +11,65 @@ import ( "cryptoHermes/internal/entity" ) -// CGEnvelope 是所有 CoinGlass V2 endpoint 的统一外壳。 -// 解密后的 plaintext JSON 形如:{"success":true,"code":"0","msg":"","data":{...}} +// CGEnvelope 是 CoinGlass V2 endpoint 的兼容外壳。 +// HTTP body 解密前有 success/code/data;解密后的 plaintext 通常已经是 data 本体。 +// 测试和旧 dump 可能仍喂 envelope,mapper 保留兼容。 type CGEnvelope struct { - Success bool `json:"success"` - Code json.Number `json:"code"` + Success *bool `json:"success"` + Code json.RawMessage `json:"code"` Msg string `json:"msg"` Data json.RawMessage `json:"data"` } -// MapLiqHeatMap 把解密后的 plaintext envelope 映射成 entity.CGLiqHeatMap。 +// MapLiqHeatMap 把解密后的 plaintext 映射成 entity.CGLiqHeatMap。 // -// PR-1 阶段未拿到真实 schema dump,先做最薄映射:data 整体进 RawGrid; +// 真实响应的 plaintext 是 data 本体(例如 {"instrument":...,"liq":[...]})。 +// 为兼容早期 fixture,如 plaintext 仍带 success/code/data envelope,则先拆 data。 +// PR-1 阶段做最薄映射: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) + raw, err := liqHeatMapDataPayload(plaintext) + if err != nil { + return nil, err } out := &entity.CGLiqHeatMap{ Symbol: symbol, Interval: interval, Timestamp: ts, - RawGrid: append(json.RawMessage(nil), env.Data...), + RawGrid: append(json.RawMessage(nil), raw...), } var probe map[string]json.RawMessage - if err := json.Unmarshal(env.Data, &probe); err == nil { + if err := json.Unmarshal(raw, &probe); err == nil { out.PriceMin = pickFloatField(probe, "priceMin", "minPrice", "min") out.PriceMax = pickFloatField(probe, "priceMax", "maxPrice", "max") } return out, nil } +func liqHeatMapDataPayload(plaintext []byte) (json.RawMessage, error) { + if !json.Valid(plaintext) { + return nil, fmt.Errorf("liq_heatmap plaintext: invalid json") + } + + var env CGEnvelope + if err := json.Unmarshal(plaintext, &env); err == nil && env.Success != nil { + if !*env.Success { + return nil, fmt.Errorf("%w: code=%s msg=%q", ErrAPIBusiness, envelopeCode(env.Code), env.Msg) + } + if len(env.Data) == 0 || string(env.Data) == "null" { + return nil, fmt.Errorf("%w: empty data code=%s msg=%q", ErrFakeSuccess, envelopeCode(env.Code), env.Msg) + } + return append(json.RawMessage(nil), env.Data...), nil + } + + return append(json.RawMessage(nil), plaintext...), nil +} + // pickFloatField 在 probe map 里按候选 key 顺序找第一个能解成 float64 的字段, // 用 formatFloat 转回 string。找不到返回 ""。 func pickFloatField(probe map[string]json.RawMessage, keys ...string) string { diff --git a/internal/repo/webapi/coinglass/mapper_test.go b/internal/repo/webapi/coinglass/mapper_test.go index 2c94d55..cf470c8 100644 --- a/internal/repo/webapi/coinglass/mapper_test.go +++ b/internal/repo/webapi/coinglass/mapper_test.go @@ -11,16 +11,24 @@ import ( func TestMapLiqHeatMap_FloatToString(t *testing.T) { cases := []struct { name string - envelope string + plaintext string wantSymbol string wantMin string wantMax string wantErr bool probeRawKey string }{ + { + name: "real decrypted data object", + plaintext: `{"instrument":{"exName":"Binance","instrumentId":"BTCUSDT"},"liq":[[1,2,3.4]],"priceMin":110234.5,"priceMax":118888.0}`, + wantSymbol: "Binance_BTCUSDT", + wantMin: "110234.5", + wantMax: "118888", + probeRawKey: "liq", + }, { 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]]}}`, + plaintext: `{"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", @@ -28,35 +36,35 @@ func TestMapLiqHeatMap_FloatToString(t *testing.T) { }, { name: "string-typed numbers are passed through unchanged", - envelope: `{"success":true,"code":"0","msg":"","data":{"priceMin":"99999.99","priceMax":"100000.01"}}`, + plaintext: `{"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}}`, + plaintext: `{"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":[]}}`, + plaintext: `{"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, + name: "business error propagates", + plaintext: `{"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)) + got, err := MapLiqHeatMap("Binance_BTCUSDT", "5", 1735689600000, []byte(tc.plaintext)) if tc.wantErr { require.Error(t, err) return diff --git a/internal/worker/coinglass_collector.go b/internal/worker/coinglass_collector.go index 506c8a1..dfbc08b 100644 --- a/internal/worker/coinglass_collector.go +++ b/internal/worker/coinglass_collector.go @@ -81,6 +81,10 @@ func (c *CoinglassCollector) CollectLiqHeatMap(ctx context.Context) error { item, err := c.d.Provider.GetLiqHeatMap(ctx, symbol, c.d.LiqHeatMapInterval, c.d.LiqHeatMapLimit) if err != nil { + if errors.Is(err, cgapi.ErrAPIBusiness) { + c.d.Logger.Warn("coinglass_liq_heatmap_business_error", "symbol", symbol, "err", err) + continue + } hadError = true c.recordLiqHeatMapError(symbol, err) continue