feat(gateway): 内嵌 admin 控制台(go:embed),去掉单独 nginx 容器

复刻 sundynix-site 的 webfs 模式:gateway 一个容器同时 serve 控制台 UI + API + 供
桌面端直连。admin 用 HashRouter + API base 走相对 /api/v1(构建传 VITE_GATEWAY=''),
源码零改动即可同源 serve。

- internal/webui:go:embed all:admin_dist + Dist();占位 index.html 让不构建前端也能编译。
  目录命名 admin_dist(避开 .gitignore dist/ 与 .dockerignore **/dist 通配)。
- router.go NoRoute:非 /api/ 路径走内嵌静态,命中 /assets/* 直吐、否则回退 index.html;
  embed 失败仅 log 降级不影响 API。/metrics /healthz /readyz /api/v1 已注册,永不进 NoRoute。
- Dockerfile 加 admin node 构建 stage,go build 前 COPY dist 覆盖 embed 占位。
- live 验证:/ 返回 admin(200)、/assets/*.js(200)、/api/v1/admin/overview(401 走 API)、/healthz(200)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-18 14:45:24 +08:00
parent 3a4e1d53a5
commit 28a1543249
4 changed files with 83 additions and 2 deletions
+18 -2
View File
@@ -1,6 +1,19 @@
# 第 2 层 业务网关。多阶段构建:编译静态二进制 → distroless 最小运行镜像
# 构建上下文须为「仓库根」(因 go.mod 用 replace ../sundynix-shared 引本地共享模块):
# 第 2 层 业务网关。多阶段构建:node 构建 admin 控制台 → Go 编译(内嵌 admin)→ distroless 运行
# 一个 gateway 容器同时 serve 控制台 UI + API + 供桌面端直连,无需单独 admin nginx 容器。
# 构建上下文须为「仓库根」(因 go.mod 用 replace ../sundynix-shared,且需 COPY sundynix-admin/):
# docker build -f sundynix-gateway/Dockerfile -t sundynix/gateway .
# ── 0. 构建 admin 运维控制台前端 ──
FROM node:20-alpine AS admin
WORKDIR /app
COPY sundynix-admin/package*.json ./
RUN npm config set registry https://registry.npmmirror.com && npm ci
COPY sundynix-admin/ ./
# VITE_GATEWAY 留空 → 前端走相对 /api/v1,内嵌进 gateway 后同源、免跨域。
ENV VITE_GATEWAY=""
RUN npm run build # 产物 /app/dist
# ── 1. 编译 Gogo:embed 内嵌 admin 产物)──
FROM golang:1.25-alpine AS build
WORKDIR /src
# 先拷依赖清单分层缓存(命中则跳过重下)
@@ -14,8 +27,11 @@ WORKDIR /src
COPY sundynix-shared/ ./sundynix-shared/
COPY sundynix-gateway/ ./sundynix-gateway/
WORKDIR /src/sundynix-gateway
# 用真实 admin 构建产物覆盖 embed 占位目录(go:embed 从 internal/webui/admin_dist 读)
COPY --from=admin /app/dist ./internal/webui/admin_dist
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/gateway ./cmd/server
# ── 2. 运行镜像 ──
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/gateway /gateway
EXPOSE 8080
@@ -3,7 +3,9 @@ package router
import (
"context"
"io/fs"
"log"
"net/http"
"os"
"strings"
@@ -16,6 +18,7 @@ import (
"github.com/sundynix/sundynix-gateway/internal/middleware"
"github.com/sundynix/sundynix-gateway/internal/nats"
"github.com/sundynix/sundynix-gateway/internal/store"
"github.com/sundynix/sundynix-gateway/internal/webui"
)
// New 构建带有 Guardrail / 限流中间件的 Gin 引擎。
@@ -163,6 +166,31 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
admin.GET("/guardrail-events", h.GuardrailEvents) // 护栏命中安全事件流(倒序,翻页)
}
}
// ── 内嵌 admin 运维控制台:非 API 路径走 SPA ──
// admin 用 HashRouter,深链走 /#/...,服务端只会收到对 / 和 /assets/* 的请求:
// 命中真实文件直吐,其余回退根 index.html 交前端接管。/api/、/metrics、/healthz、
// /readyz 都是已注册路由,永不进 NoRoute。embed 加载失败仅降级(控制台不可用),不影响 API。
if adminDist, err := webui.Dist(); err != nil {
log.Printf("[gateway] admin 控制台静态资源加载失败(UI 不可用,API 不受影响): %v", err)
} else {
adminServer := http.FileServer(http.FS(adminDist))
r.NoRoute(func(c *gin.Context) {
p := c.Request.URL.Path
if strings.HasPrefix(p, "/api/") {
c.JSON(http.StatusNotFound, gin.H{"error": "接口不存在"})
return
}
if rel := strings.TrimPrefix(p, "/"); rel != "" {
if _, statErr := fs.Stat(adminDist, rel); statErr == nil {
adminServer.ServeHTTP(c.Writer, c.Request) // /assets/* 等真实文件
return
}
}
c.Request.URL.Path = "/" // 其余回退 index.html
adminServer.ServeHTTP(c.Writer, c.Request)
})
}
return r
}
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>sundynix-agentix · 运维控制台</title>
</head>
<body>
<!-- 占位页:真实控制台由 Docker 多阶段构建注入(COPY --from=admin /app/dist)。
本地未构建前端时直接 go run 会看到这一页,属正常。 -->
<div id="root" style="font-family: system-ui; padding: 2rem; color: #444">
admin 控制台尚未构建。请用 <code>docker build -f sundynix-gateway/Dockerfile .</code>
构建(会自动 npm run build 并内嵌),或参见 deploy/132-app。
</div>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
// Package webui 把 admin 运维控制台的前端构建产物打进 gateway 二进制。
// admin_dist/ 默认只有占位页;Docker 多阶段构建会先 `npm run build` 出真实产物、
// COPY 覆盖这里再编译 Go —— 于是一个 gateway 容器同时 serve 控制台 UI + API
// 无需单独的 admin nginx 容器(部署见 deploy/132-app)。
//
// 目录刻意命名 admin_dist 而非 dist:根 .gitignore(dist/) 与 .dockerignore(**/dist)
// 会忽略/排除 dist 目录,占位文件将无法提交、也进不了构建上下文。
package webui
import (
"embed"
"io/fs"
)
//go:embed all:admin_dist
var embeddedAdmin embed.FS
// Dist 返回 admin 控制台静态文件系统(挂在根 /)。
func Dist() (fs.FS, error) {
return fs.Sub(embeddedAdmin, "admin_dist")
}