feat: server static with localfile -- docker

This commit is contained in:
engigu
2026-03-11 17:32:12 +08:00
parent 3e902954a7
commit c2e8352e1f
3 changed files with 77 additions and 59 deletions
+4 -1
View File
@@ -64,7 +64,7 @@ RUN go run github.com/swaggo/swag/cmd/swag@latest init -g main.go -o ./openapi_d
RUN VERSION_VAL=$(cat /build-info/version.txt) && \ RUN VERSION_VAL=$(cat /build-info/version.txt) && \
BUILD_TIME_VAL=$(cat /build-info/build_time.txt) && \ BUILD_TIME_VAL=$(cat /build-info/build_time.txt) && \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -tags web -ldflags="-s -w -X github.com/engigu/baihu-panel/internal/constant.Version=${VERSION_VAL} -X 'github.com/engigu/baihu-panel/internal/constant.BuildTime=${BUILD_TIME_VAL}'" \ go build -ldflags="-s -w -X github.com/engigu/baihu-panel/internal/constant.Version=${VERSION_VAL} -X 'github.com/engigu/baihu-panel/internal/constant.BuildTime=${BUILD_TIME_VAL}'" \
-o baihu . -o baihu .
# ================================ # ================================
@@ -134,6 +134,9 @@ WORKDIR /app
# Copy Go binary # Copy Go binary
COPY --from=backend-builder /app/baihu . COPY --from=backend-builder /app/baihu .
# Copy frontend assets to /www/baihu (No embed in Docker)
COPY --from=frontend-builder /app/web/dist /www/baihu
# Copy configs and entrypoint # Copy configs and entrypoint
COPY --from=backend-builder /app/configs ./configs COPY --from=backend-builder /app/configs ./configs
COPY docker/docker-entrypoint.sh . COPY docker/docker-entrypoint.sh .
+45 -56
View File
@@ -1,7 +1,6 @@
package router package router
import ( import (
"bytes"
"compress/gzip" "compress/gzip"
"io" "io"
"io/fs" "io/fs"
@@ -31,46 +30,37 @@ func initStaticRoutes(root *gin.RouterGroup) {
// 专门处理 /assets 目录下的资源 // 专门处理 /assets 目录下的资源
root.GET("/assets/*filepath", cacheControl("public, max-age=31536000, immutable"), func(ctx *gin.Context) { root.GET("/assets/*filepath", cacheControl("public, max-age=31536000, immutable"), func(ctx *gin.Context) {
// 获取相对路径,例如 "assets/chunk-123.js"
fullPath := "assets" + ctx.Param("filepath") fullPath := "assets" + ctx.Param("filepath")
fullPath = strings.TrimPrefix(fullPath, "/") fullPath = strings.TrimPrefix(fullPath, "/")
// 1. 检查浏览器是否支持 gzip
isGzipSupported := strings.Contains(ctx.GetHeader("Accept-Encoding"), "gzip") isGzipSupported := strings.Contains(ctx.GetHeader("Accept-Encoding"), "gzip")
// 2. 构造压缩路径
gzPath := fullPath + ".gz" gzPath := fullPath + ".gz"
// 3. 确定 MIME 类型 (优先硬编码常用类型,防止 Windows 注册表错误) // 确定 MIME 类型
ext := filepath.Ext(fullPath) ext := filepath.Ext(fullPath)
contentType := mime.TypeByExtension(ext) contentType := mime.TypeByExtension(ext)
if contentType == "" { if contentType == "" {
switch ext { switch ext {
case ".js": case ".js": contentType = "application/javascript"
contentType = "application/javascript" case ".css": contentType = "text/css"
case ".css": case ".svg": contentType = "image/svg+xml"
contentType = "text/css" default: contentType = "application/octet-stream"
case ".svg":
contentType = "image/svg+xml"
case ".json":
contentType = "application/json"
case ".wasm":
contentType = "application/wasm"
default:
contentType = "application/octet-stream"
} }
} }
// 4. 发送逻辑 // 优先尝试读取 .gz 文件
// 优先尝试发送 .gz 版本 if gzFile, err := staticFS.Open(gzPath); err == nil {
if gzData, err := fs.ReadFile(staticFS, gzPath); err == nil { defer gzFile.Close()
ctx.Header("Content-Type", contentType) ctx.Header("Content-Type", contentType)
if isGzipSupported { if isGzipSupported {
// 极致性能:流式透传压缩包 (RSS 占用极低)
ctx.Header("Content-Encoding", "gzip") ctx.Header("Content-Encoding", "gzip")
ctx.Data(http.StatusOK, contentType, gzData) ctx.Status(http.StatusOK)
io.Copy(ctx.Writer, gzFile)
} else { } else {
// 客户端不支持 Gzip,解压后返回 // 兼容处理:流式解压发送
gr, _ := gzip.NewReader(bytes.NewReader(gzData)) gr, _ := gzip.NewReader(gzFile)
defer gr.Close() defer gr.Close()
ctx.Status(http.StatusOK) ctx.Status(http.StatusOK)
io.Copy(ctx.Writer, gr) io.Copy(ctx.Writer, gr)
@@ -78,17 +68,19 @@ func initStaticRoutes(root *gin.RouterGroup) {
return return
} }
// 如果没有 .gz尝试发送原文件 // 如果没有 .gz流式读取原文件
if data, err := fs.ReadFile(staticFS, fullPath); err == nil { if file, err := staticFS.Open(fullPath); err == nil {
ctx.Data(http.StatusOK, contentType, data) defer file.Close()
ctx.Header("Content-Type", contentType)
ctx.Status(http.StatusOK)
io.Copy(ctx.Writer, file)
return return
} }
// 都没找到 ctx.Status(404)
ctx.Status(http.StatusNotFound)
}) })
// logo.svg 处理 // logo.svg 等单文件处理
root.GET("/logo.svg", func(ctx *gin.Context) { root.GET("/logo.svg", func(ctx *gin.Context) {
serveSingleFile(ctx, "logo.svg", "image/svg+xml", "public, max-age=86400") serveSingleFile(ctx, "logo.svg", "image/svg+xml", "public, max-age=86400")
}) })
@@ -101,30 +93,32 @@ func serveSingleFile(ctx *gin.Context, filename string, contentType string, cach
return return
} }
if cache != "" { if cache != "" { ctx.Header("Cache-Control", cache) }
ctx.Header("Cache-Control", cache) ctx.Header("Content-Type", contentType)
}
isGzipSupported := strings.Contains(ctx.GetHeader("Accept-Encoding"), "gzip") isGzipSupported := strings.Contains(ctx.GetHeader("Accept-Encoding"), "gzip")
// 尝试压缩版 // 尝试流式发送压缩版
if gzData, err := fs.ReadFile(staticFS, filename+".gz"); err == nil { if gzFile, err := staticFS.Open(filename + ".gz"); err == nil {
ctx.Header("Content-Type", contentType) defer gzFile.Close()
if isGzipSupported { if isGzipSupported {
ctx.Header("Content-Encoding", "gzip") ctx.Header("Content-Encoding", "gzip")
ctx.Data(http.StatusOK, contentType, gzData) ctx.Status(200)
io.Copy(ctx.Writer, gzFile)
} else { } else {
gr, _ := gzip.NewReader(bytes.NewReader(gzData)) gr, _ := gzip.NewReader(gzFile)
defer gr.Close() defer gr.Close()
ctx.Status(http.StatusOK) ctx.Status(200)
io.Copy(ctx.Writer, gr) io.Copy(ctx.Writer, gr)
} }
return return
} }
// 尝试原版 // 尝试流式发送原版
if data, err := fs.ReadFile(staticFS, filename); err == nil { if file, err := staticFS.Open(filename); err == nil {
ctx.Data(http.StatusOK, contentType, data) defer file.Close()
ctx.Status(200)
io.Copy(ctx.Writer, file)
return return
} }
@@ -135,30 +129,30 @@ func serveSingleFile(ctx *gin.Context, filename string, contentType string, cach
func serveSPA(ctx *gin.Context, urlPrefix string, status int) { func serveSPA(ctx *gin.Context, urlPrefix string, status int) {
staticFS := static.GetFS() staticFS := static.GetFS()
if staticFS == nil { if staticFS == nil {
serveFallback(ctx, urlPrefix, status) ctx.String(status, "Frontend assets not found.")
return return
} }
var data []byte var data []byte
// 尝试读取并解压为字符串以便注入配置 // index.html 较小且需要修改字符串,可以一次性读入内存
if gzData, err := fs.ReadFile(staticFS, "index.html.gz"); err == nil { if gzFile, err := staticFS.Open("index.html.gz"); err == nil {
gr, _ := gzip.NewReader(bytes.NewReader(gzData)) defer gzFile.Close()
gr, _ := gzip.NewReader(gzFile)
data, _ = io.ReadAll(gr) data, _ = io.ReadAll(gr)
gr.Close() gr.Close()
} else if rawData, err := fs.ReadFile(staticFS, "index.html"); err == nil { } else if file, err := staticFS.Open("index.html"); err == nil {
data = rawData defer file.Close()
data, _ = io.ReadAll(file)
} }
if data == nil { if data == nil {
serveFallback(ctx, urlPrefix, status) ctx.String(status, "index.html not found.")
return return
} }
html := string(data) html := string(data)
baseHref := urlPrefix + "/" baseHref := urlPrefix + "/"
if urlPrefix == "" { baseHref = "/" } if urlPrefix == "" { baseHref = "/" }
// 注入 Base 和 Config
html = strings.Replace(html, "<head>", "<head>\n <base href=\""+baseHref+"\">", 1) html = strings.Replace(html, "<head>", "<head>\n <base href=\""+baseHref+"\">", 1)
configScript := `<script>window.__BASE_URL__ = "` + urlPrefix + `"; window.__API_VERSION__ = "/api/v1";</script>` configScript := `<script>window.__BASE_URL__ = "` + urlPrefix + `"; window.__API_VERSION__ = "/api/v1";</script>`
html = strings.Replace(html, "</head>", configScript+"</head>", 1) html = strings.Replace(html, "</head>", configScript+"</head>", 1)
@@ -167,8 +161,3 @@ func serveSPA(ctx *gin.Context, urlPrefix string, status int) {
ctx.Header("Cache-Control", "no-cache, no-store, must-revalidate") ctx.Header("Cache-Control", "no-cache, no-store, must-revalidate")
ctx.Data(status, "text/html; charset=utf-8", []byte(html)) ctx.Data(status, "text/html; charset=utf-8", []byte(html))
} }
func serveFallback(ctx *gin.Context, urlPrefix string, status int) {
ctx.Header("Content-Type", "text/html; charset=utf-8")
ctx.String(status, "Frontend assets not found. Please run 'npm run build'.")
}
+28 -2
View File
@@ -5,12 +5,38 @@ package static
import ( import (
"io/fs" "io/fs"
"os" "os"
"path/filepath"
) )
// DefaultStaticDir 是 Docker 模式下默认的静态资源目录
const DefaultStaticDir = "/www/baihu"
func GetFS() fs.FS { func GetFS() fs.FS {
return nil dir := getStaticDir()
if dir == "" {
return nil
}
return os.DirFS(dir)
} }
func ReadFile(name string) ([]byte, error) { func ReadFile(name string) ([]byte, error) {
return nil, os.ErrNotExist dir := getStaticDir()
if dir == "" {
return nil, os.ErrNotExist
}
return os.ReadFile(filepath.Join(dir, name))
}
func getStaticDir() string {
// 优先从环境变量读取
if dir := os.Getenv("BH_STATIC_DIR"); dir != "" {
if _, err := os.Stat(dir); err == nil {
return dir
}
}
// 回退到默认目录
if _, err := os.Stat(DefaultStaticDir); err == nil {
return DefaultStaticDir
}
return ""
} }