Compare commits

..

3 Commits

Author SHA1 Message Date
admin_gitea 18db389beb docs: add v1.0.0 release notes and package naming
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
2026-08-05 15:28:29 +08:00
admin_gitea 6f07849a53 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
2026-08-05 15:13:29 +08:00
admin_gitea 56db1cc642 fix: 完善管理后台密码修改逻辑 2026-08-05 07:24:49 +08:00
14 changed files with 695 additions and 396 deletions
+46
View File
@@ -0,0 +1,46 @@
# Home-Vue-Go v1.0.0
Home-Vue-Go 的首个正式版本,将 Vue 3 前端与 Go 后端整合为可直接部署的单文件应用。
## 主要功能
- Vue 3 + Vite 前端,提供主页、登录页和可视化管理后台。
- Go + Gin API,使用 Ent ORM 与 SQLite 存储站点配置和业务数据。
- 支持站点、联系方式、轮换文本和站点外观配置管理。
- 支持图片上传、JWT 登录认证、密码修改和登录历史记录。
- 支持本地访问统计以及 Umami 自托管、Umami Cloud 数据源。
- 前端资源嵌入可执行文件,无需单独部署 Web 静态目录。
## 单端口部署
前端页面、API 和上传文件统一通过一个 HTTP 端口提供服务:
- 默认访问地址:`http://localhost:1552`
- API 地址:`http://localhost:1552/api`
- 管理后台:`http://localhost:1552/admin`
- 可通过 `PORT` 环境变量修改监听端口。
## 下载与运行
请根据系统下载对应安装包:
- `Home-Vue-Go_Windows.zip`
- `Home-Vue-Go_Linux.tar.gz`
- `Home-Vue-Go_macOS.tar.gz`
解压后直接运行 `Home-Vue-Go`Windows 运行 `Home-Vue-Go.exe`。Linux 或 macOS 首次运行前可能需要增加执行权限:
```bash
chmod +x Home-Vue-Go
./Home-Vue-Go
```
首次启动会在程序同级目录创建 `data` 数据目录。默认管理员账号为 `admin`,默认密码为 `admin123`,登录后请立即修改密码。
## 升级与数据
升级前请备份程序同级目录中的 `data` 文件夹。替换可执行文件即可升级,请勿删除原有的数据库和上传文件。
## 构建产物
本版本通过 GitHub Actions 自动测试并构建 Windows、Linux 和 macOS amd64 安装包。所有安装包均包含 README 和与原仓库一致的 MIT 许可证。
+134
View File
@@ -0,0 +1,134 @@
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
platform: Windows
goos: windows
extension: .exe
archive: zip
- name: Linux
platform: Linux
goos: linux
extension: ''
archive: tar.gz
- name: macOS
platform: 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_${{ matrix.platform }}"
binary_name="Home-Vue-Go${{ 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: Home-Vue-Go_${{ matrix.platform }}
path: Home-Vue-Go_${{ matrix.platform }}.${{ 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: Checkout
uses: actions/checkout@v4
- name: Select release notes
id: notes
shell: bash
run: |
notes_file=".github/release-notes/${GITHUB_REF_NAME}.md"
if [ -f "${notes_file}" ]; then
echo "path=${notes_file}" >> "${GITHUB_OUTPUT}"
else
echo "path=" >> "${GITHUB_OUTPUT}"
fi
- name: Download package artifacts
uses: actions/download-artifact@v4
with:
pattern: Home-Vue-Go_*
path: release
merge-multiple: true
- name: Publish release
uses: softprops/action-gh-release@v2
with:
name: Home-Vue-Go ${{ github.ref_name }}
body_path: ${{ steps.notes.outputs.path }}
generate_release_notes: ${{ steps.notes.outputs.path == '' }}
files: release/*
+19 -45
View File
@@ -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
- 管理界面:http://localhost:1552/admin
- 登录页面:http://localhost:1552/login
**注意**:前端会自动将 `/api` 请求代理到 `http://localhost:1551`,无需额外配置。
- 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,15 +154,13 @@ cd dist
**Windows:**
```bash
set API_PORT=8080
set FRONTEND_PORT=8081
set PORT=8080
home-vue-go.exe
```
**Linux/macOS:**
```bash
export API_PORT=8080
export FRONTEND_PORT=8081
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) │
统一 HTTP 服务 :1552
/api /uploads /assets SPA
└─────────────────────────────────┘
```
- **后端服务(1551**提供所有API接口
- **前端服务(1552**
- 从嵌入的文件系统提供前端静态文件(HTML、CSS、JS)
- 自动代理 `/api/*` 请求到后端1551端口
- 支持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 - 后端**
**终端1 - 统一服务**
```bash
make run
#
go run main.go
# 后端运行在 http://localhost:1551
# 前端和 API 均运行在 http://localhost:1552
```
**终端2 - 前端:**
```bash
npm run dev
# 前端运行在 http://localhost:1552,自动代理API到1551
# 热更新页面运行在 http://localhost:5173API代理到统一服务端口
```
## 部署优势
**单一可执行文件**:前后端一体化,所有文件嵌入在二进制中
**无需依赖**:不需要Node.js、npm或其他运行时
**端口分离**API和前端服务分离,便于管理和扩展
**自动代理**:前端自动代理API请求,无需额外配置
**1Panel友好**:只需配置两个端口,运行一个命令即可
**端口服务**前端、API和上传文件共用一个端口
**1Panel友好**:只需配置一个端口,运行一个命令即可
**部署简单**:上传一个文件,配置端口,即可运行
**跨平台构建**:使用make统一构建流程,支持多平台
+21 -21
View File
@@ -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
+28 -13
View File
@@ -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
- APIhttp://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`Home-Vue-Go_Windows.zip`
- Linux amd64`Home-Vue-Go_Linux.tar.gz`
- macOS amd64`Home-Vue-Go_macOS.tar.gz`
在 GitHub Actions 的运行详情页下载对应平台的 `Home-Vue-Go_<平台>` 构建产物。发布版本时推送一个 `v` 开头的标签,工作流会自动创建 GitHub Release、读取对应版本的发行说明并附加这三个安装包:
```bash
git tag v1.0.0
git push github v1.0.0
```
### 许可证
MIT License
本项目沿用原仓库的 [MIT License](./LICENSE),发行包中也会包含完整的 `LICENSE` 文件。原项目版权声明及许可条款予以保留。
+21 -8
View File
@@ -10,6 +10,7 @@ import (
"home-vue-go/internal/config"
"home-vue-go/internal/database"
"home-vue-go/internal/ent"
"home-vue-go/internal/ent/user"
"github.com/gin-gonic/gin"
@@ -84,11 +85,11 @@ func ChangePassword(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
OldPassword string `json:"oldPassword" binding:"required"`
NewPassword string `json:"newPassword" binding:"required,min=8"`
NewPassword string `json:"newPassword" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "密码和新密码不能为空,且新密码至少8位"})
c.JSON(http.StatusBadRequest, gin.H{"error": "当前密码和新密码不能为空"})
return
}
@@ -99,19 +100,32 @@ func ChangePassword(db *database.Database) gin.HandlerFunc {
return
}
usernameStr := username.(string)
usernameStr, ok := username.(string)
if !ok || strings.TrimSpace(usernameStr) == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户信息无效"})
return
}
ctx := c.Request.Context()
// 查询用户
user, err := db.Client.User.Query().Where(user.UsernameEQ(usernameStr)).First(ctx)
if err != nil {
if ent.IsNotFound(err) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户不存在"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询用户失败"})
}
return
}
// 验证旧密码
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "密码错误"})
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "当前密码错误"})
return
}
if err := validateNewPassword(usernameStr, req.OldPassword, req.NewPassword); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -125,13 +139,12 @@ func ChangePassword(db *database.Database) gin.HandlerFunc {
// 更新密码到数据库
updatedUser, err := db.Client.User.UpdateOneID(user.ID).SetPassword(string(hashedPassword)).Save(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码更新失败: " + err.Error()})
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码更新失败"})
return
}
// 验证密码已保存(可选,用于调试)
if updatedUser == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码更新失败: 未返回更新后的用户"})
if err := bcrypt.CompareHashAndPassword([]byte(updatedUser.Password), []byte(req.NewPassword)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码更新校验失败"})
return
}
+132
View File
@@ -0,0 +1,132 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"home-vue-go/internal/config"
"home-vue-go/internal/database"
"github.com/gin-gonic/gin"
)
func newAuthTestServer(t *testing.T) (*gin.Engine, *database.Database, *config.Config) {
t.Helper()
gin.SetMode(gin.TestMode)
cfg := config.New(t.TempDir())
db, err := database.Init(cfg.DatabasePath, cfg)
if err != nil {
t.Fatal(err)
}
r := gin.New()
r.POST("/login", Login(db, cfg))
r.PUT("/change-password", JWTAuthMiddleware(cfg.JWTSecret), ChangePassword(db))
t.Cleanup(func() { _ = db.Close() })
return r, db, cfg
}
func authJSONRequest(t *testing.T, router http.Handler, method, path string, payload any, token string) *httptest.ResponseRecorder {
t.Helper()
body, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(method, path, strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
response := httptest.NewRecorder()
router.ServeHTTP(response, req)
return response
}
func tokenFromResponse(t *testing.T, response *httptest.ResponseRecorder) string {
t.Helper()
var payload struct {
Token string `json:"token"`
}
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload.Token == "" {
t.Fatalf("login did not return a token: %s", response.Body.String())
}
return payload.Token
}
func TestChangePasswordPersistsAndAllowsNewLogin(t *testing.T) {
router, _, _ := newAuthTestServer(t)
login := authJSONRequest(t, router, http.MethodPost, "/login", map[string]string{"username": "admin", "password": "admin123"}, "")
if login.Code != http.StatusOK {
t.Fatalf("initial login failed: %d %s", login.Code, login.Body.String())
}
token := tokenFromResponse(t, login)
change := authJSONRequest(t, router, http.MethodPut, "/change-password", map[string]string{
"oldPassword": "admin123",
"newPassword": "New-admin-2026!",
}, token)
if change.Code != http.StatusOK {
t.Fatalf("password change failed: %d %s", change.Code, change.Body.String())
}
oldLogin := authJSONRequest(t, router, http.MethodPost, "/login", map[string]string{"username": "admin", "password": "admin123"}, "")
if oldLogin.Code != http.StatusUnauthorized {
t.Fatalf("old password should be rejected: %d", oldLogin.Code)
}
newLogin := authJSONRequest(t, router, http.MethodPost, "/login", map[string]string{"username": "admin", "password": "New-admin-2026!"}, "")
if newLogin.Code != http.StatusOK {
t.Fatalf("new password should work: %d %s", newLogin.Code, newLogin.Body.String())
}
}
func TestChangePasswordRejectsInvalidInputWithoutLoggingOut(t *testing.T) {
router, _, _ := newAuthTestServer(t)
login := authJSONRequest(t, router, http.MethodPost, "/login", map[string]string{"username": "admin", "password": "admin123"}, "")
token := tokenFromResponse(t, login)
weak := authJSONRequest(t, router, http.MethodPut, "/change-password", map[string]string{
"oldPassword": "admin123",
"newPassword": "12345678",
}, token)
if weak.Code != http.StatusBadRequest {
t.Fatalf("weak password should be rejected: %d", weak.Code)
}
wrongOld := authJSONRequest(t, router, http.MethodPut, "/change-password", map[string]string{
"oldPassword": "wrong-password",
"newPassword": "New-admin-2026!",
}, token)
if wrongOld.Code != http.StatusUnprocessableEntity {
t.Fatalf("wrong current password should be a validation error: %d", wrongOld.Code)
}
stillValid := authJSONRequest(t, router, http.MethodPut, "/change-password", map[string]string{
"oldPassword": "admin123",
"newPassword": "New-admin-2026!",
}, token)
if stillValid.Code != http.StatusOK {
t.Fatalf("valid token should remain usable after a rejected attempt: %d %s", stillValid.Code, stillValid.Body.String())
}
}
func TestValidateNewPassword(t *testing.T) {
if err := validateNewPassword("admin", "admin123", "New-admin-2026!"); err != nil {
t.Fatalf("expected valid password: %v", err)
}
for _, password := range []string{"short1!", "admin123", "12345678", "lettersonly", "New-admin-2026! "} {
if err := validateNewPassword("admin", "admin123", password); err == nil {
t.Errorf("expected password to be rejected: %q", password)
}
}
if err := validateNewPassword("admin", "admin123", strings.Repeat("a1!", 30)); err == nil {
t.Fatal("expected bcrypt-overlong password to be rejected")
}
if err := validateNewPassword("admin", "admin123", "New-管理-2026!"); err != nil {
t.Fatalf("expected unicode password to be valid: %v", err)
}
}
+72
View File
@@ -0,0 +1,72 @@
package api
import (
"fmt"
"strings"
"unicode"
"unicode/utf8"
)
const (
minPasswordRunes = 8
maxPasswordBytes = 72 // bcrypt only uses the first 72 bytes.
)
var commonPasswords = map[string]struct{}{
"12345678": {},
"admin123": {},
"password": {},
"password123": {},
"qwerty123": {},
}
func validateNewPassword(username, oldPassword, newPassword string) error {
if !utf8.ValidString(newPassword) {
return fmt.Errorf("新密码包含无效字符")
}
if newPassword == oldPassword {
return fmt.Errorf("新密码不能与当前密码相同")
}
if utf8.RuneCountInString(newPassword) < minPasswordRunes {
return fmt.Errorf("新密码至少需要%d个字符", minPasswordRunes)
}
if len([]byte(newPassword)) > maxPasswordBytes {
return fmt.Errorf("新密码不能超过%d字节", maxPasswordBytes)
}
if strings.TrimSpace(newPassword) != newPassword {
return fmt.Errorf("新密码不能以空格开头或结尾")
}
for _, char := range newPassword {
if unicode.IsControl(char) {
return fmt.Errorf("新密码不能包含控制字符")
}
}
if _, exists := commonPasswords[strings.ToLower(newPassword)]; exists {
return fmt.Errorf("新密码过于常见,请使用更复杂的密码")
}
if username != "" && strings.EqualFold(newPassword, username) {
return fmt.Errorf("新密码不能与用户名相同")
}
categoryCount := 0
hasLetter, hasNumber, hasSymbol := false, false, false
for _, char := range newPassword {
switch {
case unicode.IsLetter(char):
hasLetter = true
case unicode.IsNumber(char):
hasNumber = true
case unicode.IsPunct(char) || unicode.IsSymbol(char):
hasSymbol = true
}
}
for _, present := range []bool{hasLetter, hasNumber, hasSymbol} {
if present {
categoryCount++
}
}
if categoryCount < 2 {
return fmt.Errorf("新密码至少需要包含字母、数字、符号中的两类")
}
return nil
}
+72 -247
View File
@@ -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.htmlSPA路由支持)
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
log.Fatal("无法加载嵌入的前端文件:", err)
}
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.htmlSPA路由)
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)
}
})
router.NoRoute(serveFrontend(distRoot))
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")
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)
server := &http.Server{
Addr: ":" + port,
Handler: router,
}
}()
// 在主goroutine中启动API服务器
if err := apiServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("API服务器启动失败: %v", err)
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)
}
}
+44 -23
View File
@@ -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("<html>app</html>")},
"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: "<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)
}
}
+8 -15
View File
@@ -138,12 +138,13 @@
<IconPicker v-model="iconPickerValue" @close="iconPickerOpen = false" />
</AdminModal>
<AdminModal :open="passwordModalOpen" title="修改密码" description="新密码至少八位,建议混合大小写、数字符号" size="small" @close="closePasswordModal">
<AdminModal :open="passwordModalOpen" title="修改密码" description="新密码至少八个字符,并包含字母、数字符号中的至少两类" size="small" @close="closePasswordModal">
<form id="password-form" class="password-form" @submit.prevent="changePassword">
<label><span>当前密码</span><div class="password-input"><input v-model="passwordForm.oldPassword" :type="passwordVisibility.old ? 'text' : 'password'" autocomplete="current-password" required /><button type="button" :aria-label="passwordVisibility.old ? '隐藏密码' : '显示密码'" @click="passwordVisibility.old = !passwordVisibility.old"><i :class="passwordVisibility.old ? 'fas fa-eye-slash' : 'fas fa-eye'"></i></button></div></label>
<label><span>新密码</span><div class="password-input"><input v-model="passwordForm.newPassword" :type="passwordVisibility.new ? 'text' : 'password'" autocomplete="new-password" minlength="8" required /><button type="button" :aria-label="passwordVisibility.new ? '隐藏密码' : '显示密码'" @click="passwordVisibility.new = !passwordVisibility.new"><i :class="passwordVisibility.new ? 'fas fa-eye-slash' : 'fas fa-eye'"></i></button></div></label>
<label><span>新密码</span><div class="password-input"><input v-model="passwordForm.newPassword" :type="passwordVisibility.new ? 'text' : 'password'" autocomplete="new-password" minlength="8" maxlength="72" required /><button type="button" :aria-label="passwordVisibility.new ? '隐藏密码' : '显示密码'" @click="passwordVisibility.new = !passwordVisibility.new"><i :class="passwordVisibility.new ? 'fas fa-eye-slash' : 'fas fa-eye'"></i></button></div></label>
<div class="password-strength"><span :style="{ width: `${passwordStrength.percent}%` }" :class="passwordStrength.level"></span></div>
<small>{{ passwordStrength.label }}</small>
<p v-if="passwordError" class="field-error">{{ passwordError }}</p>
<label><span>确认新密码</span><div class="password-input"><input v-model="passwordForm.confirmPassword" :type="passwordVisibility.confirm ? 'text' : 'password'" autocomplete="new-password" required /><button type="button" :aria-label="passwordVisibility.confirm ? '隐藏密码' : '显示密码'" @click="passwordVisibility.confirm = !passwordVisibility.confirm"><i :class="passwordVisibility.confirm ? 'fas fa-eye-slash' : 'fas fa-eye'"></i></button></div></label>
<p v-if="passwordMismatch" class="field-error">两次输入的新密码不一致</p>
</form>
@@ -163,6 +164,7 @@ import { useRoute, useRouter } from 'vue-router'
import { adminAPI } from '../api'
import { useTheme } from '../composables/useTheme'
import { loadAndApplyFrontendConfig } from '../utils/frontendConfig'
import { passwordMetrics } from '../utils/passwordPolicy'
import AppIcon from './AppIcon.vue'
import Dashboard from './Dashboard.vue'
import IconPicker from './IconPicker.vue'
@@ -241,20 +243,11 @@ const colorPickerValue = computed({
get: () => /^#[0-9a-fA-F]{6}$/.test(contactForm.hoverColor) ? contactForm.hoverColor : '#555555',
set: (value) => { contactForm.hoverColor = value },
})
const passwordMetricsResult = computed(() => passwordMetrics(passwordForm.newPassword, passwordForm.oldPassword))
const passwordMismatch = computed(() => Boolean(passwordForm.newPassword && passwordForm.confirmPassword && passwordForm.newPassword !== passwordForm.confirmPassword))
const canChangePassword = computed(() => passwordForm.oldPassword && passwordForm.newPassword.length >= 8 && passwordForm.confirmPassword && !passwordMismatch.value)
const passwordStrength = computed(() => {
const password = passwordForm.newPassword
if (!password) return { percent: 0, level: '', label: '尚未输入新密码' }
let score = password.length >= 8 ? 1 : 0
if (password.length >= 12) score++
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++
if (/\d/.test(password)) score++
if (/[^a-zA-Z0-9]/.test(password)) score++
if (score <= 2) return { percent: 34, level: 'weak', label: '密码强度:弱' }
if (score <= 4) return { percent: 68, level: 'medium', label: '密码强度:中' }
return { percent: 100, level: 'strong', label: '密码强度:强' }
})
const passwordError = computed(() => passwordMetricsResult.value.error)
const canChangePassword = computed(() => Boolean(passwordForm.oldPassword && passwordForm.confirmPassword && passwordMetricsResult.value.valid && !passwordMismatch.value))
const passwordStrength = computed(() => passwordMetricsResult.value)
const toastIcon = computed(() => ({ success: 'fas fa-circle-check', error: 'fas fa-circle-exclamation', warning: 'fas fa-triangle-exclamation' }[toast.type] || 'fas fa-circle-info'))
const confirmTitle = computed(() => confirmAction.value?.kind === 'logout' ? '退出登录' : `删除${confirmAction.value?.kind === 'site' ? '站点' : '联系方式'}`)
const confirmDescription = computed(() => confirmAction.value?.kind === 'logout' ? '确认结束当前管理会话吗?' : `确认删除“${confirmAction.value?.item?.name || confirmAction.value?.item?.type || ''}”吗?`)
+50
View File
@@ -0,0 +1,50 @@
export const PASSWORD_MIN_LENGTH = 8
export const PASSWORD_MAX_BYTES = 72
const commonPasswords = new Set(['12345678', 'admin123', 'password', 'password123', 'qwerty123'])
const byteLength = (value) => {
if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(value).length
return unescape(encodeURIComponent(value)).length
}
export const passwordMetrics = (password = '', oldPassword = '') => {
const value = String(password)
const length = Array.from(value).length
const categories = new Set()
for (const char of value) {
if (/\p{L}/u.test(char)) categories.add('letter')
else if (/\p{N}/u.test(char)) categories.add('number')
else if (/[^\p{L}\p{N}\s]/u.test(char)) categories.add('symbol')
}
const score = (length >= PASSWORD_MIN_LENGTH ? 1 : 0)
+ (length >= 12 ? 1 : 0)
+ categories.size
let strength = { percent: 0, level: '', label: '尚未输入新密码' }
if (value) {
strength = score <= 2
? { percent: 34, level: 'weak', label: '密码强度:弱' }
: score <= 4
? { percent: 68, level: 'medium', label: '密码强度:中' }
: { percent: 100, level: 'strong', label: '密码强度:强' }
}
let error = ''
if (value && length < PASSWORD_MIN_LENGTH) error = `新密码至少需要${PASSWORD_MIN_LENGTH}个字符`
else if (value && byteLength(value) > PASSWORD_MAX_BYTES) error = `新密码不能超过${PASSWORD_MAX_BYTES}字节`
else if (value && value.trim() !== value) error = '新密码不能以空格开头或结尾'
else if (value && /[\u0000-\u001f\u007f]/.test(value)) error = '新密码不能包含控制字符'
else if (value && value === oldPassword) error = '新密码不能与当前密码相同'
else if (value && commonPasswords.has(value.toLowerCase())) error = '新密码过于常见,请使用更复杂的密码'
else if (value && categories.size < 2) error = '新密码至少需要包含字母、数字、符号中的两类'
return {
length,
bytes: byteLength(value),
categoryCount: categories.size,
error,
valid: Boolean(value) && !error,
...strength,
}
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { passwordMetrics } from './passwordPolicy'
describe('passwordPolicy', () => {
it('accepts a strong password and reports its strength', () => {
const result = passwordMetrics('New-admin-2026!', 'admin123')
expect(result.valid).toBe(true)
expect(result.level).toBe('strong')
expect(result.categoryCount).toBe(3)
})
it('rejects weak, repeated, common and padded passwords', () => {
expect(passwordMetrics('short1!', 'admin123').valid).toBe(false)
expect(passwordMetrics('admin123', 'admin123').error).toContain('相同')
expect(passwordMetrics('12345678', 'admin123').error).toContain('常见')
expect(passwordMetrics('New-admin-2026! ', 'admin123').error).toContain('空格')
})
it('limits bcrypt-compatible UTF-8 byte length', () => {
const result = passwordMetrics(`${'管理'.repeat(36)}1!`, 'admin123')
expect(result.bytes).toBeGreaterThan(72)
expect(result.error).toContain('72')
})
})
+4 -4
View File
@@ -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,
},
},