refactor(supply-api): narrow runtime background surface

This commit is contained in:
Your Name
2026-04-16 11:38:56 +08:00
parent 45029b44d1
commit 7e945868a5
3 changed files with 261 additions and 43 deletions

View File

@@ -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"
```

View File

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

View File

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