Files
ai-customer-service/internal/app/app.go
Your Name 142b991334 fix(config+app): production fail-fast + readiness收紧
1. config.go: AI_CS_ENV runtime mode with production restriction
   - New RuntimeConfig.Env field (AI_CS_ENV / AI_CS_RUNTIME_ENV)
   - production + Postgres.Enabled=false → Load() returns error
   - production + empty webhook secret → Load() returns error
   - normalizeRuntimeEnv: dev/dev/ → development, prod/production → production, test → test

2. app.go: probe.SetReady only when store is confirmed ready
   - Postgres.Enabled: probe.SetReady(true) after DB+migration OK
   - Memory mode: probe.SetReady(false) — not production-ready

3. health_handler_test.go: add probe live+ready state transition tests

4. config_test.go: add TestLoad_RejectsProdWhenPostgresDisabled,
   TestLoad_RejectsProdWhenWebhookSecretMissing

5. app_test.go: add TestNew_RejectsMemoryModeWithoutExplicitNonProdEnv,
   TestNew_AllowsMemoryModeInTestEnv, TestNew_WithPostgresEnabled_*
   for invalid DSN and migration-failure paths

Phase 1 (code gate) objectives met:
 prod cannot fall back to memory store
 readiness reflects actual store readiness
 both changes have test coverage
2026-05-04 07:38:10 +08:00

153 lines
5.5 KiB
Go

package app
import (
"context"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/bridge/ai-customer-service/internal/config"
"github.com/bridge/ai-customer-service/internal/domain/ticket"
"github.com/bridge/ai-customer-service/internal/domain/ticketstats"
httpserver "github.com/bridge/ai-customer-service/internal/http"
"github.com/bridge/ai-customer-service/internal/http/handlers"
"github.com/bridge/ai-customer-service/internal/platform/health"
"github.com/bridge/ai-customer-service/internal/platform/httpx"
"github.com/bridge/ai-customer-service/internal/service/dialog"
"github.com/bridge/ai-customer-service/internal/service/handoff"
intentservice "github.com/bridge/ai-customer-service/internal/service/intent"
"github.com/bridge/ai-customer-service/internal/service/reply"
memoryStore "github.com/bridge/ai-customer-service/internal/store/memory"
pgstore "github.com/bridge/ai-customer-service/internal/store/postgres"
)
type App struct {
Server *http.Server
Probe *health.Probe
Logger *slog.Logger
closers []func() error
ticketStore ticketLister
}
// ticketLister abstracts the ticket store for test access.
type ticketLister interface {
ListAll(ctx context.Context) ([]ticket.Ticket, error)
GetStats(ctx context.Context) (ticketstats.Stats, error)
}
func New(cfg *config.Config, logger *slog.Logger) (*App, error) {
if cfg == nil {
return nil, fmt.Errorf("config is required")
}
if logger == nil {
logger = slog.Default()
}
if !cfg.Postgres.Enabled && cfg.Runtime.Env == "" {
return nil, fmt.Errorf("runtime env is required when postgres is disabled; memory mode must be explicitly limited to non-prod")
}
var (
sessions dialog.SessionRepository
audits dialog.AuditRepository
tickets dialog.TicketRepository
dedup dialog.DedupRepository
ticketService handlers.TicketService
checkers []health.Checker
closers []func() error
ticketListerStore ticketLister
sessionStore dialog.SessionRepository
ticketStore dialog.TicketRepository
)
probe := health.NewProbe()
if cfg.Postgres.Enabled {
db, err := pgstore.Open(pgstore.Config{DSN: cfg.Postgres.DSN, MaxOpenConns: cfg.Postgres.MaxOpenConns, MaxIdleConns: cfg.Postgres.MaxIdleConns, ConnMaxLifetime: time.Duration(cfg.Postgres.ConnMaxLifetime) * time.Second})
if err != nil {
return nil, err
}
if err := pgstore.RunMigrations(db, cfg.Postgres.MigrationDir); err != nil {
_ = db.Close()
return nil, err
}
sessionStore := pgstore.NewSessionStore(db)
auditStore := pgstore.NewAuditStore(db)
ticketStore := pgstore.NewTicketStore(db)
dedupStore := pgstore.NewDedupStore(db)
sessions = sessionStore
audits = auditStore
tickets = ticketStore
dedup = dedupStore
ticketService = pgstore.NewTicketWorkflowStore(db, auditStore)
checkers = append(checkers, pgstore.NewDBChecker(db))
closers = append(closers, db.Close)
ticketListerStore = ticketStore
probe.SetReady(true)
} else {
sessionStore := memoryStore.NewSessionStore()
auditStore := memoryStore.NewAuditStore()
ticketStore := memoryStore.NewTicketStore()
dedupStore := memoryStore.NewDedupStore()
sessions = sessionStore
audits = auditStore
tickets = ticketStore
dedup = dedupStore
ticketService = ticketStore
ticketListerStore = ticketStore
probe.SetReady(false)
}
knowledgeStore := memoryStore.NewKnowledgeStore()
intentSvc := intentservice.NewService()
replySvc := reply.NewService(knowledgeStore)
handoffSvc := handoff.NewService()
dialogSvc := dialog.NewService(sessions, audits, tickets, dedup, intentSvc, replySvc, handoffSvc)
rateLimiter := httpx.NewRateLimiter(time.Second, 10)
healthHandler := handlers.NewHealthHandler(probe, checkers...)
webhookHandler := handlers.NewWebhookHandler(dialogSvc, logger, audits)
ticketHandler := handlers.NewTicketHandler(ticketService, audits)
ticketStatsHandler := handlers.NewTicketStatsHandler(ticketListerStore, audits)
sessionHandler := handlers.NewSessionHandler(sessionStore, ticketStore, audits)
webhookSecurity := handlers.WebhookSecurity{Secret: cfg.Webhook.Secret, TimestampHeader: cfg.Webhook.TimestampHeader, SignatureHeader: cfg.Webhook.SignatureHeader, MaxSkew: time.Duration(cfg.Webhook.MaxSkewSeconds) * time.Second, Audit: audits}
router := httpserver.NewRouter(httpserver.RouterDeps{Health: healthHandler, Webhook: webhookHandler, Tickets: ticketHandler, TicketStats: ticketStatsHandler, Sessions: sessionHandler, WebhookAuth: webhookSecurity, MaxBodyBytes: cfg.HTTP.MaxBodyBytes, RateLimiter: rateLimiter})
return &App{
Server: &http.Server{
Addr: cfg.HTTP.Addr,
Handler: router,
ReadHeaderTimeout: time.Duration(cfg.HTTP.ReadHeaderTimeout) * time.Second,
ReadTimeout: time.Duration(cfg.HTTP.ReadTimeout) * time.Second,
WriteTimeout: time.Duration(cfg.HTTP.WriteTimeout) * time.Second,
IdleTimeout: time.Duration(cfg.HTTP.IdleTimeout) * time.Second,
MaxHeaderBytes: cfg.HTTP.MaxHeaderBytes,
},
Probe: probe,
Logger: logger,
closers: closers,
ticketStore: ticketListerStore,
}, nil
}
func (a *App) TicketStore() ticketLister {
return a.ticketStore
}
func (a *App) Shutdown(ctx context.Context) error {
if a == nil || a.Server == nil {
return nil
}
if a.Probe != nil {
a.Probe.SetReady(false)
a.Probe.SetLive(false)
}
err := a.Server.Shutdown(ctx)
for _, closeFn := range a.closers {
if closeErr := closeFn(); err == nil && closeErr != nil {
err = closeErr
}
}
return err
}