refactor(supply-api): reduce runtime aggregation density

This commit is contained in:
Your Name
2026-04-16 12:03:57 +08:00
parent 7e945868a5
commit 8eab2a10f7
4 changed files with 357 additions and 103 deletions

View File

@@ -0,0 +1,143 @@
# Supply API Runtime Layered Surfaces Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:**`Runtime` 自身继续降噪为“外部资源句柄 + 业务启动视图”两层结构,进一步压低 `Runtime` 顶层字段密度。
**Architecture:** 保留 `BuildRuntime``Runtime.BuildServer``Runtime.StartBackgroundWorkers` 等对外接口不变,但把 `Runtime` 顶层的 `db/redis/api/auth/tuning/serverConfig/env/logger` 等字段收进更有语义的聚合体。具体做法是新增 `runtimeExternalResources``runtimeStartupViews`,其中启动视图再分为 HTTP 与 background 两支;现有 `runtimeHTTPView`/`runtimeBackgroundView` builder 改为从分层后的 `Runtime` 聚合中派生。
**Tech Stack:** Go, Go test
---
### Task 1: 为 Runtime 引入分层聚合
**Files:**
- Modify: `supply-api/internal/app/runtime.go`
- Modify: `supply-api/internal/app/runtime_test.go`
**Step 1: Write the failing test**
```go
func TestBuildRuntime_GroupsResourcesAndStartupViews(t *testing.T) {
runtime, err := buildRuntimeWithFactory(...)
if err != nil {
t.Fatalf("expected runtime build to succeed: %v", err)
}
if runtime.startupViews.http.env != "dev" {
t.Fatal("expected http startup view env")
}
}
```
**Step 2: Run test to verify it fails**
Run: `cd "supply-api" && go test ./internal/app -run 'TestBuildRuntime_GroupsResourcesAndStartupViews' -v`
Expected: FAIL因为新聚合字段尚不存在
**Step 3: Write minimal implementation**
```go
type runtimeExternalResources struct { ... }
type runtimeStartupViews struct { ... }
type runtimeHTTPStartupView struct { ... }
type runtimeBackgroundStartupView struct { ... }
type Runtime struct { resources runtimeExternalResources; startupViews runtimeStartupViews }
```
**Step 4: Run test to verify it passes**
Run: `cd "supply-api" && go test ./internal/app -run 'TestBuildRuntime_GroupsResourcesAndStartupViews' -v`
Expected: PASS
**Step 5: Commit**
```bash
git add supply-api/internal/app/runtime.go supply-api/internal/app/runtime_test.go
git commit -m "refactor(supply-api): layer runtime resources and startup views"
```
### Task 2: 改造现有 HTTP/background view builder 读取分层 Runtime
**Files:**
- Modify: `supply-api/internal/app/runtime.go`
- Modify: `supply-api/internal/app/background.go`
- Modify: `supply-api/internal/app/runtime_test.go`
**Step 1: Write the failing test**
```go
func TestBuildRuntimeHTTPView_MapsLayeredRuntimeFields(t *testing.T) {
view, err := buildRuntimeHTTPView(runtime)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if view.supplyAPI == nil {
t.Fatal("expected supply api")
}
}
```
**Step 2: Run test to verify it fails**
Run: `cd "supply-api" && go test ./internal/app -run 'Test(BuildRuntimeHTTPView_MapsHTTPFields|BuildRuntimeBackgroundView_MapsBackgroundFields|AdaptRuntimeToBuildServerOptions_MapsRuntimeFields)' -v`
Expected: FAIL旧 builder 仍读取旧顶层字段
**Step 3: Write minimal implementation**
```go
func resolveRuntimeHealthChecks(runtime *Runtime) runtimeHealthChecks { ... } // 改为读取 runtime.resources
func buildRuntimeHTTPView(runtime *Runtime) (runtimeHTTPView, error) { ... } // 改为读取 runtime.startupViews.http
func buildRuntimeBackgroundView(runtime *Runtime) (runtimeBackgroundView, error) { ... } // 改为读取 runtime.startupViews.background + runtime.resources
```
**Step 4: Run test to verify it passes**
Run: `cd "supply-api" && go test ./internal/app -run 'Test(BuildRuntimeHTTPView_MapsHTTPFields|BuildRuntimeBackgroundView_MapsBackgroundFields|AdaptRuntimeToBuildServerOptions_MapsRuntimeFields)' -v`
Expected: PASS
**Step 5: Commit**
```bash
git add supply-api/internal/app/runtime.go supply-api/internal/app/background.go supply-api/internal/app/runtime_test.go
git commit -m "refactor(supply-api): route runtime views through layered surfaces"
```
### Task 3: 回归验证与收尾
**Files:**
- Modify: `supply-api/internal/app/runtime.go`
- Modify: `supply-api/internal/app/background.go`
- Verify: `supply-api/internal/app/runtime_test.go`
**Step 1: Run focused tests**
Run: `cd "supply-api" && go test ./internal/app -run 'Test(BuildRuntime_GroupsResourcesAndStartupViews|BuildRuntimeHTTPView_.*|BuildRuntimeBackgroundView_.*|AdaptRuntime(ToBuildServerOptions|HTTPViewToBuildServerOptions)_.*|Runtime_StartBackgroundWorkers_.*|BuildRuntime_.*)' -v`
Expected: PASS
**Step 2: Run package regression**
Run: `cd "supply-api" && go test ./internal/app ./cmd/supply-api ./internal/httpapi`
Expected: PASS
**Step 3: Run e2e tests**
Run: `cd "supply-api" && go test -tags=e2e ./e2e`
Expected: PASS
**Step 4: Run repo exit verification**
Run: `bash "scripts/ci/repo_integrity_check.sh"`
Expected: PASS
**Step 5: Check formatting**
Run: `git diff --check`
Expected: no output
**Step 6: Commit**
```bash
git add docs/plans/2026-04-16-supply-api-runtime-layered-surfaces-plan.md supply-api/internal/app/runtime.go supply-api/internal/app/background.go supply-api/internal/app/runtime_test.go
git commit -m "refactor(supply-api): reduce runtime aggregation density"
```

