Files
QiuSWandClaude Opus 5.5 0b01b92c98 fix(server): serve the Admin SPA at / when dist exists (#346)
go-admin registered its welcome page on GET / in every non-prod mode, so a
reverse proxy that forwarded / to the server showed 「GO-ADMIN欢迎您」
instead of the Admin (happened after the 2026-09-28 server migration).
When dist/index.html exists, / now returns it; without a dist (vite
development) the previous welcome/prod behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-28 16:07:49 +08:00

101 lines
3.2 KiB
Go

package router
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
// SPADirEnv overrides where the built frontend lives. Without it the server
// looks for ./dist next to the working directory.
const SPADirEnv = "GOAUTO_WEB_DIST"
// InitSPARouter serves the built Vue app from the same origin as the API.
//
// Same-origin is what web/.env.production already assumes: VUE_APP_BASE_API is
// empty there, so the bundle issues relative requests. Serving dist from this
// binary makes that assumption true without a second web server and without
// CORS.
//
// `[必须]` Registering this is conditional on dist actually existing. In
// development the frontend runs under vite on its own port and there is no
// dist; a NoRoute handler installed anyway would turn every genuine 404 into
// an HTML page, which is far more confusing than a plain 404.
func InitSPARouter(engine *gin.Engine) {
dist, index, ok := spaIndex()
if !ok {
return
}
engine.Static("/assets", filepath.Join(dist, "assets"))
for _, name := range []string{"favicon.ico", "logo.png"} {
if path := filepath.Join(dist, name); fileExists(path) {
engine.StaticFile("/"+name, path)
}
}
// History-mode routing means any unmatched non-API GET is a client route
// and must return index.html so the app can boot and route it itself.
//
// `[必须]` API paths are excluded. Answering a mistyped API call with 200
// and an HTML body would surface in the client as a JSON parse error
// instead of the 404 it actually is.
engine.NoRoute(func(c *gin.Context) {
if c.Request.Method != http.MethodGet || isAPIPath(c.Request.URL.Path) {
c.Status(http.StatusNotFound)
return
}
c.File(index)
})
}
// spaIndex resolves the built frontend directory and reports whether its
// index.html exists.
func spaIndex() (dist, index string, ok bool) {
dist = strings.TrimSpace(os.Getenv(SPADirEnv))
if dist == "" {
dist = "dist"
}
index = filepath.Join(dist, "index.html")
if _, err := os.Stat(index); err != nil {
return dist, index, false
}
return dist, index, true
}
// registerRootRoute decides what `GET /` returns (#346).
//
// `[必须]` When the built frontend exists, `/` must be the Admin SPA. go-admin's
// welcome page used to own `/` in every non-prod mode, so any reverse proxy
// that forwarded `/` to this server (instead of serving dist itself) showed
// "GO-ADMIN欢迎您" instead of the Admin — which is exactly what happened after
// the 2026-09-28 server migration. The welcome page is kept only for
// development without a dist, where the frontend runs under vite.
func registerRootRoute(r gin.IRoutes, mode string, welcome gin.HandlerFunc) {
if _, index, ok := spaIndex(); ok {
r.GET("/", func(c *gin.Context) { c.File(index) })
return
}
if mode != "prod" {
r.GET("/", welcome)
}
}
// isAPIPath reports whether a path belongs to the server rather than the SPA.
func isAPIPath(path string) bool {
for _, prefix := range []string{"/api/", "/swagger/", "/static/", "/form-generator/", "/ws/", "/wslogout/", "/info"} {
if strings.HasPrefix(path, prefix) {
return true
}
}
return false
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}