feat(supply-api): gate and wire iam routes explicitly
This commit is contained in:
@@ -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 仅在数据库可用时启动。
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user