diff --git a/.gitignore b/.gitignore index 08264ccb..e7fee76a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ warehouse-mms.exe frontend/dist/ .history/ +deploy/ +internal/web/dist/ diff --git a/Dockerfile.deploy b/Dockerfile.deploy new file mode 100644 index 00000000..574ef447 --- /dev/null +++ b/Dockerfile.deploy @@ -0,0 +1,18 @@ +# 部署镜像:只打包本机已编译好的二进制(含嵌入前端),镜像内不做任何编译。 +# 构建前置:先执行 scripts/deploy-build.sh 产出 deploy/server。 +FROM alpine:3.20 + +WORKDIR /app + +# 运行时依赖:证书、时区;nc 由 busybox 自带(entrypoint 探活用) +RUN apk add --no-cache ca-certificates tzdata && mkdir -p /app/uploads + +COPY deploy/server /app/server +COPY deploy/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +EXPOSE 8082 +ENV GIN_MODE=release + +ENTRYPOINT ["/app/entrypoint.sh"] +CMD ["/app/server"] diff --git a/cmd/server/main.go b/cmd/server/main.go index 95f3f12e..01b365f6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,15 +1,17 @@ package main import ( + "io/fs" "log" + "net/http" "os" - "path/filepath" "strings" "github.com/gin-gonic/gin" "github.com/wmms/wmms/internal/handler" "github.com/wmms/wmms/internal/repository" "github.com/wmms/wmms/internal/service" + "github.com/wmms/wmms/internal/web" "github.com/wmms/wmms/pkg/database" ) @@ -45,7 +47,7 @@ func main() { r := gin.Default() - registerFrontendRoutes(r, "./frontend/dist") + registerFrontendRoutes(r) // 提供上传的文件 r.Static("/uploads", "./uploads") @@ -71,19 +73,64 @@ func main() { } } -func registerFrontendRoutes(r *gin.Engine, distDir string) { - r.Static("/web", distDir) - r.Static("/assets", filepath.Join(distDir, "assets")) - r.GET("/", func(c *gin.Context) { - c.File(filepath.Join(distDir, "index.html")) +func registerFrontendRoutes(r *gin.Engine) { + dist, err := web.DistFS() + if err != nil { + log.Fatalf("embedded frontend unavailable: %v", err) + } + + // 静态资源(js/css 等)直接由 embed FS 提供;带一年 immutable 缓存。 + fileServer := http.FileServer(http.FS(dist)) + r.GET("/assets/*filepath", func(c *gin.Context) { + c.Header("Cache-Control", "public, max-age=31536000, immutable") + c.Request.URL.Path = strings.TrimPrefix(c.Request.URL.Path, "/") + fileServer.ServeHTTP(c.Writer, c.Request) }) - r.NoRoute(func(c *gin.Context) { - path := c.Request.URL.Path - if c.Request.Method != "GET" || path == "/api" || strings.HasPrefix(path, "/api/") || - path == "/ws" || strings.HasPrefix(path, "/uploads/") || filepath.Ext(path) != "" { - c.Status(404) + + indexHandler := func(c *gin.Context) { + data, err := web.IndexHTML() + if err != nil { + c.Status(http.StatusInternalServerError) return } - c.File(filepath.Join(distDir, "index.html")) + c.Data(http.StatusOK, "text/html; charset=utf-8", data) + } + r.GET("/", indexHandler) + + r.NoRoute(func(c *gin.Context) { + path := c.Request.URL.Path + // API / WebSocket / uploads / 带扩展名的真实文件不做 SPA fallback + if c.Request.Method != "GET" || path == "/api" || strings.HasPrefix(path, "/api/") || + path == "/ws" || strings.HasPrefix(path, "/uploads/") { + c.Status(http.StatusNotFound) + return + } + if ext := strings.LastIndex(path, "."); ext != -1 { + // 尝试从 embed 提供真实静态文件(favicon、图片等) + rel := strings.TrimPrefix(path, "/") + if b, err := fs.ReadFile(dist, rel); err == nil { + c.Data(http.StatusOK, mimeFor(rel), b) + return + } + c.Status(http.StatusNotFound) + return + } + // 其余路径交给 Vue Router(SPA) + indexHandler(c) }) } + +func mimeFor(name string) string { + switch { + case strings.HasSuffix(name, ".js"): + return "application/javascript; charset=utf-8" + case strings.HasSuffix(name, ".css"): + return "text/css; charset=utf-8" + case strings.HasSuffix(name, ".svg"): + return "image/svg+xml" + case strings.HasSuffix(name, ".html"): + return "text/html; charset=utf-8" + default: + return "application/octet-stream" + } +} diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml new file mode 100644 index 00000000..711b90ab --- /dev/null +++ b/docker-compose.deploy.yml @@ -0,0 +1,31 @@ +# WMS 生产部署(单容器,前端已嵌入 Go 二进制) +# 使用: +# ./scripts/deploy-build.sh # 本机编译前后端 +# docker compose -f docker-compose.deploy.yml up -d --build +# 公共 MySQL/网络由仓库公共层提供,不自带数据库、不 publish 端口。 + +services: + wms: + build: + context: . + dockerfile: Dockerfile.deploy + image: wms:local + container_name: wms + restart: unless-stopped + environment: + DB_DSN: "${WMS_DB_USER}:${WMS_DB_PASSWORD}@tcp(mysql:3306)/wms_default?charset=utf8mb4&parseTime=true" + GIN_MODE: release + volumes: + - ${VOL_DIR}/wms/uploads:/app/uploads + networks: + - services-net + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8082/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +networks: + services-net: + external: true diff --git a/entrypoint-deploy.sh b/entrypoint-deploy.sh new file mode 100755 index 00000000..8e189e0b --- /dev/null +++ b/entrypoint-deploy.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -e + +# 等待公共 MySQL(services-net 内容器名 mysql)。 +# alpine busybox 自带 nc,无需额外安装客户端。 +echo "Waiting for MySQL (mysql:3306)..." +i=0 +until nc -z mysql 3306 2>/dev/null; do + i=$((i+1)) + if [ "$i" -ge 60 ]; then + echo "MySQL not reachable after ~120s, exiting." >&2 + exit 1 + fi + sleep 2 +done +echo "MySQL is reachable." + +# Schema 首次由人工灌入 wms_default;应用启动时自动 seed 默认 admin。 +echo "Starting application..." +exec "$@" diff --git a/internal/web/web.go b/internal/web/web.go new file mode 100644 index 00000000..b3c64cbf --- /dev/null +++ b/internal/web/web.go @@ -0,0 +1,22 @@ +// Package web 通过 go:embed 持有前端构建产物(Vue3 dist)。 +// dist 目录由 Dockerfile 的 node 构建阶段生成到本目录后再编译 Go, +// 因此最终二进制自带前端,无需独立 Nginx 容器。 +package web + +import ( + "embed" + "io/fs" +) + +//go:embed all:dist +var distFS embed.FS + +// DistFS 返回以 dist 内容为根的只读文件系统。 +func DistFS() (fs.FS, error) { + return fs.Sub(distFS, "dist") +} + +// IndexHTML 返回前端入口页字节。 +func IndexHTML() ([]byte, error) { + return distFS.ReadFile("dist/index.html") +} diff --git a/scripts/deploy-build.sh b/scripts/deploy-build.sh new file mode 100755 index 00000000..52c226bf --- /dev/null +++ b/scripts/deploy-build.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# 在本机编译前后端,产出供 Dockerfile.deploy 打包的文件。 +# 产物:deploy/server(已 embed 前端 dist)、deploy/entrypoint.sh +set -euo pipefail + +# 解析到服务根目录(脚本位于 scripts/) +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT}" + +echo "==> 1/3 构建前端 (npm run build)" +cd frontend +[ -d node_modules ] || npm install +npm run build +cd "${ROOT}" + +echo "==> 2/3 将 dist 放入 embed 目录 internal/web/dist" +rm -rf internal/web/dist +mkdir -p internal/web/dist +cp -r frontend/dist/. internal/web/dist/ + +echo "==> 3/3 本机编译 Go(前端嵌入二进制)" +mkdir -p deploy +CGO_ENABLED=0 GOOS=linux go build -o deploy/server ./cmd/server +cp entrypoint-deploy.sh deploy/entrypoint.sh +chmod +x deploy/entrypoint.sh + +echo "done -> deploy/server (含前端)"