diff --git a/docs/plans/2026-04-16-supply-api-runtime-background-view-plan.md b/docs/plans/2026-04-16-supply-api-runtime-background-view-plan.md new file mode 100644 index 00000000..e6da9ba0 --- /dev/null +++ b/docs/plans/2026-04-16-supply-api-runtime-background-view-plan.md @@ -0,0 +1,136 @@ +# Supply API Runtime Background View Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 把 `Runtime` 暴露给后台 worker 启动层的字段收成更窄的 `runtimeBackgroundView`,与现有 `runtimeHTTPView` 对称,进一步缩小启动层直接读取 `Runtime` 的表面积。 + +**Architecture:** 保留 `Runtime.StartBackgroundWorkers` 和 `startBackgroundWorkersWithFactory` 的对外/测试入口,新增 `buildRuntimeBackgroundView` 负责从 runtime 提取后台启动所需字段并补默认 tuning。后台启动 helper 统一改为读取 `runtimeBackgroundView`,不再直接读完整 `Runtime`。 + +**Tech Stack:** Go, Go test + +--- + +### Task 1: 提取 runtimeBackgroundView + +**Files:** +- Modify: `supply-api/internal/app/background.go` +- Modify: `supply-api/internal/app/runtime.go` +- Modify: `supply-api/internal/app/runtime_test.go` + +**Step 1: Write the failing test** + +```go +func TestBuildRuntimeBackgroundView_RequiresRuntime(t *testing.T) { + _, err := buildRuntimeBackgroundView(nil) + if err == nil { + t.Fatal("expected nil runtime to fail") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd "supply-api" && go test ./internal/app -run 'TestBuildRuntimeBackgroundView_(RequiresRuntime|MapsBackgroundFields)' -v` +Expected: FAIL,因为 helper 尚不存在 + +**Step 3: Write minimal implementation** + +```go +type runtimeBackgroundView struct { ... } +func buildRuntimeBackgroundView(runtime *Runtime) (runtimeBackgroundView, error) { ... } +``` + +**Step 4: Run test to verify it passes** + +Run: `cd "supply-api" && go test ./internal/app -run 'TestBuildRuntimeBackgroundView_(RequiresRuntime|MapsBackgroundFields)' -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add supply-api/internal/app/background.go supply-api/internal/app/runtime.go supply-api/internal/app/runtime_test.go +git commit -m "refactor(supply-api): extract runtime background view" +``` + +### Task 2: 改造后台 helper 使用 background view + +**Files:** +- Modify: `supply-api/internal/app/background.go` +- Modify: `supply-api/internal/app/runtime_test.go` + +**Step 1: Write the failing test** + +```go +func TestStartOutboxProcessor_ProdRequiresBroker(t *testing.T) { + err := startOutboxProcessor(context.Background(), runtimeBackgroundView{...}, backgroundFactory{...}) + if err == nil { + t.Fatal("expected missing outbox broker to fail in prod") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd "supply-api" && go test ./internal/app -run 'Test(StartOutboxProcessor_ProdRequiresBroker|Runtime_StartBackgroundWorkers_UsesDefaultCompensationInterval)' -v` +Expected: FAIL,因为 helper 签名/实现尚未切到 view + +**Step 3: Write minimal implementation** + +```go +func startBackgroundWorkersWithViewAndFactory(...) error { ... } +func startRevocationSubscriber(ctx context.Context, view runtimeBackgroundView) { ... } +func startOutboxProcessor(ctx context.Context, view runtimeBackgroundView, factory backgroundFactory) error { ... } +func startPartitionMaintenanceWorker(..., view runtimeBackgroundView, ...) { ... } +func startCompensationWorker(ctx context.Context, view runtimeBackgroundView, factory backgroundFactory) { ... } +``` + +**Step 4: Run test to verify it passes** + +Run: `cd "supply-api" && go test ./internal/app -run 'Test(StartOutboxProcessor_ProdRequiresBroker|Runtime_StartBackgroundWorkers_UsesDefaultCompensationInterval)' -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add supply-api/internal/app/background.go supply-api/internal/app/runtime_test.go +git commit -m "refactor(supply-api): route background workers through view" +``` + +### Task 3: 回归验证与收尾 + +**Files:** +- Modify: `supply-api/internal/app/background.go` +- Modify: `supply-api/internal/app/runtime.go` +- Verify: `supply-api/internal/app/runtime_test.go` + +**Step 1: Run focused tests** + +Run: `cd "supply-api" && go test ./internal/app -run 'Test(BuildRuntimeBackgroundView_(RequiresRuntime|MapsBackgroundFields)|Runtime_StartBackgroundWorkers_.*|StartOutboxProcessor_ProdRequiresBroker|StartCompensationWorker_UsesConfiguredInterval|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-background-view-plan.md supply-api/internal/app/background.go supply-api/internal/app/runtime.go supply-api/internal/app/runtime_test.go +git commit -m "refactor(supply-api): narrow runtime background surface" +``` diff --git a/supply-api/internal/app/background.go b/supply-api/internal/app/background.go index 6daf4fa8..e5332f84 100644 --- a/supply-api/internal/app/background.go +++ b/supply-api/internal/app/background.go @@ -34,6 +34,15 @@ type compensationWorker interface { StartBackgroundWorker(ctx context.Context, interval time.Duration) context.Context } +type runtimeBackgroundView struct { + env string + logger logging.Logger + tuning runtimeTuning + db *repository.DB + redisCache *cache.RedisCache + revocationSubscriber revocationSubscriber +} + type backgroundFactory struct { newOutboxRepository func(db *repository.DB) outboxRepository newMessageBroker func(redisCache *cache.RedisCache) messaging.MessageBroker @@ -59,35 +68,61 @@ func startBackgroundWorkersWithFactory( runtime *Runtime, factory backgroundFactory, ) error { + view, err := buildRuntimeBackgroundView(runtime) + if err != nil { + return err + } + return startBackgroundWorkersWithViewAndFactory(rootCtx, initCtx, view, factory) +} + +func buildRuntimeBackgroundView(runtime *Runtime) (runtimeBackgroundView, error) { if runtime == nil { - return errors.New("runtime is required") + return runtimeBackgroundView{}, errors.New("runtime is required") } if runtime.logger == nil { - return errors.New("runtime logger is required") + 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, + } + if view.tuning.outboxStreamName == "" { + view.tuning = defaultRuntimeTuning() + } + return view, nil +} + +func startBackgroundWorkersWithViewAndFactory( + rootCtx context.Context, + initCtx context.Context, + view runtimeBackgroundView, + factory backgroundFactory, +) error { if rootCtx == nil { rootCtx = context.Background() } if initCtx == nil { initCtx = rootCtx } - if runtime.tuning.outboxStreamName == "" { - runtime.tuning = defaultRuntimeTuning() - } - factory = withDefaultBackgroundFactory(factory, runtime.tuning) + factory = withDefaultBackgroundFactory(factory, view.tuning) - startRevocationSubscriber(rootCtx, runtime) + startRevocationSubscriber(rootCtx, view) - if runtime.db == nil { + if view.db == nil { return nil } - if err := startOutboxProcessor(rootCtx, runtime, factory); err != nil { + if err := startOutboxProcessor(rootCtx, view, factory); err != nil { return err } - startPartitionMaintenanceWorker(rootCtx, initCtx, runtime, factory) - startCompensationWorker(rootCtx, runtime, factory) + startPartitionMaintenanceWorker(rootCtx, initCtx, view, factory) + startCompensationWorker(rootCtx, view, factory) return nil } @@ -140,79 +175,76 @@ var compensationNewDefaultExecutor = func() domain.OperationExecutor { return compensation.NewDefaultCompensationExecutor() } -func startRevocationSubscriber(ctx context.Context, runtime *Runtime) { - if runtime == nil || runtime.revocationSubscriber == nil || runtime.redisCache == nil { +func startRevocationSubscriber(ctx context.Context, view runtimeBackgroundView) { + if view.revocationSubscriber == nil || view.redisCache == nil { return } - if err := runtime.revocationSubscriber.StartRevocationSubscriber(ctx); err != nil { - warnf(runtime.logger, "启动主动吊销订阅失败: %v", err) + if err := view.revocationSubscriber.StartRevocationSubscriber(ctx); err != nil { + warnf(view.logger, "启动主动吊销订阅失败: %v", err) return } - runtime.logger.Info("主动吊销机制: 已启动 (Redis Pub/Sub)", nil) + view.logger.Info("主动吊销机制: 已启动 (Redis Pub/Sub)", nil) } -func startOutboxProcessor(ctx context.Context, runtime *Runtime, factory backgroundFactory) error { - if runtime == nil { - return errors.New("runtime is required") - } - if runtime.db == nil { +func startOutboxProcessor(ctx context.Context, view runtimeBackgroundView, factory backgroundFactory) error { + if view.db == nil { return nil } - factory = withDefaultBackgroundFactory(factory, runtime.tuning) + factory = withDefaultBackgroundFactory(factory, view.tuning) - outboxRepo := factory.newOutboxRepository(runtime.db) - msgBroker := factory.newMessageBroker(runtime.redisCache) + outboxRepo := factory.newOutboxRepository(view.db) + msgBroker := factory.newMessageBroker(view.redisCache) if msgBroker == nil { - if runtime.env == "prod" { + if view.env == "prod" { return errors.New("outbox message broker unavailable") } - runtime.logger.Warn("OutboxProcessor未启动 (message broker不可用)", nil) + view.logger.Warn("OutboxProcessor未启动 (message broker不可用)", nil) return nil } stats := &messaging.NoOpOutboxStats{} runner := factory.newOutboxRunner(outboxRepo, msgBroker, stats) go runner.Start(ctx) - runtime.logger.Info("OutboxProcessor已启动", nil) + view.logger.Info("OutboxProcessor已启动", nil) return nil } func startPartitionMaintenanceWorker( rootCtx context.Context, initCtx context.Context, - runtime *Runtime, + view runtimeBackgroundView, factory backgroundFactory, ) { - if runtime == nil || runtime.db == nil { + if view.db == nil { return } - factory = withDefaultBackgroundFactory(factory, runtime.tuning) - manager := factory.newPartitionManager(runtime.db) + factory = withDefaultBackgroundFactory(factory, view.tuning) + manager := factory.newPartitionManager(view.db) if err := manager.EnsureFuturePartitions(initCtx); err != nil { - warnf(runtime.logger, "预创建未来分区失败: %v", err) + warnf(view.logger, "预创建未来分区失败: %v", err) } else { - runtime.logger.Info("分区管理: 未来分区已确保存在", nil) + view.logger.Info("分区管理: 未来分区已确保存在", nil) } - go runPartitionMaintenanceLoop(rootCtx, runtime.logger, manager, runtime.tuning) + go runPartitionMaintenanceLoop(rootCtx, view.logger, manager, view.tuning) } -func startCompensationWorker(ctx context.Context, runtime *Runtime, factory backgroundFactory) { - if runtime == nil || runtime.db == nil { +func startCompensationWorker(ctx context.Context, view runtimeBackgroundView, factory backgroundFactory) { + if view.db == nil { return } - factory = withDefaultBackgroundFactory(factory, runtime.tuning) - compensationStore := factory.newCompensationStore(runtime.db) + factory = withDefaultBackgroundFactory(factory, view.tuning) + compensationStore := factory.newCompensationStore(view.db) compensationStats := &domain.NoOpCompensationStats{} compensationExecutor := factory.newCompensationExecutor() compensationProcessor := factory.newCompensationProcessor(compensationStore, compensationExecutor, compensationStats) - runtime.logger.Info("批量补偿处理器: 已初始化", nil) - compensationProcessor.StartBackgroundWorker(ctx, runtime.tuning.compensationCheckInterval) - infof(runtime.logger, "批量补偿处理器: 后台worker已启动 (每%s检查一次)", runtime.tuning.compensationCheckInterval) + view.logger.Info("批量补偿处理器: 已初始化", nil) + compensationProcessor.StartBackgroundWorker(ctx, view.tuning.compensationCheckInterval) + infof(view.logger, "批量补偿处理器: 后台worker已启动 (每%s检查一次)", view.tuning.compensationCheckInterval) } func runPartitionMaintenanceLoop(ctx context.Context, logger logging.Logger, manager partitionManager, tuning runtimeTuning) { diff --git a/supply-api/internal/app/runtime_test.go b/supply-api/internal/app/runtime_test.go index eb6c887c..d5d1afc1 100644 --- a/supply-api/internal/app/runtime_test.go +++ b/supply-api/internal/app/runtime_test.go @@ -530,6 +530,50 @@ func TestAdaptRuntimeHTTPViewToBuildServerOptions_MapsHealthChecks(t *testing.T) } } +func TestBuildRuntimeBackgroundView_RequiresRuntime(t *testing.T) { + _, err := buildRuntimeBackgroundView(nil) + if err == nil { + t.Fatal("expected nil runtime to fail") + } + if !strings.Contains(err.Error(), "runtime is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +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, + }) + if err != nil { + t.Fatalf("expected background view build to succeed, got %v", err) + } + if view.env != "prod" { + t.Fatalf("unexpected env: %s", view.env) + } + if view.logger == nil { + t.Fatal("expected logger to be preserved") + } + if view.db == nil { + t.Fatal("expected db to be preserved") + } + if view.redisCache == nil { + t.Fatal("expected redis cache to be preserved") + } + if view.revocationSubscriber == nil { + t.Fatal("expected revocation subscriber to be preserved") + } + if view.tuning.outboxStreamName != "supply:outbox:stream" { + t.Fatalf("unexpected outbox stream: %s", view.tuning.outboxStreamName) + } +} + func TestRuntime_StartBackgroundWorkers_WithoutDatabaseIsNoop(t *testing.T) { var outboxRepoCalled bool @@ -572,7 +616,7 @@ func TestRuntime_StartBackgroundWorkers_ProdRequiresOutboxBroker(t *testing.T) { } func TestStartOutboxProcessor_ProdRequiresBroker(t *testing.T) { - err := startOutboxProcessor(context.Background(), &Runtime{ + err := startOutboxProcessor(context.Background(), runtimeBackgroundView{ env: "prod", logger: testLogger{}, db: &repository.DB{}, @@ -643,7 +687,7 @@ func TestRuntime_StartBackgroundWorkers_UsesDefaultCompensationInterval(t *testi func TestStartCompensationWorker_UsesConfiguredInterval(t *testing.T) { var gotInterval time.Duration - startCompensationWorker(context.Background(), &Runtime{ + startCompensationWorker(context.Background(), runtimeBackgroundView{ env: "dev", logger: testLogger{}, db: &repository.DB{}, @@ -755,6 +799,12 @@ func testRuntimeConfig() *config.Config { type stubOutboxRepository struct{} +type stubRevocationSubscriber struct{} + +func (stubRevocationSubscriber) StartRevocationSubscriber(context.Context) error { + return nil +} + func (stubOutboxRepository) FetchAndLock(context.Context, int) ([]*repository.OutboxEvent, error) { return nil, nil }