Compare commits

..

2 Commits

Author SHA1 Message Date
文睿 张
b6035976ec Merge origin/dev into feature/material-bulk-import: complete v1 2026-10-12 17:03:03 +08:00
copper-xw
9c78dcb6db feat: 支持前端 go:embed 单容器部署
新增部署专用 Dockerfile.deploy / docker-compose.deploy.yml 与本机构建脚本:
前端 dist 经 go:embed 编入 Go 二进制,alpine 运行时只打包不编译,
旧 Dockerfile 与双容器编排保持不变。
2026-10-10 16:14:49 +08:00
7 changed files with 180 additions and 13 deletions

2
.gitignore vendored
View File

@ -11,3 +11,5 @@ warehouse-mms.exe
frontend/dist/ frontend/dist/
.history/ .history/
deploy/
internal/web/dist/

18
Dockerfile.deploy Normal file
View File

@ -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"]

View File

@ -1,15 +1,17 @@
package main package main
import ( import (
"io/fs"
"log" "log"
"net/http"
"os" "os"
"path/filepath"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/wmms/wmms/internal/handler" "github.com/wmms/wmms/internal/handler"
"github.com/wmms/wmms/internal/repository" "github.com/wmms/wmms/internal/repository"
"github.com/wmms/wmms/internal/service" "github.com/wmms/wmms/internal/service"
"github.com/wmms/wmms/internal/web"
"github.com/wmms/wmms/pkg/database" "github.com/wmms/wmms/pkg/database"
) )
@ -45,7 +47,7 @@ func main() {
r := gin.Default() r := gin.Default()
registerFrontendRoutes(r, "./frontend/dist") registerFrontendRoutes(r)
// 提供上传的文件 // 提供上传的文件
r.Static("/uploads", "./uploads") r.Static("/uploads", "./uploads")
@ -71,19 +73,64 @@ func main() {
} }
} }
func registerFrontendRoutes(r *gin.Engine, distDir string) { func registerFrontendRoutes(r *gin.Engine) {
r.Static("/web", distDir) dist, err := web.DistFS()
r.Static("/assets", filepath.Join(distDir, "assets")) if err != nil {
r.GET("/", func(c *gin.Context) { log.Fatalf("embedded frontend unavailable: %v", err)
c.File(filepath.Join(distDir, "index.html")) }
// 静态资源(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 indexHandler := func(c *gin.Context) {
if c.Request.Method != "GET" || path == "/api" || strings.HasPrefix(path, "/api/") || data, err := web.IndexHTML()
path == "/ws" || strings.HasPrefix(path, "/uploads/") || filepath.Ext(path) != "" { if err != nil {
c.Status(404) c.Status(http.StatusInternalServerError)
return 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"
}
}

31
docker-compose.deploy.yml Normal file
View File

@ -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

20
entrypoint-deploy.sh Executable file
View File

@ -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 "$@"

22
internal/web/web.go Normal file
View File

@ -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")
}

27
scripts/deploy-build.sh Executable file
View File

@ -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 (含前端)"