feat: unify service port and add GitHub packages workflow
Build Packages / Test (push) Has been cancelled
Build Packages / Build macOS package (push) Has been cancelled
Build Packages / Build Linux package (push) Has been cancelled
Build Packages / Build Windows package (push) Has been cancelled
Build Packages / Publish GitHub Release (push) Has been cancelled
Build Packages / Test (push) Has been cancelled
Build Packages / Build macOS package (push) Has been cancelled
Build Packages / Build Linux package (push) Has been cancelled
Build Packages / Build Windows package (push) Has been cancelled
Build Packages / Publish GitHub Release (push) Has been cancelled
This commit is contained in:
@@ -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/*
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
# 构建说明
|
# 构建说明
|
||||||
|
|
||||||
本项目支持打包为**单一可执行文件**,包含前后端,启动后同时提供:
|
本项目支持打包为**单一可执行文件**,包含前后端。启动后通过一个端口同时提供前端、API和上传文件,默认端口为 **1552**。
|
||||||
- **1551端口**:后端API服务
|
|
||||||
- **1552端口**:前端界面服务(自动代理API请求到1551)
|
|
||||||
|
|
||||||
所有前端文件已嵌入到二进制文件中,无需额外文件。
|
所有前端文件已嵌入到二进制文件中,无需额外文件。
|
||||||
|
|
||||||
@@ -123,18 +121,13 @@ cd dist
|
|||||||
|
|
||||||
## 访问地址
|
## 访问地址
|
||||||
|
|
||||||
启动后,服务器会同时提供两个服务:
|
启动后,服务器通过统一端点提供完整服务:
|
||||||
|
|
||||||
- **后端API**: http://localhost:1551
|
- **访问端点**: http://localhost:1552
|
||||||
- API接口:http://localhost:1551/api
|
|
||||||
- 管理接口:http://localhost:1551/api/admin
|
|
||||||
|
|
||||||
- **前端界面**: http://localhost:1552
|
|
||||||
- 主页:http://localhost:1552
|
- 主页:http://localhost:1552
|
||||||
- 管理界面:http://localhost:1552/admin
|
- 管理界面:http://localhost:1552/admin
|
||||||
- 登录页面:http://localhost:1552/login
|
- 登录页面:http://localhost:1552/login
|
||||||
|
- API接口:http://localhost:1552/api
|
||||||
**注意**:前端会自动将 `/api` 请求代理到 `http://localhost:1551`,无需额外配置。
|
|
||||||
|
|
||||||
## 1Panel 配置
|
## 1Panel 配置
|
||||||
|
|
||||||
@@ -142,9 +135,7 @@ cd dist
|
|||||||
|
|
||||||
1. **上传文件**:将 `home-vue-go`(Linux版本)上传到服务器
|
1. **上传文件**:将 `home-vue-go`(Linux版本)上传到服务器
|
||||||
|
|
||||||
2. **配置端口**:
|
2. **配置端口**:只需放行统一服务端口 `1552`
|
||||||
- 后端API端口:`1551`
|
|
||||||
- 前端服务端口:`1552`
|
|
||||||
|
|
||||||
3. **运行命令**:
|
3. **运行命令**:
|
||||||
```bash
|
```bash
|
||||||
@@ -155,8 +146,7 @@ cd dist
|
|||||||
|
|
||||||
## 配置说明
|
## 配置说明
|
||||||
|
|
||||||
- **后端API端口**:默认 `1551`,可通过环境变量 `API_PORT` 修改
|
- **统一服务端口**:默认 `1552`,可通过环境变量 `PORT` 修改
|
||||||
- **前端服务端口**:默认 `1552`,可通过环境变量 `FRONTEND_PORT` 修改
|
|
||||||
- **数据目录**:运行时会自动在二进制文件同目录下创建 `data` 目录
|
- **数据目录**:运行时会自动在二进制文件同目录下创建 `data` 目录
|
||||||
- **默认管理员账号**:`admin` / `admin123`(首次启动时显示)
|
- **默认管理员账号**:`admin` / `admin123`(首次启动时显示)
|
||||||
|
|
||||||
@@ -164,15 +154,13 @@ cd dist
|
|||||||
|
|
||||||
**Windows:**
|
**Windows:**
|
||||||
```bash
|
```bash
|
||||||
set API_PORT=8080
|
set PORT=8080
|
||||||
set FRONTEND_PORT=8081
|
|
||||||
home-vue-go.exe
|
home-vue-go.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
**Linux/macOS:**
|
**Linux/macOS:**
|
||||||
```bash
|
```bash
|
||||||
export API_PORT=8080
|
export PORT=8080
|
||||||
export FRONTEND_PORT=8081
|
|
||||||
./home-vue-go
|
./home-vue-go
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -181,7 +169,7 @@ export FRONTEND_PORT=8081
|
|||||||
1. **SQLite驱动**:使用纯 Go SQLite 驱动,构建无需 CGO 或 GCC
|
1. **SQLite驱动**:使用纯 Go SQLite 驱动,构建无需 CGO 或 GCC
|
||||||
2. **前端构建**:构建时必须先运行 `npm run build` 生成dist目录,Go编译时会嵌入这些文件
|
2. **前端构建**:构建时必须先运行 `npm run build` 生成dist目录,Go编译时会嵌入这些文件
|
||||||
3. **Go版本**:需要Go 1.23或更高版本(支持embed功能)
|
3. **Go版本**:需要Go 1.23或更高版本(支持embed功能)
|
||||||
4. **端口占用**:确保1551和1552端口未被占用
|
4. **端口占用**:确保统一服务端口(默认1552)未被占用
|
||||||
5. **单一文件**:构建完成后,只需一个可执行文件即可运行,无需其他依赖
|
5. **单一文件**:构建完成后,只需一个可执行文件即可运行,无需其他依赖
|
||||||
6. **Make工具**:Windows用户需要安装make工具(Git for Windows自带,或使用Chocolatey安装)
|
6. **Make工具**:Windows用户需要安装make工具(Git for Windows自带,或使用Chocolatey安装)
|
||||||
7. **跨平台编译**:Windows、Linux 和 macOS amd64 目标均可直接交叉编译
|
7. **跨平台编译**:Windows、Linux 和 macOS amd64 目标均可直接交叉编译
|
||||||
@@ -222,24 +210,13 @@ choco install make
|
|||||||
│ 包含: 后端代码 + 前端文件(嵌入) │
|
│ 包含: 后端代码 + 前端文件(嵌入) │
|
||||||
├─────────────────────────────────┤
|
├─────────────────────────────────┤
|
||||||
│ │
|
│ │
|
||||||
│ ┌──────────────┐ ┌──────────┐ │
|
│ │
|
||||||
│ │ 后端API服务 │ │ 前端服务 │ │
|
│ 统一 HTTP 服务 :1552 │
|
||||||
│ │ 端口: 1551 │ │ 端口:1552│ │
|
│ /api /uploads /assets SPA │
|
||||||
│ └──────┬───────┘ └────┬─────┘ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ └───────┬───────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ API代理 │
|
|
||||||
│ (前端/api/* → 后端1551) │
|
|
||||||
└─────────────────────────────────┘
|
└─────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- **后端服务(1551)**:提供所有API接口
|
- **统一服务(1552)**:直接提供 API、上传文件、嵌入的前端静态文件和 SPA 路由
|
||||||
- **前端服务(1552)**:
|
|
||||||
- 从嵌入的文件系统提供前端静态文件(HTML、CSS、JS)
|
|
||||||
- 自动代理 `/api/*` 请求到后端1551端口
|
|
||||||
- 支持SPA路由
|
|
||||||
|
|
||||||
## 完整构建流程示例
|
## 完整构建流程示例
|
||||||
|
|
||||||
@@ -331,7 +308,7 @@ make build-linux
|
|||||||
|
|
||||||
**1Panel配置:**
|
**1Panel配置:**
|
||||||
- 运行命令:`./home-vue-go`(或完整路径)
|
- 运行命令:`./home-vue-go`(或完整路径)
|
||||||
- 端口映射:1551(后端API)、1552(前端界面)
|
- 端口映射:1552(统一服务端口)
|
||||||
- 工作目录:可执行文件所在目录
|
- 工作目录:可执行文件所在目录
|
||||||
|
|
||||||
**注意**:SQLite 使用纯 Go 驱动,因此从 Windows 交叉构建 Linux 版本不需要额外的 C 工具链。
|
**注意**:SQLite 使用纯 Go 驱动,因此从 Windows 交叉构建 Linux 版本不需要额外的 C 工具链。
|
||||||
@@ -365,26 +342,23 @@ make clean
|
|||||||
|
|
||||||
开发时,可以分别运行前后端:
|
开发时,可以分别运行前后端:
|
||||||
|
|
||||||
**终端1 - 后端:**
|
**终端1 - 统一服务:**
|
||||||
```bash
|
```bash
|
||||||
make run
|
make run
|
||||||
# 或
|
# 前端和 API 均运行在 http://localhost:1552
|
||||||
go run main.go
|
|
||||||
# 后端运行在 http://localhost:1551
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**终端2 - 前端:**
|
**终端2 - 前端:**
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
# 前端运行在 http://localhost:1552,自动代理API到1551
|
# 热更新页面运行在 http://localhost:5173,API代理到统一服务端口
|
||||||
```
|
```
|
||||||
|
|
||||||
## 部署优势
|
## 部署优势
|
||||||
|
|
||||||
✅ **单一可执行文件**:前后端一体化,所有文件嵌入在二进制中
|
✅ **单一可执行文件**:前后端一体化,所有文件嵌入在二进制中
|
||||||
✅ **无需依赖**:不需要Node.js、npm或其他运行时
|
✅ **无需依赖**:不需要Node.js、npm或其他运行时
|
||||||
✅ **端口分离**:API和前端服务分离,便于管理和扩展
|
✅ **单端口服务**:前端、API和上传文件共用一个端口
|
||||||
✅ **自动代理**:前端自动代理API请求,无需额外配置
|
✅ **1Panel友好**:只需配置一个端口,运行一个命令即可
|
||||||
✅ **1Panel友好**:只需配置两个端口,运行一个命令即可
|
|
||||||
✅ **部署简单**:上传一个文件,配置端口,即可运行
|
✅ **部署简单**:上传一个文件,配置端口,即可运行
|
||||||
✅ **跨平台构建**:使用make统一构建流程,支持多平台
|
✅ **跨平台构建**:使用make统一构建流程,支持多平台
|
||||||
|
|||||||
@@ -42,35 +42,35 @@ frontend:
|
|||||||
backend-windows: frontend
|
backend-windows: frontend
|
||||||
@echo [backend] Building Windows amd64 executable...
|
@echo [backend] Building Windows amd64 executable...
|
||||||
ifeq ($(OS),Windows_NT)
|
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
|
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
|
endif
|
||||||
|
|
||||||
backend-linux: frontend
|
backend-linux: frontend
|
||||||
@echo [backend] Building Linux amd64 executable...
|
@echo [backend] Building Linux amd64 executable...
|
||||||
ifeq ($(OS),Windows_NT)
|
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
|
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)
|
@chmod +x $(DIST_DIR)/$(BINARY_NAME)
|
||||||
endif
|
endif
|
||||||
|
|
||||||
backend-darwin: frontend
|
backend-darwin: frontend
|
||||||
@echo [backend] Building macOS amd64 executable...
|
@echo [backend] Building macOS amd64 executable...
|
||||||
ifeq ($(OS),Windows_NT)
|
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
|
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)
|
@chmod +x $(DIST_DIR)/$(BINARY_NAME)
|
||||||
endif
|
endif
|
||||||
|
|
||||||
backend: frontend
|
backend: frontend
|
||||||
@echo [backend] Building for the current platform...
|
@echo [backend] Building for the current platform...
|
||||||
ifeq ($(OS),Windows_NT)
|
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
|
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)
|
@chmod +x $(DIST_DIR)/$(BINARY_NAME)
|
||||||
endif
|
endif
|
||||||
|
|
||||||
@@ -78,12 +78,12 @@ endif
|
|||||||
build-windows: clean frontend
|
build-windows: clean frontend
|
||||||
@echo [build] Building Windows amd64 package...
|
@echo [build] Building Windows amd64 package...
|
||||||
ifeq ($(OS),Windows_NT)
|
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)
|
@if exist $(DIST_DIR) rmdir /s /q $(DIST_DIR)
|
||||||
@mkdir $(DIST_DIR)
|
@mkdir $(DIST_DIR)
|
||||||
@move /y $(WINDOWS_TEMP) $(DIST_DIR)\$(BINARY_NAME).exe >nul
|
@move /y $(WINDOWS_TEMP) $(DIST_DIR)\$(BINARY_NAME).exe >nul
|
||||||
else
|
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)
|
@rm -rf $(DIST_DIR) && mkdir -p $(DIST_DIR)
|
||||||
@mv $(WINDOWS_TEMP) $(DIST_DIR)/$(BINARY_NAME).exe
|
@mv $(WINDOWS_TEMP) $(DIST_DIR)/$(BINARY_NAME).exe
|
||||||
endif
|
endif
|
||||||
@@ -92,12 +92,12 @@ endif
|
|||||||
build-linux: clean frontend
|
build-linux: clean frontend
|
||||||
@echo [build] Building Linux amd64 package...
|
@echo [build] Building Linux amd64 package...
|
||||||
ifeq ($(OS),Windows_NT)
|
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)
|
@if exist $(DIST_DIR) rmdir /s /q $(DIST_DIR)
|
||||||
@mkdir $(DIST_DIR)
|
@mkdir $(DIST_DIR)
|
||||||
@move /y $(LINUX_TEMP) $(DIST_DIR)\$(BINARY_NAME) >nul
|
@move /y $(LINUX_TEMP) $(DIST_DIR)\$(BINARY_NAME) >nul
|
||||||
else
|
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)
|
@rm -rf $(DIST_DIR) && mkdir -p $(DIST_DIR)
|
||||||
@mv $(LINUX_TEMP) $(DIST_DIR)/$(BINARY_NAME)
|
@mv $(LINUX_TEMP) $(DIST_DIR)/$(BINARY_NAME)
|
||||||
@chmod +x $(DIST_DIR)/$(BINARY_NAME)
|
@chmod +x $(DIST_DIR)/$(BINARY_NAME)
|
||||||
@@ -107,12 +107,12 @@ endif
|
|||||||
build-darwin: clean frontend
|
build-darwin: clean frontend
|
||||||
@echo [build] Building macOS amd64 package...
|
@echo [build] Building macOS amd64 package...
|
||||||
ifeq ($(OS),Windows_NT)
|
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)
|
@if exist $(DIST_DIR) rmdir /s /q $(DIST_DIR)
|
||||||
@mkdir $(DIST_DIR)
|
@mkdir $(DIST_DIR)
|
||||||
@move /y $(DARWIN_TEMP) $(DIST_DIR)\$(BINARY_NAME) >nul
|
@move /y $(DARWIN_TEMP) $(DIST_DIR)\$(BINARY_NAME) >nul
|
||||||
else
|
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)
|
@rm -rf $(DIST_DIR) && mkdir -p $(DIST_DIR)
|
||||||
@mv $(DARWIN_TEMP) $(DIST_DIR)/$(BINARY_NAME)
|
@mv $(DARWIN_TEMP) $(DIST_DIR)/$(BINARY_NAME)
|
||||||
@chmod +x $(DIST_DIR)/$(BINARY_NAME)
|
@chmod +x $(DIST_DIR)/$(BINARY_NAME)
|
||||||
@@ -122,22 +122,22 @@ endif
|
|||||||
build: clean frontend
|
build: clean frontend
|
||||||
@echo [build] Building Windows amd64 package...
|
@echo [build] Building Windows amd64 package...
|
||||||
ifeq ($(OS),Windows_NT)
|
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...
|
@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...
|
@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)
|
@if exist $(DIST_DIR) rmdir /s /q $(DIST_DIR)
|
||||||
@mkdir $(DIST_DIR)
|
@mkdir $(DIST_DIR)
|
||||||
@move /y $(ALL_WINDOWS_TEMP) $(DIST_DIR)\$(BINARY_NAME)-windows-amd64.exe >nul
|
@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_LINUX_TEMP) $(DIST_DIR)\$(BINARY_NAME)-linux-amd64 >nul
|
||||||
@move /y $(ALL_DARWIN_TEMP) $(DIST_DIR)\$(BINARY_NAME)-darwin-amd64 >nul
|
@move /y $(ALL_DARWIN_TEMP) $(DIST_DIR)\$(BINARY_NAME)-darwin-amd64 >nul
|
||||||
else
|
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...
|
@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...
|
@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)
|
@rm -rf $(DIST_DIR) && mkdir -p $(DIST_DIR)
|
||||||
@mv $(ALL_WINDOWS_TEMP) $(DIST_DIR)/$(BINARY_NAME)-windows-amd64.exe
|
@mv $(ALL_WINDOWS_TEMP) $(DIST_DIR)/$(BINARY_NAME)-windows-amd64.exe
|
||||||
@mv $(ALL_LINUX_TEMP) $(DIST_DIR)/$(BINARY_NAME)-linux-amd64
|
@mv $(ALL_LINUX_TEMP) $(DIST_DIR)/$(BINARY_NAME)-linux-amd64
|
||||||
@@ -150,7 +150,7 @@ endif
|
|||||||
|
|
||||||
dist: build
|
dist: build
|
||||||
|
|
||||||
run:
|
run: frontend
|
||||||
ifeq ($(OS),Windows_NT)
|
ifeq ($(OS),Windows_NT)
|
||||||
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && $(GO) run .
|
@set "GOPROXY=$(GOPROXY)" && set "CGO_ENABLED=0" && $(GO) run .
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -82,12 +82,12 @@ cd ../..
|
|||||||
|
|
||||||
**开发模式:**
|
**开发模式:**
|
||||||
|
|
||||||
需要打开两个终端窗口:
|
生产运行只需要启动一个服务。需要前端热更新时,可额外启动 Vite 开发服务器:
|
||||||
|
|
||||||
**终端1 - 启动Go后端:**
|
**终端1 - 启动统一服务:**
|
||||||
```bash
|
```bash
|
||||||
# 在项目根目录运行
|
# 在项目根目录运行
|
||||||
go run main.go
|
make run
|
||||||
```
|
```
|
||||||
|
|
||||||
**终端2 - 启动前端开发服务器:**
|
**终端2 - 启动前端开发服务器:**
|
||||||
@@ -97,11 +97,11 @@ npm run dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
**说明:**
|
**说明:**
|
||||||
- `go run main.go` 会编译并运行Go程序
|
- `make run` 会先构建前端,再启动同时提供前端、API和上传文件的统一服务
|
||||||
- 后端默认运行在 `http://localhost:1551`
|
- 统一服务默认运行在 `http://localhost:1552`
|
||||||
- 前端默认运行在 `http://localhost:1552`
|
- Vite 开发服务器运行在 `http://localhost:5173`,并将 API 请求代理到统一服务
|
||||||
- 首次运行会自动创建 `data/` 目录和数据库
|
- 首次运行会自动创建 `data/` 目录和数据库
|
||||||
- 可以通过环境变量 `PORT` 修改后端端口(默认1551)
|
- 可以通过环境变量 `PORT` 修改统一服务端口(默认1552)
|
||||||
|
|
||||||
**Windows用户注意:** 如果遇到中文乱码,在PowerShell中运行:
|
**Windows用户注意:** 如果遇到中文乱码,在PowerShell中运行:
|
||||||
```powershell
|
```powershell
|
||||||
@@ -113,6 +113,7 @@ chcp 65001
|
|||||||
- 前端:http://localhost:1552
|
- 前端:http://localhost:1552
|
||||||
- 管理界面:http://localhost:1552/admin
|
- 管理界面:http://localhost:1552/admin
|
||||||
- 登录页面:http://localhost:1552/login
|
- 登录页面:http://localhost:1552/login
|
||||||
|
- API:http://localhost:1552/api
|
||||||
|
|
||||||
**默认管理员账号:**
|
**默认管理员账号:**
|
||||||
- 用户名:`admin`
|
- 用户名:`admin`
|
||||||
@@ -146,8 +147,8 @@ npm run build
|
|||||||
```
|
```
|
||||||
|
|
||||||
构建完成后:
|
构建完成后:
|
||||||
- Go二进制文件:`./home-vue-go` (Linux) 或 `./home-vue-go.exe` (Windows)
|
- `make build` 的发布包位于 `./dist/`
|
||||||
- 前端构建文件:`./dist`
|
- 前端资源已经嵌入每个平台的二进制文件,部署时只需要对应的可执行文件
|
||||||
|
|
||||||
**说明:**
|
**说明:**
|
||||||
- `go build` 编译Go程序为二进制文件
|
- `go build` 编译Go程序为二进制文件
|
||||||
@@ -159,7 +160,6 @@ npm run build
|
|||||||
|
|
||||||
1. **上传文件到服务器:**
|
1. **上传文件到服务器:**
|
||||||
- 上传 `home-vue-go` 二进制文件
|
- 上传 `home-vue-go` 二进制文件
|
||||||
- 上传 `dist` 目录(前端构建文件)
|
|
||||||
|
|
||||||
2. **运行二进制文件:**
|
2. **运行二进制文件:**
|
||||||
```bash
|
```bash
|
||||||
@@ -173,7 +173,7 @@ npm run build
|
|||||||
|
|
||||||
4. **环境变量(可选):**
|
4. **环境变量(可选):**
|
||||||
```bash
|
```bash
|
||||||
export PORT=1551 # 服务端口,默认1551
|
export PORT=1552 # 统一服务端口,默认1552
|
||||||
export JWT_SECRET=your-secret-key # JWT密钥,建议修改
|
export JWT_SECRET=your-secret-key # JWT密钥,建议修改
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -239,7 +239,7 @@ go mod download
|
|||||||
cd internal/ent && go generate ./... && cd ../..
|
cd internal/ent && go generate ./... && cd ../..
|
||||||
|
|
||||||
# 运行后端(开发模式)
|
# 运行后端(开发模式)
|
||||||
go run main.go
|
make run
|
||||||
|
|
||||||
# 构建后端(当前平台)
|
# 构建后端(当前平台)
|
||||||
CGO_ENABLED=0 go build -o home-vue-go .
|
CGO_ENABLED=0 go build -o home-vue-go .
|
||||||
@@ -269,6 +269,21 @@ npm run build
|
|||||||
npm run preview
|
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
|
MIT License
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"io"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -24,263 +23,120 @@ import (
|
|||||||
var distFS embed.FS
|
var distFS embed.FS
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// 设置亚洲/上海时区
|
|
||||||
loc, err := time.LoadLocation("Asia/Shanghai")
|
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("无法加载时区:", err)
|
log.Fatal("无法加载时区:", err)
|
||||||
}
|
}
|
||||||
time.Local = loc
|
time.Local = loc
|
||||||
|
|
||||||
// 获取可执行文件所在目录
|
|
||||||
exePath, err := os.Executable()
|
exePath, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("无法获取可执行文件路径:", err)
|
log.Fatal("无法获取可执行文件路径:", err)
|
||||||
}
|
}
|
||||||
exeDir := filepath.Dir(exePath)
|
dataDir := filepath.Join(filepath.Dir(exePath), "data")
|
||||||
|
|
||||||
// 创建data目录
|
|
||||||
dataDir := filepath.Join(exeDir, "data")
|
|
||||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||||
log.Fatal("无法创建data目录:", err)
|
log.Fatal("无法创建data目录:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化配置
|
|
||||||
cfg := config.New(dataDir)
|
cfg := config.New(dataDir)
|
||||||
|
|
||||||
// 初始化数据库
|
|
||||||
db, err := database.Init(cfg.DatabasePath, cfg)
|
db, err := database.Init(cfg.DatabasePath, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("数据库初始化失败:", err)
|
log.Fatal("数据库初始化失败:", err)
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
// 设置Gin模式
|
|
||||||
if os.Getenv("GIN_MODE") == "" {
|
if os.Getenv("GIN_MODE") == "" {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建Gin路由(不使用Default以避免重复日志)
|
port := os.Getenv("PORT")
|
||||||
r := gin.New()
|
if port == "" {
|
||||||
|
port = "1552"
|
||||||
// 添加恢复中间件
|
|
||||||
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文件夹中")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 配置CORS
|
router := gin.New()
|
||||||
r.Use(corsMiddleware())
|
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")
|
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 {
|
if err != nil {
|
||||||
c.Status(http.StatusNotFound)
|
log.Fatal("无法加载嵌入的前端文件:", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
defer data.Close()
|
router.NoRoute(serveFrontend(distRoot))
|
||||||
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("使用嵌入的前端文件(单一可执行文件模式)")
|
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("警告: 未找到前端文件,前端功能不可用")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 启动两个服务器
|
|
||||||
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")
|
firstRunFile := filepath.Join(dataDir, ".first_run")
|
||||||
if _, err := os.Stat(firstRunFile); os.IsNotExist(err) {
|
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("默认管理员账号: admin, 密码: admin123")
|
||||||
log.Printf("提示: 首次启动后,请及时修改默认密码以确保安全")
|
log.Printf("提示: 首次启动后,请及时修改默认密码以确保安全")
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("========================================")
|
log.Printf("========================================")
|
||||||
log.Printf("服务器启动成功!")
|
log.Printf("服务器启动成功!")
|
||||||
log.Printf("后端API: http://localhost:%s", apiPort)
|
log.Printf("访问端点: http://localhost:%s", port)
|
||||||
log.Printf("前端界面: http://localhost:%s", frontendPort)
|
|
||||||
log.Printf("========================================")
|
log.Printf("========================================")
|
||||||
|
|
||||||
// 在goroutine中启动前端服务器
|
server := &http.Server{
|
||||||
go func() {
|
Addr: ":" + port,
|
||||||
if err := frontendServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
Handler: router,
|
||||||
log.Fatalf("前端服务器启动失败: %v", err)
|
|
||||||
}
|
}
|
||||||
}()
|
if err := server.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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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{})
|
allowedOrigins := make(map[string]struct{})
|
||||||
for _, origin := range strings.Split(os.Getenv("CORS_ALLOWED_ORIGINS"), ",") {
|
for _, origin := range strings.Split(os.Getenv("CORS_ALLOWED_ORIGINS"), ",") {
|
||||||
if value := strings.TrimSpace(origin); value != "" {
|
if value := strings.TrimSpace(origin); value != "" {
|
||||||
@@ -288,8 +144,8 @@ func corsMiddleware() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(allowedOrigins) == 0 {
|
if len(allowedOrigins) == 0 {
|
||||||
allowedOrigins["http://localhost:1552"] = struct{}{}
|
allowedOrigins["http://localhost:"+port] = struct{}{}
|
||||||
allowedOrigins["http://127.0.0.1:1552"] = struct{}{}
|
allowedOrigins["http://127.0.0.1:"+port] = struct{}{}
|
||||||
}
|
}
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
origin := c.GetHeader("Origin")
|
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-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")
|
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH")
|
||||||
|
|
||||||
if c.Request.Method == "OPTIONS" {
|
if c.Request.Method == http.MethodOptions {
|
||||||
c.AbortWithStatus(204)
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Next()
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+44
-23
@@ -1,38 +1,59 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProxyAPIRequestUsesConfiguredPort(t *testing.T) {
|
func TestAPIAndFrontendShareRouter(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)
|
|
||||||
}
|
|
||||||
|
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
router.Any("/api/*path", proxyAPIRequest(port))
|
router.GET("/api/ping", func(c *gin.Context) {
|
||||||
recorder := httptest.NewRecorder()
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/config?custom=1", nil))
|
})
|
||||||
|
router.NoRoute(serveFrontend(fstest.MapFS{
|
||||||
|
"index.html": {Data: []byte("<html>app</html>")},
|
||||||
|
"assets/app.js": {Data: []byte("console.log('app')")},
|
||||||
|
}))
|
||||||
|
|
||||||
if recorder.Code != http.StatusOK || recorder.Body.String() != `{"path":"/api/config?custom=1"}` {
|
tests := []struct {
|
||||||
t.Fatalf("unexpected proxy response: status=%d body=%s", recorder.Code, recorder.Body.String())
|
path string
|
||||||
|
statusCode int
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{path: "/api/ping", statusCode: http.StatusOK, body: `{"status":"ok"}`},
|
||||||
|
{path: "/admin", statusCode: http.StatusOK, body: "<html>app</html>"},
|
||||||
|
{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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import vue from '@vitejs/plugin-vue';
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
|
||||||
const apiPort = process.env.API_PORT || '1551';
|
const servicePort = process.env.PORT || '1552';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
@@ -17,14 +17,14 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 1552,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: `http://localhost:${apiPort}`,
|
target: `http://localhost:${servicePort}`,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/uploads': {
|
'/uploads': {
|
||||||
target: `http://localhost:${apiPort}`,
|
target: `http://localhost:${servicePort}`,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user