From f3dc4d09399d48e1a74d8cb691c664d129fd1ca2 Mon Sep 17 00:00:00 2001 From: dela Date: Sun, 24 May 2026 17:51:11 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=8A=8A=20/derivatives=20?= =?UTF-8?q?=E7=9A=84=E5=A4=9A=E6=BA=90=E7=BC=96=E6=8E=92=E4=BB=8E=20contro?= =?UTF-8?q?ller=20=E6=90=AC=E8=BF=9B=20usecase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:原 controller handler 在 HTTP 层同时承担了 4 项 usecase 职责 (多源拼装、错误降级策略、limit 硬编码、响应结构组装),违反了 "controller 只做参数解析 + 调用 + 序列化" 的边界。等后续给衍生品加 缓存 / taker volume / 数据质量字段时,HTTP 层会持续变厚。 改动: - 新增 internal/usecase/market_query.go: MarketQueryUsecase.GetDerivatives() 并发拉 current funding/OI + 串行查历史 + 错误聚合为 warnings 返回 - /derivatives handler 退化到 4 行;MarketDeps / restapi.Deps 同步收窄 - /klines 保持直查 KlineRepo(thin query,G11 例外) - 新增 G11 守卫:grep "FundingRepo|OIRepo|LSRepo|TakerRepo" internal/controller 破坏性 API 变更(/derivatives 响应): - 删除 funding.error / openInterest.error - 新增顶层 warnings: []string 顶层 symbol / funding / openInterest / longShortRatio / takerBuySellVolume 保持。 harness 同步: - AGENTS.md §6 加 G11 grep - ai/project-map.md 加 market_query.go + /derivatives 数据流分支 - ai/task-templates.md T6 加"破坏性变体"分支(本次重构暴露了原假设过严) - ai/harness-health.md W1 记一次轻量验证 + 守卫数 10→11 验收: go build / go vet / G6 grep / G11 grep 全绿。 --- AGENTS.md | 5 +- ai/harness-health.md | 3 +- ai/project-map.md | 24 ++-- ai/risk-guardrails.md | 16 +++ ai/task-templates.md | 10 +- internal/app/app.go | 12 +- internal/controller/restapi/router.go | 18 +-- .../controller/restapi/v1/market_routes.go | 51 ++------ internal/usecase/market_query.go | 113 ++++++++++++++++++ 9 files changed, 185 insertions(+), 67 deletions(-) create mode 100644 internal/usecase/market_query.go diff --git a/AGENTS.md b/AGENTS.md index f7e10ea..8b80ac6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,11 +103,14 @@ grep -r "repo/webapi/binance\|repo/persistent" internal/controller # 必须无 # usecase 不可直接 import binance/postgres grep -r "repo/webapi/binance\|repo/persistent" internal/usecase # 必须无输出 +# controller 不可直接编排 derivatives 多源查询(G11) +grep -rn "FundingRepo\|OIRepo\|LSRepo\|TakerRepo" internal/controller # 必须无输出 + # 全项目不可出现签名 / 私钥相关字段 grep -ri "apikey\|x-mbx-apikey\|hmac\|secret_key\|api_secret" --include="*.go" . # 必须无输出(不算注释/文档里的"不接入"说明) ``` -任何 PR 都应该满足以上三条。 +任何 PR 都应该满足以上四条。 --- diff --git a/ai/harness-health.md b/ai/harness-health.md index eee4956..d4bc684 100644 --- a/ai/harness-health.md +++ b/ai/harness-health.md @@ -15,7 +15,7 @@ | 验证命令可跑 | strong | Makefile 全部命令在 README + AGENTS.md 双重列出 | | 快速反馈环路 | strong | `go build ./...` < 2s,`go vet ./...` < 1s,单包测试支持 | | 任务契约质量 | partial | 模板已写(`ai/task-templates.md`),但还没有真实任务用过 | -| 守卫规则清晰度 | strong | 10 条 G1-G10,每条都有可机械验证命令 | +| 守卫规则清晰度 | strong | 11 条 G1-G11,每条都有可机械验证命令 | | 架构决策耐久度 | strong | ADR 0001 记录 9 条核心决策;其他 ADR 等触发 | | Spec / test-plan | partial | 还没有 specs/,因为 v1 没有需要 spec 的任务;v2 新数据源时必填 | | 失败反馈环路 | partial | AGENTS.md §10 写了升级时机,但还没有 `ai/risk-guardrails.md` 改动史可参考 | @@ -44,6 +44,7 @@ **现状**:模板已写完,但没有 PR 用过这些契约。 **影响**:模板可能太严格或太松。 **纠正时机**:完成第一个非平凡 PR(比如 Milestone 6 的技术指标)后回看,调整模板。 +**2026-05-24 更新**:`/derivatives` controller→usecase 重构(抽 `MarketQueryUsecase`、新增 G11)算一次轻量验证。发现 T6 "API 响应字节级一致" 假设过严——抽离 usecase 时合理地把 sub-object `error` 字段升级成顶层 `warnings` 数组,已在 T6 加"破坏性变体"分支。Milestone 6 真正落地时再做一次完整复评。 ### W2. 还没有 work-status.md **现状**:目前是单 agent / 单会话工作,不需要 work-status 协调。 diff --git a/ai/project-map.md b/ai/project-map.md index 45bfb9c..b6eb5c7 100644 --- a/ai/project-map.md +++ b/ai/project-map.md @@ -82,6 +82,7 @@ Clean Architecture 四层。**依赖方向单向:`controller → usecase → e - `DerivativesProvider`(funding/OI/多空比/taker volume 共 7 个方法) - `KlineRepository` / `FundingRepository` / `OpenInterestRepository` / `LongShortRatioRepository` / `TakerVolumeRepository` - `market_context.go`:聚合 `/v1/market/context`。errgroup 并发拉 snapshot/funding/OI,DB K 线不足 200 根回源 Binance 补,异步写回。 +- `market_query.go`:`/v1/market/derivatives` 后端。`GetDerivatives(symbol, period)` 并发拉 current funding/OI + 串行查 funding/OI/多空比历史,错误降级为 warnings 返回。 **约束**(grep 可验证): ```bash @@ -98,8 +99,9 @@ usecase 不可直接 import 任何具体实现。新增业务流程 → 在 `por **约束**: ```bash grep -r "repo/webapi/binance\|repo/persistent" internal/controller # 必须无输出 +grep -rn "FundingRepo\|OIRepo\|LSRepo\|TakerRepo" internal/controller # 必须无输出(G11) ``` -controller 只能依赖 usecase 和 entity,不能直接调 binance client 或 postgres repo。 +controller 只能依赖 usecase 和 entity,不能直接调 binance client 或 postgres repo。衍生品多源查询必须经 usecase(详见 `ai/risk-guardrails.md` G11)。`KlineRepo` 暂时允许直查 `/klines`,因为它现在是 thin query。 **扩展点**:新增路由 → 加 handler → 通过 `MarketDeps` 注入依赖(不要直接 import repo 包)。 @@ -191,13 +193,19 @@ cron tick ``` HTTP request └→ internal/controller/restapi/v1/market_routes.go - └→ usecase.MarketContext.Build - ├→ marketData.GetTicker24h ┐ - ├→ derivatives.GetCurrent… ├ errgroup 并发 - ├→ derivatives.GetCurrent… ┘ - ├→ klineRepo.FindRecent × 5 周期 - │ └ DB 不足 → 回源 binance.GetKlines(异步写回) - └→ fundingRepo / oiRepo / lsRepo.FindRecent + ├→ /context → usecase.MarketContext.Build + │ ├→ marketData.GetTicker24h ┐ + │ ├→ derivatives.GetCurrent… ├ errgroup 并发 + │ ├→ derivatives.GetCurrent… ┘ + │ ├→ klineRepo.FindRecent × 5 周期 + │ │ └ DB 不足 → 回源 binance.GetKlines(异步写回) + │ └→ fundingRepo / oiRepo / lsRepo.FindRecent + ├→ /derivatives → usecase.MarketQuery.GetDerivatives + │ ├→ derivatives.GetCurrentFunding ┐ errgroup + │ ├→ derivatives.GetCurrentOI ┘ + │ └→ fundingRepo / oiRepo / lsRepo.FindRecent + ├→ /klines → klineRepo.FindRecent(thin query,允许直查) + └→ /snapshot → marketData.GetTicker24h ``` --- diff --git a/ai/risk-guardrails.md b/ai/risk-guardrails.md index 06b90e3..c60bbdf 100644 --- a/ai/risk-guardrails.md +++ b/ai/risk-guardrails.md @@ -177,6 +177,22 @@ grep -rn --include="*.go" "websocket\|fstream.binance" . --- +## G11. Controller 不编排 derivatives 多源查询 + +**规则**:`internal/controller/**` 不得直接持有或调用 `FundingRepository` / `OpenInterestRepository` / `LongShortRatioRepository` / `TakerVolumeRepository`。这类衍生品历史数据的"查哪些表 + 多源拼装 + 错误降级 → warnings"必须在 `internal/usecase/` 完成,controller 只做参数解析、调用 usecase、返回 JSON。 + +**为什么**:曾经 `/derivatives` handler 一次性编排了 6 处查询、4 个静默吞错的 `_`、硬编码的 limit 和响应结构——HTTP 层在替业务层做决定。要给衍生品加 taker volume、缓存、降级策略、数据质量 warnings 时,应该只动 usecase,不该让 HTTP handler 跟着变厚。 + +**例外**:`KlineRepository` 暂时允许 controller 直查,因为 `/klines` 现在就是 thin query。一旦它要加缓存 / live fallback / interval 聚合,就同样要搬进 usecase。 + +**怎么验证**: +```bash +grep -rn "FundingRepo\|OIRepo\|LSRepo\|TakerRepo" internal/controller +# 期望:无输出 +``` + +--- + ## 升级守护规则的时机 同一类错误第二次出现 → 写一条新规则进来。修改本文件 = 改动了项目契约,需要在 PR 描述里说明原因并 @ 维护者。 diff --git a/ai/task-templates.md b/ai/task-templates.md index a5df989..456a92a 100644 --- a/ai/task-templates.md +++ b/ai/task-templates.md @@ -159,14 +159,18 @@ acceptance: objective: <消除哪个边界违反 / 抽取哪段重复> scope: <文件列表> out-of-scope: - - 不改行为(API 响应字节级一致 / DB schema 不变) -constraints: G6 + - 行为变化(默认:API 响应字节级一致 / DB schema 不变) + - 例外:如果重构本身是为了把不该在 controller 的"错误降级 / 响应组装"搬进 usecase, + 可以一并修响应契约(如 sub-object 的 error 字段聚合为顶层 warnings)。 + 必须在 acceptance 显式标 "破坏性",并在 PR 描述列出旧 → 新字段映射。 +constraints: G6 + G11(如果动 controller) validation: - go build ./... && go vet ./... - 全部 grep 边界检查(AGENTS.md §6) - make test acceptance: - - diff 中不含行为变化(只是搬代码 / 重命名 / 抽接口) + - 默认:diff 中不含行为变化(只是搬代码 / 重命名 / 抽接口) + - 破坏性变体:列出响应字段变化,确认上游(Hermes)消费方已知或不受影响 ``` --- diff --git a/internal/app/app.go b/internal/app/app.go index fce8615..5d801bb 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -58,6 +58,13 @@ func Run(cfg *config.Config, log *slog.Logger) error { log, ) + queryUC := usecase.NewMarketQueryUsecase( + binanceClient, + fundingRepo, + oiRepo, + lsRepo, + ) + collector := worker.NewCollector(worker.Deps{ MarketData: binanceClient, Derivatives: binanceClient, @@ -90,12 +97,9 @@ func Run(cfg *config.Config, log *slog.Logger) error { restapi.Register(fiberApp, restapi.Deps{ Logger: log, MarketContext: contextUC, + MarketQuery: queryUC, MarketData: binanceClient, - Derivatives: binanceClient, KlineRepo: klineRepo, - FundingRepo: fundingRepo, - OIRepo: oiRepo, - LSRepo: lsRepo, }) listenErr := make(chan error, 1) diff --git a/internal/controller/restapi/router.go b/internal/controller/restapi/router.go index 9768270..91f8a54 100644 --- a/internal/controller/restapi/router.go +++ b/internal/controller/restapi/router.go @@ -11,14 +11,11 @@ import ( ) type Deps struct { - Logger *slog.Logger - MarketContext *usecase.MarketContextUsecase - MarketData usecase.MarketDataProvider - Derivatives usecase.DerivativesProvider - KlineRepo usecase.KlineRepository - FundingRepo usecase.FundingRepository - OIRepo usecase.OpenInterestRepository - LSRepo usecase.LongShortRatioRepository + Logger *slog.Logger + MarketContext *usecase.MarketContextUsecase + MarketQuery *usecase.MarketQueryUsecase + MarketData usecase.MarketDataProvider + KlineRepo usecase.KlineRepository } func Register(app *fiber.App, deps Deps) { @@ -29,11 +26,8 @@ func Register(app *fiber.App, deps Deps) { v1.RegisterHealth(api) v1.RegisterMarket(api, v1.MarketDeps{ MarketContext: deps.MarketContext, + MarketQuery: deps.MarketQuery, MarketData: deps.MarketData, - Derivatives: deps.Derivatives, KlineRepo: deps.KlineRepo, - FundingRepo: deps.FundingRepo, - OIRepo: deps.OIRepo, - LSRepo: deps.LSRepo, }) } diff --git a/internal/controller/restapi/v1/market_routes.go b/internal/controller/restapi/v1/market_routes.go index 605bd40..7200afa 100644 --- a/internal/controller/restapi/v1/market_routes.go +++ b/internal/controller/restapi/v1/market_routes.go @@ -6,18 +6,14 @@ import ( "github.com/gofiber/fiber/v2" - "cryptoHermes/internal/entity" "cryptoHermes/internal/usecase" ) type MarketDeps struct { MarketContext *usecase.MarketContextUsecase + MarketQuery *usecase.MarketQueryUsecase MarketData usecase.MarketDataProvider - Derivatives usecase.DerivativesProvider KlineRepo usecase.KlineRepository - FundingRepo usecase.FundingRepository - OIRepo usecase.OpenInterestRepository - LSRepo usecase.LongShortRatioRepository } var allowedIntervals = map[string]bool{ @@ -80,39 +76,18 @@ func RegisterMarket(r fiber.Router, d MarketDeps) { return fiber.NewError(fiber.StatusBadRequest, "symbol is required") } - fundCur, ferr := d.Derivatives.GetCurrentFunding(c.UserContext(), symbol) - oiCur, oerr := d.Derivatives.GetCurrentOpenInterest(c.UserContext(), symbol) - - fundHist, _ := d.FundingRepo.FindRecent(c.UserContext(), symbol, 100) - oiHist, _ := d.OIRepo.FindRecent(c.UserContext(), symbol, period, 200) - globalLS, _ := d.LSRepo.FindRecent(c.UserContext(), symbol, period, entity.RatioTypeGlobalAccount, 200) - topLS, _ := d.LSRepo.FindRecent(c.UserContext(), symbol, period, entity.RatioTypeTopTraderPosition, 200) - - resp := fiber.Map{ - "symbol": symbol, - "funding": fiber.Map{ - "current": fundCur, - "history": fundHist, - "error": errString(ferr), - }, - "openInterest": fiber.Map{ - "current": oiCur, - "history": oiHist, - "error": errString(oerr), - }, - "longShortRatio": fiber.Map{ - "global": globalLS, - "topTraderPosition": topLS, - }, - "takerBuySellVolume": []any{}, + bundle, warnings, err := d.MarketQuery.GetDerivatives(c.UserContext(), symbol, period) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - return c.JSON(resp) + + return c.JSON(fiber.Map{ + "symbol": symbol, + "funding": bundle.Funding, + "openInterest": bundle.OpenInterest, + "longShortRatio": bundle.LongShortRatio, + "takerBuySellVolume": bundle.TakerBuySellVolume, + "warnings": warnings, + }) }) } - -func errString(e error) string { - if e == nil { - return "" - } - return e.Error() -} diff --git a/internal/usecase/market_query.go b/internal/usecase/market_query.go new file mode 100644 index 0000000..171bfc5 --- /dev/null +++ b/internal/usecase/market_query.go @@ -0,0 +1,113 @@ +package usecase + +import ( + "context" + "sync" + + "golang.org/x/sync/errgroup" + + "cryptoHermes/internal/entity" +) + +const ( + derivativesFundingHistoryLen = 100 + derivativesOIHistoryLen = 200 + derivativesLongShortLen = 200 +) + +type MarketQueryUsecase struct { + derivatives DerivativesProvider + fundingRepo FundingRepository + oiRepo OpenInterestRepository + lsRepo LongShortRatioRepository +} + +func NewMarketQueryUsecase( + derivatives DerivativesProvider, + fundingRepo FundingRepository, + oiRepo OpenInterestRepository, + lsRepo LongShortRatioRepository, +) *MarketQueryUsecase { + return &MarketQueryUsecase{ + derivatives: derivatives, + fundingRepo: fundingRepo, + oiRepo: oiRepo, + lsRepo: lsRepo, + } +} + +func (u *MarketQueryUsecase) GetDerivatives(ctx context.Context, symbol, period string) (*entity.DerivativesBundle, []string, error) { + var ( + currentFund *entity.FundingRate + currentOI *entity.OpenInterest + warnMu sync.Mutex + warnings []string + ) + addWarn := func(w string) { + warnMu.Lock() + warnings = append(warnings, w) + warnMu.Unlock() + } + + g, gctx := errgroup.WithContext(ctx) + g.Go(func() error { + f, err := u.derivatives.GetCurrentFunding(gctx, symbol) + if err != nil { + addWarn("current funding fetch failed: " + err.Error()) + return nil + } + currentFund = f + return nil + }) + g.Go(func() error { + oi, err := u.derivatives.GetCurrentOpenInterest(gctx, symbol) + if err != nil { + addWarn("current OI fetch failed: " + err.Error()) + return nil + } + currentOI = oi + return nil + }) + _ = g.Wait() + + fundingHist, err := u.fundingRepo.FindRecent(ctx, symbol, derivativesFundingHistoryLen) + if err != nil { + addWarn("funding history query failed: " + err.Error()) + } + + oiHist, err := u.oiRepo.FindRecent(ctx, symbol, period, derivativesOIHistoryLen) + if err != nil { + addWarn("OI history query failed: " + err.Error()) + } + + globalLS, err := u.lsRepo.FindRecent(ctx, symbol, period, entity.RatioTypeGlobalAccount, derivativesLongShortLen) + if err != nil { + addWarn("global long/short query failed: " + err.Error()) + } + + topLS, err := u.lsRepo.FindRecent(ctx, symbol, period, entity.RatioTypeTopTraderPosition, derivativesLongShortLen) + if err != nil { + addWarn("top trader position long/short query failed: " + err.Error()) + } + + bundle := &entity.DerivativesBundle{ + Funding: entity.FundingBundle{ + Current: currentFund, + History: fundingHist, + }, + OpenInterest: entity.OpenInterestBundle{ + Current: currentOI, + History: oiHist, + }, + LongShortRatio: entity.LongShortBundle{ + Global: globalLS, + TopTraderPosition: topLS, + }, + TakerBuySellVolume: []entity.TakerBuySellVolume{}, + } + + if warnings == nil { + warnings = []string{} + } + return bundle, warnings, nil +}