diff --git a/internal/server/server.go b/internal/server/server.go index 7297aa9e..cfcd6ed9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -4,6 +4,9 @@ import ( "context" "fmt" "net/http" + "os" + "path/filepath" + "strings" "sync" "time" @@ -91,9 +94,56 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("POST /api/v1/docgov/comments", s.handleDocGovAddComment) mux.HandleFunc("GET /api/v1/docgov/audit-logs", s.handleDocGovListAuditLogs) + // 9. 现代 SPA 前端页面与静态资产托管 + mux.HandleFunc("GET /", s.serveSPA) + return s.corsMiddleware(mux) } +// serveSPA 托管 web/dist 静态产物并提供前端路由自动回退 +func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") { + http.NotFound(w, r) + return + } + + candidates := []string{ + "web/dist", + "../web/dist", + "../../web/dist", + filepath.Join(s.cfg.DataDir, "web/dist"), + filepath.Join(s.cfg.DataDir, "../web/dist"), + } + + var distDir string + for _, dir := range candidates { + if fi, err := os.Stat(filepath.Join(dir, "index.html")); err == nil && !fi.IsDir() { + distDir = dir + break + } + } + + if distDir == "" { + http.NotFound(w, r) + return + } + + reqPath := filepath.Clean(r.URL.Path) + if reqPath == "/" { + http.ServeFile(w, r, filepath.Join(distDir, "index.html")) + return + } + + targetFile := filepath.Join(distDir, reqPath) + if fi, err := os.Stat(targetFile); err == nil && !fi.IsDir() { + http.ServeFile(w, r, targetFile) + return + } + + // SPA Fallback 默认回退 index.html + http.ServeFile(w, r, filepath.Join(distDir, "index.html")) +} + // Start 启动 HTTP 守护进程 func (s *Server) Start() error { addr := fmt.Sprintf("%s:%d", s.cfg.HTTPHost, s.cfg.HTTPPort) diff --git a/internal/server/web_assets_test.go b/internal/server/web_assets_test.go new file mode 100644 index 00000000..17a3e775 --- /dev/null +++ b/internal/server/web_assets_test.go @@ -0,0 +1,51 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + + "chat-agent-system/internal/config" +) + +func TestServer_WebAssetsHostingAndFallback(t *testing.T) { + cfg, err := config.Load("") + if err != nil { + t.Fatalf("加载配置失败: %v", err) + } + cfg.HTTPPort = 8089 + + srv := NewServer(cfg, nil) + handler := srv.Routes() + + // 1. 测试访问根路径 "/" 应返回前端 SPA index.html + reqRoot := httptest.NewRequest(http.MethodGet, "/", nil) + wRoot := httptest.NewRecorder() + handler.ServeHTTP(wRoot, reqRoot) + + if wRoot.Code != http.StatusOK { + t.Errorf("expected status 200 for root SPA, got %d", wRoot.Code) + } + bodyStr := wRoot.Body.String() + if bodyStr == "" { + t.Errorf("expected non-empty SPA body") + } + + // 2. 测试 SPA 客户端路由回退 (如访问 /sessions/sess-001 应返回 index.html 而不是 404) + reqClientRoute := httptest.NewRequest(http.MethodGet, "/sessions/sess-001", nil) + wClient := httptest.NewRecorder() + handler.ServeHTTP(wClient, reqClientRoute) + + if wClient.Code != http.StatusOK { + t.Errorf("expected status 200 for client route fallback, got %d", wClient.Code) + } + + // 3. 测试 API 路由不受静态路由影响 (GET /api/v1/chat/sessions 依然正常工作) + reqAPI := httptest.NewRequest(http.MethodGet, "/api/v1/chat/sessions", nil) + wAPI := httptest.NewRecorder() + handler.ServeHTTP(wAPI, reqAPI) + + if wAPI.Code != http.StatusOK { + t.Errorf("expected status 200 for /api/v1/chat/sessions, got %d", wAPI.Code) + } +}