- 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
184 lines
6.3 KiB
Go
184 lines
6.3 KiB
Go
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"
|
|
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
func SetupRouter(db *sql.DB, adminToken string, corsOrigins ...string) *gin.Engine {
|
|
r := gin.Default()
|
|
|
|
allowOrigin := "*"
|
|
if len(corsOrigins) > 0 && corsOrigins[0] != "" {
|
|
allowOrigin = corsOrigins[0]
|
|
}
|
|
|
|
// CORS Middleware
|
|
r.Use(func(c *gin.Context) {
|
|
c.Writer.Header().Set("Access-Control-Allow-Origin", allowOrigin)
|
|
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
|
|
if c.Request.Method == "OPTIONS" {
|
|
c.AbortWithStatus(http.StatusNoContent)
|
|
return
|
|
}
|
|
c.Next()
|
|
})
|
|
|
|
// Repositories
|
|
homeRepo := repository.NewHomeRepository(db)
|
|
membersRepo := repository.NewMembersRepository(db)
|
|
pubsRepo := repository.NewPublicationsRepository(db)
|
|
lecturesRepo := repository.NewLecturesRepository(db)
|
|
standardsRepo := repository.NewStandardizationRepository(db)
|
|
|
|
// Handlers
|
|
homeHandler := handlers.NewHomeHandler(homeRepo)
|
|
membersHandler := handlers.NewMembersHandler(membersRepo)
|
|
pubsHandler := handlers.NewPublicationsHandler(pubsRepo)
|
|
lecturesHandler := handlers.NewLecturesHandler(lecturesRepo)
|
|
standardsHandler := handlers.NewStandardizationHandler(standardsRepo)
|
|
|
|
// API v1 Public Group (Read-only)
|
|
v1 := r.Group("/api/v1")
|
|
{
|
|
v1.GET("/health", func(c *gin.Context) {
|
|
httpx.OK(c, gin.H{"status": "ok", "service": "anl-backend-api"})
|
|
})
|
|
|
|
// Home
|
|
v1.GET("/research-projects", homeHandler.GetResearchProjects)
|
|
v1.GET("/research-areas", homeHandler.GetResearchAreas)
|
|
v1.GET("/stats/summary", homeHandler.GetStatsSummary)
|
|
|
|
// Members & Alumni
|
|
v1.GET("/members", membersHandler.GetMembers)
|
|
v1.GET("/alumni", membersHandler.GetAlumni)
|
|
|
|
// Publications & Patents
|
|
v1.GET("/publications", pubsHandler.GetPublications)
|
|
v1.GET("/publications/highlights", pubsHandler.GetHighlights)
|
|
v1.GET("/patents", pubsHandler.GetPatents)
|
|
|
|
// Lectures
|
|
v1.GET("/semesters", lecturesHandler.GetSemesters)
|
|
|
|
// Standardization
|
|
v1.GET("/standards-bodies", standardsHandler.GetStandardsBodies)
|
|
}
|
|
|
|
// API v1 Admin Group (Mutating operations requiring Bearer token)
|
|
admin := v1.Group("")
|
|
admin.Use(httpx.RequireAdminToken(adminToken))
|
|
{
|
|
admin.GET("/admin/verify", func(c *gin.Context) {
|
|
httpx.OK(c, gin.H{"status": "authenticated"})
|
|
})
|
|
|
|
// Research Projects
|
|
admin.POST("/research-projects", homeHandler.CreateResearchProject)
|
|
admin.PUT("/research-projects/:id", homeHandler.UpdateResearchProject)
|
|
admin.DELETE("/research-projects/:id", homeHandler.DeleteResearchProject)
|
|
|
|
// Research Areas
|
|
admin.POST("/research-areas", homeHandler.CreateResearchArea)
|
|
admin.PUT("/research-areas/:id", homeHandler.UpdateResearchArea)
|
|
admin.DELETE("/research-areas/:id", homeHandler.DeleteResearchArea)
|
|
|
|
// Members & Alumni
|
|
admin.POST("/members", membersHandler.CreateMember)
|
|
admin.PUT("/members/:id", membersHandler.UpdateMember)
|
|
admin.DELETE("/members/:id", membersHandler.DeleteMember)
|
|
|
|
admin.POST("/alumni", membersHandler.CreateAlumnus)
|
|
admin.PUT("/alumni/:id", membersHandler.UpdateAlumnus)
|
|
admin.DELETE("/alumni/:id", membersHandler.DeleteAlumnus)
|
|
|
|
// Publications & Patents
|
|
admin.POST("/publications", pubsHandler.CreatePublication)
|
|
admin.PUT("/publications/:id", pubsHandler.UpdatePublication)
|
|
admin.DELETE("/publications/:id", pubsHandler.DeletePublication)
|
|
|
|
admin.POST("/patents", pubsHandler.CreatePatent)
|
|
admin.PUT("/patents/:id", pubsHandler.UpdatePatent)
|
|
admin.DELETE("/patents/:id", pubsHandler.DeletePatent)
|
|
|
|
// Lectures (Semesters & Courses)
|
|
admin.POST("/semesters", lecturesHandler.CreateSemester)
|
|
admin.PUT("/semesters/:id", lecturesHandler.UpdateSemester)
|
|
admin.DELETE("/semesters/:id", lecturesHandler.DeleteSemester)
|
|
|
|
admin.POST("/semesters/:semesterId/courses", lecturesHandler.CreateCourse)
|
|
admin.PUT("/courses/:id", lecturesHandler.UpdateCourse)
|
|
admin.DELETE("/courses/:id", lecturesHandler.DeleteCourse)
|
|
|
|
// Standardization (Bodies & Documents)
|
|
admin.POST("/standards-bodies", standardsHandler.CreateStandardsBody)
|
|
admin.PUT("/standards-bodies/:id", standardsHandler.UpdateStandardsBody)
|
|
admin.DELETE("/standards-bodies/:id", standardsHandler.DeleteStandardsBody)
|
|
|
|
admin.POST("/standards-bodies/:bodyId/documents", standardsHandler.CreateStandardDocument)
|
|
admin.PUT("/standard-documents/:id", standardsHandler.UpdateStandardDocument)
|
|
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
|
|
}
|