View File

@@ -79,17 +79,17 @@ func buildRuntimeBackgroundView(runtime *Runtime) (runtimeBackgroundView, error)
if runtime == nil {
return runtimeBackgroundView{}, errors.New("runtime is required")
}
if runtime.logger == nil {
if runtime.startupViews.background.logger == nil {
return runtimeBackgroundView{}, errors.New("runtime logger is required")
}
view := runtimeBackgroundView{
env: runtime.env,
logger: runtime.logger,
tuning: runtime.tuning,
db: runtime.db,
redisCache: runtime.redisCache,
revocationSubscriber: runtime.revocationSubscriber,
env: runtime.startupViews.background.env,
logger: runtime.startupViews.background.logger,
tuning: runtime.startupViews.background.tuning,
db: runtime.resources.db,
redisCache: runtime.resources.redisCache,
revocationSubscriber: runtime.startupViews.background.revocationSubscriber,
}
if view.tuning.outboxStreamName == "" {
view.tuning = defaultRuntimeTuning()

View File

@@ -41,24 +41,41 @@ type runtimeTuning struct {
// Runtime 聚合 HTTP 启动和后台任务启动所需的运行时依赖。
type Runtime struct {
env string
logger logging.Logger
now func() time.Time
tuning runtimeTuning
serverConfig config.ServerConfig
db *repository.DB
redisCache *cache.RedisCache
supplyAPI *httpapi.SupplyAPI
alertAPI *httpapi.AlertAPI
authMiddleware *middleware.AuthMiddleware
rateLimitConfig *middleware.RateLimitConfig
revocationSubscriber revocationSubscriber
resources runtimeExternalResources
startupViews runtimeStartupViews
}
type revocationSubscriber interface {
StartRevocationSubscriber(ctx context.Context) error
}
type runtimeExternalResources struct {
db *repository.DB
redisCache *cache.RedisCache
}
type runtimeHTTPStartupView struct {
env string
logger logging.Logger
serverConfig config.ServerConfig
supplyAPI *httpapi.SupplyAPI
alertAPI *httpapi.AlertAPI
authMiddleware *middleware.AuthMiddleware
rateLimitConfig *middleware.RateLimitConfig
}
type runtimeBackgroundStartupView struct {
env string
logger logging.Logger
tuning runtimeTuning
revocationSubscriber revocationSubscriber
}
type runtimeStartupViews struct {
http runtimeHTTPStartupView
background runtimeBackgroundStartupView
}
type runtimeFactory struct {
newDB func(ctx context.Context, cfg config.DatabaseConfig) (*repository.DB, error)
newRedisCache func(cfg config.RedisConfig) (*cache.RedisCache, error)
@@ -173,18 +190,27 @@ func buildRuntimeWithFactory(opts RuntimeOptions, factory runtimeFactory) (*Runt
}
return &Runtime{
env: env,
logger: opts.Logger,
now: now,
tuning: tuning,
serverConfig: normalizeServerConfig(opts.Config.Server),
db: db,
redisCache: redisCache,
supplyAPI: apiBundle.supplyAPI,
alertAPI: apiBundle.alertAPI,
authMiddleware: securityBundle.authMiddleware,
rateLimitConfig: apiBundle.rateLimitConfig,
revocationSubscriber: securityBundle.revocationSubscriber,
resources: runtimeExternalResources{
db: db,
redisCache: redisCache,
},
startupViews: runtimeStartupViews{
http: runtimeHTTPStartupView{
env: env,
logger: opts.Logger,
serverConfig: normalizeServerConfig(opts.Config.Server),
supplyAPI: apiBundle.supplyAPI,
alertAPI: apiBundle.alertAPI,
authMiddleware: securityBundle.authMiddleware,
rateLimitConfig: apiBundle.rateLimitConfig,
},
background: runtimeBackgroundStartupView{
env: env,
logger: opts.Logger,
tuning: tuning,
revocationSubscriber: securityBundle.revocationSubscriber,
},
},
}, nil
}
@@ -361,11 +387,11 @@ func resolveRuntimeHealthChecks(runtime *Runtime) runtimeHealthChecks {
if runtime == nil {
return checks
}
if runtime.db != nil {
checks.DBHealthCheck = runtime.db.HealthCheck
if runtime.resources.db != nil {
checks.DBHealthCheck = runtime.resources.db.HealthCheck
}
if runtime.redisCache != nil {
checks.RedisHealthCheck = runtime.redisCache.HealthCheck
if runtime.resources.redisCache != nil {
checks.RedisHealthCheck = runtime.resources.redisCache.HealthCheck
}
return checks
}
@@ -376,13 +402,13 @@ func buildRuntimeHTTPView(runtime *Runtime) (runtimeHTTPView, error) {
}
return runtimeHTTPView{
env: runtime.env,
logger: runtime.logger,
serverConfig: runtime.serverConfig,
supplyAPI: runtime.supplyAPI,
alertAPI: runtime.alertAPI,
authMiddleware: runtime.authMiddleware,
rateLimitConfig: runtime.rateLimitConfig,
env: runtime.startupViews.http.env,
logger: runtime.startupViews.http.logger,
serverConfig: runtime.startupViews.http.serverConfig,
supplyAPI: runtime.startupViews.http.supplyAPI,
alertAPI: runtime.startupViews.http.alertAPI,
authMiddleware: runtime.startupViews.http.authMiddleware,
rateLimitConfig: runtime.startupViews.http.rateLimitConfig,
healthChecks: resolveRuntimeHealthChecks(runtime),
}, nil
}
@@ -414,11 +440,11 @@ func (r *Runtime) Close() {
if r == nil {
return
}
if r.redisCache != nil {
_ = r.redisCache.Close()
if r.resources.redisCache != nil {
_ = r.resources.redisCache.Close()
}
if r.db != nil {
r.db.Close()
if r.resources.db != nil {
r.resources.db.Close()
}
}
@@ -427,7 +453,7 @@ func (r *Runtime) ShutdownTimeout() time.Duration {
if r == nil {
return 0
}
return r.serverConfig.ShutdownTimeout
return r.startupViews.http.serverConfig.ShutdownTimeout
}
func ResolveEnv(env string) (string, error) {

View File

@@ -243,19 +243,19 @@ func TestBuildRuntime_DevFallsBackToInMemoryDependencies(t *testing.T) {
if runtime == nil {
t.Fatal("expected runtime")
}
if runtime.db != nil {
if runtime.resources.db != nil {
t.Fatal("expected nil db after dev fallback")
}
if runtime.redisCache != nil {
if runtime.resources.redisCache != nil {
t.Fatal("expected nil redis cache after dev fallback")
}
if runtime.supplyAPI == nil || runtime.alertAPI == nil {
if runtime.startupViews.http.supplyAPI == nil || runtime.startupViews.http.alertAPI == nil {
t.Fatal("expected apis to be initialized")
}
if runtime.authMiddleware == nil {
if runtime.startupViews.http.authMiddleware == nil {
t.Fatal("expected auth middleware to be initialized")
}
if runtime.rateLimitConfig == nil {
if runtime.startupViews.http.rateLimitConfig == nil {
t.Fatal("expected rate limit config to be initialized")
}
}
@@ -283,8 +283,8 @@ func TestBuildRuntime_NormalizesServerConfigDefaults(t *testing.T) {
if err != nil {
t.Fatalf("expected runtime build to succeed, got %v", err)
}
if runtime.serverConfig.Addr != ":18082" {
t.Fatalf("unexpected addr: %s", runtime.serverConfig.Addr)
if runtime.startupViews.http.serverConfig.Addr != ":18082" {
t.Fatalf("unexpected addr: %s", runtime.startupViews.http.serverConfig.Addr)
}
if runtime.ShutdownTimeout() != 5*time.Second {
t.Fatalf("unexpected shutdown timeout: %s", runtime.ShutdownTimeout())
@@ -311,20 +311,20 @@ func TestBuildRuntime_SeedsDefaultTuning(t *testing.T) {
if err != nil {
t.Fatalf("expected runtime build to succeed, got %v", err)
}
if runtime.tuning.outboxStreamName != "supply:outbox:stream" {
t.Fatalf("unexpected outbox stream: %s", runtime.tuning.outboxStreamName)
if runtime.startupViews.background.tuning.outboxStreamName != "supply:outbox:stream" {
t.Fatalf("unexpected outbox stream: %s", runtime.startupViews.background.tuning.outboxStreamName)
}
if runtime.tuning.outboxConsumerGroup != "outbox-processor" {
t.Fatalf("unexpected outbox group: %s", runtime.tuning.outboxConsumerGroup)
if runtime.startupViews.background.tuning.outboxConsumerGroup != "outbox-processor" {
t.Fatalf("unexpected outbox group: %s", runtime.startupViews.background.tuning.outboxConsumerGroup)
}
if runtime.tuning.idempotencyTTL != 24*time.Hour {
t.Fatalf("unexpected idempotency ttl: %s", runtime.tuning.idempotencyTTL)
if runtime.startupViews.background.tuning.idempotencyTTL != 24*time.Hour {
t.Fatalf("unexpected idempotency ttl: %s", runtime.startupViews.background.tuning.idempotencyTTL)
}
if runtime.tuning.partitionMaintenanceInterval != time.Hour {
t.Fatalf("unexpected partition maintenance interval: %s", runtime.tuning.partitionMaintenanceInterval)
if runtime.startupViews.background.tuning.partitionMaintenanceInterval != time.Hour {
t.Fatalf("unexpected partition maintenance interval: %s", runtime.startupViews.background.tuning.partitionMaintenanceInterval)
}
if runtime.tuning.compensationCheckInterval != 5*time.Minute {
t.Fatalf("unexpected compensation interval: %s", runtime.tuning.compensationCheckInterval)
if runtime.startupViews.background.tuning.compensationCheckInterval != 5*time.Minute {
t.Fatalf("unexpected compensation interval: %s", runtime.startupViews.background.tuning.compensationCheckInterval)
}
}
@@ -355,6 +355,49 @@ func TestBuildRuntime_DevFallbackLogsWarnings(t *testing.T) {
}
}
func TestBuildRuntime_GroupsResourcesAndStartupViews(t *testing.T) {
runtime, err := buildRuntimeWithFactory(RuntimeOptions{
Env: "dev",
Config: testRuntimeConfig(),
Logger: testLogger{},
InitContext: context.Background(),
Now: func() time.Time {
return time.Unix(1712800000, 0).UTC()
},
}, runtimeFactory{
newDB: func(context.Context, config.DatabaseConfig) (*repository.DB, error) {
return nil, errors.New("db down")
},
newRedisCache: func(config.RedisConfig) (*cache.RedisCache, error) {
return nil, errors.New("redis down")
},
})
if err != nil {
t.Fatalf("expected runtime build to succeed, got %v", err)
}
if runtime.startupViews.http.env != "dev" {
t.Fatalf("unexpected http env: %s", runtime.startupViews.http.env)
}
if runtime.startupViews.background.env != "dev" {
t.Fatalf("unexpected background env: %s", runtime.startupViews.background.env)
}
if runtime.resources.db != nil {
t.Fatal("expected nil db resource after dev fallback")
}
if runtime.resources.redisCache != nil {
t.Fatal("expected nil redis resource after dev fallback")
}
if runtime.startupViews.http.supplyAPI == nil || runtime.startupViews.http.alertAPI == nil {
t.Fatal("expected http startup view apis to be initialized")
}
if runtime.startupViews.http.authMiddleware == nil {
t.Fatal("expected http startup view auth middleware")
}
if runtime.startupViews.background.tuning.outboxStreamName != "supply:outbox:stream" {
t.Fatalf("unexpected background outbox stream: %s", runtime.startupViews.background.tuning.outboxStreamName)
}
}
func TestResolveRuntimeHealthChecks_OmitsUnavailableDependencies(t *testing.T) {
checks := resolveRuntimeHealthChecks(&Runtime{})
if checks.DBHealthCheck != nil {
@@ -367,8 +410,10 @@ func TestResolveRuntimeHealthChecks_OmitsUnavailableDependencies(t *testing.T) {
func TestResolveRuntimeHealthChecks_ExposesAvailableDependencies(t *testing.T) {
checks := resolveRuntimeHealthChecks(&Runtime{
db: &repository.DB{},
redisCache: &cache.RedisCache{},
resources: runtimeExternalResources{
db: &repository.DB{},
redisCache: &cache.RedisCache{},
},
})
if checks.DBHealthCheck == nil {
t.Fatal("expected db health check")
@@ -394,15 +439,21 @@ func TestBuildRuntimeHTTPView_MapsHTTPFields(t *testing.T) {
rateLimitConfig := &middleware.RateLimitConfig{Enabled: true}
view, err := buildRuntimeHTTPView(&Runtime{
env: "staging",
logger: testLogger{},
serverConfig: config.ServerConfig{Addr: ":19090"},
supplyAPI: supplyAPI,
alertAPI: alertAPI,
authMiddleware: authMiddleware,
rateLimitConfig: rateLimitConfig,
db: &repository.DB{},
redisCache: &cache.RedisCache{},
resources: runtimeExternalResources{
db: &repository.DB{},
redisCache: &cache.RedisCache{},
},
startupViews: runtimeStartupViews{
http: runtimeHTTPStartupView{
env: "staging",
logger: testLogger{},
serverConfig: config.ServerConfig{Addr: ":19090"},
supplyAPI: supplyAPI,
alertAPI: alertAPI,
authMiddleware: authMiddleware,
rateLimitConfig: rateLimitConfig,
},
},
})
if err != nil {
t.Fatalf("expected view build to succeed, got %v", err)
@@ -449,15 +500,21 @@ func TestAdaptRuntimeToBuildServerOptions_MapsRuntimeFields(t *testing.T) {
rateLimitConfig := &middleware.RateLimitConfig{Enabled: true}
opts, err := adaptRuntimeToBuildServerOptions(&Runtime{
env: "staging",
logger: testLogger{},
serverConfig: config.ServerConfig{Addr: ":19090"},
supplyAPI: supplyAPI,
alertAPI: alertAPI,
authMiddleware: authMiddleware,
rateLimitConfig: rateLimitConfig,
db: &repository.DB{},
redisCache: &cache.RedisCache{},
resources: runtimeExternalResources{
db: &repository.DB{},
redisCache: &cache.RedisCache{},
},
startupViews: runtimeStartupViews{
http: runtimeHTTPStartupView{
env: "staging",
logger: testLogger{},
serverConfig: config.ServerConfig{Addr: ":19090"},
supplyAPI: supplyAPI,
alertAPI: alertAPI,
authMiddleware: authMiddleware,
rateLimitConfig: rateLimitConfig,
},
},
})
if err != nil {
t.Fatalf("expected adapter to succeed, got %v", err)
@@ -544,12 +601,18 @@ func TestBuildRuntimeBackgroundView_MapsBackgroundFields(t *testing.T) {
subscriber := stubRevocationSubscriber{}
view, err := buildRuntimeBackgroundView(&Runtime{
env: "prod",
logger: testLogger{},
tuning: defaultRuntimeTuning(),
db: &repository.DB{},
redisCache: &cache.RedisCache{},
revocationSubscriber: subscriber,
resources: runtimeExternalResources{
db: &repository.DB{},
redisCache: &cache.RedisCache{},
},
startupViews: runtimeStartupViews{
background: runtimeBackgroundStartupView{
env: "prod",
logger: testLogger{},
tuning: defaultRuntimeTuning(),
revocationSubscriber: subscriber,
},
},
})
if err != nil {
t.Fatalf("expected background view build to succeed, got %v", err)
@@ -578,8 +641,12 @@ func TestRuntime_StartBackgroundWorkers_WithoutDatabaseIsNoop(t *testing.T) {
var outboxRepoCalled bool
err := startBackgroundWorkersWithFactory(context.Background(), context.Background(), &Runtime{
env: "dev",
logger: testLogger{},
startupViews: runtimeStartupViews{
background: runtimeBackgroundStartupView{
env: "dev",
logger: testLogger{},
},
},
}, backgroundFactory{
newOutboxRepository: func(*repository.DB) outboxRepository {
outboxRepoCalled = true
@@ -596,9 +663,15 @@ func TestRuntime_StartBackgroundWorkers_WithoutDatabaseIsNoop(t *testing.T) {
func TestRuntime_StartBackgroundWorkers_ProdRequiresOutboxBroker(t *testing.T) {
err := startBackgroundWorkersWithFactory(context.Background(), context.Background(), &Runtime{
env: "prod",
logger: testLogger{},
db: &repository.DB{},
resources: runtimeExternalResources{
db: &repository.DB{},
},
startupViews: runtimeStartupViews{
background: runtimeBackgroundStartupView{
env: "prod",
logger: testLogger{},
},
},
}, backgroundFactory{
newOutboxRepository: func(*repository.DB) outboxRepository {
return stubOutboxRepository{}
@@ -641,10 +714,16 @@ func TestRuntime_StartBackgroundWorkers_UsesDefaultCompensationInterval(t *testi
var gotInterval time.Duration
err := startBackgroundWorkersWithFactory(context.Background(), context.Background(), &Runtime{
env: "dev",
logger: testLogger{},
db: &repository.DB{},
tuning: defaultRuntimeTuning(),
resources: runtimeExternalResources{
db: &repository.DB{},
},
startupViews: runtimeStartupViews{
background: runtimeBackgroundStartupView{
env: "dev",
logger: testLogger{},
tuning: defaultRuntimeTuning(),
},
},
}, backgroundFactory{
newOutboxRepository: func(*repository.DB) outboxRepository {
return stubOutboxRepository{}
@@ -720,10 +799,16 @@ func TestRuntime_StartBackgroundWorkers_DevMissingOutboxBrokerLogsWarning(t *tes
logger := &captureLogger{}
err := startBackgroundWorkersWithFactory(context.Background(), context.Background(), &Runtime{
env: "dev",
logger: logger,
db: &repository.DB{},
tuning: defaultRuntimeTuning(),
resources: runtimeExternalResources{
db: &repository.DB{},
},
startupViews: runtimeStartupViews{
background: runtimeBackgroundStartupView{
env: "dev",
logger: logger,
tuning: defaultRuntimeTuning(),
},
},
}, backgroundFactory{
newOutboxRepository: func(*repository.DB) outboxRepository {
return stubOutboxRepository{}