Files
lijiaoqiao/supply-api/internal/app/runtime.go

403 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"lijiaoqiao/supply-api/internal/adapter"
"lijiaoqiao/supply-api/internal/audit"
auditrepo "lijiaoqiao/supply-api/internal/audit/repository"
auditservice "lijiaoqiao/supply-api/internal/audit/service"
"lijiaoqiao/supply-api/internal/cache"
"lijiaoqiao/supply-api/internal/config"
"lijiaoqiao/supply-api/internal/domain"
"lijiaoqiao/supply-api/internal/httpapi"
"lijiaoqiao/supply-api/internal/middleware"
"lijiaoqiao/supply-api/internal/pkg/logging"
"lijiaoqiao/supply-api/internal/repository"
)
// RuntimeOptions 定义构建 supply-api 运行时所需的输入。
type RuntimeOptions struct {
Env string
Config *config.Config
Logger logging.Logger
InitContext context.Context
Now func() time.Time
}
type runtimeTuning struct {
outboxStreamName string
outboxConsumerGroup string
idempotencyTTL time.Duration
partitionMaintenanceInterval time.Duration
compensationCheckInterval time.Duration
partitionedTables []string
}
// Runtime 聚合 HTTP 启动和后台任务启动所需的运行时依赖。
type Runtime struct {
env string
logger logging.Logger
now func() time.Time
tuning runtimeTuning
serverConfig config.ServerConfig
db *repository.DB
redisCache *cache.RedisCache
supplyAPI *httpapi.SupplyAPI
alertAPI *httpapi.AlertAPI
authMiddleware *middleware.AuthMiddleware
rateLimitConfig *middleware.RateLimitConfig
revocationSubscriber revocationSubscriber
}
type revocationSubscriber interface {
StartRevocationSubscriber(ctx context.Context) error
}
type runtimeFactory struct {
newDB func(ctx context.Context, cfg config.DatabaseConfig) (*repository.DB, error)
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{
newDB: repository.NewDB,
newRedisCache: cache.NewRedisCache,
})
}
func buildRuntimeWithFactory(opts RuntimeOptions, factory runtimeFactory) (*Runtime, error) {
if opts.Config == nil {
return nil, errors.New("config is required")
}
if opts.Logger == nil {
return nil, errors.New("logger is required")
}
if factory.newDB == nil {
factory.newDB = repository.NewDB
}
if factory.newRedisCache == nil {
factory.newRedisCache = cache.NewRedisCache
}
env, err := ResolveEnv(opts.Env)
if err != nil {
return nil, err
}
now := opts.Now
if now == nil {
now = time.Now
}
initCtx := opts.InitContext
if initCtx == nil {
initCtx = context.Background()
}
isProd := env == "prod"
tuning := defaultRuntimeTuning()
db, err := factory.newDB(initCtx, opts.Config.Database)
if err != nil {
if isProd {
return nil, fmt.Errorf("database unavailable: %w", err)
}
warnf(opts.Logger, "failed to connect to database: %v (using in-memory store)", err)
db = nil
} else if db != nil {
infof(opts.Logger, "connected to database at %s:%d", opts.Config.Database.Host, opts.Config.Database.Port)
}
redisCache, err := factory.newRedisCache(opts.Config.Redis)
if err != nil {
if isProd {
warnf(opts.Logger, "redis unavailable at startup: %v", err)
} else {
warnf(opts.Logger, "failed to connect to redis: %v (caching disabled)", err)
}
redisCache = nil
} else if redisCache != nil {
infof(opts.Logger, "connected to redis at %s:%d", opts.Config.Redis.Host, opts.Config.Redis.Port)
}
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, err
}
return &Runtime{
env: env,
logger: opts.Logger,
now: now,
tuning: tuning,
serverConfig: normalizeServerConfig(opts.Config.Server),
db: db,
redisCache: redisCache,
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 {
if db != nil {
bundle := buildDBStoreBundle(db)
logger.Info("审计存储: 使用PostgreSQL (DB-backed)", nil)
logger.Info("告警存储: 使用PostgreSQL (DB-backed)", nil)
logger.Info("外键校验器: 已初始化 (PostgreSQL-backed)", nil)
return bundle
}
bundle := buildMemoryStoreBundle()
logger.Warn("审计存储使用内存实现 (生产环境不应使用)", nil)
logger.Warn("告警存储使用内存实现 (仅开发环境允许)", nil)
logger.Warn("外键校验器未启用 (db不可用)", nil)
return bundle
}
func buildDBStoreBundle(db *repository.DB) runtimeStoreBundle {
accountRepo := repository.NewAccountRepository(db.Pool)
packageRepo := repository.NewPackageRepository(db.Pool)
settlementRepo := repository.NewSettlementRepository(db.Pool)
usageRepo := repository.NewUsageRepository(db.Pool)
return runtimeStoreBundle{
accountStore: adapter.NewDBAccountStore(accountRepo),
packageStore: adapter.NewDBPackageStore(packageRepo),
settlementStore: adapter.NewDBSettlementStore(settlementRepo, accountRepo, db.Pool),
earningStore: adapter.NewDBEarningStore(usageRepo),
auditStore: audit.NewPostgresAuditStore(auditrepo.NewPostgresAuditRepository(db.Pool)),
alertService: auditservice.NewAlertService(auditrepo.NewPostgresAlertRepository(db.Pool)),
fkValidator: repository.NewForeignKeyValidator(db.Pool),
tokenStatusRepo: repository.NewTokenStatusRepository(db.Pool),
idempotencyRepo: repository.NewIdempotencyRepository(db.Pool),
}
}
func buildMemoryStoreBundle() runtimeStoreBundle {
return runtimeStoreBundle{
accountStore: adapter.NewInMemoryAccountStoreAdapter(),
packageStore: adapter.NewInMemoryPackageStoreAdapter(),
settlementStore: adapter.NewInMemorySettlementStoreAdapter(),
earningStore: adapter.NewInMemoryEarningStoreAdapter(),
auditStore: audit.NewMemoryAuditStore(),
alertService: auditservice.NewAlertService(auditservice.NewInMemoryAlertStore()),
}
}
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
}
func defaultRuntimeTuning() runtimeTuning {
return runtimeTuning{
outboxStreamName: "supply:outbox:stream",
outboxConsumerGroup: "outbox-processor",
idempotencyTTL: 24 * time.Hour,
partitionMaintenanceInterval: time.Hour,
compensationCheckInterval: 5 * time.Minute,
partitionedTables: []string{
"audit_events",
"supply_usage_records",
"supply_idempotency_records",
},
}
}
// BuildServer 使用运行时依赖构建 HTTP server。
func (r *Runtime) BuildServer() (*http.Server, error) {
if r == nil {
return nil, errors.New("runtime is required")
}
var dbHealthCheck func(context.Context) error
var redisHealthCheck func(context.Context) error
if r.db != nil {
dbHealthCheck = r.db.HealthCheck
}
if r.redisCache != nil {
redisHealthCheck = r.redisCache.HealthCheck
}
return BuildServer(BuildServerOptions{
Env: r.env,
ServerConfig: r.serverConfig,
Logger: r.logger,
SupplyAPI: r.supplyAPI,
AlertAPI: r.alertAPI,
AuthMiddleware: r.authMiddleware,
RateLimitConfig: r.rateLimitConfig,
DBHealthCheck: dbHealthCheck,
RedisHealthCheck: redisHealthCheck,
})
}
// Close 关闭运行时持有的外部资源。
func (r *Runtime) Close() {
if r == nil {
return
}
if r.redisCache != nil {
_ = r.redisCache.Close()
}
if r.db != nil {
r.db.Close()
}
}
// ShutdownTimeout 返回服务优雅关闭超时时间。
func (r *Runtime) ShutdownTimeout() time.Duration {
if r == nil {
return 0
}
return r.serverConfig.ShutdownTimeout
}
func ResolveEnv(env string) (string, error) {
normalized := strings.ToLower(strings.TrimSpace(env))
if normalized == "" {
return "dev", nil
}
switch normalized {
case "dev", "staging", "prod":
return normalized, nil
default:
return "", fmt.Errorf("unsupported env %q", env)
}
}
func infof(logger logging.Logger, format string, args ...any) {
logger.Info(fmt.Sprintf(format, args...), nil)
}
func warnf(logger logging.Logger, format string, args ...any) {
logger.Warn(fmt.Sprintf(format, args...), nil)
}