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
+1
View File
@@ -3,6 +3,7 @@
/export-content
/api
/seed
/web/
# SQLite databases and WAL journal files
*.db
+25 -30
View File
@@ -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 \
+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
}
+41
View File
@@ -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)
}