diff --git a/docs/plans/2026-04-15-supply-api-runtime-helper-split-plan.md b/docs/plans/2026-04-15-supply-api-runtime-helper-split-plan.md new file mode 100644 index 00000000..0595486f --- /dev/null +++ b/docs/plans/2026-04-15-supply-api-runtime-helper-split-plan.md @@ -0,0 +1,129 @@ +# Supply API Runtime Helper Split Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 把 `supply-api/internal/app/runtime.go` 中偏大的 `buildRuntimeWithFactory` 拆成更小的装配 helper,降低单函数复杂度,同时保持现有行为不变。 + +**Architecture:** 新增存储装配、鉴权装配和 API 装配的内部 helper struct/func,由 `buildRuntimeWithFactory` 只负责串联。继续保留现有外部 API、日志口径和运行时语义,测试先锁 helper 级行为,再复用现有高层回归。 + +**Tech Stack:** Go, Go test + +--- + +### Task 1: 提取存储装配 helper + +**Files:** +- Modify: `supply-api/internal/app/runtime.go` +- Modify: `supply-api/internal/app/runtime_test.go` + +**Step 1: Write the failing test** + +```go +func TestBuildStoreBundle_UsesInMemoryStoresWithoutDatabase(t *testing.T) { + bundle := buildStoreBundle(nil, testLogger{}) + if bundle.accountStore == nil { + t.Fatal("expected account store") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd "supply-api" && go test ./internal/app -run 'TestBuildStoreBundle_(UsesInMemoryStoresWithoutDatabase|UsesDatabaseBackedStoresWithDatabase)' -v` +Expected: FAIL,因为 helper 尚不存在 + +**Step 3: Write minimal implementation** + +```go +type runtimeStoreBundle struct { ... } +func buildStoreBundle(db *repository.DB, logger logging.Logger) runtimeStoreBundle { ... } +``` + +**Step 4: Run test to verify it passes** + +Run: `cd "supply-api" && go test ./internal/app -run 'TestBuildStoreBundle_(UsesInMemoryStoresWithoutDatabase|UsesDatabaseBackedStoresWithDatabase)' -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): extract runtime store bundle" +``` + +### Task 2: 提取安全与 API 装配 helper + +**Files:** +- Modify: `supply-api/internal/app/runtime.go` +- Modify: `supply-api/internal/app/runtime_test.go` + +**Step 1: Write the failing test** + +```go +func TestBuildSecurityBundle_UsesMemoryTokenBackendWithoutRepository(t *testing.T) { + bundle := buildSecurityBundle("dev", cfg, testLogger{}, auditStore, nil, nil) + if bundle.authMiddleware == nil { + t.Fatal("expected auth middleware") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd "supply-api" && go test ./internal/app -run 'TestBuildSecurityBundle_UsesMemoryTokenBackendWithoutRepository' -v` +Expected: FAIL,因为 helper 尚不存在 + +**Step 3: Write minimal implementation** + +```go +type runtimeSecurityBundle struct { ... } +type runtimeAPIBundle struct { ... } +func buildSecurityBundle(...) runtimeSecurityBundle { ... } +func buildAPIBundle(...) (runtimeAPIBundle, error) { ... } +``` + +**Step 4: Run test to verify it passes** + +Run: `cd "supply-api" && go test ./internal/app -run 'TestBuildSecurityBundle_UsesMemoryTokenBackendWithoutRepository' -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): extract runtime security and api bundles" +``` + +### Task 3: 验证与收尾 + +**Files:** +- Verify: `supply-api/internal/app/runtime.go` +- Verify: `supply-api/internal/app/bootstrap.go` +- Verify: `supply-api/cmd/supply-api/main.go` + +**Step 1: Run focused tests** + +Run: `cd "supply-api" && go test ./internal/app ./cmd/supply-api ./internal/httpapi` +Expected: PASS + +**Step 2: Run e2e build-tag tests** + +Run: `cd "supply-api" && go test -tags=e2e ./e2e` +Expected: PASS + +**Step 3: Run repo exit verification** + +Run: `bash "scripts/ci/repo_integrity_check.sh"` +Expected: PASS + +**Step 4: Check formatting** + +Run: `git diff --check` +Expected: no output + +**Step 5: Commit** + +```bash +git add docs/plans/2026-04-15-supply-api-runtime-helper-split-plan.md supply-api/internal/app/runtime.go supply-api/internal/app/runtime_test.go +git commit -m "refactor(supply-api): split runtime assembly helpers" +``` diff --git a/supply-api/internal/app/runtime.go b/supply-api/internal/app/runtime.go index f2aed6c5..ebbf48bf 100644 --- a/supply-api/internal/app/runtime.go +++ b/supply-api/internal/app/runtime.go @@ -64,6 +64,29 @@ type runtimeFactory struct { newRedisCache func(cfg config.RedisConfig) (*cache.RedisCache, error) } +type runtimeStoreBundle struct { + accountStore domain.AccountStore + packageStore domain.PackageStore + settlementStore domain.SettlementStore + earningStore domain.EarningStore + auditStore audit.AuditStore + alertService *auditservice.AlertService + fkValidator *repository.ForeignKeyValidator + tokenStatusRepo *repository.TokenStatusRepository + idempotencyRepo *repository.IdempotencyRepository +} + +type runtimeSecurityBundle struct { + authMiddleware *middleware.AuthMiddleware + revocationSubscriber revocationSubscriber +} + +type runtimeAPIBundle struct { + supplyAPI *httpapi.SupplyAPI + alertAPI *httpapi.AlertAPI + rateLimitConfig *middleware.RateLimitConfig +} + // BuildRuntime 构建 supply-api 运行时依赖。 func BuildRuntime(opts RuntimeOptions) (*Runtime, error) { return buildRuntimeWithFactory(opts, runtimeFactory{ @@ -126,129 +149,11 @@ func buildRuntimeWithFactory(opts RuntimeOptions, factory runtimeFactory) (*Runt infof(opts.Logger, "connected to redis at %s:%d", opts.Config.Redis.Host, opts.Config.Redis.Port) } - var accountStore domain.AccountStore - var packageStore domain.PackageStore - var settlementStore domain.SettlementStore - var earningStore domain.EarningStore - var auditRepository *auditrepo.PostgresAuditRepository - var tokenStatusRepo *repository.TokenStatusRepository - var idempotencyRepo *repository.IdempotencyRepository - - if db != nil { - accountRepo := repository.NewAccountRepository(db.Pool) - packageRepo := repository.NewPackageRepository(db.Pool) - settlementRepo := repository.NewSettlementRepository(db.Pool) - usageRepo := repository.NewUsageRepository(db.Pool) - idempotencyRepo = repository.NewIdempotencyRepository(db.Pool) - auditRepository = auditrepo.NewPostgresAuditRepository(db.Pool) - tokenStatusRepo = repository.NewTokenStatusRepository(db.Pool) - - accountStore = adapter.NewDBAccountStore(accountRepo) - packageStore = adapter.NewDBPackageStore(packageRepo) - settlementStore = adapter.NewDBSettlementStore(settlementRepo, accountRepo, db.Pool) - earningStore = adapter.NewDBEarningStore(usageRepo) - } else { - accountStore = adapter.NewInMemoryAccountStoreAdapter() - packageStore = adapter.NewInMemoryPackageStoreAdapter() - settlementStore = adapter.NewInMemorySettlementStoreAdapter() - earningStore = adapter.NewInMemoryEarningStoreAdapter() - } - - var auditStore audit.AuditStore - if auditRepository != nil { - auditStore = audit.NewPostgresAuditStore(auditRepository) - opts.Logger.Info("审计存储: 使用PostgreSQL (DB-backed)", nil) - } else { - auditStore = audit.NewMemoryAuditStore() - opts.Logger.Warn("审计存储使用内存实现 (生产环境不应使用)", nil) - } - - var alertStore auditservice.AlertStoreInterface - if db != nil { - alertStore = auditrepo.NewPostgresAlertRepository(db.Pool) - opts.Logger.Info("告警存储: 使用PostgreSQL (DB-backed)", nil) - } else { - alertStore = auditservice.NewInMemoryAlertStore() - opts.Logger.Warn("告警存储使用内存实现 (仅开发环境允许)", nil) - } - alertService := auditservice.NewAlertService(alertStore) - - var fkValidator *repository.ForeignKeyValidator - if db != nil { - fkValidator = repository.NewForeignKeyValidator(db.Pool) - opts.Logger.Info("外键校验器: 已初始化 (PostgreSQL-backed)", nil) - } else { - opts.Logger.Warn("外键校验器未启用 (db不可用)", nil) - } - - _ = domain.NewInvariantChecker(accountStore, packageStore, settlementStore) - - accountService := domain.NewAccountService(accountStore, auditStore) - packageService := domain.NewPackageService(packageStore, accountStore, auditStore) - settlementService := domain.NewSettlementService(settlementStore, earningStore, auditStore) - earningService := domain.NewEarningService(earningStore) - - tokenCache := middleware.NewTokenCache() - var tokenBackend middleware.TokenStatusBackend - var revocationSubscriber revocationSubscriber - if tokenStatusRepo != nil { - dbTokenBackend := middleware.NewDBTokenStatusBackend(tokenStatusRepo, redisCache, opts.Config.Token.RevocationCacheTTL) - tokenBackend = dbTokenBackend - revocationSubscriber = dbTokenBackend - opts.Logger.Info("Token状态后端: 使用PostgreSQL (DB-backed)", nil) - } else { - tokenBackend = adapter.NewMemoryTokenBackend() - opts.Logger.Warn("Token状态后端使用内存实现 (生产环境不应使用)", nil) - } - - auditEmitter := adapter.NewAuditEmitterAdapter(auditStore) - authMiddleware := middleware.NewAuthMiddleware(middleware.AuthConfig{ - SecretKey: opts.Config.Token.SecretKey, - PublicKey: opts.Config.Token.PublicKey, - Algorithm: opts.Config.Token.Algorithm, - Issuer: opts.Config.Token.Issuer, - CacheTTL: opts.Config.Token.RevocationCacheTTL, - Enabled: env != "dev", - }, tokenCache, tokenBackend, auditEmitter) - - var idempotencyMiddleware *middleware.IdempotencyMiddleware - if db != nil && idempotencyRepo != nil { - idempotencyMiddleware = middleware.NewIdempotencyMiddleware(idempotencyRepo, middleware.IdempotencyConfig{ - TTL: tuning.idempotencyTTL, - Enabled: env != "dev", - }) - opts.Logger.Info("幂等中间件已启用(DB-backed)", nil) - } else { - if isProd { - return nil, errors.New("idempotency repository unavailable") - } - opts.Logger.Warn("幂等中间件未启用(db或repo不可用)- 需要幂等的写接口将返回 503", nil) - } - - rateLimitConfig := middleware.DefaultRateLimitConfig() - rateLimitConfig.Enabled = env != "dev" - opts.Logger.Info("限流中间件已初始化", nil) - - supplyAPI, err := httpapi.NewSupplyAPI( - accountService, - packageService, - settlementService, - earningService, - idempotencyMiddleware, - auditStore, - fkValidator, - opts.Config.Server.DefaultSupplierID, - opts.Config.Server.StatementBaseURL, - now, - ) + storeBundle := buildStoreBundle(db, opts.Logger) + securityBundle := buildSecurityBundle(env, opts.Config, opts.Logger, storeBundle.auditStore, redisCache, storeBundle.tokenStatusRepo) + apiBundle, err := buildAPIBundle(env, opts.Config, now, tuning, opts.Logger, isProd, storeBundle) if err != nil { - return nil, fmt.Errorf("failed to initialize supply api: %w", err) - } - supplyAPI.SetWithdrawEnabled(opts.Config.Settlement.WithdrawEnabled) - - alertAPI, err := httpapi.NewAlertAPI(alertService) - if err != nil { - return nil, fmt.Errorf("failed to initialize alert api: %w", err) + return nil, err } return &Runtime{ @@ -259,11 +164,147 @@ func buildRuntimeWithFactory(opts RuntimeOptions, factory runtimeFactory) (*Runt serverConfig: normalizeServerConfig(opts.Config.Server), db: db, redisCache: redisCache, - supplyAPI: supplyAPI, - alertAPI: alertAPI, - authMiddleware: authMiddleware, - rateLimitConfig: rateLimitConfig, + supplyAPI: apiBundle.supplyAPI, + alertAPI: apiBundle.alertAPI, + authMiddleware: securityBundle.authMiddleware, + rateLimitConfig: apiBundle.rateLimitConfig, + revocationSubscriber: securityBundle.revocationSubscriber, + }, nil +} + +func buildStoreBundle(db *repository.DB, logger logging.Logger) runtimeStoreBundle { + var bundle runtimeStoreBundle + + if db != nil { + accountRepo := repository.NewAccountRepository(db.Pool) + packageRepo := repository.NewPackageRepository(db.Pool) + settlementRepo := repository.NewSettlementRepository(db.Pool) + usageRepo := repository.NewUsageRepository(db.Pool) + + bundle.idempotencyRepo = repository.NewIdempotencyRepository(db.Pool) + bundle.tokenStatusRepo = repository.NewTokenStatusRepository(db.Pool) + bundle.accountStore = adapter.NewDBAccountStore(accountRepo) + bundle.packageStore = adapter.NewDBPackageStore(packageRepo) + bundle.settlementStore = adapter.NewDBSettlementStore(settlementRepo, accountRepo, db.Pool) + bundle.earningStore = adapter.NewDBEarningStore(usageRepo) + bundle.auditStore = audit.NewPostgresAuditStore(auditrepo.NewPostgresAuditRepository(db.Pool)) + bundle.alertService = auditservice.NewAlertService(auditrepo.NewPostgresAlertRepository(db.Pool)) + bundle.fkValidator = repository.NewForeignKeyValidator(db.Pool) + + logger.Info("审计存储: 使用PostgreSQL (DB-backed)", nil) + logger.Info("告警存储: 使用PostgreSQL (DB-backed)", nil) + logger.Info("外键校验器: 已初始化 (PostgreSQL-backed)", nil) + return bundle + } + + bundle.accountStore = adapter.NewInMemoryAccountStoreAdapter() + bundle.packageStore = adapter.NewInMemoryPackageStoreAdapter() + bundle.settlementStore = adapter.NewInMemorySettlementStoreAdapter() + bundle.earningStore = adapter.NewInMemoryEarningStoreAdapter() + bundle.auditStore = audit.NewMemoryAuditStore() + bundle.alertService = auditservice.NewAlertService(auditservice.NewInMemoryAlertStore()) + + logger.Warn("审计存储使用内存实现 (生产环境不应使用)", nil) + logger.Warn("告警存储使用内存实现 (仅开发环境允许)", nil) + logger.Warn("外键校验器未启用 (db不可用)", nil) + return bundle +} + +func buildSecurityBundle( + env string, + cfg *config.Config, + logger logging.Logger, + auditStore audit.AuditStore, + redisCache *cache.RedisCache, + tokenStatusRepo *repository.TokenStatusRepository, +) runtimeSecurityBundle { + tokenCache := middleware.NewTokenCache() + var tokenBackend middleware.TokenStatusBackend + var revocationSubscriber revocationSubscriber + + if tokenStatusRepo != nil { + dbTokenBackend := middleware.NewDBTokenStatusBackend(tokenStatusRepo, redisCache, cfg.Token.RevocationCacheTTL) + tokenBackend = dbTokenBackend + revocationSubscriber = dbTokenBackend + logger.Info("Token状态后端: 使用PostgreSQL (DB-backed)", nil) + } else { + tokenBackend = adapter.NewMemoryTokenBackend() + logger.Warn("Token状态后端使用内存实现 (生产环境不应使用)", nil) + } + + return runtimeSecurityBundle{ + authMiddleware: middleware.NewAuthMiddleware(middleware.AuthConfig{ + SecretKey: cfg.Token.SecretKey, + PublicKey: cfg.Token.PublicKey, + Algorithm: cfg.Token.Algorithm, + Issuer: cfg.Token.Issuer, + CacheTTL: cfg.Token.RevocationCacheTTL, + Enabled: env != "dev", + }, tokenCache, tokenBackend, adapter.NewAuditEmitterAdapter(auditStore)), revocationSubscriber: revocationSubscriber, + } +} + +func buildAPIBundle( + env string, + cfg *config.Config, + now func() time.Time, + tuning runtimeTuning, + logger logging.Logger, + isProd bool, + storeBundle runtimeStoreBundle, +) (runtimeAPIBundle, error) { + _ = domain.NewInvariantChecker(storeBundle.accountStore, storeBundle.packageStore, storeBundle.settlementStore) + + accountService := domain.NewAccountService(storeBundle.accountStore, storeBundle.auditStore) + packageService := domain.NewPackageService(storeBundle.packageStore, storeBundle.accountStore, storeBundle.auditStore) + settlementService := domain.NewSettlementService(storeBundle.settlementStore, storeBundle.earningStore, storeBundle.auditStore) + earningService := domain.NewEarningService(storeBundle.earningStore) + + var idempotencyMiddleware *middleware.IdempotencyMiddleware + if storeBundle.idempotencyRepo != nil { + idempotencyMiddleware = middleware.NewIdempotencyMiddleware(storeBundle.idempotencyRepo, middleware.IdempotencyConfig{ + TTL: tuning.idempotencyTTL, + Enabled: env != "dev", + }) + logger.Info("幂等中间件已启用(DB-backed)", nil) + } else { + if isProd { + return runtimeAPIBundle{}, errors.New("idempotency repository unavailable") + } + logger.Warn("幂等中间件未启用(db或repo不可用)- 需要幂等的写接口将返回 503", nil) + } + + rateLimitConfig := middleware.DefaultRateLimitConfig() + rateLimitConfig.Enabled = env != "dev" + logger.Info("限流中间件已初始化", nil) + + supplyAPI, err := httpapi.NewSupplyAPI( + accountService, + packageService, + settlementService, + earningService, + idempotencyMiddleware, + storeBundle.auditStore, + storeBundle.fkValidator, + cfg.Server.DefaultSupplierID, + cfg.Server.StatementBaseURL, + now, + ) + if err != nil { + return runtimeAPIBundle{}, fmt.Errorf("failed to initialize supply api: %w", err) + } + supplyAPI.SetWithdrawEnabled(cfg.Settlement.WithdrawEnabled) + + alertAPI, err := httpapi.NewAlertAPI(storeBundle.alertService) + if err != nil { + return runtimeAPIBundle{}, fmt.Errorf("failed to initialize alert api: %w", err) + } + + return runtimeAPIBundle{ + supplyAPI: supplyAPI, + alertAPI: alertAPI, + rateLimitConfig: rateLimitConfig, }, nil } diff --git a/supply-api/internal/app/runtime_test.go b/supply-api/internal/app/runtime_test.go index 6ebe46b9..ed0e29ef 100644 --- a/supply-api/internal/app/runtime_test.go +++ b/supply-api/internal/app/runtime_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "lijiaoqiao/supply-api/internal/audit" "lijiaoqiao/supply-api/internal/cache" "lijiaoqiao/supply-api/internal/config" "lijiaoqiao/supply-api/internal/domain" @@ -64,6 +65,72 @@ func TestBuildRuntime_ProdRequiresDatabase(t *testing.T) { } } +func TestBuildStoreBundle_UsesInMemoryStoresWithoutDatabase(t *testing.T) { + bundle := buildStoreBundle(nil, testLogger{}) + if bundle.accountStore == nil { + t.Fatal("expected account store") + } + if bundle.packageStore == nil { + t.Fatal("expected package store") + } + if bundle.settlementStore == nil { + t.Fatal("expected settlement store") + } + if bundle.earningStore == nil { + t.Fatal("expected earning store") + } + if bundle.auditStore == nil { + t.Fatal("expected audit store") + } + if bundle.alertService == nil { + t.Fatal("expected alert service") + } + if bundle.tokenStatusRepo != nil { + t.Fatal("expected nil token status repo without database") + } + if bundle.idempotencyRepo != nil { + t.Fatal("expected nil idempotency repo without database") + } +} + +func TestBuildStoreBundle_UsesDatabaseBackedStoresWithDatabase(t *testing.T) { + bundle := buildStoreBundle(&repository.DB{}, testLogger{}) + if bundle.accountStore == nil { + t.Fatal("expected account store") + } + if bundle.packageStore == nil { + t.Fatal("expected package store") + } + if bundle.settlementStore == nil { + t.Fatal("expected settlement store") + } + if bundle.earningStore == nil { + t.Fatal("expected earning store") + } + if bundle.auditStore == nil { + t.Fatal("expected audit store") + } + if bundle.alertService == nil { + t.Fatal("expected alert service") + } + if bundle.tokenStatusRepo == nil { + t.Fatal("expected token status repo with database") + } + if bundle.idempotencyRepo == nil { + t.Fatal("expected idempotency repo with database") + } +} + +func TestBuildSecurityBundle_UsesMemoryTokenBackendWithoutRepository(t *testing.T) { + security := buildSecurityBundle("dev", testRuntimeConfig(), testLogger{}, audit.NewMemoryAuditStore(), nil, nil) + if security.authMiddleware == nil { + t.Fatal("expected auth middleware") + } + if security.revocationSubscriber != nil { + t.Fatal("expected nil revocation subscriber without token repository") + } +} + func TestResolveEnv_RejectsUnsupportedValue(t *testing.T) { _, err := ResolveEnv("qa") if err == nil {