This commit is contained in:
truewhile
2026-08-27 11:07:18 +08:00
parent a00504080a
commit 9f5ff7e6f0
6 changed files with 237 additions and 31 deletions
+117
View File
@@ -27,6 +27,9 @@ permissions:
jobs:
version-and-publish:
runs-on: ubuntu-latest
outputs:
new_version: ${{ steps.bump_version.outputs.new_version }}
tag: ${{ steps.bump_version.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
@@ -149,3 +152,117 @@ jobs:
VERSION=${{ steps.bump_version.outputs.new_version }}
cache-from: type=gha
cache-to: type=gha,mode=max
# 单文件可执行构建:把前端打包进二进制(go:embed),交叉编译 Windows /
# Linux / macOS 的 amd64 / arm64 产物,作为 GitHub Release 附件发布。
build-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install
working-directory: web
run: npm ci
- name: Build SPA
working-directory: web
run: npm run build
- name: Upload web/dist
uses: actions/upload-artifact@v4
with:
name: web-dist
path: web/dist
retention-days: 1
build-binaries:
needs: [version-and-publish, build-frontend]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- goos: linux
goarch: amd64
ext: ""
- goos: linux
goarch: arm64
ext: ""
- goos: windows
goarch: amd64
ext: .exe
- goos: windows
goarch: arm64
ext: .exe
- goos: darwin
goarch: amd64
ext: ""
- goos: darwin
goarch: arm64
ext: ""
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
- name: Download web/dist
uses: actions/download-artifact@v4
with:
name: web-dist
path: web/dist
- name: Build binary
run: |
CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \
go build -trimpath -ldflags="-s -w -X main.version=${{ needs.version-and-publish.outputs.tag }}" \
-o "dist/mmtl-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" ./cmd/server
- name: Package
run: |
mkdir -p package/mmtl
cp "dist/mmtl-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" package/mmtl/mmtl${{ matrix.ext }}
cp README.md package/mmtl/ 2>/dev/null || true
if [ "${{ matrix.goos }}" = "windows" ]; then
(cd package && zip -r "../mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.zip" mmtl)
else
tar -czf "mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz" -C package mmtl
fi
- name: Upload package
uses: actions/upload-artifact@v4
with:
name: mmtl-package-${{ matrix.goos }}-${{ matrix.goarch }}
path: |
mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.zip
mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz
if-no-files-found: ignore
retention-days: 1
publish-release:
needs: [version-and-publish, build-binaries]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: release-assets
merge-multiple: true
- name: Create / update GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.version-and-publish.outputs.tag }}
run: |
set -eux
echo "tag=$RELEASE_TAG"
# 若 release 不存在则创建(tag 已由 version-and-publish 推送)
gh release create "$RELEASE_TAG" \
--title "MMTL ${{ needs.version-and-publish.outputs.new_version }}" \
--notes "自动化发布 ${{ needs.version-and-publish.outputs.new_version }}" \
--verify-tag --latest || true
# 上传所有平台产物(已存在的同名 asset 会直接覆盖)
for f in release-assets/mmtl_*.zip release-assets/mmtl_*.tar.gz; do
[ -e "$f" ] && gh release upload "$RELEASE_TAG" "$f" --clobber || true
done
+13
View File
@@ -18,6 +18,19 @@ jobs:
go-version: '1.25'
cache: true
# The binary embeds the SPA (web/dist) via go:embed, so the dist must exist
# before the Go toolchain touches the `web` package.
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Build SPA
working-directory: web
run: |
npm ci
npm run build
- name: go vet
run: go vet ./...
+22 -5
View File
@@ -360,15 +360,17 @@ docker compose -f docker-compose.search.yml up -d --no-deps mmtl
本地开发需要 Go、Node.js 和 npm。
后端会将 `web/dist` 通过 `go:embed` 编进二进制,因此**在编译 / 运行后端之前要先构建前端**,否则 `web` 包会因为缺少嵌入资源而编译失败。
```bash
# 前端依赖与构建(必须先做,产物被 go:embed 打进二进制)
npm --prefix web ci
npm --prefix web run build
# 后端测试
go test ./...
# 前端依赖与构建
npm --prefix web install
npm --prefix web run build
# 本地运行后端
# 本地运行后端(二进制自带前端界面,无需额外 web 目录)
go run ./cmd/server
# 本地运行前端开发服务器
@@ -387,6 +389,21 @@ http://127.0.0.1:3000
http://127.0.0.1:8080/api/health
```
### 交叉编译单文件发布物
CI(`.github/workflows/Auto-docker-publish.yml`)每次发布会自动为 Windows / Linux(含 Debian) / macOS 交叉编译 amd64 + arm64 的单文件可执行程序,并上传到对应的 GitHub Release。你可以在 Releases 页面下载 `.zip`(Windows)或 `.tar.gz`(Linux / macOS)附件,解压后直接运行其中的 `mmtl`(Windows 为 `mmtl.exe`),无需额外携带前端目录。
本地手动交叉编译某个平台:
```bash
# 先构建前端
npm --prefix web ci && npm --prefix web run build
# 例如:构建 Linux amd64 单文件
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o mmtl-linux-amd64 ./cmd/server
```
## 贡献与反馈
提交 Bug、功能建议或 Pull Request 前,请先阅读 [贡献规范](CONTRIBUTING.md)。
+3 -3
View File
@@ -52,7 +52,7 @@ func TestServeSPANoCachesIndexAndServesRoutes(t *testing.T) {
}
router := gin.New()
serveSPA(router, webDir)
serveSPA(router, os.DirFS(webDir))
for _, path := range []string{"/", "/login", "/library/e1c3507e-2878-40ae-a0e1-6b6e44b7fa7a", "/media/abc"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
@@ -93,7 +93,7 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
}
router := gin.New()
serveSPA(router, webDir)
serveSPA(router, os.DirFS(webDir))
assetReq := httptest.NewRequest(http.MethodGet, "/assets/app.js", nil)
assetResp := httptest.NewRecorder()
@@ -155,7 +155,7 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
func TestServeSPAMissingIndexReportsExplicit404(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
serveSPA(router, t.TempDir())
serveSPA(router, os.DirFS(t.TempDir()))
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
+59 -23
View File
@@ -1,6 +1,8 @@
package main
import (
"io/fs"
"mime"
"net/http"
"os"
"path/filepath"
@@ -13,6 +15,8 @@ import (
"github.com/ShukeBta/MMTL/internal/handler"
"github.com/ShukeBta/MMTL/internal/middleware"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/ShukeBta/MMTL/web"
)
func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container) *gin.Engine {
@@ -29,31 +33,41 @@ func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container)
handler.Register(r, cfg, logger, svc)
if cfg.App.WebDir != "" {
serveSPA(r, cfg.App.WebDir)
// Prefer a directory on disk when configured explicitly (e.g. the Docker image
// mounts web/dist from the build stage, or an operator overrides app.web_dir
// with a custom skin). Otherwise fall back to the SPA embedded into the binary,
// which is what makes the cross-platform single-file artifacts work.
uiFS := webui.DistFS()
if dir := cfg.App.WebDir; dir != "" {
disk := os.DirFS(dir)
if index, err := fs.Stat(disk, "index.html"); err == nil && !index.IsDir() {
uiFS = disk
}
}
serveSPA(r, uiFS)
return r
}
// serveSPA serves the React build artifacts and falls back to index.html for
// non-API, non-asset paths so client-side routing keeps working.
func serveSPA(r *gin.Engine, webDir string) {
// non-API, non-asset paths so client-side routing keeps working. The UI tree
// comes from root, which is either the compiled-in SPA or an on-disk web dir.
func serveSPA(r *gin.Engine, root fs.FS) {
assets := r.Group("/assets")
assets.Use(func(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Next()
})
assets.Static("/", filepath.Join(webDir, "assets"))
assets.GET("/*filepath", serveFSDir(root, "assets"))
brand := r.Group("/brand")
brand.Use(func(c *gin.Context) {
setNoCacheHeaders(c)
c.Next()
})
brand.Static("/", filepath.Join(webDir, "brand"))
brand.GET("/*filepath", serveFSDir(root, "brand"))
for _, rootFile := range []string{"/favicon.ico", "/favicon.svg", "/artwork-cache-sw.js"} {
filePath := filepath.Join(webDir, strings.TrimPrefix(rootFile, "/"))
r.GET(rootFile, serveNoCacheFile(filePath))
r.HEAD(rootFile, serveNoCacheFile(filePath))
name := strings.TrimPrefix(rootFile, "/")
r.GET(rootFile, serveFSFile(root, name))
r.HEAD(rootFile, serveFSFile(root, name))
}
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
@@ -61,28 +75,50 @@ func serveSPA(r *gin.Engine, webDir string) {
c.Status(http.StatusNotFound)
return
}
serveSPAIndex(c, filepath.Join(webDir, "index.html"))
setNoCacheHeaders(c)
data, err := fs.ReadFile(root, "index.html")
if err != nil {
c.String(http.StatusNotFound, "MMTL web UI not found")
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
})
}
func serveNoCacheFile(filePath string) gin.HandlerFunc {
// serveFSDir serves a static subdirectory of root. A missing asset returns 404.
func serveFSDir(root fs.FS, dir string) gin.HandlerFunc {
sub, err := fs.Sub(root, dir)
if err != nil {
return func(c *gin.Context) { c.Status(http.StatusNotFound) }
}
handler := http.StripPrefix("/"+dir, http.FileServerFS(sub))
return func(c *gin.Context) {
setNoCacheHeaders(c)
if _, err := os.Stat(filePath); err != nil {
c.Status(http.StatusNotFound)
return
}
c.File(filePath)
handler.ServeHTTP(c.Writer, c.Request)
}
}
func serveSPAIndex(c *gin.Context, indexPath string) {
setNoCacheHeaders(c)
if _, err := os.Stat(indexPath); err != nil {
c.String(http.StatusNotFound, "MMTL web UI not found: %s", indexPath)
return
// serveFSFile serves a single root-level file (favicon / service worker) with
// no-cache headers. It reads from root, which may be the embedded SPA or disk.
func serveFSFile(root fs.FS, name string) gin.HandlerFunc {
return func(c *gin.Context) {
setNoCacheHeaders(c)
data, err := fs.ReadFile(root, name)
if err != nil {
c.Status(http.StatusNotFound)
return
}
c.Data(http.StatusOK, mimeTypeByName(name), data)
}
}
// mimeTypeByName returns an HTTP content type guessed from a file extension.
func mimeTypeByName(name string) string {
switch mime.TypeByExtension(filepath.Ext(name)) {
case "":
return "application/octet-stream"
default:
return mime.TypeByExtension(filepath.Ext(name))
}
c.File(indexPath)
}
func setNoCacheHeaders(c *gin.Context) {
+23
View File
@@ -0,0 +1,23 @@
// Package webui embeds the built React SPA so a single binary can serve the
// UI without requiring a separate web/dist directory on disk. The embed
// happens at compile time, so `web/dist` must exist before `go build` runs
// (the CI pipeline builds it via `npm run build` first).
package webui
import (
"embed"
"io/fs"
)
//go:embed all:dist
var distFS embed.FS
// DistFS returns the embedded SPA build artifacts rooted at the directory
// containing index.html (i.e. web/dist).
func DistFS() fs.FS {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
panic(err)
}
return sub
}