Files
lijiaoqiao/projects/ai-customer-service/internal/app/app.go
Your Name 687c4535f8 fix: P0-1 RateLimiter并发写安全 + P0-2工单操作错误码区分 + P1 rows.Close修复
P0-1 (limits.go): Allow()方法改为全程使用写锁保护counters map读写,避免RLock写入时的data race
P0-2 (ticket_workflow.go+ticket_handler.go): Assign/Resolve/Close操作先查询ticket存在性和状态,返回明确的CS_TICKET_4001/CS_TKT_4002/CS_TICKET_4092/CS_TICKET_4093错误码,handler根据错误前缀路由HTTP状态码
P1-1 (ticket_store.go): 移除GetStats中3处手动rows.Close(),只保留defer Close()
2026-05-01 20:56:25 +08:00

149 lines
5.3 KiB
Go

package app
import (
"context"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/bridge/ai-customer-service/internal/config"
httpserver "github.com/bridge/ai-customer-service/internal/http"
"github.com/bridge/ai-customer-service/internal/domain/ticketstats"
"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"
intentservice "github.com/bridge/ai-customer-service/internal/service/intent"
"github.com/bridge/ai-customer-service/internal/service/dialog"
"github.com/bridge/ai-customer-service/internal/service/handoff"
"github.com/bridge/ai-customer-service/internal/service/reply"
"github.com/bridge/ai-customer-service/internal/domain/ticket"
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()
}
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
)
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
} 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
}
knowledgeStore := memoryStore.NewKnowledgeStore()
intentSvc := intentservice.NewService()
replySvc := reply.NewService(knowledgeStore)
handoffSvc := handoff.NewService()
dialogSvc := dialog.NewService(sessions, audits, tickets, dedup, intentSvc, replySvc, handoffSvc)
// P1-2: webhook rate limiter — 10 messages per second per IP
rateLimiter := httpx.NewRateLimiter(time.Second, 10)
probe := health.NewProbe()
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})
probe.SetReady(true)
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
}