Files
fire-safety-ymd/internal/app/app.go
2026-09-06 01:40:15 +08:00

212 lines
6.6 KiB
Go

// Package app assembles the application and owns process-level lifecycles.
package app
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"time"
"fire-safety-ymd/internal/config"
"fire-safety-ymd/internal/domain"
"fire-safety-ymd/internal/handler"
"fire-safety-ymd/internal/integration/superagent"
"fire-safety-ymd/internal/repository"
"fire-safety-ymd/internal/service"
)
const (
readHeaderTimeout = 5 * time.Second
readTimeout = 15 * time.Second
writeTimeout = 35 * time.Second
idleTimeout = 60 * time.Second
shutdownTimeout = 10 * time.Second
readinessTimeout = 30 * time.Second
)
// Application owns the HTTP server and coordinates graceful shutdown.
type Application struct {
server *http.Server
close func()
}
// New wires the application's current dependencies.
func New(ctx context.Context, cfg config.Config) (*Application, error) {
routerOptions := handler.RouterOptions{}
closeDependencies := func() {}
if cfg.Chat.PageEnabled {
if !cfg.Chat.Enabled {
return nil, fmt.Errorf("initialize chat page: chat must be enabled")
}
if cfg.Chat.CompatAppID == "" {
return nil, fmt.Errorf("initialize chat page: compatibility app ID is required")
}
if len(cfg.Chat.AllowedOrigins) == 0 {
return nil, fmt.Errorf("initialize chat page: at least one allowed origin is required")
}
}
if cfg.Chat.Enabled {
if !cfg.SuperAgent.Enabled {
return nil, fmt.Errorf("initialize chat: SuperAgent must be enabled")
}
if cfg.Chat.AllowLegacyShortToken {
log.Printf("security warning: legacy short chat auth token compatibility is enabled")
}
superAgentClient, err := superagent.NewHTTPClient(superagent.Config{
Enabled: cfg.SuperAgent.Enabled,
BaseURL: cfg.SuperAgent.BaseURL,
APIKey: cfg.SuperAgent.OpenAPIKey,
IncludeTrace: cfg.SuperAgent.IncludeTrace,
ConnectTimeout: cfg.SuperAgent.ConnectTimeout,
RecoveryMaxAttempts: cfg.SuperAgent.RecoveryMaxAttempts,
RecoveryInitialBackoff: cfg.SuperAgent.RecoveryInitialBackoff,
MaxMessageBytes: cfg.SuperAgent.MaxMessageBytes,
})
if err != nil {
return nil, fmt.Errorf("initialize SuperAgent client: %w", err)
}
chatAgent, err := superagent.NewChatAgentAdapter(superAgentClient)
if err != nil {
return nil, fmt.Errorf("initialize SuperAgent chat adapter: %w", err)
}
chatService, err := service.NewChatService(chatAgent, service.ChatOptions{
ExternalSubjectID: cfg.Chat.SubjectID,
MaxMessageBytes: cfg.SuperAgent.MaxMessageBytes,
SessionTTL: cfg.Chat.SessionTTL,
MaxSessions: cfg.Chat.MaxSessions,
})
if err != nil {
return nil, fmt.Errorf("initialize chat service: %w", err)
}
chatHandler, err := handler.NewChatHandler(chatService, handler.ChatOptions{
AuthToken: cfg.Chat.AuthToken,
AllowLegacyShortToken: cfg.Chat.AllowLegacyShortToken,
AllowedOrigins: cfg.Chat.AllowedOrigins,
MaxBodyBytes: cfg.Chat.MaxBodyBytes,
RunTimeout: cfg.Chat.RunTimeout,
Logger: log.Default(),
})
if err != nil {
return nil, fmt.Errorf("initialize chat handler: %w", err)
}
routerOptions.Chat = chatHandler
if cfg.Chat.CompatAppID != "" {
dashScopeChatHandler, err := handler.NewDashScopeChatHandler(chatService, handler.DashScopeChatOptions{
AppID: cfg.Chat.CompatAppID,
AuthToken: cfg.Chat.AuthToken,
AllowLegacyShortToken: cfg.Chat.AllowLegacyShortToken,
AllowedOrigins: cfg.Chat.AllowedOrigins,
MaxBodyBytes: cfg.Chat.MaxBodyBytes,
RunTimeout: cfg.Chat.RunTimeout,
Logger: log.Default(),
})
if err != nil {
return nil, fmt.Errorf("initialize DashScope-compatible chat handler: %w", err)
}
routerOptions.DashScopeChat = dashScopeChatHandler
}
if cfg.Chat.PageEnabled {
chatPageHandler, err := handler.NewChatPageHandler(handler.ChatPageOptions{
AppID: cfg.Chat.CompatAppID,
})
if err != nil {
return nil, fmt.Errorf("initialize chat page handler: %w", err)
}
routerOptions.ChatPage = chatPageHandler
}
}
if cfg.MCP.Enabled {
log.Printf("MCP data scope mode=%s", cfg.MCP.ScopeMode)
postGIS, err := repository.OpenPostGIS(ctx, repository.PostGISOptions{
DSN: cfg.PostGIS.DSN,
MaxConns: cfg.PostGIS.MaxConns,
ConnectTimeout: cfg.PostGIS.ConnectTimeout,
QueryTimeout: cfg.PostGIS.QueryTimeout,
})
if err != nil {
return nil, fmt.Errorf("initialize PostGIS: %w", err)
}
closeDependencies = postGIS.Close
readinessCtx, cancel := context.WithTimeout(ctx, readinessTimeout)
report, err := postGIS.ValidateForMCP(readinessCtx, cfg.PostGIS.ExpectedSRID)
cancel()
if err != nil {
postGIS.Close()
return nil, fmt.Errorf("validate PostGIS for MCP: %w", err)
}
for _, warning := range report.Warnings {
log.Printf("PostGIS readiness warning=%s", warning)
}
spatialService, err := service.NewSpatialService(postGIS, domain.SpatialScope{
AllTowns: cfg.MCP.ScopeMode == config.MCPScopeModeAll,
AllowedTowns: cfg.MCP.AllowedTowns,
}, cfg.PostGIS.QueryTimeout)
if err != nil {
postGIS.Close()
return nil, fmt.Errorf("initialize spatial service: %w", err)
}
mcpHandler, err := handler.NewMCPHandler(spatialService, handler.MCPOptions{
AuthToken: cfg.MCP.AuthToken,
MaxBodyBytes: cfg.MCP.MaxBodyBytes,
ToolTimeout: cfg.MCP.ToolTimeout,
Logger: log.Default(),
})
if err != nil {
postGIS.Close()
return nil, fmt.Errorf("initialize MCP handler: %w", err)
}
routerOptions.MCP = mcpHandler
}
return &Application{
server: &http.Server{
Addr: cfg.HTTPAddress,
Handler: handler.NewRouter(routerOptions),
ReadHeaderTimeout: readHeaderTimeout,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
IdleTimeout: idleTimeout,
},
close: closeDependencies,
}, nil
}
// Run starts the HTTP server and shuts it down when ctx is canceled.
func (a *Application) Run(ctx context.Context) error {
defer a.close()
serverErr := make(chan error, 1)
go func() {
serverErr <- a.server.ListenAndServe()
}()
select {
case err := <-serverErr:
return normalizeServerError(err)
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := a.server.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shut down HTTP server: %w", err)
}
return normalizeServerError(<-serverErr)
}
}
func normalizeServerError(err error) error {
if err == nil || errors.Is(err, http.ErrServerClosed) {
return nil
}
return fmt.Errorf("serve HTTP: %w", err)
}