diff --git a/.github/workflows/Auto-docker-publish.yml b/.github/workflows/Auto-docker-publish.yml index 3603f80..84c91c2 100644 --- a/.github/workflows/Auto-docker-publish.yml +++ b/.github/workflows/Auto-docker-publish.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 790467b..ad26953 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 ./... diff --git a/README.md b/README.md index da6446e..bf33934 100644 --- a/README.md +++ b/README.md @@ -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)。 diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index c423b48..39620bf 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -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() diff --git a/cmd/server/router.go b/cmd/server/router.go index 8ec8515..cef84eb 100644 --- a/cmd/server/router.go +++ b/cmd/server/router.go @@ -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) { diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..d5f6a4b --- /dev/null +++ b/web/embed.go @@ -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 +} \ No newline at end of file