feat(arch): unify frontend and backend into single Go backend server with static export

- Configure Next.js static export (output: 'export') with client-side dynamic fetching
- Implement Go static serving with SPA fallback in backend/internal/router/router.go
- Unify multi-stage build in backend/Dockerfile (Node -> Go -> minimal Alpine runtime)
- Simplify root docker-compose.yml to single anl-app service on port 8080
- Update REQUIREMENTS.md DM-03 and AC-17 normative contracts to v3.0 Unified Go Backend
- Restore strict regression verification in Gate 9 (AC-35/36/37) and unittest suite
- All Go unit tests, TypeScript type checks, ESLint, and E2E gates passed clean
This commit is contained in:
2026-08-25 12:54:39 +09:00
parent 5f87631530
commit 9bdc9bdd9a
27 changed files with 597 additions and 812 deletions
+52
View File
@@ -3,6 +3,9 @@ package router
import (
"database/sql"
"net/http"
"os"
"path/filepath"
"strings"
"git.godopu.com/lab/landing_page/backend/internal/handlers"
"git.godopu.com/lab/landing_page/backend/internal/httpx"
@@ -127,5 +130,54 @@ func SetupRouter(db *sql.DB, adminToken string, corsOrigins ...string) *gin.Engi
admin.DELETE("/standard-documents/:id", standardsHandler.DeleteStandardDocument)
}
// 308 Permanent Redirect for /intro -> / (H-11 / IA-02)
r.GET("/intro", func(c *gin.Context) {
c.Redirect(http.StatusPermanentRedirect, "/")
})
// Static assets serving
r.Static("/_next", "./web/_next")
r.StaticFile("/favicon.ico", "./web/favicon.ico")
r.StaticFile("/robots.txt", "./web/robots.txt")
r.StaticFile("/sitemap.xml", "./web/sitemap.xml")
r.StaticFile("/icon.svg", "./web/icon.svg")
r.Static("/fonts", "./web/fonts")
// NoRoute fallback for static exported HTML & 404
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
// Never let a mistyped/unknown API path fall through to HTML.
if strings.HasPrefix(path, "/api/") {
httpx.NotFound(c, "Route not found")
return
}
// Try an exact static file (e.g. an asset with an extension)
candidate := filepath.Join("./web", filepath.Clean(path))
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
c.File(candidate)
return
}
// Try <path>/index.html — how every known page route is emitted by trailingSlash
indexCandidate := filepath.Join("./web", filepath.Clean(path), "index.html")
if _, err := os.Stat(indexCandidate); err == nil {
c.File(indexCandidate)
return
}
// Genuinely unknown route: serve site's 404 page with 404 status
notFoundPath := "./web/404/index.html"
if _, err := os.Stat(notFoundPath); err != nil {
notFoundPath = "./web/404.html"
}
if notFoundBytes, err := os.ReadFile(notFoundPath); err == nil {
c.Data(http.StatusNotFound, "text/html; charset=utf-8", notFoundBytes)
} else {
c.String(http.StatusNotFound, "404 page not found")
}
})
return r
}