feat(supply-api): gate and wire iam routes explicitly

This commit is contained in:
Your Name
2026-04-17 19:19:37 +08:00
parent 9279e65cd7
commit 9bb1d6ce3e
6 changed files with 141 additions and 1 deletions

View File

@@ -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 仅在数据库可用时启动。

View File

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

View File

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

View File

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

View File

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

View File

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