From 9bb1d6ce3ef05726a147d498893cbcb239401ed8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 17 Apr 2026 19:19:37 +0800 Subject: [PATCH] feat(supply-api): gate and wire iam routes explicitly --- supply-api/README.md | 1 + supply-api/internal/app/bootstrap.go | 12 +++++ supply-api/internal/app/bootstrap_test.go | 38 +++++++++++++++ supply-api/internal/app/runtime.go | 28 ++++++++++- supply-api/internal/app/runtime_test.go | 59 +++++++++++++++++++++++ supply-api/internal/config/config.go | 4 ++ 6 files changed, 141 insertions(+), 1 deletion(-) diff --git a/supply-api/README.md b/supply-api/README.md index 09e8afaa..b69fee3c 100644 --- a/supply-api/README.md +++ b/supply-api/README.md @@ -8,6 +8,7 @@ - PostgreSQL 可用时,会装配 DB-backed 的账户、套餐、结算、收益、审计、告警、token 状态、Outbox 与补偿链路。 - 数据库不可用时,开发模式下仍保留部分内存降级路径;当前仓库不把这种模式视为生产可用状态。 - 告警 API 在 PostgreSQL 可用时走数据库仓储;数据库不可用时才显式回退内存实现。 +- IAM 路由默认不进入对外交付面;只有 `server.iam_enabled=true` 且 runtime 具备数据库依赖时才注册 `/api/v1/iam/*`。 - 依赖幂等仓储的写接口在中间件缺失时会返回 `503 SUP_HTTP_5031`,不再静默切回内联逻辑。 - Outbox processor 与补偿 worker 仅在数据库可用时启动。 diff --git a/supply-api/internal/app/bootstrap.go b/supply-api/internal/app/bootstrap.go index 7b33759a..2c6948e8 100644 --- a/supply-api/internal/app/bootstrap.go +++ b/supply-api/internal/app/bootstrap.go @@ -13,6 +13,10 @@ import ( "lijiaoqiao/supply-api/internal/pkg/logging" ) +type routeRegistrar interface { + RegisterRoutes(mux *http.ServeMux) +} + // BuildServerOptions 定义 HTTP 服务装配所需的最小输入。 type BuildServerOptions struct { Env string @@ -20,6 +24,7 @@ type BuildServerOptions struct { Logger logging.Logger SupplyAPI *httpapi.SupplyAPI AlertAPI *httpapi.AlertAPI + IAMHandler routeRegistrar AuthMiddleware *middleware.AuthMiddleware RateLimitConfig *middleware.RateLimitConfig DBHealthCheck func(context.Context) error @@ -29,6 +34,7 @@ type BuildServerOptions struct { type buildRouteMuxOptions struct { SupplyAPI *httpapi.SupplyAPI AlertAPI *httpapi.AlertAPI + IAMHandler routeRegistrar DBHealthCheck func(context.Context) error RedisHealthCheck func(context.Context) error } @@ -46,6 +52,7 @@ type resolvedBuildServerOptions struct { Logger logging.Logger SupplyAPI *httpapi.SupplyAPI AlertAPI *httpapi.AlertAPI + IAMHandler routeRegistrar AuthMiddleware *middleware.AuthMiddleware RateLimitConfig *middleware.RateLimitConfig DBHealthCheck func(context.Context) error @@ -62,6 +69,7 @@ func BuildServer(opts BuildServerOptions) (*http.Server, error) { mux := buildRouteMux(buildRouteMuxOptions{ SupplyAPI: resolved.SupplyAPI, AlertAPI: resolved.AlertAPI, + IAMHandler: resolved.IAMHandler, DBHealthCheck: resolved.DBHealthCheck, RedisHealthCheck: resolved.RedisHealthCheck, }) @@ -107,6 +115,7 @@ func resolveBuildServerOptions(opts BuildServerOptions) (resolvedBuildServerOpti Logger: opts.Logger, SupplyAPI: opts.SupplyAPI, AlertAPI: opts.AlertAPI, + IAMHandler: opts.IAMHandler, AuthMiddleware: opts.AuthMiddleware, RateLimitConfig: resolveRateLimitConfig(env, opts.RateLimitConfig), DBHealthCheck: opts.DBHealthCheck, @@ -149,6 +158,9 @@ func buildRouteMux(opts buildRouteMuxOptions) *http.ServeMux { healthHandler.RegisterRoutes(mux) opts.SupplyAPI.Register(mux) opts.AlertAPI.Register(mux) + if opts.IAMHandler != nil { + opts.IAMHandler.RegisterRoutes(mux) + } return mux } diff --git a/supply-api/internal/app/bootstrap_test.go b/supply-api/internal/app/bootstrap_test.go index 5cf4f21b..9ec64ce3 100644 --- a/supply-api/internal/app/bootstrap_test.go +++ b/supply-api/internal/app/bootstrap_test.go @@ -13,6 +13,8 @@ import ( "lijiaoqiao/supply-api/internal/config" "lijiaoqiao/supply-api/internal/domain" "lijiaoqiao/supply-api/internal/httpapi" + iamhandler "lijiaoqiao/supply-api/internal/iam/handler" + iamservice "lijiaoqiao/supply-api/internal/iam/service" "lijiaoqiao/supply-api/internal/middleware" ) @@ -204,6 +206,42 @@ func TestBuildRouteMux_RegistersHealthAndSupplyRoutes(t *testing.T) { } } +func TestBuildRouteMux_KeepsIAMRoutesDisabledByDefault(t *testing.T) { + supplyAPI, alertAPI := mustBuildTestAPIs(t) + + mux := buildRouteMux(buildRouteMuxOptions{ + SupplyAPI: supplyAPI, + AlertAPI: alertAPI, + }) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/iam/roles", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected IAM route to stay unregistered, got=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestBuildRouteMux_RegistersIAMRoutesWhenHandlerProvided(t *testing.T) { + supplyAPI, alertAPI := mustBuildTestAPIs(t) + iamAPI := iamhandler.NewIAMHandler(iamservice.NewDefaultIAMService()) + + mux := buildRouteMux(buildRouteMuxOptions{ + SupplyAPI: supplyAPI, + AlertAPI: alertAPI, + IAMHandler: iamAPI, + }) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/iam/roles", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected IAM route to be registered, got=%d body=%s", rec.Code, rec.Body.String()) + } +} + func mustBuildTestAPIs(t *testing.T) (*httpapi.SupplyAPI, *httpapi.AlertAPI) { t.Helper() diff --git a/supply-api/internal/app/runtime.go b/supply-api/internal/app/runtime.go index 0f6d3977..c5bd70c9 100644 --- a/supply-api/internal/app/runtime.go +++ b/supply-api/internal/app/runtime.go @@ -16,6 +16,9 @@ import ( "lijiaoqiao/supply-api/internal/config" "lijiaoqiao/supply-api/internal/domain" "lijiaoqiao/supply-api/internal/httpapi" + iamhandler "lijiaoqiao/supply-api/internal/iam/handler" + iamrepo "lijiaoqiao/supply-api/internal/iam/repository" + iamservice "lijiaoqiao/supply-api/internal/iam/service" "lijiaoqiao/supply-api/internal/middleware" "lijiaoqiao/supply-api/internal/pkg/logging" "lijiaoqiao/supply-api/internal/repository" @@ -60,6 +63,7 @@ type runtimeHTTPStartupView struct { serverConfig config.ServerConfig supplyAPI *httpapi.SupplyAPI alertAPI *httpapi.AlertAPI + iamHandler routeRegistrar authMiddleware *middleware.AuthMiddleware rateLimitConfig *middleware.RateLimitConfig } @@ -111,6 +115,7 @@ type runtimeSecurityBundle struct { type runtimeAPIBundle struct { supplyAPI *httpapi.SupplyAPI alertAPI *httpapi.AlertAPI + iamHandler routeRegistrar rateLimitConfig *middleware.RateLimitConfig } @@ -125,6 +130,7 @@ type runtimeHTTPView struct { serverConfig config.ServerConfig supplyAPI *httpapi.SupplyAPI alertAPI *httpapi.AlertAPI + iamHandler routeRegistrar authMiddleware *middleware.AuthMiddleware rateLimitConfig *middleware.RateLimitConfig healthChecks runtimeHealthChecks @@ -151,7 +157,7 @@ func buildRuntimeWithFactory(opts RuntimeOptions, factory runtimeFactory) (*Runt storeBundle := buildStoreBundle(resources.db, inputs.logger) securityBundle := buildSecurityBundle(inputs.env, inputs.cfg, inputs.logger, storeBundle.auditStore, resources.redisCache, storeBundle.tokenStatusRepo) - apiBundle, err := buildAPIBundle(inputs.env, inputs.cfg, inputs.now, inputs.tuning, inputs.logger, inputs.isProd, storeBundle) + apiBundle, err := buildAPIBundle(inputs.env, inputs.cfg, inputs.now, inputs.tuning, inputs.logger, inputs.isProd, resources.db, storeBundle) if err != nil { return nil, err } @@ -257,6 +263,7 @@ func buildRuntimeStartupViews( serverConfig: normalizeServerConfig(serverConfig), supplyAPI: apiBundle.supplyAPI, alertAPI: apiBundle.alertAPI, + iamHandler: apiBundle.iamHandler, authMiddleware: securityBundle.authMiddleware, rateLimitConfig: apiBundle.rateLimitConfig, }, @@ -359,6 +366,7 @@ func buildAPIBundle( tuning runtimeTuning, logger logging.Logger, isProd bool, + db *repository.DB, storeBundle runtimeStoreBundle, ) (runtimeAPIBundle, error) { _ = domain.NewInvariantChecker(storeBundle.accountStore, storeBundle.packageStore, storeBundle.settlementStore) @@ -367,6 +375,7 @@ func buildAPIBundle( packageService := domain.NewPackageService(storeBundle.packageStore, storeBundle.accountStore, storeBundle.auditStore) settlementService := domain.NewSettlementService(storeBundle.settlementStore, storeBundle.earningStore, storeBundle.auditStore) earningService := domain.NewEarningService(storeBundle.earningStore) + var iamAPI routeRegistrar var idempotencyMiddleware *middleware.IdempotencyMiddleware if storeBundle.idempotencyRepo != nil { @@ -386,6 +395,20 @@ func buildAPIBundle( rateLimitConfig.Enabled = env != "dev" logger.Info("限流中间件已初始化", nil) + if cfg.Server.IAMEnabled { + if db == nil { + return runtimeAPIBundle{}, errors.New("iam requires database-backed runtime") + } + iamAPI = iamhandler.NewIAMHandler( + iamservice.NewDatabaseIAMService( + iamrepo.NewPostgresIAMRepository(db.Pool), + ), + ) + logger.Info("IAM 路由已启用(DB-backed)", nil) + } else { + logger.Info("IAM 路由未启用(server.iam_enabled=false)", nil) + } + supplyAPI, err := httpapi.NewSupplyAPI( accountService, packageService, @@ -411,6 +434,7 @@ func buildAPIBundle( return runtimeAPIBundle{ supplyAPI: supplyAPI, alertAPI: alertAPI, + iamHandler: iamAPI, rateLimitConfig: rateLimitConfig, }, nil } @@ -464,6 +488,7 @@ func buildRuntimeHTTPView(runtime *Runtime) (runtimeHTTPView, error) { serverConfig: runtime.startupViews.http.serverConfig, supplyAPI: runtime.startupViews.http.supplyAPI, alertAPI: runtime.startupViews.http.alertAPI, + iamHandler: runtime.startupViews.http.iamHandler, authMiddleware: runtime.startupViews.http.authMiddleware, rateLimitConfig: runtime.startupViews.http.rateLimitConfig, healthChecks: resolveRuntimeHealthChecks(runtime), @@ -477,6 +502,7 @@ func adaptRuntimeHTTPViewToBuildServerOptions(view runtimeHTTPView) BuildServerO Logger: view.logger, SupplyAPI: view.supplyAPI, AlertAPI: view.alertAPI, + IAMHandler: view.iamHandler, AuthMiddleware: view.authMiddleware, RateLimitConfig: view.rateLimitConfig, DBHealthCheck: view.healthChecks.DBHealthCheck, diff --git a/supply-api/internal/app/runtime_test.go b/supply-api/internal/app/runtime_test.go index 5a728585..f9cef65f 100644 --- a/supply-api/internal/app/runtime_test.go +++ b/supply-api/internal/app/runtime_test.go @@ -347,6 +347,65 @@ func TestBuildRuntime_DevFallsBackToInMemoryDependencies(t *testing.T) { if runtime.startupViews.http.rateLimitConfig == nil { t.Fatal("expected rate limit config to be initialized") } + if runtime.startupViews.http.iamHandler != nil { + t.Fatal("expected IAM routes to stay disabled by default") + } +} + +func TestBuildRuntime_EnableIAMRequiresDatabaseBackedRuntime(t *testing.T) { + cfg := testRuntimeConfig() + cfg.Server.IAMEnabled = true + + _, err := buildRuntimeWithFactory(RuntimeOptions{ + Env: "dev", + Config: cfg, + 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, nil + }, + }) + if err == nil { + t.Fatal("expected IAM-enabled runtime build to reject missing database") + } + if !strings.Contains(err.Error(), "iam requires database-backed runtime") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBuildRuntime_EnablesIAMRoutesWhenConfigured(t *testing.T) { + cfg := testRuntimeConfig() + cfg.Server.IAMEnabled = true + + runtime, err := buildRuntimeWithFactory(RuntimeOptions{ + Env: "dev", + Config: cfg, + 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 &repository.DB{}, nil + }, + newRedisCache: func(config.RedisConfig) (*cache.RedisCache, error) { + return nil, nil + }, + }) + if err != nil { + t.Fatalf("expected IAM-enabled runtime build to succeed, got %v", err) + } + if runtime.startupViews.http.iamHandler == nil { + t.Fatal("expected IAM handler to be wired when enabled") + } } func TestBuildRuntime_NormalizesServerConfigDefaults(t *testing.T) { diff --git a/supply-api/internal/config/config.go b/supply-api/internal/config/config.go index 9621bfe9..b2687ae6 100644 --- a/supply-api/internal/config/config.go +++ b/supply-api/internal/config/config.go @@ -29,6 +29,7 @@ type ServerConfig struct { ShutdownTimeout time.Duration DefaultSupplierID int64 // 默认供应商ID(仅用于开发/单供应商模式) StatementBaseURL string // 账单PDF下载基础URL + IAMEnabled bool // 是否显式启用 IAM HTTP 能力 } // DatabaseConfig PostgreSQL配置 @@ -162,6 +163,7 @@ func load(env, configPath string) (*Config, error) { cfg.Server.ShutdownTimeout = v.GetDuration("server.shutdown_timeout") cfg.Server.DefaultSupplierID = v.GetInt64("server.default_supplier_id") cfg.Server.StatementBaseURL = v.GetString("server.statement_base_url") + cfg.Server.IAMEnabled = v.GetBool("server.iam_enabled") // Database配置 cfg.Database.Host = v.GetString("database.host") @@ -215,6 +217,7 @@ func setDefaults(v *viper.Viper) { v.SetDefault("server.shutdown_timeout", 5*time.Second) v.SetDefault("server.default_supplier_id", 1) v.SetDefault("server.statement_base_url", "https://example.com/statements") + v.SetDefault("server.iam_enabled", false) // Database defaults v.SetDefault("database.host", "localhost") @@ -255,6 +258,7 @@ func bindEnvVars(v *viper.Viper) { _ = v.BindEnv("server.addr", "SUPPLY_API_ADDR") _ = v.BindEnv("server.read_timeout", "SUPPLY_API_READ_TIMEOUT") _ = v.BindEnv("server.write_timeout", "SUPPLY_API_WRITE_TIMEOUT") + _ = v.BindEnv("server.iam_enabled", "SUPPLY_API_IAM_ENABLED") _ = v.BindEnv("database.host", "SUPPLY_DB_HOST") _ = v.BindEnv("database.port", "SUPPLY_DB_PORT")