From 6f07849a53678f705620c3142cbf8030d3b80099 Mon Sep 17 00:00:00 2001 From: admin_gitea Date: Wed, 5 Aug 2026 15:13:29 +0800 Subject: [PATCH] feat: unify service port and add GitHub packages workflow --- .github/workflows/build.yml | 116 +++++++++++++ BUILD.md | 88 ++++------ Makefile | 42 ++--- README.md | 39 +++-- main.go | 325 +++++++++--------------------------- main_test.go | 67 +++++--- vite.config.js | 8 +- 7 files changed, 318 insertions(+), 367 deletions(-) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..424e340 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,116 @@ +name: Build Packages + +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + - name: Install frontend dependencies + run: npm ci + - name: Run frontend tests + run: npm test -- --run + - name: Build frontend assets + run: npm run build + - name: Run Go tests + run: go test -buildvcs=false ./... + + build: + name: Build ${{ matrix.name }} package + needs: test + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: Windows + goos: windows + extension: .exe + archive: zip + - name: Linux + goos: linux + extension: '' + archive: tar.gz + - name: macOS + goos: darwin + extension: '' + archive: tar.gz + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + - name: Build embedded frontend + run: | + npm ci + npm run build + - name: Build binary and package + shell: bash + env: + GOOS: ${{ matrix.goos }} + GOARCH: amd64 + CGO_ENABLED: 0 + run: | + set -euo pipefail + package_name="home-vue-go-${GOOS}-amd64" + binary_name="${package_name}${{ matrix.extension }}" + go build -trimpath -buildvcs=false -ldflags="-s -w" -o "${binary_name}" . + mkdir package + cp "${binary_name}" README.md LICENSE package/ + if [ "${{ matrix.archive }}" = "zip" ]; then + (cd package && zip -q -r "../${package_name}.zip" .) + else + tar -czf "${package_name}.tar.gz" -C package . + fi + - name: Upload package artifact + uses: actions/upload-artifact@v4 + with: + name: package-${{ matrix.goos }} + path: home-vue-go-${{ matrix.goos }}-amd64.${{ matrix.archive }} + if-no-files-found: error + + release: + name: Publish GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download package artifacts + uses: actions/download-artifact@v4 + with: + pattern: package-* + path: release + merge-multiple: true + - name: Publish release + uses: softprops/action-gh-release@v2 + with: + files: release/* diff --git a/BUILD.md b/BUILD.md index e23f4e9..944320a 100644 --- a/BUILD.md +++ b/BUILD.md @@ -1,8 +1,6 @@ # 构建说明 -本项目支持打包为**单一可执行文件**,包含前后端,启动后同时提供: -- **1551端口**:后端API服务 -- **1552端口**:前端界面服务(自动代理API请求到1551) +本项目支持打包为**单一可执行文件**,包含前后端。启动后通过一个端口同时提供前端、API和上传文件,默认端口为 **1552**。 所有前端文件已嵌入到二进制文件中,无需额外文件。 @@ -123,18 +121,13 @@ cd dist ## 访问地址 -启动后,服务器会同时提供两个服务: - -- **后端API**: http://localhost:1551 - - API接口:http://localhost:1551/api - - 管理接口:http://localhost:1551/api/admin - -- **前端界面**: http://localhost:1552 - - 主页:http://localhost:1552 - - 管理界面:http://localhost:1552/admin - - 登录页面:http://localhost:1552/login - -**注意**:前端会自动将 `/api` 请求代理到 `http://localhost:1551`,无需额外配置。 +启动后,服务器通过统一端点提供完整服务: + +- **访问端点**: http://localhost:1552 + - 主页:http://localhost:1552 + - 管理界面:http://localhost:1552/admin + - 登录页面:http://localhost:1552/login + - API接口:http://localhost:1552/api ## 1Panel 配置 @@ -142,9 +135,7 @@ cd dist 1. **上传文件**:将 `home-vue-go`(Linux版本)上传到服务器 -2. **配置端口**: - - 后端API端口:`1551` - - 前端服务端口:`1552` +2. **配置端口**:只需放行统一服务端口 `1552` 3. **运行命令**: ```bash @@ -155,8 +146,7 @@ cd dist ## 配置说明 -- **后端API端口**:默认 `1551`,可通过环境变量 `API_PORT` 修改 -- **前端服务端口**:默认 `1552`,可通过环境变量 `FRONTEND_PORT` 修改 +- **统一服务端口**:默认 `1552`,可通过环境变量 `PORT` 修改 - **数据目录**:运行时会自动在二进制文件同目录下创建 `data` 目录 - **默认管理员账号**:`admin` / `admin123`(首次启动时显示) @@ -164,16 +154,14 @@ cd dist **Windows:** ```bash -set API_PORT=8080 -set FRONTEND_PORT=8081 -home-vue-go.exe +set PORT=8080 +home-vue-go.exe ``` **Linux/macOS:** ```bash -export API_PORT=8080 -export FRONTEND_PORT=8081 -./home-vue-go +export PORT=8080 +./home-vue-go ``` ## 注意事项 @@ -181,7 +169,7 @@ export FRONTEND_PORT=8081 1. **SQLite驱动**:使用纯 Go SQLite 驱动,构建无需 CGO 或 GCC 2. **前端构建**:构建时必须先运行 `npm run build` 生成dist目录,Go编译时会嵌入这些文件 3. **Go版本**:需要Go 1.23或更高版本(支持embed功能) -4. **端口占用**:确保1551和1552端口未被占用 +4. **端口占用**:确保统一服务端口(默认1552)未被占用 5. **单一文件**:构建完成后,只需一个可执行文件即可运行,无需其他依赖 6. **Make工具**:Windows用户需要安装make工具(Git for Windows自带,或使用Chocolatey安装) 7. **跨平台编译**:Windows、Linux 和 macOS amd64 目标均可直接交叉编译 @@ -222,24 +210,13 @@ choco install make │ 包含: 后端代码 + 前端文件(嵌入) │ ├─────────────────────────────────┤ │ │ -│ ┌──────────────┐ ┌──────────┐ │ -│ │ 后端API服务 │ │ 前端服务 │ │ -│ │ 端口: 1551 │ │ 端口:1552│ │ -│ └──────┬───────┘ └────┬─────┘ │ -│ │ │ │ -│ │ │ │ -│ └───────┬───────┘ │ -│ │ │ -│ API代理 │ -│ (前端/api/* → 后端1551) │ -└─────────────────────────────────┘ -``` - -- **后端服务(1551)**:提供所有API接口 -- **前端服务(1552)**: - - 从嵌入的文件系统提供前端静态文件(HTML、CSS、JS) - - 自动代理 `/api/*` 请求到后端1551端口 - - 支持SPA路由 +│ │ +│ 统一 HTTP 服务 :1552 │ +│ /api /uploads /assets SPA │ +└─────────────────────────────────┘ +``` + +- **统一服务(1552)**:直接提供 API、上传文件、嵌入的前端静态文件和 SPA 路由 ## 完整构建流程示例 @@ -331,7 +308,7 @@ make build-linux **1Panel配置:** - 运行命令:`./home-vue-go`(或完整路径) -- 端口映射:1551(后端API)、1552(前端界面) +- 端口映射:1552(统一服务端口) - 工作目录:可执行文件所在目录 **注意**:SQLite 使用纯 Go 驱动,因此从 Windows 交叉构建 Linux 版本不需要额外的 C 工具链。 @@ -365,26 +342,23 @@ make clean 开发时,可以分别运行前后端: -**终端1 - 后端:** -```bash -make run -# 或 -go run main.go -# 后端运行在 http://localhost:1551 +**终端1 - 统一服务:** +```bash +make run +# 前端和 API 均运行在 http://localhost:1552 ``` **终端2 - 前端:** ```bash -npm run dev -# 前端运行在 http://localhost:1552,自动代理API到1551 +npm run dev +# 热更新页面运行在 http://localhost:5173,API代理到统一服务端口 ``` ## 部署优势 ✅ **单一可执行文件**:前后端一体化,所有文件嵌入在二进制中 ✅ **无需依赖**:不需要Node.js、npm或其他运行时 -✅ **端口分离**:API和前端服务分离,便于管理和扩展 -✅ **自动代理**:前端自动代理API请求,无需额外配置 -✅ **1Panel友好**:只需配置两个端口,运行一个命令即可 +✅ **单端口服务**:前端、API和上传文件共用一个端口 +✅ **1Panel友好**:只需配置一个端口,运行一个命令即可 ✅ **部署简单**:上传一个文件,配置端口,即可运行 ✅ **跨平台构建**:使用make统一构建流程,支持多平台 diff --git a/Makefile b/Makefile index 9de0eeb..4d4607c 100644 --- a/Makefile +++ b/Makefile @@ -42,35 +42,35 @@ frontend: backend-windows: frontend @echo [backend] Building Windows amd64 executable... ifeq ($(OS),Windows_NT) - @set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=windows" && set "GOARCH=amd64" && $(GO) build -trimpath -ldflags="-s -w" -o $(DIST_DIR)\$(BINARY_NAME).exe . + @set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=windows" && set "GOARCH=amd64" && $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(DIST_DIR)\$(BINARY_NAME).exe . else - @GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(DIST_DIR)/$(BINARY_NAME).exe . + @GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(DIST_DIR)/$(BINARY_NAME).exe . endif backend-linux: frontend @echo [backend] Building Linux amd64 executable... ifeq ($(OS),Windows_NT) - @set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=linux" && set "GOARCH=amd64" && $(GO) build -trimpath -ldflags="-s -w" -o $(DIST_DIR)\$(BINARY_NAME) . + @set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=linux" && set "GOARCH=amd64" && $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(DIST_DIR)\$(BINARY_NAME) . else - @GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(DIST_DIR)/$(BINARY_NAME) . + @GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(DIST_DIR)/$(BINARY_NAME) . @chmod +x $(DIST_DIR)/$(BINARY_NAME) endif backend-darwin: frontend @echo [backend] Building macOS amd64 executable... ifeq ($(OS),Windows_NT) - @set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=darwin" && set "GOARCH=amd64" && $(GO) build -trimpath -ldflags="-s -w" -o $(DIST_DIR)\$(BINARY_NAME) . + @set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=darwin" && set "GOARCH=amd64" && $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(DIST_DIR)\$(BINARY_NAME) . else - @GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(DIST_DIR)/$(BINARY_NAME) . + @GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(DIST_DIR)/$(BINARY_NAME) . @chmod +x $(DIST_DIR)/$(BINARY_NAME) endif backend: frontend @echo [backend] Building for the current platform... ifeq ($(OS),Windows_NT) - @set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && $(GO) build -trimpath -ldflags="-s -w" -o $(DIST_DIR)\$(BINARY_NAME).exe . + @set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(DIST_DIR)\$(BINARY_NAME).exe . else - @GOPROXY="$(GOPROXY)" CGO_ENABLED=0 $(GO) build -trimpath -ldflags="-s -w" -o $(DIST_DIR)/$(BINARY_NAME) . + @GOPROXY="$(GOPROXY)" CGO_ENABLED=0 $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(DIST_DIR)/$(BINARY_NAME) . @chmod +x $(DIST_DIR)/$(BINARY_NAME) endif @@ -78,12 +78,12 @@ endif build-windows: clean frontend @echo [build] Building Windows amd64 package... ifeq ($(OS),Windows_NT) - @set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=windows" && set "GOARCH=amd64" && $(GO) build -trimpath -ldflags="-s -w" -o $(WINDOWS_TEMP) . + @set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=windows" && set "GOARCH=amd64" && $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(WINDOWS_TEMP) . @if exist $(DIST_DIR) rmdir /s /q $(DIST_DIR) @mkdir $(DIST_DIR) @move /y $(WINDOWS_TEMP) $(DIST_DIR)\$(BINARY_NAME).exe >nul else - @GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(WINDOWS_TEMP) . + @GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(WINDOWS_TEMP) . @rm -rf $(DIST_DIR) && mkdir -p $(DIST_DIR) @mv $(WINDOWS_TEMP) $(DIST_DIR)/$(BINARY_NAME).exe endif @@ -92,12 +92,12 @@ endif build-linux: clean frontend @echo [build] Building Linux amd64 package... ifeq ($(OS),Windows_NT) - @set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=linux" && set "GOARCH=amd64" && $(GO) build -trimpath -ldflags="-s -w" -o $(LINUX_TEMP) . + @set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=linux" && set "GOARCH=amd64" && $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(LINUX_TEMP) . @if exist $(DIST_DIR) rmdir /s /q $(DIST_DIR) @mkdir $(DIST_DIR) @move /y $(LINUX_TEMP) $(DIST_DIR)\$(BINARY_NAME) >nul else - @GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(LINUX_TEMP) . + @GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(LINUX_TEMP) . @rm -rf $(DIST_DIR) && mkdir -p $(DIST_DIR) @mv $(LINUX_TEMP) $(DIST_DIR)/$(BINARY_NAME) @chmod +x $(DIST_DIR)/$(BINARY_NAME) @@ -107,12 +107,12 @@ endif build-darwin: clean frontend @echo [build] Building macOS amd64 package... ifeq ($(OS),Windows_NT) - @set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=darwin" && set "GOARCH=amd64" && $(GO) build -trimpath -ldflags="-s -w" -o $(DARWIN_TEMP) . + @set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=darwin" && set "GOARCH=amd64" && $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(DARWIN_TEMP) . @if exist $(DIST_DIR) rmdir /s /q $(DIST_DIR) @mkdir $(DIST_DIR) @move /y $(DARWIN_TEMP) $(DIST_DIR)\$(BINARY_NAME) >nul else - @GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(DARWIN_TEMP) . + @GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(DARWIN_TEMP) . @rm -rf $(DIST_DIR) && mkdir -p $(DIST_DIR) @mv $(DARWIN_TEMP) $(DIST_DIR)/$(BINARY_NAME) @chmod +x $(DIST_DIR)/$(BINARY_NAME) @@ -122,22 +122,22 @@ endif build: clean frontend @echo [build] Building Windows amd64 package... ifeq ($(OS),Windows_NT) - @set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=windows" && set "GOARCH=amd64" && $(GO) build -trimpath -ldflags="-s -w" -o $(ALL_WINDOWS_TEMP) . + @set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=windows" && set "GOARCH=amd64" && $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(ALL_WINDOWS_TEMP) . @echo [build] Building Linux amd64 package... - @set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=linux" && set "GOARCH=amd64" && $(GO) build -trimpath -ldflags="-s -w" -o $(ALL_LINUX_TEMP) . + @set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=linux" && set "GOARCH=amd64" && $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(ALL_LINUX_TEMP) . @echo [build] Building macOS amd64 package... - @set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=darwin" && set "GOARCH=amd64" && $(GO) build -trimpath -ldflags="-s -w" -o $(ALL_DARWIN_TEMP) . + @set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && set "GOOS=darwin" && set "GOARCH=amd64" && $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(ALL_DARWIN_TEMP) . @if exist $(DIST_DIR) rmdir /s /q $(DIST_DIR) @mkdir $(DIST_DIR) @move /y $(ALL_WINDOWS_TEMP) $(DIST_DIR)\$(BINARY_NAME)-windows-amd64.exe >nul @move /y $(ALL_LINUX_TEMP) $(DIST_DIR)\$(BINARY_NAME)-linux-amd64 >nul @move /y $(ALL_DARWIN_TEMP) $(DIST_DIR)\$(BINARY_NAME)-darwin-amd64 >nul else - @GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(ALL_WINDOWS_TEMP) . + @GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(ALL_WINDOWS_TEMP) . @echo [build] Building Linux amd64 package... - @GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(ALL_LINUX_TEMP) . + @GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(ALL_LINUX_TEMP) . @echo [build] Building macOS amd64 package... - @GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build -trimpath -ldflags="-s -w" -o $(ALL_DARWIN_TEMP) . + @GOPROXY="$(GOPROXY)" CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build -trimpath -buildvcs=false -ldflags="-s -w" -o $(ALL_DARWIN_TEMP) . @rm -rf $(DIST_DIR) && mkdir -p $(DIST_DIR) @mv $(ALL_WINDOWS_TEMP) $(DIST_DIR)/$(BINARY_NAME)-windows-amd64.exe @mv $(ALL_LINUX_TEMP) $(DIST_DIR)/$(BINARY_NAME)-linux-amd64 @@ -150,7 +150,7 @@ endif dist: build -run: +run: frontend ifeq ($(OS),Windows_NT) @set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && $(GO) run . else diff --git a/README.md b/README.md index 3ea2159..d5d585d 100644 --- a/README.md +++ b/README.md @@ -82,12 +82,12 @@ cd ../.. **开发模式:** -需要打开两个终端窗口: +生产运行只需要启动一个服务。需要前端热更新时,可额外启动 Vite 开发服务器: -**终端1 - 启动Go后端:** +**终端1 - 启动统一服务:** ```bash # 在项目根目录运行 -go run main.go +make run ``` **终端2 - 启动前端开发服务器:** @@ -97,11 +97,11 @@ npm run dev ``` **说明:** -- `go run main.go` 会编译并运行Go程序 -- 后端默认运行在 `http://localhost:1551` -- 前端默认运行在 `http://localhost:1552` +- `make run` 会先构建前端,再启动同时提供前端、API和上传文件的统一服务 +- 统一服务默认运行在 `http://localhost:1552` +- Vite 开发服务器运行在 `http://localhost:5173`,并将 API 请求代理到统一服务 - 首次运行会自动创建 `data/` 目录和数据库 -- 可以通过环境变量 `PORT` 修改后端端口(默认1551) +- 可以通过环境变量 `PORT` 修改统一服务端口(默认1552) **Windows用户注意:** 如果遇到中文乱码,在PowerShell中运行: ```powershell @@ -113,6 +113,7 @@ chcp 65001 - 前端:http://localhost:1552 - 管理界面:http://localhost:1552/admin - 登录页面:http://localhost:1552/login +- API:http://localhost:1552/api **默认管理员账号:** - 用户名:`admin` @@ -146,8 +147,8 @@ npm run build ``` 构建完成后: -- Go二进制文件:`./home-vue-go` (Linux) 或 `./home-vue-go.exe` (Windows) -- 前端构建文件:`./dist` +- `make build` 的发布包位于 `./dist/` +- 前端资源已经嵌入每个平台的二进制文件,部署时只需要对应的可执行文件 **说明:** - `go build` 编译Go程序为二进制文件 @@ -159,7 +160,6 @@ npm run build 1. **上传文件到服务器:** - 上传 `home-vue-go` 二进制文件 - - 上传 `dist` 目录(前端构建文件) 2. **运行二进制文件:** ```bash @@ -173,7 +173,7 @@ npm run build 4. **环境变量(可选):** ```bash - export PORT=1551 # 服务端口,默认1551 + export PORT=1552 # 统一服务端口,默认1552 export JWT_SECRET=your-secret-key # JWT密钥,建议修改 ``` @@ -239,7 +239,7 @@ go mod download cd internal/ent && go generate ./... && cd ../.. # 运行后端(开发模式) -go run main.go +make run # 构建后端(当前平台) CGO_ENABLED=0 go build -o home-vue-go . @@ -269,6 +269,21 @@ npm run build npm run preview ``` +### GitHub Actions 构建与安装包 + +仓库内置 `.github/workflows/build.yml`,在推送到 `main` 或创建 Pull Request 时运行测试和跨平台构建。每次工作流运行都会产生三个可下载的安装包: + +- Windows amd64:ZIP +- Linux amd64:TAR.GZ +- macOS amd64:TAR.GZ + +在 GitHub Actions 的运行详情页下载 `package-windows`、`package-linux` 或 `package-darwin`。发布版本时推送一个 `v` 开头的标签,工作流会自动创建 GitHub Release 并附加这三个安装包: + +```bash +git tag v1.0.0 +git push github v1.0.0 +``` + ### 许可证 MIT License diff --git a/main.go b/main.go index 3ad0804..e4ad2aa 100644 --- a/main.go +++ b/main.go @@ -2,11 +2,10 @@ package main import ( "embed" - "io" "io/fs" "log" + "mime" "net/http" - "net/url" "os" "path/filepath" "strings" @@ -24,263 +23,120 @@ import ( var distFS embed.FS func main() { - // 设置亚洲/上海时区 loc, err := time.LoadLocation("Asia/Shanghai") if err != nil { log.Fatal("无法加载时区:", err) } time.Local = loc - // 获取可执行文件所在目录 exePath, err := os.Executable() if err != nil { log.Fatal("无法获取可执行文件路径:", err) } - exeDir := filepath.Dir(exePath) - - // 创建data目录 - dataDir := filepath.Join(exeDir, "data") + dataDir := filepath.Join(filepath.Dir(exePath), "data") if err := os.MkdirAll(dataDir, 0755); err != nil { log.Fatal("无法创建data目录:", err) } - // 初始化配置 cfg := config.New(dataDir) - - // 初始化数据库 db, err := database.Init(cfg.DatabasePath, cfg) if err != nil { log.Fatal("数据库初始化失败:", err) } defer db.Close() - // 设置Gin模式 if os.Getenv("GIN_MODE") == "" { gin.SetMode(gin.ReleaseMode) } - // 创建Gin路由(不使用Default以避免重复日志) - r := gin.New() - - // 添加恢复中间件 - r.Use(gin.Recovery()) - - // 静态文件服务 - 从文件系统提供前端构建文件 - // 优先使用可执行文件同目录下的dist目录 - distPath := filepath.Join(exeDir, "dist") - if _, err := os.Stat(distPath); err == nil { - // 使用可执行文件同目录下的dist - r.Static("/static", filepath.Join(distPath, "static")) - r.StaticFile("/favicon.ico", filepath.Join(distPath, "favicon.ico")) - r.NoRoute(func(c *gin.Context) { - path := c.Request.URL.Path - // API和上传路径不处理 - if path == "/api" || strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/uploads/") { - c.Status(404) - return - } - // 其他路径返回index.html(SPA路由支持) - c.File(filepath.Join(distPath, "index.html")) - }) - log.Printf("前端文件目录: %s", distPath) - } else { - // 回退到当前工作目录的dist(开发模式) - if _, err := os.Stat("./dist"); err == nil { - r.Static("/static", "./dist/static") - r.StaticFile("/favicon.ico", "./dist/favicon.ico") - r.NoRoute(func(c *gin.Context) { - if c.Request.URL.Path != "/api" && !strings.HasPrefix(c.Request.URL.Path, "/api/") && !strings.HasPrefix(c.Request.URL.Path, "/uploads/") { - c.File("./dist/index.html") - } - }) - log.Println("使用当前目录的dist文件夹(开发模式)") - } else { - log.Println("警告: 未找到前端文件目录,前端功能不可用") - log.Println("提示: 请将前端构建文件放在可执行文件同目录的dist文件夹中") - } + port := os.Getenv("PORT") + if port == "" { + port = "1552" } - // 配置CORS - r.Use(corsMiddleware()) + router := gin.New() + router.Use(gin.Recovery(), corsMiddleware(port)) + api.SetupRoutes(router, db, cfg) - // 初始化API路由 - api.SetupRoutes(r, db, cfg) - - apiPort := os.Getenv("API_PORT") - if apiPort == "" { - apiPort = "1551" - } - - // 创建前端服务器(1552端口)- 从嵌入的文件系统提供前端文件 - frontendRouter := gin.New() - frontendRouter.Use(gin.Recovery()) - frontendRouter.Use(corsMiddleware()) - - // 从嵌入的文件系统加载前端文件 distRoot, err := fs.Sub(distFS, "dist") - if err == nil { - // 使用嵌入的文件系统 - frontendRouter.StaticFS("/static", http.FS(distRoot)) - - // 提供favicon - frontendRouter.GET("/favicon.ico", func(c *gin.Context) { - data, err := distRoot.Open("favicon.ico") - if err != nil { - c.Status(http.StatusNotFound) - return - } - defer data.Close() - content, err := io.ReadAll(data) - if err != nil { - c.Status(http.StatusInternalServerError) - return - } - c.Data(http.StatusOK, "image/x-icon", content) - }) - - // API代理:将/api请求转发到配置的API端口 - frontendRouter.Any("/api/*path", proxyAPIRequest(apiPort)) - - // /uploads代理 - frontendRouter.Static("/uploads", cfg.UploadDir) - - // SPA路由支持 - frontendRouter.NoRoute(func(c *gin.Context) { - path := c.Request.URL.Path - if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/uploads/") { - c.Status(404) - return - } - - // 尝试打开文件 - filePath := strings.TrimPrefix(path, "/") - if filePath == "" { - filePath = "index.html" - } - file, err := distRoot.Open(filePath) - if err == nil { - defer file.Close() - stat, err := file.Stat() - if err == nil && !stat.IsDir() { - content, err := io.ReadAll(file) - if err == nil { - contentType := "text/html" - if strings.HasSuffix(filePath, ".css") { - contentType = "text/css" - } else if strings.HasSuffix(filePath, ".js") { - contentType = "application/javascript" - } else if strings.HasSuffix(filePath, ".json") { - contentType = "application/json" - } else if strings.HasSuffix(filePath, ".ico") { - contentType = "image/x-icon" - } else if strings.HasSuffix(filePath, ".png") { - contentType = "image/png" - } else if strings.HasSuffix(filePath, ".jpg") || strings.HasSuffix(filePath, ".jpeg") { - contentType = "image/jpeg" - } else if strings.HasSuffix(filePath, ".svg") { - contentType = "image/svg+xml" - } - c.Data(http.StatusOK, contentType, content) - return - } - } - } - - // 返回index.html(SPA路由) - indexFile, err := distRoot.Open("index.html") - if err == nil { - defer indexFile.Close() - content, err := io.ReadAll(indexFile) - if err == nil { - c.Data(http.StatusOK, "text/html; charset=utf-8", content) - } else { - c.Status(http.StatusNotFound) - } - } else { - c.Status(http.StatusNotFound) - } - }) - - log.Println("使用嵌入的前端文件(单一可执行文件模式)") - } else { - // 回退到文件系统(开发模式) - log.Println("警告: 无法加载嵌入的前端文件,尝试从文件系统加载") - distPath := filepath.Join(exeDir, "dist") - if _, err := os.Stat(distPath); err == nil { - frontendRouter.Static("/static", filepath.Join(distPath, "static")) - frontendRouter.StaticFile("/favicon.ico", filepath.Join(distPath, "favicon.ico")) - - frontendRouter.Any("/api/*path", proxyAPIRequest(apiPort)) - - frontendRouter.Static("/uploads", cfg.UploadDir) - frontendRouter.NoRoute(func(c *gin.Context) { - if !strings.HasPrefix(c.Request.URL.Path, "/api/") && !strings.HasPrefix(c.Request.URL.Path, "/uploads/") { - c.File(filepath.Join(distPath, "index.html")) - } - }) - log.Printf("使用文件系统前端文件: %s", distPath) - } else if _, err := os.Stat("./dist"); err == nil { - frontendRouter.Static("/static", "./dist/static") - frontendRouter.StaticFile("/favicon.ico", "./dist/favicon.ico") - frontendRouter.Any("/api/*path", proxyAPIRequest(apiPort)) - frontendRouter.Static("/uploads", cfg.UploadDir) - frontendRouter.NoRoute(func(c *gin.Context) { - if !strings.HasPrefix(c.Request.URL.Path, "/api/") && !strings.HasPrefix(c.Request.URL.Path, "/uploads/") { - c.File("./dist/index.html") - } - }) - log.Println("使用当前目录的dist文件夹(开发模式)") - } else { - log.Println("警告: 未找到前端文件,前端功能不可用") - } + if err != nil { + log.Fatal("无法加载嵌入的前端文件:", err) } + router.NoRoute(serveFrontend(distRoot)) + log.Println("使用嵌入的前端文件(单一可执行文件模式)") - // 启动两个服务器 - frontendPort := os.Getenv("FRONTEND_PORT") - if frontendPort == "" { - frontendPort = "1552" - } - - // 创建API服务器 - apiServer := &http.Server{ - Addr: ":" + apiPort, - Handler: r, - } - - // 创建前端服务器 - frontendServer := &http.Server{ - Addr: ":" + frontendPort, - Handler: frontendRouter, - } - - // 只在首次启动时显示默认账号信息 firstRunFile := filepath.Join(dataDir, ".first_run") if _, err := os.Stat(firstRunFile); os.IsNotExist(err) { - os.WriteFile(firstRunFile, []byte(""), 0644) + if err := os.WriteFile(firstRunFile, []byte(""), 0644); err != nil { + log.Printf("记录首次启动状态失败: %v", err) + } log.Printf("默认管理员账号: admin, 密码: admin123") log.Printf("提示: 首次启动后,请及时修改默认密码以确保安全") } log.Printf("========================================") log.Printf("服务器启动成功!") - log.Printf("后端API: http://localhost:%s", apiPort) - log.Printf("前端界面: http://localhost:%s", frontendPort) + log.Printf("访问端点: http://localhost:%s", port) log.Printf("========================================") - // 在goroutine中启动前端服务器 - go func() { - if err := frontendServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("前端服务器启动失败: %v", err) - } - }() - - // 在主goroutine中启动API服务器 - if err := apiServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("API服务器启动失败: %v", err) + server := &http.Server{ + Addr: ":" + port, + Handler: router, + } + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("服务器启动失败: %v", err) } } -func corsMiddleware() gin.HandlerFunc { +func serveFrontend(root fs.FS) gin.HandlerFunc { + return func(c *gin.Context) { + requestPath := strings.TrimPrefix(c.Request.URL.Path, "/") + if requestPath == "api" || strings.HasPrefix(requestPath, "api/") || + requestPath == "uploads" || strings.HasPrefix(requestPath, "uploads/") { + c.Status(http.StatusNotFound) + return + } + if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead { + c.Status(http.StatusNotFound) + return + } + + filePath := requestPath + if filePath == "" { + filePath = "index.html" + } + if serveEmbeddedFile(c, root, filePath) { + return + } + if !serveEmbeddedFile(c, root, "index.html") { + c.Status(http.StatusNotFound) + } + } +} + +func serveEmbeddedFile(c *gin.Context, root fs.FS, filePath string) bool { + file, err := root.Open(filePath) + if err != nil { + return false + } + defer file.Close() + stat, err := file.Stat() + if err != nil || stat.IsDir() { + return false + } + content, err := fs.ReadFile(root, filePath) + if err != nil { + return false + } + contentType := mime.TypeByExtension(filepath.Ext(filePath)) + if contentType == "" { + contentType = http.DetectContentType(content) + } + c.Data(http.StatusOK, contentType, content) + return true +} + +func corsMiddleware(port string) gin.HandlerFunc { allowedOrigins := make(map[string]struct{}) for _, origin := range strings.Split(os.Getenv("CORS_ALLOWED_ORIGINS"), ",") { if value := strings.TrimSpace(origin); value != "" { @@ -288,8 +144,8 @@ func corsMiddleware() gin.HandlerFunc { } } if len(allowedOrigins) == 0 { - allowedOrigins["http://localhost:1552"] = struct{}{} - allowedOrigins["http://127.0.0.1:1552"] = struct{}{} + allowedOrigins["http://localhost:"+port] = struct{}{} + allowedOrigins["http://127.0.0.1:"+port] = struct{}{} } return func(c *gin.Context) { origin := c.GetHeader("Origin") @@ -301,41 +157,10 @@ func corsMiddleware() gin.HandlerFunc { c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH") - if c.Request.Method == "OPTIONS" { - c.AbortWithStatus(204) + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) return } - c.Next() } } - -func proxyAPIRequest(apiPort string) gin.HandlerFunc { - client := &http.Client{Timeout: 30 * time.Second} - return func(c *gin.Context) { - target := &url.URL{Scheme: "http", Host: "localhost:" + apiPort, Path: c.Request.URL.Path, RawQuery: c.Request.URL.RawQuery} - req, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, target.String(), c.Request.Body) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "创建代理请求失败"}) - return - } - for key, values := range c.Request.Header { - for _, value := range values { - req.Header.Add(key, value) - } - } - resp, err := client.Do(req) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": "代理请求失败"}) - return - } - defer resp.Body.Close() - for key, values := range resp.Header { - for _, value := range values { - c.Writer.Header().Add(key, value) - } - } - c.Status(resp.StatusCode) - _, _ = io.Copy(c.Writer, resp.Body) - } -} diff --git a/main_test.go b/main_test.go index 123dd2f..6d992a0 100644 --- a/main_test.go +++ b/main_test.go @@ -1,38 +1,59 @@ package main import ( - "net" "net/http" "net/http/httptest" - "net/url" "testing" + "testing/fstest" "github.com/gin-gonic/gin" ) -func TestProxyAPIRequestUsesConfiguredPort(t *testing.T) { - backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"path":"` + r.URL.RequestURI() + `"}`)) - })) - defer backend.Close() - - backendURL, err := url.Parse(backend.URL) - if err != nil { - t.Fatal(err) - } - _, port, err := net.SplitHostPort(backendURL.Host) - if err != nil { - t.Fatal(err) - } - +func TestAPIAndFrontendShareRouter(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() - router.Any("/api/*path", proxyAPIRequest(port)) - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/config?custom=1", nil)) + router.GET("/api/ping", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + router.NoRoute(serveFrontend(fstest.MapFS{ + "index.html": {Data: []byte("app")}, + "assets/app.js": {Data: []byte("console.log('app')")}, + })) - if recorder.Code != http.StatusOK || recorder.Body.String() != `{"path":"/api/config?custom=1"}` { - t.Fatalf("unexpected proxy response: status=%d body=%s", recorder.Code, recorder.Body.String()) + tests := []struct { + path string + statusCode int + body string + }{ + {path: "/api/ping", statusCode: http.StatusOK, body: `{"status":"ok"}`}, + {path: "/admin", statusCode: http.StatusOK, body: "app"}, + {path: "/assets/app.js", statusCode: http.StatusOK, body: "console.log('app')"}, + {path: "/api/missing", statusCode: http.StatusNotFound, body: "404 page not found"}, + {path: "/uploads/missing.png", statusCode: http.StatusNotFound, body: "404 page not found"}, + } + for _, test := range tests { + t.Run(test.path, func(t *testing.T) { + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, test.path, nil)) + if recorder.Code != test.statusCode || recorder.Body.String() != test.body { + t.Fatalf("unexpected response: status=%d body=%q", recorder.Code, recorder.Body.String()) + } + }) + } +} + +func TestCORSMiddlewareUsesServicePort(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(corsMiddleware("8080")) + router.GET("/api/ping", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/api/ping", nil) + request.Header.Set("Origin", "http://localhost:8080") + router.ServeHTTP(recorder, request) + + if origin := recorder.Header().Get("Access-Control-Allow-Origin"); origin != "http://localhost:8080" { + t.Fatalf("unexpected allowed origin: %q", origin) } } diff --git a/vite.config.js b/vite.config.js index b874755..4420d0f 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,7 +1,7 @@ import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; -const apiPort = process.env.API_PORT || '1551'; +const servicePort = process.env.PORT || '1552'; export default defineConfig({ plugins: [vue()], @@ -17,14 +17,14 @@ export default defineConfig({ }, }, server: { - port: 1552, + port: 5173, proxy: { '/api': { - target: `http://localhost:${apiPort}`, + target: `http://localhost:${servicePort}`, changeOrigin: true, }, '/uploads': { - target: `http://localhost:${apiPort}`, + target: `http://localhost:${servicePort}`, changeOrigin: true, }, },