初始化第一版
This commit is contained in:
commit
8a6c31c14d
83 files changed
+14302
No files matched your search
@@ -0,0 +1,184 @@
|
||||
// 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.Enabled {
|
||||
if !cfg.SuperAgent.Enabled {
|
||||
return nil, fmt.Errorf("initialize chat: SuperAgent must be enabled")
|
||||
}
|
||||
superAgentClient, err := superagent.NewHTTPClient(superagent.Config{
|
||||
Enabled: cfg.SuperAgent.Enabled,
|
||||
BaseURL: cfg.SuperAgent.BaseURL,
|
||||
APIKey: cfg.SuperAgent.OpenAPIKey,
|
||||
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,
|
||||
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,
|
||||
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.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)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"fire-safety-ymd/internal/config"
|
||||
)
|
||||
|
||||
func TestNewKeepsExternalIntegrationsDisabledByDefault(t *testing.T) {
|
||||
application, err := New(context.Background(), config.Config{HTTPAddress: ":0"})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
healthRequest := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
healthResponse := httptest.NewRecorder()
|
||||
application.server.Handler.ServeHTTP(healthResponse, healthRequest)
|
||||
if healthResponse.Code != http.StatusOK {
|
||||
t.Fatalf("health status = %d, want %d", healthResponse.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
mcpRequest := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||
mcpResponse := httptest.NewRecorder()
|
||||
application.server.Handler.ServeHTTP(mcpResponse, mcpRequest)
|
||||
if mcpResponse.Code != http.StatusNotFound {
|
||||
t.Fatalf("MCP status = %d, want %d while disabled", mcpResponse.Code, http.StatusNotFound)
|
||||
}
|
||||
chatRequest := httptest.NewRequest(http.MethodPost, "/api/chat", nil)
|
||||
chatResponse := httptest.NewRecorder()
|
||||
application.server.Handler.ServeHTTP(chatResponse, chatRequest)
|
||||
if chatResponse.Code != http.StatusNotFound {
|
||||
t.Fatalf("chat status = %d, want %d while disabled", chatResponse.Code, http.StatusNotFound)
|
||||
}
|
||||
if application.server.ReadHeaderTimeout <= 0 || application.server.ReadTimeout <= 0 || application.server.WriteTimeout <= 0 || application.server.IdleTimeout <= 0 {
|
||||
t.Fatalf("HTTP timeouts are incomplete: %#v", application.server)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWiresChatAPIToSuperAgent(t *testing.T) {
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer provider-open-api-key" {
|
||||
t.Errorf("provider Authorization = %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/api/open/agent-sessions":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, `{"session_id":"provider-session-1"}`)
|
||||
case "/api/open/agent-sessions/provider-session-1/messages/stream":
|
||||
if r.URL.Query().Get("include_trace") != "true" {
|
||||
t.Errorf("include_trace = %q", r.URL.Query().Get("include_trace"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = fmt.Fprint(w, "event: trace\ndata: {\"event\":\"message.final\",\"text\":\"测试回答\",\"run_id\":\"run-1\"}\n\n")
|
||||
_, _ = fmt.Fprint(w, "event: trace\ndata: {\"event\":\"run.completed\",\"status\":\"success\",\"run_id\":\"run-1\"}\n\n")
|
||||
_, _ = fmt.Fprint(w, "event: end\n\n")
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer provider.Close()
|
||||
|
||||
application, err := New(context.Background(), config.Config{
|
||||
HTTPAddress: ":0",
|
||||
SuperAgent: config.SuperAgentConfig{
|
||||
Enabled: true,
|
||||
BaseURL: provider.URL,
|
||||
OpenAPIKey: "provider-open-api-key",
|
||||
ConnectTimeout: time.Second,
|
||||
RecoveryMaxAttempts: 1,
|
||||
RecoveryInitialBackoff: time.Millisecond,
|
||||
MaxMessageBytes: 4096,
|
||||
},
|
||||
Chat: config.ChatConfig{
|
||||
Enabled: true,
|
||||
AuthToken: "0123456789abcdef0123456789abcdef",
|
||||
SubjectID: "app-chat-test-subject",
|
||||
CompatAppID: "fire-safety-app",
|
||||
MaxBodyBytes: 4096,
|
||||
RunTimeout: time.Minute,
|
||||
SessionTTL: time.Minute,
|
||||
MaxSessions: 10,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
defer application.close()
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/chat", strings.NewReader(`{"message":"你好"}`))
|
||||
request.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "text/event-stream")
|
||||
response := httptest.NewRecorder()
|
||||
application.server.Handler.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "event: message") ||
|
||||
!strings.Contains(response.Body.String(), "测试回答") || !strings.Contains(response.Body.String(), "event: done") {
|
||||
t.Fatalf("chat status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
|
||||
compatRequest := httptest.NewRequest(http.MethodPost, "/api/v1/apps/fire-safety-app/completion", strings.NewReader(`{"input":{"prompt":"你好"},"parameters":{}}`))
|
||||
compatRequest.Header.Set("xtoken", "0123456789abcdef0123456789abcdef")
|
||||
compatRequest.Header.Set("Content-Type", "application/json")
|
||||
compatResponse := httptest.NewRecorder()
|
||||
application.server.Handler.ServeHTTP(compatResponse, compatRequest)
|
||||
if compatResponse.Code != http.StatusOK || !strings.Contains(compatResponse.Body.String(), "event: result") ||
|
||||
!strings.Contains(compatResponse.Body.String(), `"finish_reason":"stop"`) || !strings.Contains(compatResponse.Body.String(), "测试回答") {
|
||||
t.Fatalf("compat chat status=%d body=%s", compatResponse.Code, compatResponse.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user