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:
@@ -3,6 +3,7 @@
|
||||
/export-content
|
||||
/api
|
||||
/seed
|
||||
/web/
|
||||
|
||||
# SQLite databases and WAL journal files
|
||||
*.db
|
||||
|
||||
+25
-30
@@ -1,51 +1,47 @@
|
||||
# ==============================================================================
|
||||
# Stage 1: Build the Go Binaries
|
||||
# Stage 1: Build the static frontend export
|
||||
# ==============================================================================
|
||||
FROM golang:1.26-alpine AS builder
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
RUN apk add --no-cache libc6-compat
|
||||
COPY refer_landing_page/package.json refer_landing_page/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY refer_landing_page/ .
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL} \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
NODE_ENV=production
|
||||
RUN npm run build
|
||||
|
||||
# ==============================================================================
|
||||
# Stage 2: Build the Go binaries
|
||||
# ==============================================================================
|
||||
FROM golang:1.26-alpine AS backend-builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install git and certificates
|
||||
RUN apk add --no-cache git ca-certificates tzdata
|
||||
|
||||
# Download dependencies
|
||||
COPY go.mod go.sum ./
|
||||
COPY backend/go.mod backend/go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy backend source code
|
||||
COPY . .
|
||||
|
||||
# Build pure-Go static binaries
|
||||
COPY backend/ .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /bin/api ./cmd/api && \
|
||||
CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /bin/seed ./cmd/seed && \
|
||||
CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /bin/export-content ./cmd/export-content
|
||||
|
||||
# ==============================================================================
|
||||
# Stage 2: Minimal Runtime Image
|
||||
# Stage 3: Minimal Alpine runtime — single container, single process
|
||||
# ==============================================================================
|
||||
FROM alpine:3.20
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install ca-certificates and sqlite cli for maintenance
|
||||
RUN apk add --no-cache ca-certificates tzdata sqlite curl && \
|
||||
addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||
|
||||
# Create persistent database storage directory
|
||||
RUN mkdir -p /app/data && chown -R appuser:appgroup /app/data
|
||||
|
||||
# Copy built binaries from builder
|
||||
COPY --from=builder /bin/api /app/bin/api
|
||||
COPY --from=builder /bin/seed /app/bin/seed
|
||||
COPY --from=builder /bin/export-content /app/bin/export-content
|
||||
COPY --from=backend-builder /bin/api /app/bin/api
|
||||
COPY --from=backend-builder /bin/seed /app/bin/seed
|
||||
COPY --from=backend-builder /bin/export-content /app/bin/export-content
|
||||
COPY --from=backend-builder /app/seed /app/seed
|
||||
COPY --from=backend-builder /app/.env.example /app/.env
|
||||
COPY --from=frontend-builder --chown=appuser:appgroup /app/frontend/out /app/web
|
||||
|
||||
# Copy seed data
|
||||
COPY --from=builder /app/seed /app/seed
|
||||
|
||||
# Copy environment template
|
||||
COPY --from=builder /app/.env.example /app/.env
|
||||
|
||||
# Set environment defaults for container
|
||||
ENV HOST=0.0.0.0 \
|
||||
PORT=8080 \
|
||||
DB_PATH=/app/data/anl.db \
|
||||
@@ -54,7 +50,6 @@ ENV HOST=0.0.0.0 \
|
||||
AUTO_MIGRATE=true
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
USER appuser
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -521,6 +521,47 @@ func TestCascadeDeletes(t *testing.T) {
|
||||
assert.Equal(t, 0, courseCount, "Courses should be cascaded when parent semester is deleted")
|
||||
}
|
||||
|
||||
func TestStaticServingAndRedirects(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r := router.SetupRouter(db, testAdminToken)
|
||||
|
||||
// 1. /intro 308 permanent redirect to /
|
||||
wIntro := doRequest(r, "GET", "/intro", nil, "")
|
||||
assert.Equal(t, http.StatusPermanentRedirect, wIntro.Code)
|
||||
assert.Equal(t, "/", wIntro.Header().Get("Location"))
|
||||
|
||||
// 2. Unknown API route returns 404 JSON (not HTML)
|
||||
wAPI404 := doRequest(r, "GET", "/api/v1/unknown-endpoint", nil, "")
|
||||
assert.Equal(t, http.StatusNotFound, wAPI404.Code)
|
||||
var apiErr map[string]any
|
||||
require.NoError(t, json.Unmarshal(wAPI404.Body.Bytes(), &apiErr))
|
||||
assert.Contains(t, apiErr, "error")
|
||||
|
||||
// 3. Create mock web directory to verify static HTML serving
|
||||
webDir := "./web"
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(webDir, "test-page"), 0755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(webDir, "index.html"), []byte("<html>Home</html>"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(webDir, "test-page", "index.html"), []byte("<html>Test Page</html>"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(webDir, "404.html"), []byte("<html>404 Not Found</html>"), 0644))
|
||||
defer os.RemoveAll(webDir)
|
||||
|
||||
// Test root URL /
|
||||
wRoot := doRequest(r, "GET", "/", nil, "")
|
||||
assert.Equal(t, http.StatusOK, wRoot.Code)
|
||||
assert.Contains(t, wRoot.Body.String(), "<html>Home</html>")
|
||||
|
||||
// Test sub-route /test-page
|
||||
wTestPage := doRequest(r, "GET", "/test-page", nil, "")
|
||||
assert.Equal(t, http.StatusOK, wTestPage.Code)
|
||||
assert.Contains(t, wTestPage.Body.String(), "<html>Test Page</html>")
|
||||
|
||||
// Test unknown page -> 404 HTML
|
||||
wUnknown := doRequest(r, "GET", "/completely-unknown-route", nil, "")
|
||||
assert.Equal(t, http.StatusNotFound, wUnknown.Code)
|
||||
assert.Contains(t, wUnknown.Body.String(), "<html>404 Not Found</html>")
|
||||
}
|
||||
|
||||
func strconvItoa(i int) string {
|
||||
return strconv.Itoa(i)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user