feat: enhance Makefile commands and update README for improved development workflow

refactor: separate API and SPA handling in server routes and add static file serving options

test: add tests for SPA handling and static file serving

feat: implement embedded SPA support for production and development environments
This commit is contained in:
mlogclub
2026-05-23 22:44:06 +08:00
parent c6c1c00141
commit c80bae5106
8 changed files with 466 additions and 144 deletions
+23 -27
View File
@@ -3,8 +3,6 @@ package bootstrap
import (
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"time"
@@ -12,9 +10,13 @@ import (
_ "cs-agent/internal/ai/runtime"
"cs-agent/internal/middleware"
"cs-agent/internal/pkg/config"
"cs-agent/internal/pkg/ginx"
"cs-agent/internal/pkg/httpx"
"cs-agent/internal/services"
webspa "cs-agent/web"
"github.com/gin-gonic/gin"
"github.com/mlogclub/simple/web"
_ "cs-agent/internal/services/wx_callback_handlers"
)
@@ -29,8 +31,25 @@ func NewServer() (*gin.Engine, error) {
addRouter(app)
app.StaticFS(cfg.Storage.Local.BaseURL, http.Dir(cfg.Storage.Local.Root))
registerDashboardStatic(app, "web/out")
notFoundPrefixes := []string{"/api/"}
if baseURL := strings.TrimRight(cfg.Storage.Local.BaseURL, "/"); baseURL != "" {
notFoundPrefixes = append(notFoundPrefixes, baseURL+"/")
}
app.StaticFS(cfg.Storage.Local.BaseURL, ginx.StaticFiles(cfg.Storage.Local.Root))
ginx.HandleSPA(app, ginx.SPAOptions{
Root: "./web/out",
EmbeddedFS: webspa.SPA,
EmbeddedRoot: "out",
DirOptions: ginx.DirOptions{
ShowList: false,
SPA: true,
IndexName: "index.html",
},
NotFoundPrefixes: notFoundPrefixes,
NotFoundHandler: func(ctx *gin.Context) {
httpx.WriteHttpStatusJSON(ctx, http.StatusNotFound, web.JsonErrorCode(http.StatusNotFound, "Not found"))
},
})
return app, nil
}
@@ -134,26 +153,3 @@ func addRouter(app *gin.Engine) {
thirdGroup := app.Group("/api/third")
registerThirdWechatRoutes(thirdGroup.Group("/wechat"))
}
func registerDashboardStatic(app *gin.Engine, root string) {
app.NoRoute(func(ctx *gin.Context) {
if strings.HasPrefix(ctx.Request.URL.Path, "/api/") {
ctx.JSON(http.StatusNotFound, gin.H{"success": false, "message": "not found"})
return
}
requestPath := filepath.Clean(strings.TrimPrefix(ctx.Request.URL.Path, "/"))
if strings.HasPrefix(requestPath, "..") {
ctx.Status(http.StatusBadRequest)
return
}
if requestPath == "." {
requestPath = "index.html"
}
fullPath := filepath.Join(root, requestPath)
if stat, err := os.Stat(fullPath); err == nil && !stat.IsDir() {
ctx.File(fullPath)
return
}
ctx.File(filepath.Join(root, "index.html"))
})
}
+39
View File
@@ -2,6 +2,8 @@ package bootstrap
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"cs-agent/internal/pkg/config"
@@ -43,3 +45,40 @@ func TestNewServerRegistersGinRoutes(t *testing.T) {
}
}
}
func TestNewServerSeparatesAPIStaticAndSPA(t *testing.T) {
config.SetCurrent(&config.Config{
Storage: config.StorageConfig{
Local: config.LocalStorageConfig{
Root: "storage",
BaseURL: "/storage",
},
},
})
app, err := NewServer()
if err != nil {
t.Fatalf("NewServer() error = %v", err)
}
tests := []struct {
path string
wantStatus int
contentType string
}{
{path: "/api/not-exists", wantStatus: http.StatusNotFound, contentType: "application/json"},
{path: "/dashboard/not-exists", wantStatus: http.StatusOK, contentType: "text/html"},
}
for _, tt := range tests {
rec := httptest.NewRecorder()
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil))
if rec.Code != tt.wantStatus {
t.Fatalf("%s status=%d want %d", tt.path, rec.Code, tt.wantStatus)
}
if !strings.Contains(rec.Header().Get("Content-Type"), tt.contentType) {
t.Fatalf("%s Content-Type=%q want %q", tt.path, rec.Header().Get("Content-Type"), tt.contentType)
}
}
}