refactor: 把 /derivatives 的多源编排从 controller 搬进 usecase

为什么:原 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 全绿。
This commit is contained in:
dela
2026-05-24 17:51:11 +08:00
parent cc7f5a4f32
commit f3dc4d0939
9 changed files with 185 additions and 67 deletions

View File

@@ -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 都应该满足以上条。
---

View File

@@ -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 协调。

View File

@@ -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/OIDB 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.FindRecentthin query允许直查
└→ /snapshot → marketData.GetTicker24h
```
---

View File

@@ -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 描述里说明原因并 @ 维护者。

View File

@@ -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消费方已知或不受影响
```
---

View File

@@ -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)

View File

@@ -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,
})
}

View File

@@ -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()
}

View File

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