Compare commits
31 Commits
v0.1.0
...
e4c96da8a9
| Author | SHA1 | Date | |
|---|---|---|---|
| e4c96da8a9 | |||
| 1195e119c6 | |||
| 4eeb14e844 | |||
| 0d30ed9a0f | |||
| cd0b3e8fc1 | |||
| c04a13bf8a | |||
| 5513f0c13d | |||
| 598e2acb7e | |||
| 4870d29638 | |||
| 8600a39b6c | |||
| 407d008e19 | |||
| a2751eab31 | |||
| 5655fc5560 | |||
| 49b47a1ae0 | |||
| bcf82d42bc | |||
| 394025c8ea | |||
| 34bd749741 | |||
| 290f299e22 | |||
| 859dec8ada | |||
| 993c0a72d6 | |||
| c9c3a84b33 | |||
| 6de7a2d2e1 | |||
| 6181923d8d | |||
| 235efb0e62 | |||
| 6b1af27ea2 | |||
| 32f48777f3 | |||
| bc7a7c6e81 | |||
| 3cd0458c2c | |||
| 8eea30ea11 | |||
| 9e33e570af | |||
| 7653385838 |
3
.gitattributes
vendored
3
.gitattributes
vendored
@@ -1,8 +1,9 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
assets/*.png filter=lfs diff=lfs merge=lfs -text
|
||||
assets/**/*.png filter=lfs diff=lfs merge=lfs -text
|
||||
assets/*.icns filter=lfs diff=lfs merge=lfs -text
|
||||
assets/**/*.icns filter=lfs diff=lfs merge=lfs -text
|
||||
assets/*.ico filter=lfs diff=lfs merge=lfs -text
|
||||
assets/**/*.ico filter=lfs diff=lfs merge=lfs -text
|
||||
frontend/public/*.png filter=lfs diff=lfs merge=lfs -text
|
||||
frontend/public/**/*.png filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
14
.github/workflows/ci.yml
vendored
Normal file
14
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, master]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check
|
||||
uses: ./.github/workflows/test.yml
|
||||
212
.github/workflows/release.yml
vendored
212
.github/workflows/release.yml
vendored
@@ -18,42 +18,129 @@ jobs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.work
|
||||
cache-dependency-path: |
|
||||
backend/go.sum
|
||||
versionctl/go.sum
|
||||
|
||||
- name: Verify tag and VERSION
|
||||
id: version
|
||||
run: |
|
||||
version=$(go run ./backend/cmd/versionctl print)
|
||||
go run ./backend/cmd/versionctl verify-tag "${GITHUB_REF_NAME}"
|
||||
version=$(go run ./versionctl print)
|
||||
go run ./versionctl verify-tag "${GITHUB_REF_NAME}"
|
||||
printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-linux:
|
||||
name: Build Linux Assets
|
||||
test-gate:
|
||||
name: Test Gate
|
||||
needs: prepare
|
||||
uses: ./.github/workflows/test.yml
|
||||
with:
|
||||
full: true
|
||||
|
||||
build-web:
|
||||
name: Build Web Asset
|
||||
needs: [prepare, test-gate]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.work
|
||||
cache-dependency-path: |
|
||||
backend/go.sum
|
||||
versionctl/go.sum
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install Linux desktop build dependencies
|
||||
- name: Preflight web release toolchain
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v go
|
||||
go version
|
||||
command -v bun
|
||||
bun --version
|
||||
make release-assets-check
|
||||
|
||||
- name: Build web release asset
|
||||
run: make release-assets-web
|
||||
|
||||
- name: Upload web release asset
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-web
|
||||
path: build/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
build-linux:
|
||||
name: Build Linux ${{ matrix.arch }} Assets
|
||||
needs: [prepare, test-gate]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
runner: ubuntu-latest
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.work
|
||||
cache-dependency-path: |
|
||||
backend/go.sum
|
||||
versionctl/go.sum
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install Linux desktop and package dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libayatana-appindicator3-dev libgtk-3-dev
|
||||
sudo apt-get install -y curl file libayatana-appindicator3-dev libgtk-3-dev rpm
|
||||
|
||||
- name: Preflight Linux release toolchain
|
||||
run: |
|
||||
set -euo pipefail
|
||||
printf 'runner arch: %s\n' "$(uname -m)"
|
||||
command -v go
|
||||
go version
|
||||
command -v bun
|
||||
bun --version
|
||||
command -v gcc
|
||||
gcc --version
|
||||
command -v pkg-config
|
||||
pkg-config --modversion ayatana-appindicator3-0.1
|
||||
pkg-config --modversion gtk+-3.0
|
||||
command -v curl
|
||||
command -v dpkg-deb
|
||||
dpkg-deb --version
|
||||
command -v rpmbuild
|
||||
rpmbuild --version
|
||||
make release-assets-check
|
||||
|
||||
- name: Build Linux release assets
|
||||
run: make release-assets-linux
|
||||
@@ -61,23 +148,41 @@ jobs:
|
||||
- name: Upload Linux release assets
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-linux
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: build/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
build-windows:
|
||||
name: Build Windows Assets
|
||||
needs: prepare
|
||||
runs-on: windows-latest
|
||||
name: Build Windows ${{ matrix.arch }} Assets
|
||||
needs: [prepare, test-gate]
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
runner: windows-latest
|
||||
msystem: MINGW64
|
||||
cc: gcc
|
||||
cxx: g++
|
||||
packages: >-
|
||||
make
|
||||
mingw-w64-x86_64-gcc
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.work
|
||||
cache-dependency-path: |
|
||||
backend/go.sum
|
||||
versionctl/go.sum
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
@@ -85,39 +190,89 @@ jobs:
|
||||
- name: Setup MSYS2 toolchain
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: ${{ matrix.msystem }}
|
||||
path-type: inherit
|
||||
update: true
|
||||
install: >-
|
||||
make
|
||||
mingw-w64-x86_64-gcc
|
||||
install: ${{ matrix.packages }}
|
||||
|
||||
- name: Preflight Windows release toolchain
|
||||
shell: msys2 {0}
|
||||
env:
|
||||
CC: ${{ matrix.cc }}
|
||||
CXX: ${{ matrix.cxx }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v go
|
||||
go version
|
||||
command -v bun
|
||||
bun --version
|
||||
command -v make
|
||||
make --version
|
||||
command -v "$CC"
|
||||
"$CC" --version
|
||||
command -v "$CXX"
|
||||
"$CXX" --version
|
||||
command -v windres
|
||||
windres --version
|
||||
if command -v powershell.exe >/dev/null 2>&1; then
|
||||
powershell.exe -NoProfile -Command '$PSVersionTable.PSVersion.ToString()'
|
||||
else
|
||||
command -v powershell
|
||||
powershell -NoProfile -Command '$PSVersionTable.PSVersion.ToString()'
|
||||
fi
|
||||
make release-assets-check
|
||||
|
||||
- name: Build Windows release assets
|
||||
shell: msys2 {0}
|
||||
env:
|
||||
CC: ${{ matrix.cc }}
|
||||
CXX: ${{ matrix.cxx }}
|
||||
run: make release-assets-windows
|
||||
|
||||
- name: Upload Windows release assets
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-windows
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: build/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
build-macos:
|
||||
name: Build macOS Assets
|
||||
needs: prepare
|
||||
runs-on: macos-latest
|
||||
needs: [prepare, test-gate]
|
||||
runs-on: macos-15
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.work
|
||||
cache-dependency-path: |
|
||||
backend/go.sum
|
||||
versionctl/go.sum
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Preflight macOS release toolchain
|
||||
run: |
|
||||
set -euo pipefail
|
||||
printf 'runner arch: %s\n' "$(uname -m)"
|
||||
command -v go
|
||||
go version
|
||||
command -v bun
|
||||
bun --version
|
||||
command -v ditto
|
||||
command -v hdiutil
|
||||
xcrun --find lipo
|
||||
xcrun --find vtool
|
||||
make release-assets-check
|
||||
|
||||
- name: Build macOS release assets
|
||||
run: make release-assets-macos
|
||||
|
||||
@@ -126,14 +281,18 @@ jobs:
|
||||
with:
|
||||
name: release-macos
|
||||
path: build/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
draft-release:
|
||||
name: Create Draft Release
|
||||
needs: [prepare, build-linux, build-windows, build-macos]
|
||||
needs: [prepare, build-web, build-linux, build-windows, build-macos]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Download release assets
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -141,6 +300,9 @@ jobs:
|
||||
merge-multiple: true
|
||||
path: dist
|
||||
|
||||
- name: Generate checksums
|
||||
run: make release-assets-checksums RELEASE_DIR=dist
|
||||
|
||||
- name: Publish draft release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
|
||||
109
.github/workflows/test.yml
vendored
Normal file
109
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,109 @@
|
||||
name: Test (Full)
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
full:
|
||||
description: "Run full test suite including MySQL and E2E"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.work
|
||||
cache-dependency-path: |
|
||||
backend/go.sum
|
||||
versionctl/go.sum
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Lint
|
||||
run: make lint
|
||||
|
||||
- name: Test
|
||||
run: make test
|
||||
|
||||
mysql:
|
||||
name: MySQL Tests
|
||||
if: inputs.full
|
||||
needs: check
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: testpass
|
||||
MYSQL_DATABASE: nex_test
|
||||
MYSQL_USER: nex_test
|
||||
MYSQL_PASSWORD: testpass
|
||||
ports:
|
||||
- 13306:3306
|
||||
options: >-
|
||||
--health-cmd="mysqladmin ping -h localhost -u root -ptestpass"
|
||||
--health-interval=3s
|
||||
--health-timeout=5s
|
||||
--health-retries=10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.work
|
||||
cache-dependency-path: |
|
||||
backend/go.sum
|
||||
versionctl/go.sum
|
||||
|
||||
- name: MySQL tests
|
||||
run: cd backend && go test -tags=mysql ./tests/mysql/... -v -count=1
|
||||
|
||||
e2e:
|
||||
name: E2E Tests
|
||||
if: inputs.full
|
||||
needs: check
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.work
|
||||
cache-dependency-path: |
|
||||
backend/go.sum
|
||||
versionctl/go.sum
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: cd frontend && bunx playwright install --with-deps chromium
|
||||
|
||||
- name: E2E tests
|
||||
run: cd frontend && bun run test:e2e
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -408,6 +408,9 @@ temp
|
||||
skills-lock.json
|
||||
.worktrees
|
||||
!scripts/build/
|
||||
backend/bin
|
||||
backend/server
|
||||
backend/desktop
|
||||
|
||||
# Embedfs generated
|
||||
embedfs/assets/
|
||||
|
||||
444
Makefile
444
Makefile
@@ -1,51 +1,189 @@
|
||||
.PHONY: \
|
||||
lint test clean \
|
||||
version-sync version-check \
|
||||
lint test clean hooks-install hooks-check hooks-test \
|
||||
version-sync version-check version-bump \
|
||||
server-run server-build server-lint server-test server-clean \
|
||||
desktop-build-mac desktop-build-win desktop-build-linux \
|
||||
desktop-lint desktop-test desktop-clean \
|
||||
release-assets-linux release-assets-windows release-assets-macos \
|
||||
release-assets-check release-assets-web release-assets-linux release-assets-windows release-assets-macos release-assets-checksums \
|
||||
release-assets-server-linux release-assets-server-windows release-assets-server-macos \
|
||||
release-assets-desktop-linux release-assets-desktop-windows release-assets-desktop-macos \
|
||||
_backend-lint _backend-test _backend-clean _backend-build \
|
||||
_versionctl-lint _versionctl-test \
|
||||
_frontend-install _frontend-build _frontend-check _frontend-test _frontend-dev _frontend-clean \
|
||||
_hooks-pre-commit _check-clean-worktree \
|
||||
_desktop-test _desktop-clean _desktop-prepare-frontend _desktop-prepare-embedfs _desktop-prepare-windows-resource \
|
||||
_server-run-backend _server-run-frontend
|
||||
_server-run-backend _server-run-frontend \
|
||||
_check-linux-target-arch _check-windows-target-arch _ensure-appimagetool \
|
||||
_package-linux-tar _package-linux-appimage _package-linux-deb _package-linux-rpm \
|
||||
_package-macos-zip _package-macos-dmg
|
||||
|
||||
VERSION := $(shell go run ./backend/cmd/versionctl print)
|
||||
GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || printf 'unknown')
|
||||
BUILD_TIME ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
GO_LDFLAGS := -X nex/backend/pkg/buildinfo.version=$(VERSION) -X nex/backend/pkg/buildinfo.commit=$(GIT_COMMIT) -X nex/backend/pkg/buildinfo.buildTime=$(BUILD_TIME)
|
||||
GO_LDFLAGS_WIN := $(GO_LDFLAGS) -H=windowsgui
|
||||
RELEASE_DIR := build/release
|
||||
SERVER_LINUX_ASSET := $(shell go run ./backend/cmd/versionctl asset-name server linux amd64)
|
||||
SERVER_WINDOWS_ASSET := $(shell go run ./backend/cmd/versionctl asset-name server windows amd64)
|
||||
SERVER_DARWIN_AMD64_ASSET := $(shell go run ./backend/cmd/versionctl asset-name server darwin amd64)
|
||||
SERVER_DARWIN_ARM64_ASSET := $(shell go run ./backend/cmd/versionctl asset-name server darwin arm64)
|
||||
DESKTOP_LINUX_ASSET := $(shell go run ./backend/cmd/versionctl asset-name desktop linux)
|
||||
DESKTOP_WINDOWS_ASSET := $(shell go run ./backend/cmd/versionctl asset-name desktop windows)
|
||||
DESKTOP_MACOS_ASSET := $(shell go run ./backend/cmd/versionctl asset-name desktop macos)
|
||||
# Delay shell lookups until a target needs them, then cache the result for this make run.
|
||||
lazy_shell = $(or $($(1)),$(eval $(1) := $(shell $(2)))$($(1)))
|
||||
|
||||
VERSION = $(call lazy_shell,_VERSION,go run ./versionctl print)
|
||||
GIT_COMMIT ?= $(call lazy_shell,_GIT_COMMIT,git rev-parse --short HEAD 2>/dev/null || printf 'unknown')
|
||||
BUILD_TIME ?= $(call lazy_shell,_BUILD_TIME,date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
TARGET_ARCH ?= $(call lazy_shell,_TARGET_ARCH,go env GOARCH)
|
||||
GO_LDFLAGS = -X nex/backend/pkg/buildinfo.version=$(VERSION) -X nex/backend/pkg/buildinfo.commit=$(GIT_COMMIT) -X nex/backend/pkg/buildinfo.buildTime=$(BUILD_TIME)
|
||||
GO_LDFLAGS_WIN = $(GO_LDFLAGS) -H=windowsgui
|
||||
RELEASE_DIR ?= build/release
|
||||
LINUX_DESKTOP_BINARY = build/nex-linux-$(TARGET_ARCH)
|
||||
WINDOWS_DESKTOP_BINARY = build/nex-win-$(TARGET_ARCH).exe
|
||||
WINDOWS_SERVER_BINARY = build/nex-server-windows-$(TARGET_ARCH).exe
|
||||
WINDRES ?= windres
|
||||
|
||||
ifeq ($(TARGET_ARCH),arm64)
|
||||
APPIMAGE_ARCH := aarch64
|
||||
DEB_ARCH := arm64
|
||||
RPM_ARCH := aarch64
|
||||
else
|
||||
APPIMAGE_ARCH := x86_64
|
||||
DEB_ARCH := amd64
|
||||
RPM_ARCH := x86_64
|
||||
endif
|
||||
|
||||
WINDOWS_WINDRES_FORMAT_BFD := pe-x86-64
|
||||
WINDOWS_WINDRES_FORMAT_LLVM := x86_64-w64-mingw32
|
||||
WINDOWS_RESOURCE := rsrc_windows_amd64.syso
|
||||
|
||||
APPIMAGETOOL_PATH := build/tools/appimagetool-$(APPIMAGE_ARCH).AppImage
|
||||
APPIMAGETOOL_URL ?= https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-$(APPIMAGE_ARCH).AppImage
|
||||
APPIMAGETOOL ?= $(APPIMAGETOOL_PATH)
|
||||
|
||||
# ============================================
|
||||
# 全局命令
|
||||
# ============================================
|
||||
|
||||
lint: _backend-lint _frontend-check
|
||||
lint: _backend-lint _frontend-check _versionctl-lint
|
||||
@printf 'Lint complete\n'
|
||||
|
||||
test: _backend-test _frontend-test _desktop-test
|
||||
test: _backend-test _frontend-test _desktop-test _versionctl-test
|
||||
@printf 'All tests passed\n'
|
||||
|
||||
clean: _backend-clean _frontend-clean _desktop-clean
|
||||
@printf 'Clean complete\n'
|
||||
|
||||
# ============================================
|
||||
# Git hooks
|
||||
# ============================================
|
||||
|
||||
hooks-install:
|
||||
@hooks_dir=$$(git rev-parse --git-path hooks); \
|
||||
mkdir -p "$$hooks_dir"; \
|
||||
for hook in pre-commit commit-msg prepare-commit-msg; do \
|
||||
src="scripts/git-hooks/$$hook"; \
|
||||
if [ ! -f "$$src" ]; then \
|
||||
printf 'ERROR: source hook not found: %s\n' "$$src" >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
cp "$$src" "$$hooks_dir/$$hook"; \
|
||||
chmod +x "$$hooks_dir/$$hook"; \
|
||||
done; \
|
||||
printf 'Installed Git hooks to %s\n' "$$hooks_dir"
|
||||
|
||||
hooks-check:
|
||||
@hooks_dir=$$(git rev-parse --git-path hooks); \
|
||||
status=0; \
|
||||
for hook in pre-commit commit-msg prepare-commit-msg; do \
|
||||
if [ -x "$$hooks_dir/$$hook" ]; then \
|
||||
printf 'OK: %s\n' "$$hook"; \
|
||||
else \
|
||||
printf 'MISSING: %s (%s/%s)\n' "$$hook" "$$hooks_dir" "$$hook"; \
|
||||
status=1; \
|
||||
fi; \
|
||||
done; \
|
||||
exit $$status
|
||||
|
||||
hooks-test:
|
||||
@scripts/git-hooks/test-hooks.sh
|
||||
|
||||
_hooks-pre-commit:
|
||||
@set -ef; \
|
||||
staged_files=$$(git diff --cached --name-only --diff-filter=ACM); \
|
||||
if [ -z "$$staged_files" ]; then \
|
||||
printf 'No staged files to check\n'; \
|
||||
exit 0; \
|
||||
fi; \
|
||||
run_backend_lint=; \
|
||||
run_versionctl_lint=; \
|
||||
run_frontend_check=; \
|
||||
lfs_patterns=$$(grep 'filter=lfs' .gitattributes 2>/dev/null | awk '{print $$1}' || true); \
|
||||
for file in $$staged_files; do \
|
||||
[ -n "$$file" ] || continue; \
|
||||
if git show ":$$file" 2>/dev/null | grep -Eq '^(<<<<<<<|=======|>>>>>>>)'; then \
|
||||
printf 'Found conflict markers in staged file: %s\n' "$$file" >&2; \
|
||||
printf 'Resolve conflict markers before committing.\n' >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
size=$$(git cat-file -s ":$$file" 2>/dev/null || printf '0'); \
|
||||
if [ "$$size" -gt 512000 ] 2>/dev/null; then \
|
||||
if git show ":$$file" 2>/dev/null | LC_ALL=C grep -Iq .; then \
|
||||
printf 'Warning: large staged text file (%s bytes): %s\n' "$$size" "$$file" >&2; \
|
||||
fi; \
|
||||
fi; \
|
||||
if [ -n "$$lfs_patterns" ]; then \
|
||||
for lfs_pat in $$lfs_patterns; do \
|
||||
case "$$file" in $$lfs_pat) \
|
||||
content=$$(git show ":$$file" 2>/dev/null | head -1); \
|
||||
case "$$content" in \
|
||||
"version https://git-lfs.github.com/spec/v1"*) ;; \
|
||||
*) \
|
||||
printf 'LFS-tracked file not using LFS pointer: %s\n' "$$file" >&2; \
|
||||
printf 'Run "git lfs install" and re-add this file.\n' >&2; \
|
||||
exit 1; \
|
||||
;; \
|
||||
esac; \
|
||||
break; \
|
||||
;; \
|
||||
esac; \
|
||||
done; \
|
||||
fi; \
|
||||
case "$$file" in \
|
||||
backend/*.go) run_backend_lint=1 ;; \
|
||||
versionctl/*.go) run_versionctl_lint=1 ;; \
|
||||
frontend/*.ts|frontend/*.tsx|frontend/*.scss) run_frontend_check=1 ;; \
|
||||
esac; \
|
||||
done; \
|
||||
if [ -n "$$run_backend_lint" ]; then \
|
||||
printf 'Running backend lint...\n'; \
|
||||
$(MAKE) _backend-lint; \
|
||||
fi; \
|
||||
if [ -n "$$run_versionctl_lint" ]; then \
|
||||
printf 'Running versionctl lint...\n'; \
|
||||
$(MAKE) _versionctl-lint; \
|
||||
fi; \
|
||||
if [ -n "$$run_frontend_check" ]; then \
|
||||
printf 'Running frontend check...\n'; \
|
||||
$(MAKE) _frontend-check; \
|
||||
fi; \
|
||||
printf 'Pre-commit checks passed\n'
|
||||
|
||||
# ============================================
|
||||
# 版本管理
|
||||
# ============================================
|
||||
|
||||
version-sync:
|
||||
go run ./backend/cmd/versionctl sync
|
||||
go run ./versionctl sync
|
||||
|
||||
version-check:
|
||||
go run ./backend/cmd/versionctl check
|
||||
go run ./versionctl check
|
||||
|
||||
version-bump: BUMP ?= patch
|
||||
version-bump: lint test _check-clean-worktree
|
||||
@set -e; \
|
||||
bump_arg="$(if $(SET_VERSION),$(SET_VERSION),$(BUMP))"; \
|
||||
new_version=$$(go run ./versionctl bump "$$bump_arg"); \
|
||||
git add VERSION frontend/; \
|
||||
git commit -m "chore: 版本升迁 v$$new_version"; \
|
||||
git tag "v$$new_version"; \
|
||||
printf '版本升迁完成: v%s\n' "$$new_version"
|
||||
|
||||
_check-clean-worktree:
|
||||
@if [ -n "$$(git status --porcelain)" ]; then \
|
||||
printf '工作区不干净,请先提交或清理改动后再执行版本升迁。\n' >&2; \
|
||||
git status --short; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# ============================================
|
||||
# Server 模式
|
||||
@@ -81,37 +219,37 @@ desktop-build-mac: version-check _desktop-prepare-frontend _desktop-prepare-embe
|
||||
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-mac-arm64 ./cmd/desktop
|
||||
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-mac-amd64 ./cmd/desktop
|
||||
lipo -create build/nex-mac-arm64 build/nex-mac-amd64 -output build/nex-mac-universal
|
||||
lipo -info build/nex-mac-universal | grep -q 'x86_64 arm64'
|
||||
rm -f build/nex-mac-arm64 build/nex-mac-amd64
|
||||
@printf 'Packaging macOS app bundle...\n'
|
||||
rm -rf build/Nex.app
|
||||
mkdir -p build/Nex.app/Contents/MacOS build/Nex.app/Contents/Resources
|
||||
cp build/nex-mac-universal build/Nex.app/Contents/MacOS/nex
|
||||
@if [ -f assets/icon.icns ]; then \
|
||||
cp assets/icon.icns build/Nex.app/Contents/Resources/; \
|
||||
else \
|
||||
printf 'Missing assets/icon.icns\n'; \
|
||||
exit 1; \
|
||||
fi
|
||||
@MIN_MACOS_VERSION=$$(vtool -show-build build/nex-mac-universal | awk '/minos / {print $$2; exit}'); \
|
||||
if [ -z "$$MIN_MACOS_VERSION" ]; then \
|
||||
printf 'Unable to read macOS minimum version\n'; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
go run ./backend/cmd/versionctl macos-plist "$$MIN_MACOS_VERSION" > build/Nex.app/Contents/Info.plist
|
||||
go run ./versionctl macos-plist "$$MIN_MACOS_VERSION" > build/Nex.app/Contents/Info.plist
|
||||
chmod +x build/Nex.app/Contents/MacOS/nex
|
||||
@printf 'macOS desktop build complete\n'
|
||||
|
||||
desktop-build-win: version-check _desktop-prepare-frontend _desktop-prepare-embedfs _desktop-prepare-windows-resource
|
||||
@printf 'Building Windows desktop...\n'
|
||||
ifeq ($(OS),Windows_NT)
|
||||
powershell -NoProfile -Command "New-Item -ItemType Directory -Path 'build' -Force | Out-Null"
|
||||
cd backend && set "CGO_ENABLED=1"&& set "GOOS=windows"&& set "GOARCH=amd64"&& go build -ldflags "$(GO_LDFLAGS_WIN)" -o ../build/nex-win-amd64.exe ./cmd/desktop
|
||||
else
|
||||
desktop-build-win: version-check _desktop-prepare-frontend _desktop-prepare-embedfs _desktop-prepare-windows-resource _check-windows-target-arch
|
||||
@printf 'Building Windows desktop $(TARGET_ARCH)...\n'
|
||||
mkdir -p build
|
||||
cd backend && CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS_WIN)" -o ../build/nex-win-amd64.exe ./cmd/desktop
|
||||
endif
|
||||
cd backend && CGO_ENABLED=1 GOOS=windows GOARCH=$(TARGET_ARCH) go build -ldflags "$(GO_LDFLAGS_WIN)" -o ../$(WINDOWS_DESKTOP_BINARY) ./cmd/desktop
|
||||
@printf 'Windows desktop build complete\n'
|
||||
|
||||
desktop-build-linux: version-check _desktop-prepare-frontend _desktop-prepare-embedfs
|
||||
@printf 'Building Linux desktop...\n'
|
||||
cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-linux-amd64 ./cmd/desktop
|
||||
desktop-build-linux: version-check _desktop-prepare-frontend _desktop-prepare-embedfs _check-linux-target-arch
|
||||
@printf 'Building Linux desktop $(TARGET_ARCH)...\n'
|
||||
mkdir -p build
|
||||
cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=$(TARGET_ARCH) go build -ldflags "$(GO_LDFLAGS)" -o ../$(LINUX_DESKTOP_BINARY) ./cmd/desktop
|
||||
@printf 'Linux desktop build complete\n'
|
||||
|
||||
desktop-lint: _backend-lint _frontend-check
|
||||
@@ -131,71 +269,215 @@ _desktop-clean:
|
||||
|
||||
_desktop-prepare-frontend: _frontend-install
|
||||
@printf 'Preparing frontend for desktop...\n'
|
||||
ifeq ($(OS),Windows_NT)
|
||||
powershell -NoProfile -Command "Copy-Item -LiteralPath 'frontend/.env.desktop' -Destination 'frontend/.env.production.local' -Force"
|
||||
cd frontend && bun run build
|
||||
powershell -NoProfile -Command "Remove-Item -LiteralPath 'frontend/.env.production.local' -Force -ErrorAction SilentlyContinue"
|
||||
else
|
||||
cd frontend && cp .env.desktop .env.production.local
|
||||
cd frontend && bun run build
|
||||
rm -f frontend/.env.production.local
|
||||
endif
|
||||
|
||||
_desktop-prepare-embedfs:
|
||||
@printf 'Preparing embedded filesystem...\n'
|
||||
ifeq ($(OS),Windows_NT)
|
||||
powershell -NoProfile -Command "Remove-Item -LiteralPath 'embedfs/assets' -Recurse -Force -ErrorAction SilentlyContinue; Remove-Item -LiteralPath 'embedfs/frontend-dist' -Recurse -Force -ErrorAction SilentlyContinue; Copy-Item -LiteralPath 'assets' -Destination 'embedfs/assets' -Recurse; Copy-Item -LiteralPath 'frontend/dist' -Destination 'embedfs/frontend-dist' -Recurse"
|
||||
else
|
||||
rm -rf embedfs/assets embedfs/frontend-dist
|
||||
cp -r assets embedfs/assets
|
||||
cp -r frontend/dist embedfs/frontend-dist
|
||||
endif
|
||||
|
||||
_desktop-prepare-windows-resource:
|
||||
@printf 'Preparing Windows executable icon...\n'
|
||||
ifeq ($(OS),Windows_NT)
|
||||
cd backend/cmd/desktop && windres -O coff -F pe-x86-64 -i icon_windows.rc -o rsrc_windows_amd64.syso
|
||||
else
|
||||
@if command -v x86_64-w64-mingw32-windres >/dev/null 2>&1; then \
|
||||
cd backend/cmd/desktop && x86_64-w64-mingw32-windres -O coff -F pe-x86-64 -i icon_windows.rc -o rsrc_windows_amd64.syso; \
|
||||
elif command -v windres >/dev/null 2>&1; then \
|
||||
cd backend/cmd/desktop && windres -O coff -F pe-x86-64 -i icon_windows.rc -o rsrc_windows_amd64.syso; \
|
||||
else \
|
||||
printf 'Missing windres for Windows icon resource generation\n'; \
|
||||
exit 1; \
|
||||
fi
|
||||
endif
|
||||
_desktop-prepare-windows-resource: _check-windows-target-arch
|
||||
@printf 'Preparing Windows $(TARGET_ARCH) executable icon...\n'
|
||||
@WINDRES_CMD="$(WINDRES)"; \
|
||||
WINDRES_FMT="$(WINDOWS_WINDRES_FORMAT_BFD)"; \
|
||||
if command -v llvm-windres >/dev/null 2>&1; then \
|
||||
WINDRES_CMD=llvm-windres; \
|
||||
WINDRES_FMT="$(WINDOWS_WINDRES_FORMAT_LLVM)"; \
|
||||
elif "$$WINDRES_CMD" --version 2>&1 | grep -qi LLVM; then \
|
||||
WINDRES_FMT="$(WINDOWS_WINDRES_FORMAT_LLVM)"; \
|
||||
fi; \
|
||||
command -v "$$WINDRES_CMD" >/dev/null 2>&1 || { printf 'Missing windres tool: %s\n' "$$WINDRES_CMD"; exit 1; }; \
|
||||
cd backend/cmd/desktop && "$$WINDRES_CMD" -O coff -F "$$WINDRES_FMT" -i icon_windows.rc -o $(WINDOWS_RESOURCE)
|
||||
|
||||
# ============================================
|
||||
# 发布资产
|
||||
# ============================================
|
||||
|
||||
release-assets-linux: version-check desktop-build-linux
|
||||
release-assets-check:
|
||||
go run ./versionctl release-assets-check
|
||||
@printf 'Release assets check passed\n'
|
||||
|
||||
release-assets-web: version-check release-assets-check _frontend-build
|
||||
rm -rf "$(RELEASE_DIR)"
|
||||
mkdir -p "$(RELEASE_DIR)"
|
||||
cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-linux-amd64 ./cmd/server
|
||||
tar -C build -czf "$(RELEASE_DIR)/$(SERVER_LINUX_ASSET)" nex-server-linux-amd64
|
||||
tar -C build -czf "$(RELEASE_DIR)/$(DESKTOP_LINUX_ASSET)" nex-linux-amd64
|
||||
asset=$$(go run ./versionctl asset-name web tar.gz); \
|
||||
tar -C frontend -czf "$(RELEASE_DIR)/$$asset" dist
|
||||
|
||||
release-assets-windows: version-check desktop-build-win
|
||||
ifeq ($(OS),Windows_NT)
|
||||
powershell -NoProfile -Command "Remove-Item -LiteralPath '$(RELEASE_DIR)' -Recurse -Force -ErrorAction SilentlyContinue; New-Item -ItemType Directory -Path '$(RELEASE_DIR)' -Force | Out-Null"
|
||||
cd backend && set "CGO_ENABLED=1"&& set "GOOS=windows"&& set "GOARCH=amd64"&& go build -ldflags "$(GO_LDFLAGS_WIN)" -o ../build/nex-server-win-amd64.exe ./cmd/server
|
||||
powershell -NoProfile -Command "Compress-Archive -LiteralPath 'build/nex-server-win-amd64.exe' -DestinationPath '$(RELEASE_DIR)/$(SERVER_WINDOWS_ASSET)' -Force"
|
||||
powershell -NoProfile -Command "Compress-Archive -LiteralPath 'build/nex-win-amd64.exe' -DestinationPath '$(RELEASE_DIR)/$(DESKTOP_WINDOWS_ASSET)' -Force"
|
||||
else
|
||||
@printf 'release-assets-windows requires Windows\n'
|
||||
@exit 1
|
||||
endif
|
||||
|
||||
release-assets-macos: version-check desktop-build-mac
|
||||
release-assets-linux: version-check release-assets-check _check-linux-target-arch
|
||||
rm -rf "$(RELEASE_DIR)"
|
||||
mkdir -p "$(RELEASE_DIR)"
|
||||
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-darwin-amd64 ./cmd/server
|
||||
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-darwin-arm64 ./cmd/server
|
||||
tar -C build -czf "$(RELEASE_DIR)/$(SERVER_DARWIN_AMD64_ASSET)" nex-server-darwin-amd64
|
||||
tar -C build -czf "$(RELEASE_DIR)/$(SERVER_DARWIN_ARM64_ASSET)" nex-server-darwin-arm64
|
||||
ditto -c -k --keepParent build/Nex.app "$(RELEASE_DIR)/$(DESKTOP_MACOS_ASSET)"
|
||||
@$(MAKE) release-assets-server-linux TARGET_ARCH=$(TARGET_ARCH) RELEASE_DIR="$(RELEASE_DIR)"
|
||||
@$(MAKE) release-assets-desktop-linux TARGET_ARCH=$(TARGET_ARCH) RELEASE_DIR="$(RELEASE_DIR)"
|
||||
|
||||
release-assets-windows: version-check release-assets-check _check-windows-target-arch
|
||||
rm -rf "$(RELEASE_DIR)"
|
||||
mkdir -p "$(RELEASE_DIR)"
|
||||
@$(MAKE) release-assets-server-windows TARGET_ARCH=$(TARGET_ARCH) RELEASE_DIR="$(RELEASE_DIR)"
|
||||
@$(MAKE) release-assets-desktop-windows TARGET_ARCH=$(TARGET_ARCH) RELEASE_DIR="$(RELEASE_DIR)"
|
||||
|
||||
release-assets-macos: version-check release-assets-check
|
||||
rm -rf "$(RELEASE_DIR)"
|
||||
mkdir -p "$(RELEASE_DIR)"
|
||||
@$(MAKE) release-assets-server-macos RELEASE_DIR="$(RELEASE_DIR)"
|
||||
@$(MAKE) release-assets-desktop-macos RELEASE_DIR="$(RELEASE_DIR)"
|
||||
|
||||
release-assets-server-linux: version-check _check-linux-target-arch
|
||||
mkdir -p build "$(RELEASE_DIR)"
|
||||
cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=$(TARGET_ARCH) go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-linux-$(TARGET_ARCH) ./cmd/server
|
||||
asset=$$(go run ./versionctl asset-name server linux $(TARGET_ARCH) tar.gz); \
|
||||
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-linux-$(TARGET_ARCH)
|
||||
|
||||
release-assets-server-windows: version-check _check-windows-target-arch
|
||||
mkdir -p build "$(RELEASE_DIR)"
|
||||
cd backend && CGO_ENABLED=1 GOOS=windows GOARCH=$(TARGET_ARCH) go build -ldflags "$(GO_LDFLAGS)" -o ../$(WINDOWS_SERVER_BINARY) ./cmd/server
|
||||
asset=$$(go run ./versionctl asset-name server windows $(TARGET_ARCH) zip); \
|
||||
if command -v powershell.exe >/dev/null 2>&1; then POWERSHELL=powershell.exe; else POWERSHELL=powershell; fi; \
|
||||
"$$POWERSHELL" -NoProfile -Command "Compress-Archive -LiteralPath '$(WINDOWS_SERVER_BINARY)' -DestinationPath '$(RELEASE_DIR)/$$asset' -Force"
|
||||
|
||||
release-assets-server-macos: version-check
|
||||
mkdir -p build "$(RELEASE_DIR)"
|
||||
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-macos-amd64 ./cmd/server
|
||||
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-macos-arm64 ./cmd/server
|
||||
lipo -create build/nex-server-macos-amd64 build/nex-server-macos-arm64 -output build/nex-server-macos-universal
|
||||
lipo -info build/nex-server-macos-universal | grep -q 'x86_64 arm64'
|
||||
asset=$$(go run ./versionctl asset-name server macos amd64 tar.gz); \
|
||||
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-macos-amd64
|
||||
asset=$$(go run ./versionctl asset-name server macos arm64 tar.gz); \
|
||||
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-macos-arm64
|
||||
asset=$$(go run ./versionctl asset-name server macos universal tar.gz); \
|
||||
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-macos-universal
|
||||
rm -f build/nex-server-macos-amd64 build/nex-server-macos-arm64 build/nex-server-macos-universal
|
||||
|
||||
release-assets-desktop-linux: version-check release-assets-check desktop-build-linux _package-linux-tar _package-linux-appimage _package-linux-deb _package-linux-rpm
|
||||
|
||||
release-assets-desktop-windows: version-check release-assets-check desktop-build-win
|
||||
mkdir -p "$(RELEASE_DIR)"
|
||||
asset=$$(go run ./versionctl asset-name desktop windows $(TARGET_ARCH) zip); \
|
||||
if command -v powershell.exe >/dev/null 2>&1; then POWERSHELL=powershell.exe; else POWERSHELL=powershell; fi; \
|
||||
"$$POWERSHELL" -NoProfile -Command "Compress-Archive -LiteralPath '$(WINDOWS_DESKTOP_BINARY)' -DestinationPath '$(RELEASE_DIR)/$$asset' -Force"
|
||||
|
||||
release-assets-desktop-macos: version-check release-assets-check desktop-build-mac _package-macos-zip _package-macos-dmg
|
||||
rm -rf build/Nex.app build/dmg
|
||||
|
||||
release-assets-checksums:
|
||||
@cd "$(RELEASE_DIR)" && \
|
||||
rm -f SHA256SUMS && \
|
||||
for asset in *; do \
|
||||
[ -f "$$asset" ] || continue; \
|
||||
if command -v sha256sum >/dev/null 2>&1; then \
|
||||
sha256sum "$$asset"; \
|
||||
elif command -v shasum >/dev/null 2>&1; then \
|
||||
shasum -a 256 "$$asset"; \
|
||||
else \
|
||||
printf 'Missing sha256sum or shasum\n' >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
done > SHA256SUMS && \
|
||||
test -s SHA256SUMS
|
||||
|
||||
_check-linux-target-arch:
|
||||
@if [ "$(TARGET_ARCH)" != "amd64" ] && [ "$(TARGET_ARCH)" != "arm64" ]; then \
|
||||
printf 'Unsupported Linux TARGET_ARCH: %s\n' "$(TARGET_ARCH)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
_check-windows-target-arch:
|
||||
@if [ "$(TARGET_ARCH)" != "amd64" ]; then \
|
||||
printf 'Unsupported Windows TARGET_ARCH: %s\n' "$(TARGET_ARCH)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
_ensure-appimagetool:
|
||||
@mkdir -p build/tools
|
||||
@if [ ! -x "$(APPIMAGETOOL)" ]; then \
|
||||
printf 'Downloading appimagetool for %s...\n' "$(APPIMAGE_ARCH)"; \
|
||||
command -v curl >/dev/null 2>&1 || { printf 'Missing curl for appimagetool download\n'; exit 1; }; \
|
||||
curl -L "$(APPIMAGETOOL_URL)" -o "$(APPIMAGETOOL)"; \
|
||||
chmod +x "$(APPIMAGETOOL)"; \
|
||||
fi; \
|
||||
printf 'Using appimagetool: %s\n' "$(APPIMAGETOOL)"
|
||||
|
||||
_package-linux-tar:
|
||||
mkdir -p "$(RELEASE_DIR)"
|
||||
asset=$$(go run ./versionctl asset-name desktop linux $(TARGET_ARCH) tar.gz); \
|
||||
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-linux-$(TARGET_ARCH)
|
||||
|
||||
_package-linux-appimage: _ensure-appimagetool
|
||||
mkdir -p "$(RELEASE_DIR)"
|
||||
appdir="build/appimage/nex-$(TARGET_ARCH).AppDir"; \
|
||||
rm -rf "$$appdir"; \
|
||||
mkdir -p "$$appdir/usr/bin" "$$appdir/usr/share/applications" "$$appdir/usr/share/icons"; \
|
||||
install -m 0755 "$(LINUX_DESKTOP_BINARY)" "$$appdir/usr/bin/nex"; \
|
||||
install -m 0644 packaging/linux/nex.desktop "$$appdir/nex.desktop"; \
|
||||
install -m 0644 packaging/linux/nex.desktop "$$appdir/usr/share/applications/nex.desktop"; \
|
||||
install -m 0755 packaging/linux/AppRun "$$appdir/AppRun"; \
|
||||
cp -R assets/icons/hicolor "$$appdir/usr/share/icons/"; \
|
||||
cp assets/icon.png "$$appdir/nex.png"; \
|
||||
asset=$$(go run ./versionctl asset-name desktop linux $(TARGET_ARCH) AppImage); \
|
||||
ARCH=$(APPIMAGE_ARCH) APPIMAGE_EXTRACT_AND_RUN=1 "$(APPIMAGETOOL)" "$$appdir" "$(RELEASE_DIR)/$$asset"; \
|
||||
chmod +x "$(RELEASE_DIR)/$$asset"; \
|
||||
test -s "$(RELEASE_DIR)/$$asset"
|
||||
|
||||
_package-linux-deb:
|
||||
mkdir -p "$(RELEASE_DIR)"
|
||||
pkgdir="build/pkg/deb/nex-$(TARGET_ARCH)"; \
|
||||
rm -rf "$$pkgdir"; \
|
||||
mkdir -p "$$pkgdir/DEBIAN" "$$pkgdir/usr/bin" "$$pkgdir/usr/share/applications" "$$pkgdir/usr/share/icons"; \
|
||||
install -m 0755 "$(LINUX_DESKTOP_BINARY)" "$$pkgdir/usr/bin/nex"; \
|
||||
install -m 0644 packaging/linux/nex.desktop "$$pkgdir/usr/share/applications/nex.desktop"; \
|
||||
cp -R assets/icons/hicolor "$$pkgdir/usr/share/icons/"; \
|
||||
printf '%s\n' \
|
||||
'Package: nex' \
|
||||
'Version: $(VERSION)' \
|
||||
'Section: utils' \
|
||||
'Priority: optional' \
|
||||
'Architecture: $(DEB_ARCH)' \
|
||||
'Maintainer: Nex Maintainers <noreply@example.com>' \
|
||||
'Depends: libgtk-3-0, libayatana-appindicator3-1, xdg-utils' \
|
||||
'Description: AI Gateway desktop application' \
|
||||
' Nex is an AI Gateway desktop application.' \
|
||||
> "$$pkgdir/DEBIAN/control"; \
|
||||
asset=$$(go run ./versionctl asset-name desktop linux $(TARGET_ARCH) deb); \
|
||||
dpkg-deb --build --root-owner-group "$$pkgdir" "$(RELEASE_DIR)/$$asset"; \
|
||||
dpkg-deb -I "$(RELEASE_DIR)/$$asset" >/dev/null
|
||||
|
||||
_package-linux-rpm:
|
||||
mkdir -p "$(RELEASE_DIR)"
|
||||
topdir="$(abspath build/rpmbuild-$(TARGET_ARCH))"; \
|
||||
rm -rf "$$topdir"; \
|
||||
mkdir -p "$$topdir/BUILD" "$$topdir/BUILDROOT" "$$topdir/RPMS" "$$topdir/SOURCES" "$$topdir/SPECS" "$$topdir/SRPMS"; \
|
||||
rpmbuild -bb --target "$(RPM_ARCH)" \
|
||||
--define "_topdir $$topdir" \
|
||||
--define "nex_version $(VERSION)" \
|
||||
--define "nex_binary $(abspath $(LINUX_DESKTOP_BINARY))" \
|
||||
--define "nex_desktop_file $(abspath packaging/linux/nex.desktop)" \
|
||||
--define "nex_icons_dir $(abspath assets/icons/hicolor)" \
|
||||
packaging/linux/nex.spec; \
|
||||
rpm_file=$$(find "$$topdir/RPMS" -type f -name '*.rpm' | sort | tail -n 1); \
|
||||
test -n "$$rpm_file"; \
|
||||
asset=$$(go run ./versionctl asset-name desktop linux $(TARGET_ARCH) rpm); \
|
||||
cp "$$rpm_file" "$(RELEASE_DIR)/$$asset"; \
|
||||
rpm -qip "$(RELEASE_DIR)/$$asset" >/dev/null
|
||||
|
||||
_package-macos-zip:
|
||||
mkdir -p "$(RELEASE_DIR)"
|
||||
asset=$$(go run ./versionctl asset-name desktop macos universal zip); \
|
||||
ditto -c -k --keepParent build/Nex.app "$(RELEASE_DIR)/$$asset"
|
||||
|
||||
_package-macos-dmg:
|
||||
mkdir -p "$(RELEASE_DIR)"
|
||||
dmgdir="build/dmg/Nex"; \
|
||||
rm -rf "$$dmgdir"; \
|
||||
mkdir -p "$$dmgdir"; \
|
||||
cp -R build/Nex.app "$$dmgdir/Nex.app"; \
|
||||
ln -s /Applications "$$dmgdir/Applications"; \
|
||||
asset=$$(go run ./versionctl asset-name desktop macos universal dmg); \
|
||||
hdiutil create -volname Nex -srcfolder "$$dmgdir" -ov -format UDZO "$(RELEASE_DIR)/$$asset"; \
|
||||
hdiutil verify "$(RELEASE_DIR)/$$asset" >/dev/null && \
|
||||
rm -rf "$$dmgdir"
|
||||
|
||||
# ============================================
|
||||
# 共享 helper targets
|
||||
@@ -213,6 +495,12 @@ _backend-test:
|
||||
_backend-clean:
|
||||
@$(MAKE) -C backend clean
|
||||
|
||||
_versionctl-lint:
|
||||
@$(MAKE) -C versionctl lint
|
||||
|
||||
_versionctl-test:
|
||||
@$(MAKE) -C versionctl test
|
||||
|
||||
_frontend-install:
|
||||
cd frontend && bun install
|
||||
|
||||
|
||||
133
README.md
133
README.md
@@ -27,7 +27,7 @@ nex/
|
||||
│ │ ├── api/ # API 层(统一请求封装 + 字段转换)
|
||||
│ │ ├── hooks/ # TanStack Query hooks
|
||||
│ │ ├── components/ # 通用组件(AppLayout)
|
||||
│ │ ├── pages/ # 页面(Providers, Stats)
|
||||
│ │ ├── pages/ # 页面(Providers, Stats, Settings)
|
||||
│ │ ├── routes/ # React Router 路由配置
|
||||
│ │ ├── types/ # TypeScript 类型定义
|
||||
│ │ └── __tests__/ # 单元测试 + 组件测试
|
||||
@@ -39,6 +39,8 @@ nex/
|
||||
│ ├── icon.icns # macOS 应用图标
|
||||
│ └── icon.ico # Windows 应用图标
|
||||
│
|
||||
├── packaging/ # 桌面发布包元数据(Linux desktop entry、RPM spec 等)
|
||||
│
|
||||
└── README.md # 本文件
|
||||
```
|
||||
|
||||
@@ -55,6 +57,7 @@ nex/
|
||||
- **多供应商管理**:配置和管理多个供应商(供应商 ID 仅限字母、数字、下划线)
|
||||
- **用量统计**:按供应商、模型、日期统计请求数量
|
||||
- **Web 配置界面**:提供供应商和模型配置管理
|
||||
- **启动参数设置**:通过 Web 界面查看和编辑启动参数(Desktop 可编辑、Server 只读)
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -64,7 +67,7 @@ nex/
|
||||
- **ORM**: GORM
|
||||
- **数据库**: SQLite / MySQL
|
||||
- **日志**: zap + lumberjack(结构化日志 + 日志轮转 + 模块标识)
|
||||
- **配置**: Viper + pflag(多层配置:CLI > 环境变量 > 配置文件 > 默认值)
|
||||
- **配置**: Viper + pflag(Server 多层配置,Desktop 配置文件快照)
|
||||
- **验证**: go-playground/validator/v10
|
||||
- **迁移**: goose
|
||||
|
||||
@@ -91,7 +94,7 @@ JSON: {"level":"info","logger":"handler.proxy","msg":"处理请求","method":
|
||||
- **图表库**: Recharts
|
||||
- **路由**: React Router v7
|
||||
- **数据获取**: TanStack Query v5
|
||||
- **样式**: SCSS Modules
|
||||
- **样式**: TDesign 组件 props 优先,TDesign tokens 次之,SCSS 作为兜底补充
|
||||
- **测试**: Vitest + React Testing Library + Playwright
|
||||
|
||||
## 快速开始
|
||||
@@ -109,10 +112,13 @@ make desktop-build-win
|
||||
|
||||
# Linux
|
||||
make desktop-build-linux
|
||||
|
||||
# Linux arm64
|
||||
make desktop-build-linux TARGET_ARCH=arm64
|
||||
```
|
||||
|
||||
**使用桌面应用**:
|
||||
- 双击启动应用(macOS: Nex.app,Windows: nex-win-amd64.exe,Linux: nex-linux-amd64)
|
||||
- 双击启动应用(macOS: Nex.app,Windows: nex-win-amd64.exe,Linux: nex-linux-amd64 / nex-linux-arm64)
|
||||
- 系统托盘图标出现,浏览器自动打开管理界面
|
||||
- 点击托盘图标显示菜单,可打开管理界面或退出
|
||||
- 关闭浏览器后服务继续运行,可通过托盘重新打开
|
||||
@@ -120,8 +126,10 @@ make desktop-build-linux
|
||||
**注意事项**:
|
||||
- 桌面应用需要 CGO 支持
|
||||
- macOS: 自带 Xcode Command Line Tools
|
||||
- Linux: 自带 gcc,部分桌面环境需要 `libappindicator3-dev`
|
||||
- Windows: 需要 MinGW-w64 或在 Windows 环境构建
|
||||
- Linux 构建: 需要 gcc、pkg-config、GTK3 开发包和 Ayatana AppIndicator 开发包(Ubuntu/Debian: `libgtk-3-dev`、`libayatana-appindicator3-dev`)
|
||||
- Linux 运行: 需要 GTK3、Ayatana AppIndicator 和 xdg-utils;AppImage 也依赖系统提供 AppImage runtime/FUSE 能力,不承诺完全自包含
|
||||
- Windows: 需要对应架构的 MinGW-w64/MSYS2 工具链,desktop 使用 GUI linker flags 隐藏控制台窗口
|
||||
- macOS DMG: 发布包暂不签名、不 notarize,首次打开可能出现 Gatekeeper 提示
|
||||
|
||||
**Linux 桌面环境兼容性**:
|
||||
- GNOME: 需要 AppIndicator 扩展
|
||||
@@ -140,7 +148,6 @@ make server-run
|
||||
- 前端开发服务器:`http://localhost:5173`
|
||||
|
||||
前端请求会继续通过 Vite proxy 转发到后端。后端首次启动会自动:
|
||||
- 创建配置文件 `~/.nex/config.yaml`
|
||||
- 初始化数据库 `~/.nex/config.db`
|
||||
- 运行数据库迁移
|
||||
- 创建日志目录 `~/.nex/log/`
|
||||
@@ -151,6 +158,40 @@ make server-run
|
||||
make server-build
|
||||
```
|
||||
|
||||
### Release 产物
|
||||
|
||||
发布流程由 Git tag `vX.Y.Z` 触发,GitHub Actions 会先通过全流程测试门禁,再构建并创建 Draft Release,上传 server、web 和 desktop 三类产物,同时生成 `SHA256SUMS`。
|
||||
|
||||
**server 产物**(不内置 Web 管理界面):
|
||||
|
||||
| 平台 | 产物 |
|
||||
|------|------|
|
||||
| Linux amd64 | `nex-server_<version>_linux_amd64.tar.gz` |
|
||||
| Linux arm64 | `nex-server_<version>_linux_arm64.tar.gz` |
|
||||
| macOS amd64 | `nex-server_<version>_macos_amd64.tar.gz` |
|
||||
| macOS arm64 | `nex-server_<version>_macos_arm64.tar.gz` |
|
||||
| macOS universal | `nex-server_<version>_macos_universal.tar.gz` |
|
||||
| Windows amd64 | `nex-server_<version>_windows_amd64.zip` |
|
||||
|
||||
**web 产物**:
|
||||
|
||||
| 内容 | 产物 |
|
||||
|------|------|
|
||||
| `frontend/dist` | `nex-web_<version>.tar.gz` |
|
||||
|
||||
**desktop 产物**:
|
||||
|
||||
| 平台 | 产物 |
|
||||
|------|------|
|
||||
| Linux amd64 | `nex-desktop_<version>_linux_amd64.tar.gz`、`.AppImage`、`.deb`、`.rpm` |
|
||||
| Linux arm64 | `nex-desktop_<version>_linux_arm64.tar.gz`、`.AppImage`、`.deb`、`.rpm` |
|
||||
| macOS universal | `nex-desktop_<version>_macos_universal.zip`、`nex-desktop_<version>_macos_universal.dmg` |
|
||||
| Windows amd64 | `nex-desktop_<version>_windows_amd64.zip` |
|
||||
|
||||
Linux deb 包声明 `libgtk-3-0`、`libayatana-appindicator3-1`、`xdg-utils` 运行依赖;rpm 包声明 `gtk3`、`libayatana-appindicator-gtk3`、`xdg-utils` 运行依赖。Rocky Linux 9 等发行版可能需要启用 EPEL 才能解析 Ayatana AppIndicator 依赖。
|
||||
|
||||
server 和 desktop 发布产物自包含运行时数据库迁移资源(通过 `go:embed` 嵌入二进制),安装后首次启动不再依赖仓库源码目录。
|
||||
|
||||
## API 接口
|
||||
|
||||
### 代理接口(对外部应用)
|
||||
@@ -199,13 +240,29 @@ make server-build
|
||||
|
||||
查询参数支持:`provider_id`、`model_name`、`start_date`、`end_date`、`group_by`
|
||||
|
||||
#### 启动参数设置
|
||||
- `GET /api/settings/startup` - 查询启动参数设置
|
||||
- `PUT /api/settings/startup` - 保存启动参数设置(仅 Desktop 模式)
|
||||
|
||||
**行为差异**:
|
||||
- **Desktop 模式**:查询返回配置文件编辑视图(`~/.nex/config.yaml` + 默认值),允许保存到配置文件,保存后当前运行服务不受影响,需重启 Desktop 生效
|
||||
- **Server 模式**:查询返回当前运行有效配置,保存请求始终返回 403
|
||||
|
||||
响应包含 `mode`、`editable`、`config_path`、`restart_required` 元数据和完整启动参数配置。Duration 字段使用字符串格式(如 `30s`、`1h`)。
|
||||
|
||||
#### 版本信息
|
||||
- `GET /api/version` - 获取后端构建版本信息(`version`、`commit`、`build_time`),用于前端 About 页面诊断前后端版本一致性
|
||||
|
||||
## 配置
|
||||
|
||||
配置支持多种方式,优先级为:**CLI 参数 > 环境变量 > 配置文件 > 默认值**
|
||||
配置方式取决于启动模式:
|
||||
|
||||
- **Server 模式**(`cmd/server`):支持 CLI 参数 > 环境变量 > 配置文件 > 默认值
|
||||
- **Desktop 模式**(`cmd/desktop`):仅支持配置文件 `~/.nex/config.yaml` > 默认值,修改配置文件后需重启 desktop 生效
|
||||
|
||||
### 配置文件
|
||||
|
||||
配置文件位于 `~/.nex/config.yaml`,首次启动自动生成:
|
||||
配置文件位于 `~/.nex/config.yaml`。配置文件不存在时使用默认值,不会自动生成;需要自定义时手动创建该文件:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
@@ -235,9 +292,9 @@ log:
|
||||
compress: true
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
### 环境变量(仅 Server 模式)
|
||||
|
||||
所有配置项支持环境变量,使用 `NEX_` 前缀:
|
||||
Server 模式下,所有配置项支持环境变量,使用 `NEX_` 前缀:
|
||||
|
||||
```bash
|
||||
export NEX_SERVER_PORT=9000
|
||||
@@ -255,7 +312,11 @@ export NEX_DATABASE_DBNAME=nex
|
||||
|
||||
命名规则:配置路径转大写 + 下划线(如 `server.port` → `NEX_SERVER_PORT`)。
|
||||
|
||||
### CLI 参数
|
||||
**Desktop 模式不支持环境变量覆盖。**Desktop 仅从 `~/.nex/config.yaml` 和默认值读取配置。
|
||||
|
||||
### CLI 参数(仅 Server 模式)
|
||||
|
||||
Server 模式下,支持命令行参数:
|
||||
|
||||
```bash
|
||||
./server --server-port 9000 --log-level debug --database-path /tmp/test.db
|
||||
@@ -263,6 +324,8 @@ export NEX_DATABASE_DBNAME=nex
|
||||
|
||||
命名规则:配置路径转 kebab-case(如 `server.port` → `--server-port`)。
|
||||
|
||||
**Desktop 不支持命令行参数覆盖配置。**Desktop 忽略所有 CLI 参数,仅从 `~/.nex/config.yaml` 读取。
|
||||
|
||||
### 数据文件
|
||||
|
||||
- `~/.nex/config.yaml` - 配置文件
|
||||
@@ -286,7 +349,13 @@ backend 分类测试、MySQL 专项测试和前端 E2E 测试请分别查看 `ba
|
||||
|
||||
```bash
|
||||
# 首次克隆后安装 Git hooks
|
||||
lefthook install
|
||||
make hooks-install
|
||||
|
||||
# 检查 Git hooks 安装状态
|
||||
make hooks-check
|
||||
|
||||
# 运行 Git hooks 回归测试
|
||||
make hooks-test
|
||||
|
||||
# 全局命令
|
||||
make lint # 前后端共享检查
|
||||
@@ -309,6 +378,12 @@ make desktop-test # desktop 专属测试
|
||||
make desktop-clean # 清理 desktop 产物
|
||||
```
|
||||
|
||||
Git hooks 使用仓库内 `scripts/git-hooks/` 的原生脚本,不依赖额外 hook 框架。当前 hooks 包含:
|
||||
|
||||
- pre-commit:检查 staged files 的冲突标记、大文件告警和 LFS 指针,并按文件类型委托后端、versionctl、前端检查
|
||||
- prepare-commit-msg:在编辑器打开时提供提交信息模板,辅助填写 `类型: 简短描述` 和多行说明
|
||||
- commit-msg:校验提交信息格式为 `类型: 简短描述`,多行说明需在首行后保留空行;提交描述按项目规范使用中文,hook 不做字符集检测
|
||||
|
||||
## 版本与发布
|
||||
|
||||
### 统一版本源
|
||||
@@ -318,27 +393,24 @@ make desktop-clean # 清理 desktop 产物
|
||||
|
||||
### 本地版本演进
|
||||
|
||||
1. 手工修改根目录 `VERSION` 为新的 `x.y.z`
|
||||
2. 同步镜像文件:
|
||||
```bash
|
||||
# 递增版本(自动 lint + test + 工作区检查 + sync/check + commit + tag)
|
||||
make version-bump BUMP=minor
|
||||
|
||||
# 或指定具体版本号
|
||||
make version-bump SET_VERSION=1.0.0
|
||||
|
||||
# 推送到远程
|
||||
git push --follow-tags
|
||||
```
|
||||
|
||||
手动同步和校验:
|
||||
|
||||
```bash
|
||||
make version-sync
|
||||
```
|
||||
|
||||
3. 校验版本一致性:
|
||||
|
||||
```bash
|
||||
make version-check
|
||||
```
|
||||
|
||||
4. 提交版本变更后,创建发布 tag:
|
||||
|
||||
```bash
|
||||
git tag -a vX.Y.Z -m "Release vX.Y.Z"
|
||||
git push origin main
|
||||
git push origin vX.Y.Z
|
||||
```
|
||||
|
||||
### 本地生成发布资产
|
||||
|
||||
```bash
|
||||
@@ -357,7 +429,10 @@ make release-assets-macos
|
||||
### GitHub Draft Release
|
||||
|
||||
- 推送 `vX.Y.Z` tag 后,`.github/workflows/release.yml` 会自动执行发布流水线
|
||||
- 流水线会先校验 tag 与 `VERSION` 一致,再构建以下资产并上传到 GitHub Draft Release:
|
||||
- 流水线会先校验 tag 与 `VERSION` 一致,再执行全流程测试门禁(lint、默认测试、MySQL 测试、E2E 测试),测试不通过则阻止构建
|
||||
- 测试通过后,三个平台 job 并行构建,各 job 会在正式构建前先检查 `go`、`bun` 和各自的平台打包工具链,缺失时快速失败并在日志中输出诊断信息
|
||||
- Windows 发布 job 在 `MSYS2 / MINGW64` shell 中执行,并继承 `setup-go` / `setup-bun` 准备好的工具链路径
|
||||
- 构建以下资产并上传到 GitHub Draft Release:
|
||||
- Linux server
|
||||
- Windows server
|
||||
- darwin-amd64 server
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
migrate-up migrate-down migrate-status migrate-create \
|
||||
mysql-up mysql-down mysql-test mysql-test-quick
|
||||
|
||||
VERSION := $(shell go run ./cmd/versionctl print)
|
||||
VERSION := $(shell go run ../versionctl print)
|
||||
GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || printf 'unknown')
|
||||
BUILD_TIME ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
GO_LDFLAGS := -X nex/backend/pkg/buildinfo.version=$(VERSION) -X nex/backend/pkg/buildinfo.commit=$(GIT_COMMIT) -X nex/backend/pkg/buildinfo.buildTime=$(BUILD_TIME)
|
||||
|
||||
@@ -54,7 +54,7 @@ func NewProxyHandler(..., logger *zap.Logger) *ProxyHandler {
|
||||
使用 `pkg/logger/field.go` 中定义的字段构造函数:
|
||||
|
||||
```go
|
||||
logger.Info("请求开始",
|
||||
logger.Debug("请求开始",
|
||||
pkglogger.Method("POST"),
|
||||
pkglogger.Path("/v1/chat"),
|
||||
pkglogger.RequestID("xxx"),
|
||||
@@ -72,7 +72,7 @@ GORM 日志自动桥接到 zap,SQL 查询映射到 Debug 级别。
|
||||
- **ORM**: GORM
|
||||
- **数据库**: SQLite / MySQL
|
||||
- **日志**: zap + lumberjack
|
||||
- **配置**: Viper + pflag(多层配置:CLI > 环境变量 > 配置文件 > 默认值)
|
||||
- **配置**: Viper + pflag(Server 多层配置,Desktop 配置文件快照)
|
||||
- **验证**: go-playground/validator/v10
|
||||
- **迁移**: goose
|
||||
|
||||
@@ -164,7 +164,11 @@ backend/
|
||||
│ └── validator/ # 验证器
|
||||
│ └── validator.go
|
||||
├── migrations/ # 数据库迁移
|
||||
│ └── 20260421000001_initial_schema.sql
|
||||
│ ├── embed.go # go:embed 迁移资源入口
|
||||
│ ├── sqlite/
|
||||
│ │ └── 20260421000001_initial_schema.sql
|
||||
│ └── mysql/
|
||||
│ └── 20260421000001_initial_schema.sql
|
||||
├── tests/ # 集成测试
|
||||
│ ├── helpers.go # 测试辅助函数
|
||||
│ ├── config/ # 测试配置
|
||||
@@ -330,15 +334,18 @@ go mod download
|
||||
go run cmd/server/main.go
|
||||
```
|
||||
|
||||
服务将在端口 9826 启动。首次启动会自动创建配置文件和运行数据库迁移。
|
||||
服务将在端口 9826 启动。首次启动会自动运行数据库迁移。
|
||||
|
||||
## 配置
|
||||
|
||||
配置支持多种方式:配置文件、环境变量、命令行参数,优先级为:**CLI 参数 > 环境变量 > 配置文件 > 默认值**
|
||||
配置方式取决于启动入口:
|
||||
|
||||
- **Server 入口**(`cmd/server`):支持 CLI 参数 > 环境变量 > 配置文件 > 默认值
|
||||
- **Desktop 入口**(`cmd/desktop`):仅支持 `~/.nex/config.yaml` > 默认值,不支持 CLI 参数和 `NEX_*` 环境变量覆盖,修改配置文件后需重启生效
|
||||
|
||||
### 配置文件
|
||||
|
||||
配置文件位于 `~/.nex/config.yaml`,首次启动自动生成。
|
||||
配置文件位于 `~/.nex/config.yaml`。配置文件不存在时使用默认值,不会自动生成;需要自定义时手动创建该文件:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
@@ -368,9 +375,9 @@ log:
|
||||
compress: true
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
### 环境变量(仅 Server 入口)
|
||||
|
||||
所有配置项都支持环境变量,使用 `NEX_` 前缀:
|
||||
Server 入口下,所有配置项都支持环境变量,使用 `NEX_` 前缀:
|
||||
|
||||
```bash
|
||||
export NEX_SERVER_PORT=9000
|
||||
@@ -388,7 +395,7 @@ export NEX_DATABASE_DBNAME=nex
|
||||
|
||||
命名规则:配置路径转大写 + 下划线 + `NEX_` 前缀(如 `server.port` → `NEX_SERVER_PORT`)。
|
||||
|
||||
### 命令行参数
|
||||
### 命令行参数(仅 Server 入口)
|
||||
|
||||
```bash
|
||||
./server --server-port 9000 --log-level debug --database-path /tmp/test.db
|
||||
@@ -456,6 +463,8 @@ make mysql-test-quick
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
应用启动时使用随二进制打包的迁移资源(`go:embed`)自动执行迁移,server 和 desktop 发布产物均自包含,不依赖源码目录。开发时可继续通过 Makefile goose CLI 操作文件系统中的 `migrations/<dialect>/` 目录,运行时嵌入资源与文件系统目录共享同一批 SQL 文件。
|
||||
|
||||
```bash
|
||||
# 使用 Makefile
|
||||
make migrate-up DB_DSN=~/.nex/config.db
|
||||
@@ -577,6 +586,20 @@ GET /anthropic/v1/models
|
||||
|
||||
查询参数:`provider_id`、`model_name`、`start_date`(YYYY-MM-DD)、`end_date`、`group_by`(provider/model/date)
|
||||
|
||||
#### 版本信息
|
||||
|
||||
- `GET /api/version` - 获取后端构建版本信息
|
||||
|
||||
响应字段来源于构建阶段注入的 `buildinfo` 元数据:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"commit": "abc1234",
|
||||
"build_time": "2026-05-05T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### 健康检查
|
||||
|
||||
- `GET /health` - 返回 `{"status": "ok"}`
|
||||
|
||||
@@ -43,10 +43,23 @@ var (
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := 9826
|
||||
|
||||
minimalLogger := pkgLogger.NewMinimal()
|
||||
|
||||
cfg, cfgMeta, err := config.LoadDesktopConfigWithMetadata()
|
||||
if err != nil {
|
||||
minimalLogger.Error("加载配置失败", zap.Error(err))
|
||||
showError(appName, desktopConfigErrorMessage(getDesktopConfigPath(), err))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
port := cfg.Server.Port
|
||||
|
||||
if err := checkPortAvailable(port); err != nil {
|
||||
minimalLogger.Error("端口不可用", zap.Error(err))
|
||||
showError(appName, err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
singleLock := NewSingletonLock(filepath.Join(os.TempDir(), "nex-gateway.lock"))
|
||||
if err := singleLock.Lock(); err != nil {
|
||||
minimalLogger.Error("已有 Nex 实例运行")
|
||||
@@ -59,17 +72,6 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
if err := checkPortAvailable(port); err != nil {
|
||||
minimalLogger.Error("端口不可用", zap.Error(err))
|
||||
showError(appName, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig()
|
||||
if err != nil {
|
||||
minimalLogger.Fatal("加载配置失败", zap.Error(err))
|
||||
}
|
||||
|
||||
zapLogger, err = pkgLogger.Upgrade(minimalLogger, pkgLogger.Config{
|
||||
Level: cfg.Log.Level,
|
||||
Path: cfg.Log.Path,
|
||||
@@ -130,6 +132,8 @@ func main() {
|
||||
providerHandler := handler.NewProviderHandler(providerService)
|
||||
modelHandler := handler.NewModelHandler(modelService)
|
||||
statsHandler := handler.NewStatsHandler(statsService)
|
||||
versionHandler := handler.NewVersionHandler()
|
||||
settingsHandler := handler.NewSettingsHandler(cfg, "desktop", true, cfgMeta.ConfigPath)
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
@@ -139,11 +143,11 @@ func main() {
|
||||
r.Use(middleware.Logging(zapLogger))
|
||||
r.Use(middleware.CORS())
|
||||
|
||||
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler)
|
||||
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler, settingsHandler)
|
||||
setupStaticFiles(r)
|
||||
|
||||
server = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", port),
|
||||
Addr: desktopListenAddr(port),
|
||||
Handler: r,
|
||||
ReadTimeout: cfg.Server.ReadTimeout,
|
||||
WriteTimeout: cfg.Server.WriteTimeout,
|
||||
@@ -164,7 +168,7 @@ func main() {
|
||||
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if err := openBrowser(fmt.Sprintf("http://localhost:%d", port)); err != nil {
|
||||
if err := openBrowser(desktopURL(port)); err != nil {
|
||||
zapLogger.Warn("无法打开浏览器", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
@@ -172,9 +176,10 @@ func main() {
|
||||
setupSystray(port)
|
||||
}
|
||||
|
||||
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler) {
|
||||
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler, versionHandler *handler.VersionHandler, settingsHandler *handler.SettingsHandler) {
|
||||
r.Any("/openai/*path", withProtocol("openai", proxyHandler.HandleProxy))
|
||||
r.Any("/anthropic/*path", withProtocol("anthropic", proxyHandler.HandleProxy))
|
||||
r.GET("/api/version", versionHandler.GetVersion)
|
||||
|
||||
providers := r.Group("/api/providers")
|
||||
{
|
||||
@@ -200,6 +205,12 @@ func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHand
|
||||
stats.GET("/aggregate", statsHandler.AggregateStats)
|
||||
}
|
||||
|
||||
settings := r.Group("/api/settings")
|
||||
{
|
||||
settings.GET("/startup", settingsHandler.GetStartupSettings)
|
||||
settings.PUT("/startup", settingsHandler.SaveStartupSettings)
|
||||
}
|
||||
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
@@ -257,13 +268,13 @@ func setupStaticFilesWithFS(r *gin.Engine, distFS fs.FS) {
|
||||
c.Data(200, getContentType(filepath), data)
|
||||
})
|
||||
|
||||
r.GET("/favicon.svg", func(c *gin.Context) {
|
||||
data, err := fs.ReadFile(distFS, "favicon.svg")
|
||||
r.GET("/icon.png", func(c *gin.Context) {
|
||||
data, err := fs.ReadFile(distFS, "icon.png")
|
||||
if err != nil {
|
||||
c.Status(404)
|
||||
return
|
||||
}
|
||||
c.Data(200, "image/svg+xml", data)
|
||||
c.Data(200, "image/png", data)
|
||||
})
|
||||
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
@@ -307,7 +318,7 @@ func setupSystray(port int) {
|
||||
systray.AddSeparator()
|
||||
mStatus := systray.AddMenuItem("状态: 运行中", "")
|
||||
mStatus.Disable()
|
||||
mPort := systray.AddMenuItem(fmt.Sprintf("端口: %d", port), "")
|
||||
mPort := systray.AddMenuItem(desktopPortMenuTitle(port), "")
|
||||
mPort.Disable()
|
||||
systray.AddSeparator()
|
||||
mQuit := systray.AddMenuItem("退出", "停止服务并退出")
|
||||
@@ -316,7 +327,7 @@ func setupSystray(port int) {
|
||||
for {
|
||||
select {
|
||||
case <-mOpen.ClickedCh:
|
||||
if err := openBrowser(fmt.Sprintf("http://localhost:%d", port)); err != nil {
|
||||
if err := openBrowser(desktopURL(port)); err != nil {
|
||||
zapLogger.Warn("打开浏览器失败", zap.Error(err))
|
||||
}
|
||||
case <-mQuit.ClickedCh:
|
||||
@@ -347,6 +358,30 @@ func doShutdown() {
|
||||
}
|
||||
}
|
||||
|
||||
func getDesktopConfigPath() string {
|
||||
configDir, err := config.GetConfigDir()
|
||||
if err != nil {
|
||||
return "~/.nex/config.yaml"
|
||||
}
|
||||
return filepath.Join(configDir, "config.yaml")
|
||||
}
|
||||
|
||||
func desktopConfigErrorMessage(configPath string, err error) string {
|
||||
return fmt.Sprintf("加载配置失败\n\n配置文件: %s\n\n%v", configPath, err)
|
||||
}
|
||||
|
||||
func desktopListenAddr(port int) string {
|
||||
return fmt.Sprintf(":%d", port)
|
||||
}
|
||||
|
||||
func desktopURL(port int) string {
|
||||
return fmt.Sprintf("http://localhost:%d", port)
|
||||
}
|
||||
|
||||
func desktopPortMenuTitle(port int) string {
|
||||
return fmt.Sprintf("端口: %d", port)
|
||||
}
|
||||
|
||||
func checkPortAvailable(port int) error {
|
||||
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||
if err != nil {
|
||||
|
||||
43
backend/cmd/desktop/migration_test.go
Normal file
43
backend/cmd/desktop/migration_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"nex/backend/internal/config"
|
||||
"nex/backend/internal/database"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestDesktop_InitMigrationsWithoutSourceTree(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
origDir, err := os.Getwd()
|
||||
if err == nil {
|
||||
defer func() {
|
||||
if chdirErr := os.Chdir(origDir); chdirErr != nil {
|
||||
t.Logf("无法恢复工作目录: %v", chdirErr)
|
||||
}
|
||||
}()
|
||||
}
|
||||
if chdirErr := os.Chdir(tmpDir); chdirErr != nil {
|
||||
t.Skipf("无法切换到临时目录: %v", chdirErr)
|
||||
}
|
||||
|
||||
cfg := &config.DatabaseConfig{
|
||||
Driver: "sqlite",
|
||||
Path: filepath.Join(tmpDir, "nex-test.db"),
|
||||
MaxIdleConns: 5,
|
||||
MaxOpenConns: 10,
|
||||
ConnMaxLifetime: 0,
|
||||
}
|
||||
|
||||
zapLogger := zap.NewNop()
|
||||
db, err := database.Init(cfg, zapLogger)
|
||||
if err != nil {
|
||||
t.Fatalf("在无源码目录环境下数据库初始化应成功,但返回错误: %v", err)
|
||||
}
|
||||
database.Close(db)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -67,3 +68,62 @@ func TestCheckPortAvailableAfterClose(t *testing.T) {
|
||||
|
||||
t.Log("端口关闭后可用测试通过")
|
||||
}
|
||||
|
||||
func TestCheckPortAvailableErrorContainsPort(t *testing.T) {
|
||||
port := 19829
|
||||
|
||||
listener, err := net.Listen("tcp", ":19829") //nolint:gosec
|
||||
if err != nil {
|
||||
t.Fatalf("无法启动测试服务器: %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
err = checkPortAvailable(port)
|
||||
if err == nil {
|
||||
t.Fatal("端口被占用时应该返回错误")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "19829") {
|
||||
t.Fatalf("错误信息应包含端口号 19829,实际: %v", err)
|
||||
}
|
||||
|
||||
t.Log("端口错误信息包含端口号测试通过")
|
||||
}
|
||||
|
||||
func TestGetDesktopConfigPath(t *testing.T) {
|
||||
path := getDesktopConfigPath()
|
||||
if path == "" {
|
||||
t.Fatal("getDesktopConfigPath 应返回非空路径")
|
||||
}
|
||||
if !strings.Contains(path, "config.yaml") {
|
||||
t.Fatalf("路径应包含 config.yaml,实际: %s", path)
|
||||
}
|
||||
t.Log("getDesktopConfigPath 测试通过")
|
||||
}
|
||||
|
||||
func TestDesktopConfiguredPortHelpers(t *testing.T) {
|
||||
port := 19830
|
||||
|
||||
if got := desktopListenAddr(port); got != ":19830" {
|
||||
t.Fatalf("HTTP 监听地址应使用配置端口,实际: %s", got)
|
||||
}
|
||||
if got := desktopURL(port); got != "http://localhost:19830" {
|
||||
t.Fatalf("浏览器 URL 应使用配置端口,实际: %s", got)
|
||||
}
|
||||
if got := desktopPortMenuTitle(port); got != "端口: 19830" {
|
||||
t.Fatalf("托盘端口显示应使用配置端口,实际: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopConfigErrorMessageContainsPathAndReason(t *testing.T) {
|
||||
msg := desktopConfigErrorMessage("/tmp/nex/config.yaml", errors.New("yaml parse failed"))
|
||||
|
||||
if !strings.Contains(msg, "/tmp/nex/config.yaml") {
|
||||
t.Fatalf("配置错误提示应包含配置路径,实际: %s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "yaml parse failed") {
|
||||
t.Fatalf("配置错误提示应包含失败原因,实际: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
44
backend/cmd/desktop/routes_test.go
Normal file
44
backend/cmd/desktop/routes_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"nex/backend/internal/handler"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestSetupRoutes_VersionDoesNotFallback(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
r := gin.New()
|
||||
setupRoutes(r, &handler.ProxyHandler{}, &handler.ProviderHandler{}, &handler.ModelHandler{}, &handler.StatsHandler{}, handler.NewVersionHandler(), handler.NewSettingsHandler(nil, "desktop", true, ""))
|
||||
setupStaticFilesWithFS(r, fstest.MapFS{
|
||||
"index.html": {Data: []byte("<html>fallback</html>")},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
if contentType := w.Header().Get("Content-Type"); contentType == "text/html; charset=utf-8" {
|
||||
t.Fatalf("版本接口不应返回 SPA fallback HTML")
|
||||
}
|
||||
|
||||
var result map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
for _, key := range []string{"version", "commit", "build_time"} {
|
||||
if result[key] == "" {
|
||||
t.Fatalf("响应缺少 %s 字段: %#v", key, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -100,6 +101,30 @@ func TestSetupStaticFiles(t *testing.T) {
|
||||
t.Log("静态文件服务测试通过")
|
||||
}
|
||||
|
||||
func TestSetupStaticFilesWithFS_IconPNG(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
r := gin.New()
|
||||
setupStaticFilesWithFS(r, fstest.MapFS{
|
||||
"icon.png": {Data: []byte("png")},
|
||||
"index.html": {Data: []byte("<html>fallback</html>")},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/icon.png", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望状态码 200, 实际 %d", w.Code)
|
||||
}
|
||||
if w.Header().Get("Content-Type") != "image/png" {
|
||||
t.Fatalf("期望 Content-Type image/png, 实际 %s", w.Header().Get("Content-Type"))
|
||||
}
|
||||
if w.Body.String() != "png" {
|
||||
t.Fatalf("期望返回 PNG 内容,实际 %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithProtocolAndStaticRoutes(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import (
|
||||
func main() {
|
||||
minimalLogger := pkgLogger.NewMinimal()
|
||||
|
||||
cfg, err := config.LoadConfig()
|
||||
cfg, cfgMeta, err := config.LoadServerConfigWithMetadata()
|
||||
if err != nil {
|
||||
minimalLogger.Fatal("加载配置失败", zap.Error(err))
|
||||
}
|
||||
@@ -93,6 +93,8 @@ func main() {
|
||||
providerHandler := handler.NewProviderHandler(providerService)
|
||||
modelHandler := handler.NewModelHandler(modelService)
|
||||
statsHandler := handler.NewStatsHandler(statsService)
|
||||
versionHandler := handler.NewVersionHandler()
|
||||
settingsHandler := handler.NewSettingsHandler(cfg, "server", false, cfgMeta.ConfigPath)
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
@@ -102,7 +104,7 @@ func main() {
|
||||
r.Use(middleware.Logging(zapLogger))
|
||||
r.Use(middleware.CORS())
|
||||
|
||||
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler)
|
||||
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler, settingsHandler)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
|
||||
@@ -140,8 +142,9 @@ func main() {
|
||||
zapLogger.Info("服务器已关闭")
|
||||
}
|
||||
|
||||
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler) {
|
||||
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler, versionHandler *handler.VersionHandler, settingsHandler *handler.SettingsHandler) {
|
||||
r.Any("/:protocol/*path", proxyHandler.HandleProxy)
|
||||
r.GET("/api/version", versionHandler.GetVersion)
|
||||
|
||||
providers := r.Group("/api/providers")
|
||||
{
|
||||
@@ -167,6 +170,12 @@ func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHand
|
||||
stats.GET("/aggregate", statsHandler.AggregateStats)
|
||||
}
|
||||
|
||||
settings := r.Group("/api/settings")
|
||||
{
|
||||
settings.GET("/startup", settingsHandler.GetStartupSettings)
|
||||
settings.PUT("/startup", settingsHandler.SaveStartupSettings)
|
||||
}
|
||||
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
37
backend/cmd/server/routes_test.go
Normal file
37
backend/cmd/server/routes_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"nex/backend/internal/handler"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestSetupRoutes_Version(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
r := gin.New()
|
||||
setupRoutes(r, &handler.ProxyHandler{}, &handler.ProviderHandler{}, &handler.ModelHandler{}, &handler.StatsHandler{}, handler.NewVersionHandler(), handler.NewSettingsHandler(nil, "server", false, ""))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var result map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
for _, key := range []string{"version", "commit", "build_time"} {
|
||||
if result[key] == "" {
|
||||
t.Fatalf("响应缺少 %s 字段: %#v", key, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -37,7 +36,7 @@ type DatabaseConfig struct {
|
||||
Driver string `yaml:"driver" mapstructure:"driver" validate:"required,oneof=sqlite mysql"`
|
||||
Path string `yaml:"path" mapstructure:"path" validate:"required_if=Driver sqlite"`
|
||||
Host string `yaml:"host" mapstructure:"host" validate:"required_if=Driver mysql"`
|
||||
Port int `yaml:"port" mapstructure:"port" validate:"required_if=Driver mysql,min=1,max=65535"`
|
||||
Port int `yaml:"port" mapstructure:"port" validate:"required_if=Driver mysql,omitempty,min=1,max=65535"`
|
||||
User string `yaml:"user" mapstructure:"user" validate:"required_if=Driver mysql"`
|
||||
Password string `yaml:"password" mapstructure:"password"`
|
||||
DBName string `yaml:"dbname" mapstructure:"dbname" validate:"required_if=Driver mysql"`
|
||||
@@ -225,82 +224,156 @@ func setupConfigFile(v *viper.Viper, configPath string) error {
|
||||
v.SetConfigFile(configPath)
|
||||
v.SetConfigType("yaml")
|
||||
|
||||
// 尝试读取配置文件,如果不存在则忽略
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return appErrors.Wrap(appErrors.ErrInternal, err)
|
||||
}
|
||||
// 配置文件不存在,创建默认配置文件
|
||||
writeErr := v.SafeWriteConfigAs(configPath)
|
||||
if writeErr == nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var alreadyExistsErr viper.ConfigFileAlreadyExistsError
|
||||
if errors.As(writeErr, &alreadyExistsErr) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return appErrors.Wrap(appErrors.ErrInternal, writeErr)
|
||||
return appErrors.Wrap(appErrors.ErrInternal, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadConfig loads config from YAML file, creates default if not exists
|
||||
func LoadConfig() (*Config, error) {
|
||||
configPath, err := GetConfigPath()
|
||||
if err != nil {
|
||||
return nil, appErrors.Wrap(appErrors.ErrInternal, err)
|
||||
}
|
||||
return LoadConfigFromPath(configPath)
|
||||
type ConfigMetadata struct {
|
||||
ConfigPath string
|
||||
}
|
||||
|
||||
// LoadConfigFromPath 从指定路径加载配置
|
||||
func LoadConfigFromPath(configPath string) (*Config, error) {
|
||||
// 1. 创建 Viper 实例
|
||||
type loadOptions struct {
|
||||
configPathOverride string
|
||||
useCLI bool
|
||||
useEnv bool
|
||||
useConfigFlag bool
|
||||
}
|
||||
|
||||
// resolveConfigPath 根据 loadOptions 解析 CLI 参数并返回最终配置文件路径
|
||||
func resolveConfigPath(v *viper.Viper, opts loadOptions) (string, error) {
|
||||
configPath := opts.configPathOverride
|
||||
|
||||
if !opts.useCLI && !opts.useConfigFlag {
|
||||
return configPath, nil
|
||||
}
|
||||
|
||||
flagSet := pflag.NewFlagSet("config", pflag.ContinueOnError)
|
||||
if opts.useConfigFlag {
|
||||
flagSet.String("config", opts.configPathOverride, "配置文件路径")
|
||||
}
|
||||
if opts.useCLI {
|
||||
setupFlags(v, flagSet)
|
||||
}
|
||||
|
||||
if err := flagSet.Parse(os.Args[1:]); err != nil {
|
||||
return "", appErrors.Wrap(appErrors.ErrInvalidRequest, err)
|
||||
}
|
||||
|
||||
if opts.useConfigFlag {
|
||||
if f, err := flagSet.GetString("config"); err == nil && f != "" {
|
||||
configPath = f
|
||||
}
|
||||
}
|
||||
|
||||
return configPath, nil
|
||||
}
|
||||
|
||||
func loadConfig(opts loadOptions) (*Config, error) {
|
||||
cfg, _, err := loadConfigWithMetadata(opts)
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
func loadConfigWithMetadata(opts loadOptions) (*Config, ConfigMetadata, error) {
|
||||
v := viper.New()
|
||||
|
||||
// 2. 定义 CLI 参数
|
||||
flagSet := pflag.NewFlagSet("config", pflag.ContinueOnError)
|
||||
flagSet.String("config", configPath, "配置文件路径")
|
||||
setupFlags(v, flagSet)
|
||||
|
||||
// 3. 解析 CLI 参数(忽略错误,因为可能没有参数)
|
||||
if err := flagSet.Parse(os.Args[1:]); err != nil {
|
||||
return nil, appErrors.Wrap(appErrors.ErrInvalidRequest, err)
|
||||
}
|
||||
|
||||
// 4. 获取配置文件路径(可能被 --config 参数覆盖)
|
||||
if configPathFlag, err := flagSet.GetString("config"); err == nil && configPathFlag != "" {
|
||||
configPath = configPathFlag
|
||||
}
|
||||
|
||||
// 5. 设置默认值
|
||||
setupDefaults(v)
|
||||
|
||||
// 6. 绑定环境变量
|
||||
setupEnv(v)
|
||||
|
||||
// 7. 读取配置文件
|
||||
if err := setupConfigFile(v, configPath); err != nil {
|
||||
return nil, err
|
||||
configPath, err := resolveConfigPath(v, opts)
|
||||
if err != nil {
|
||||
return nil, ConfigMetadata{}, err
|
||||
}
|
||||
|
||||
if opts.useEnv {
|
||||
setupEnv(v)
|
||||
}
|
||||
|
||||
if err := setupConfigFile(v, configPath); err != nil {
|
||||
return nil, ConfigMetadata{}, err
|
||||
}
|
||||
|
||||
// 8. 反序列化到结构体
|
||||
cfg := &Config{}
|
||||
if err := v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
|
||||
mapstructure.StringToTimeDurationHookFunc(),
|
||||
mapstructure.StringToSliceHookFunc(","),
|
||||
))); err != nil {
|
||||
return nil, appErrors.Wrap(appErrors.ErrInternal, err)
|
||||
return nil, ConfigMetadata{}, appErrors.Wrap(appErrors.ErrInternal, err)
|
||||
}
|
||||
|
||||
// 9. 验证配置
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
return nil, ConfigMetadata{}, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
return cfg, ConfigMetadata{ConfigPath: configPath}, nil
|
||||
}
|
||||
|
||||
// LoadServerConfig 为 server 入口加载配置,支持 CLI 参数、环境变量和 --config
|
||||
func LoadServerConfig() (*Config, error) {
|
||||
cfg, _, err := LoadServerConfigWithMetadata()
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
func LoadServerConfigWithMetadata() (*Config, ConfigMetadata, error) {
|
||||
configPath, err := GetConfigPath()
|
||||
if err != nil {
|
||||
return nil, ConfigMetadata{}, appErrors.Wrap(appErrors.ErrInternal, err)
|
||||
}
|
||||
return loadConfigWithMetadata(loadOptions{
|
||||
configPathOverride: configPath,
|
||||
useCLI: true,
|
||||
useEnv: true,
|
||||
useConfigFlag: true,
|
||||
})
|
||||
}
|
||||
|
||||
// LoadDesktopConfig 为 desktop 入口加载配置,固定使用默认配置文件,不支持 CLI、环境变量和 --config
|
||||
func LoadDesktopConfig() (*Config, error) {
|
||||
cfg, _, err := LoadDesktopConfigWithMetadata()
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
func LoadDesktopConfigWithMetadata() (*Config, ConfigMetadata, error) {
|
||||
configPath, err := GetConfigPath()
|
||||
if err != nil {
|
||||
return nil, ConfigMetadata{}, appErrors.Wrap(appErrors.ErrInternal, err)
|
||||
}
|
||||
return loadConfigWithMetadata(loadOptions{
|
||||
configPathOverride: configPath,
|
||||
useCLI: false,
|
||||
useEnv: false,
|
||||
useConfigFlag: false,
|
||||
})
|
||||
}
|
||||
|
||||
// LoadConfig loads config from YAML file.
|
||||
// 向后兼容,等同于 LoadServerConfig。
|
||||
func LoadConfig() (*Config, error) {
|
||||
return LoadServerConfig()
|
||||
}
|
||||
|
||||
// LoadConfigFromPath 从指定路径加载配置。
|
||||
// 保留向后兼容,沿用 server 语义(支持 CLI、env 和 --config 覆盖)。
|
||||
func LoadConfigFromPath(configPath string) (*Config, error) {
|
||||
return loadConfig(loadOptions{
|
||||
configPathOverride: configPath,
|
||||
useCLI: true,
|
||||
useEnv: true,
|
||||
useConfigFlag: true,
|
||||
})
|
||||
}
|
||||
|
||||
// LoadDesktopConfigAtPath 从指定路径以 desktop 语义加载配置(仅配置文件和默认值),用于测试场景。
|
||||
func LoadDesktopConfigAtPath(configPath string) (*Config, error) {
|
||||
return loadConfig(loadOptions{
|
||||
configPathOverride: configPath,
|
||||
useCLI: false,
|
||||
useEnv: false,
|
||||
useConfigFlag: false,
|
||||
})
|
||||
}
|
||||
|
||||
// SaveConfig saves config to YAML file
|
||||
@@ -309,13 +382,15 @@ func SaveConfig(cfg *Config) error {
|
||||
if err != nil {
|
||||
return appErrors.Wrap(appErrors.ErrInternal, err)
|
||||
}
|
||||
return SaveConfigToPath(cfg, configPath)
|
||||
}
|
||||
|
||||
func SaveConfigToPath(cfg *Config, configPath string) error {
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return appErrors.Wrap(appErrors.ErrInternal, err)
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(configPath)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return appErrors.Wrap(appErrors.ErrInternal, err)
|
||||
|
||||
94
backend/internal/config/config_metadata_test.go
Normal file
94
backend/internal/config/config_metadata_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestLoadDesktopConfigAtPath_WithMetadata(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.yaml")
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Server.Port = 8888
|
||||
data, err := yaml.Marshal(cfg)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(configPath, data, 0o600))
|
||||
|
||||
loaded, meta, err := loadConfigWithMetadata(loadOptions{
|
||||
configPathOverride: configPath,
|
||||
useCLI: false,
|
||||
useEnv: false,
|
||||
useConfigFlag: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 8888, loaded.Server.Port)
|
||||
assert.Equal(t, configPath, meta.ConfigPath)
|
||||
}
|
||||
|
||||
func TestSaveConfigToPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "sub", "config.yaml")
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Server.Port = 7777
|
||||
|
||||
err := SaveConfigToPath(cfg, configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(data), "7777")
|
||||
}
|
||||
|
||||
func TestSaveConfigToPath_InvalidDir(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
err := SaveConfigToPath(cfg, "/dev/null/impossible/config.yaml")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDurationConversion(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
dto := configToDTO(cfg)
|
||||
|
||||
parsed, err := time.ParseDuration(dto.Server.ReadTimeout)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, cfg.Server.ReadTimeout, parsed)
|
||||
|
||||
parsed, err = time.ParseDuration(dto.Database.ConnMaxLifetime)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, cfg.Database.ConnMaxLifetime, parsed)
|
||||
}
|
||||
|
||||
func configToDTO(c *Config) struct {
|
||||
Server struct {
|
||||
Port int `json:"port"`
|
||||
ReadTimeout string `json:"read_timeout"`
|
||||
WriteTimeout string `json:"write_timeout"`
|
||||
}
|
||||
Database struct {
|
||||
ConnMaxLifetime string `json:"conn_max_lifetime"`
|
||||
}
|
||||
} {
|
||||
var result struct {
|
||||
Server struct {
|
||||
Port int `json:"port"`
|
||||
ReadTimeout string `json:"read_timeout"`
|
||||
WriteTimeout string `json:"write_timeout"`
|
||||
}
|
||||
Database struct {
|
||||
ConnMaxLifetime string `json:"conn_max_lifetime"`
|
||||
}
|
||||
}
|
||||
result.Server.Port = c.Server.Port
|
||||
result.Server.ReadTimeout = c.Server.ReadTimeout.String()
|
||||
result.Server.WriteTimeout = c.Server.WriteTimeout.String()
|
||||
result.Database.ConnMaxLifetime = c.Database.ConnMaxLifetime.String()
|
||||
return result
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
"go.uber.org/zap"
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"nex/backend/internal/config"
|
||||
"nex/backend/migrations"
|
||||
pkglogger "nex/backend/pkg/logger"
|
||||
)
|
||||
|
||||
@@ -77,29 +78,24 @@ func runMigrations(db *gorm.DB, driver string, zapLogger *zap.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
gooseDialect := "sqlite3"
|
||||
migrationsSubDir := "sqlite"
|
||||
if driver == "mysql" {
|
||||
gooseDialect = "mysql"
|
||||
migrationsSubDir = "mysql"
|
||||
}
|
||||
|
||||
migrationsDir := getMigrationsDir(driver)
|
||||
if _, err := os.Stat(migrationsDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("迁移目录不存在: %s", migrationsDir)
|
||||
dialect, fsys, err := migrations.ForDriver(driver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if zapLogger != nil {
|
||||
zapLogger.Info("执行数据库迁移",
|
||||
zap.String("dialect", gooseDialect),
|
||||
zap.String("dir", migrationsSubDir))
|
||||
zap.String("dialect", string(dialect)),
|
||||
zap.String("driver", driver))
|
||||
}
|
||||
|
||||
if err := goose.SetDialect(gooseDialect); err != nil {
|
||||
return err
|
||||
provider, err := goose.NewProvider(dialect, sqlDB, fsys)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建迁移提供者失败: %w", err)
|
||||
}
|
||||
if err := goose.Up(sqlDB, migrationsDir); err != nil {
|
||||
return err
|
||||
|
||||
if _, err := provider.Up(context.Background()); err != nil {
|
||||
return fmt.Errorf("执行迁移失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -130,21 +126,6 @@ func configurePool(db *gorm.DB, cfg *config.DatabaseConfig, zapLogger *zap.Logge
|
||||
}
|
||||
}
|
||||
|
||||
func getMigrationsDir(driver string) string {
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if ok {
|
||||
subDir := "sqlite"
|
||||
if driver == "mysql" {
|
||||
subDir = "mysql"
|
||||
}
|
||||
dir := filepath.Join(filepath.Dir(filename), "..", "..", "migrations", subDir)
|
||||
if abs, err := filepath.Abs(dir); err == nil {
|
||||
return abs
|
||||
}
|
||||
}
|
||||
return "./migrations"
|
||||
}
|
||||
|
||||
func BuildDSN(cfg *config.DatabaseConfig) string {
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local",
|
||||
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"nex/backend/internal/config"
|
||||
"nex/backend/migrations"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -76,3 +79,87 @@ func TestBuildDSN_EmptyPassword(t *testing.T) {
|
||||
dsn := BuildDSN(cfg)
|
||||
assert.Equal(t, "root:@tcp(localhost:3306)/nex?charset=utf8mb4&parseTime=true&loc=Local", dsn)
|
||||
}
|
||||
|
||||
func TestInit_SQLite_AnyCWD(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
origDir, err := os.Getwd()
|
||||
if err == nil {
|
||||
defer func() {
|
||||
if chdirErr := os.Chdir(origDir); chdirErr != nil {
|
||||
t.Logf("无法恢复工作目录: %v", chdirErr)
|
||||
}
|
||||
}()
|
||||
}
|
||||
if chdirErr := os.Chdir(dir); chdirErr != nil {
|
||||
t.Skipf("无法切换到临时目录: %v", chdirErr)
|
||||
}
|
||||
|
||||
cfg := &config.DatabaseConfig{
|
||||
Driver: "sqlite",
|
||||
Path: filepath.Join(dir, "test.db"),
|
||||
MaxIdleConns: 5,
|
||||
MaxOpenConns: 10,
|
||||
ConnMaxLifetime: 0,
|
||||
}
|
||||
|
||||
zapLogger := zap.NewNop()
|
||||
db, err := Init(cfg, zapLogger)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, db)
|
||||
defer Close(db)
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, sqlDB)
|
||||
}
|
||||
|
||||
func TestForDriverDialect_SQLite(t *testing.T) {
|
||||
require.NoError(t, testMigrateWithDriver(t, "sqlite"))
|
||||
}
|
||||
|
||||
func TestForDriverDialect_MySQL(t *testing.T) {
|
||||
dialect, fsys, err := migrations.ForDriver("mysql")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "mysql", string(dialect))
|
||||
entries, fsErr := fs.ReadDir(fsys, ".")
|
||||
require.NoError(t, fsErr)
|
||||
assert.NotEmpty(t, entries, "MySQL 迁移资源应至少包含一个文件")
|
||||
}
|
||||
|
||||
func TestForDriverDialect_Invalid(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := &config.DatabaseConfig{
|
||||
Driver: "postgres",
|
||||
Path: filepath.Join(dir, "test.db"),
|
||||
MaxIdleConns: 5,
|
||||
MaxOpenConns: 10,
|
||||
ConnMaxLifetime: 0,
|
||||
}
|
||||
|
||||
zapLogger := zap.NewNop()
|
||||
_, err := Init(cfg, zapLogger)
|
||||
assert.Error(t, err, "非法 driver 应返回错误")
|
||||
assert.Contains(t, err.Error(), "不支持的数据库驱动")
|
||||
}
|
||||
|
||||
func testMigrateWithDriver(t *testing.T, driver string) error {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
cfg := &config.DatabaseConfig{
|
||||
Driver: driver,
|
||||
Path: filepath.Join(dir, "test.db"),
|
||||
MaxIdleConns: 5,
|
||||
MaxOpenConns: 10,
|
||||
ConnMaxLifetime: 0,
|
||||
}
|
||||
|
||||
zapLogger := zap.NewNop()
|
||||
db, err := Init(cfg, zapLogger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
Close(db)
|
||||
return nil
|
||||
}
|
||||
|
||||
71
backend/internal/database/embedded_migration_test.go
Normal file
71
backend/internal/database/embedded_migration_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"testing"
|
||||
|
||||
"nex/backend/migrations"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEmbeddedMigrations_SQLiteResourcesPresent(t *testing.T) {
|
||||
entries, err := fs.ReadDir(migrations.FS, "sqlite")
|
||||
require.NoError(t, err)
|
||||
|
||||
var sqlFiles []string
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
sqlFiles = append(sqlFiles, entry.Name())
|
||||
}
|
||||
}
|
||||
assert.NotEmpty(t, sqlFiles, "SQLite 迁移资源应至少包含一个 .sql 文件")
|
||||
}
|
||||
|
||||
func TestEmbeddedMigrations_MySQLResourcesPresent(t *testing.T) {
|
||||
entries, err := fs.ReadDir(migrations.FS, "mysql")
|
||||
require.NoError(t, err)
|
||||
|
||||
var sqlFiles []string
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
sqlFiles = append(sqlFiles, entry.Name())
|
||||
}
|
||||
}
|
||||
assert.NotEmpty(t, sqlFiles, "MySQL 迁移资源应至少包含一个 .sql 文件")
|
||||
}
|
||||
|
||||
func TestEmbeddedMigrations_SQLiteSQLParsable(t *testing.T) {
|
||||
subFS, err := fs.Sub(migrations.FS, "sqlite")
|
||||
require.NoError(t, err)
|
||||
|
||||
entries, err := fs.ReadDir(subFS, ".")
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
data, err := fs.ReadFile(subFS, entry.Name())
|
||||
require.NoError(t, err, "无法读取迁移文件: %s", entry.Name())
|
||||
assert.NotEmpty(t, data, "迁移文件内容不应为空: %s", entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedMigrations_MySQLSQLParsable(t *testing.T) {
|
||||
subFS, err := fs.Sub(migrations.FS, "mysql")
|
||||
require.NoError(t, err)
|
||||
|
||||
entries, err := fs.ReadDir(subFS, ".")
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
data, err := fs.ReadFile(subFS, entry.Name())
|
||||
require.NoError(t, err, "无法读取迁移文件: %s", entry.Name())
|
||||
assert.NotEmpty(t, data, "迁移文件内容不应为空: %s", entry.Name())
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ func Logging(logger *zap.Logger) gin.HandlerFunc {
|
||||
if id, ok := requestID.(string); ok {
|
||||
requestIDStr = id
|
||||
}
|
||||
logger.Info("请求开始",
|
||||
logger.Debug("请求开始",
|
||||
pkglogger.Method(c.Request.Method),
|
||||
pkglogger.Path(path),
|
||||
pkglogger.Query(query),
|
||||
@@ -33,7 +33,7 @@ func Logging(logger *zap.Logger) gin.HandlerFunc {
|
||||
latency := time.Since(start)
|
||||
statusCode := c.Writer.Status()
|
||||
|
||||
logger.Info("请求结束",
|
||||
logger.Debug("请求结束",
|
||||
pkglogger.StatusCode(statusCode),
|
||||
pkglogger.Method(c.Request.Method),
|
||||
pkglogger.Path(path),
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -65,6 +67,61 @@ func TestLogging(t *testing.T) {
|
||||
assert.Equal(t, 200, w.Code)
|
||||
}
|
||||
|
||||
func TestLogging_DoesNotLogLifecycleAtInfoLevel(t *testing.T) {
|
||||
core, logs := observer.New(zapcore.InfoLevel)
|
||||
logger := zap.New(core)
|
||||
|
||||
w := serveLoggingRequest(logger)
|
||||
|
||||
assert.Equal(t, 200, w.Code)
|
||||
assert.Empty(t, logs.FilterMessage("请求开始").All())
|
||||
assert.Empty(t, logs.FilterMessage("请求结束").All())
|
||||
}
|
||||
|
||||
func TestLogging_LogsLifecycleAtDebugLevel(t *testing.T) {
|
||||
core, logs := observer.New(zapcore.DebugLevel)
|
||||
logger := zap.New(core)
|
||||
|
||||
w := serveLoggingRequest(logger)
|
||||
|
||||
assert.Equal(t, 200, w.Code)
|
||||
startLogs := logs.FilterMessage("请求开始").All()
|
||||
endLogs := logs.FilterMessage("请求结束").All()
|
||||
if assert.Len(t, startLogs, 1) {
|
||||
fields := startLogs[0].ContextMap()
|
||||
assert.Equal(t, "GET", fields["method"])
|
||||
assert.Equal(t, "/test", fields["path"])
|
||||
assert.Equal(t, "key=value", fields["query"])
|
||||
assert.Equal(t, "existing-id-123", fields["request_id"])
|
||||
assert.NotEmpty(t, fields["client_ip"])
|
||||
}
|
||||
if assert.Len(t, endLogs, 1) {
|
||||
fields := endLogs[0].ContextMap()
|
||||
assert.Equal(t, int64(200), fields["status"])
|
||||
assert.Equal(t, "GET", fields["method"])
|
||||
assert.Equal(t, "/test", fields["path"])
|
||||
assert.Equal(t, int64(2), fields["body_size"])
|
||||
assert.Equal(t, "existing-id-123", fields["request_id"])
|
||||
assert.Contains(t, fields, "latency")
|
||||
}
|
||||
}
|
||||
|
||||
func serveLoggingRequest(logger *zap.Logger) *httptest.ResponseRecorder {
|
||||
r := gin.New()
|
||||
r.Use(RequestID())
|
||||
r.Use(Logging(logger))
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.String(200, "ok")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test?key=value", nil)
|
||||
req.Header.Set("X-Request-ID", "existing-id-123")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
func TestRecovery_NoPanic(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
|
||||
|
||||
223
backend/internal/handler/settings_handler.go
Normal file
223
backend/internal/handler/settings_handler.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nex/backend/internal/config"
|
||||
appErrors "nex/backend/pkg/errors"
|
||||
)
|
||||
|
||||
type SettingsHandler struct {
|
||||
runtimeCfg *config.Config
|
||||
mode string
|
||||
editable bool
|
||||
configPath string
|
||||
}
|
||||
|
||||
func NewSettingsHandler(runtimeCfg *config.Config, mode string, editable bool, configPath string) *SettingsHandler {
|
||||
return &SettingsHandler{
|
||||
runtimeCfg: runtimeCfg,
|
||||
mode: mode,
|
||||
editable: editable,
|
||||
configPath: configPath,
|
||||
}
|
||||
}
|
||||
|
||||
type serverConfigDTO struct {
|
||||
Port int `json:"port"`
|
||||
ReadTimeout string `json:"read_timeout"`
|
||||
WriteTimeout string `json:"write_timeout"`
|
||||
}
|
||||
|
||||
type databaseConfigDTO struct {
|
||||
Driver string `json:"driver"`
|
||||
Path string `json:"path"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
DBName string `json:"dbname"`
|
||||
MaxIdleConns int `json:"max_idle_conns"`
|
||||
MaxOpenConns int `json:"max_open_conns"`
|
||||
ConnMaxLifetime string `json:"conn_max_lifetime"`
|
||||
}
|
||||
|
||||
type logConfigDTO struct {
|
||||
Level string `json:"level"`
|
||||
Path string `json:"path"`
|
||||
MaxSize int `json:"max_size"`
|
||||
MaxBackups int `json:"max_backups"`
|
||||
MaxAge int `json:"max_age"`
|
||||
Compress bool `json:"compress"`
|
||||
}
|
||||
|
||||
type startupSettingsDTO struct {
|
||||
Server serverConfigDTO `json:"server"`
|
||||
Database databaseConfigDTO `json:"database"`
|
||||
Log logConfigDTO `json:"log"`
|
||||
}
|
||||
|
||||
type startupSettingsResponse struct {
|
||||
Mode string `json:"mode"`
|
||||
Editable bool `json:"editable"`
|
||||
ConfigPath string `json:"config_path"`
|
||||
RestartRequired bool `json:"restart_required"`
|
||||
Config startupSettingsDTO `json:"config"`
|
||||
}
|
||||
|
||||
func configToDTO(cfg *config.Config) startupSettingsDTO {
|
||||
return startupSettingsDTO{
|
||||
Server: serverConfigDTO{
|
||||
Port: cfg.Server.Port,
|
||||
ReadTimeout: cfg.Server.ReadTimeout.String(),
|
||||
WriteTimeout: cfg.Server.WriteTimeout.String(),
|
||||
},
|
||||
Database: databaseConfigDTO{
|
||||
Driver: cfg.Database.Driver,
|
||||
Path: cfg.Database.Path,
|
||||
Host: cfg.Database.Host,
|
||||
Port: cfg.Database.Port,
|
||||
User: cfg.Database.User,
|
||||
Password: cfg.Database.Password,
|
||||
DBName: cfg.Database.DBName,
|
||||
MaxIdleConns: cfg.Database.MaxIdleConns,
|
||||
MaxOpenConns: cfg.Database.MaxOpenConns,
|
||||
ConnMaxLifetime: cfg.Database.ConnMaxLifetime.String(),
|
||||
},
|
||||
Log: logConfigDTO{
|
||||
Level: cfg.Log.Level,
|
||||
Path: cfg.Log.Path,
|
||||
MaxSize: cfg.Log.MaxSize,
|
||||
MaxBackups: cfg.Log.MaxBackups,
|
||||
MaxAge: cfg.Log.MaxAge,
|
||||
Compress: cfg.Log.Compress,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func dtoToConfig(dto startupSettingsDTO) (*config.Config, error) {
|
||||
readTimeout, err := time.ParseDuration(dto.Server.ReadTimeout)
|
||||
if err != nil {
|
||||
return nil, appErrors.WithMessage(appErrors.ErrInvalidRequest, "read_timeout 格式错误,例如 30s")
|
||||
}
|
||||
writeTimeout, err := time.ParseDuration(dto.Server.WriteTimeout)
|
||||
if err != nil {
|
||||
return nil, appErrors.WithMessage(appErrors.ErrInvalidRequest, "write_timeout 格式错误,例如 30s")
|
||||
}
|
||||
connMaxLifetime, err := time.ParseDuration(dto.Database.ConnMaxLifetime)
|
||||
if err != nil {
|
||||
return nil, appErrors.WithMessage(appErrors.ErrInvalidRequest, "conn_max_lifetime 格式错误,例如 1h")
|
||||
}
|
||||
|
||||
return &config.Config{
|
||||
Server: config.ServerConfig{
|
||||
Port: dto.Server.Port,
|
||||
ReadTimeout: readTimeout,
|
||||
WriteTimeout: writeTimeout,
|
||||
},
|
||||
Database: config.DatabaseConfig{
|
||||
Driver: dto.Database.Driver,
|
||||
Path: dto.Database.Path,
|
||||
Host: dto.Database.Host,
|
||||
Port: dto.Database.Port,
|
||||
User: dto.Database.User,
|
||||
Password: dto.Database.Password,
|
||||
DBName: dto.Database.DBName,
|
||||
MaxIdleConns: dto.Database.MaxIdleConns,
|
||||
MaxOpenConns: dto.Database.MaxOpenConns,
|
||||
ConnMaxLifetime: connMaxLifetime,
|
||||
},
|
||||
Log: config.LogConfig{
|
||||
Level: dto.Log.Level,
|
||||
Path: dto.Log.Path,
|
||||
MaxSize: dto.Log.MaxSize,
|
||||
MaxBackups: dto.Log.MaxBackups,
|
||||
MaxAge: dto.Log.MaxAge,
|
||||
Compress: dto.Log.Compress,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) GetStartupSettings(c *gin.Context) {
|
||||
var cfg *config.Config
|
||||
var configPath string
|
||||
|
||||
if h.mode == "desktop" {
|
||||
desktopCfg, err := config.LoadDesktopConfigAtPath(h.configPath)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
cfg = desktopCfg
|
||||
configPath = h.configPath
|
||||
} else {
|
||||
cfg = h.runtimeCfg
|
||||
configPath = h.configPath
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, startupSettingsResponse{
|
||||
Mode: h.mode,
|
||||
Editable: h.editable,
|
||||
ConfigPath: configPath,
|
||||
RestartRequired: h.editable,
|
||||
Config: configToDTO(cfg),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) SaveStartupSettings(c *gin.Context) {
|
||||
if !h.editable {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "server 模式下不允许保存启动参数",
|
||||
"code": "forbidden",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Config startupSettingsDTO `json:"config"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "无效的请求格式",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := dtoToConfig(req.Config)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.SaveConfigToPath(cfg, h.configPath); err != nil {
|
||||
if errors.Is(err, appErrors.ErrInvalidRequest) {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeError(c, appErrors.Wrap(appErrors.ErrInternal, err))
|
||||
return
|
||||
}
|
||||
|
||||
savedCfg, err := config.LoadDesktopConfigAtPath(h.configPath)
|
||||
if err != nil {
|
||||
writeError(c, appErrors.Wrap(appErrors.ErrInternal, err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, startupSettingsResponse{
|
||||
Mode: h.mode,
|
||||
Editable: h.editable,
|
||||
ConfigPath: h.configPath,
|
||||
RestartRequired: true,
|
||||
Config: configToDTO(savedCfg),
|
||||
})
|
||||
}
|
||||
418
backend/internal/handler/settings_handler_test.go
Normal file
418
backend/internal/handler/settings_handler_test.go
Normal file
@@ -0,0 +1,418 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"nex/backend/internal/config"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
func createTestConfig(t *testing.T) (*config.Config, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.yaml")
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Database.Path = filepath.Join(dir, "test.db")
|
||||
cfg.Log.Path = filepath.Join(dir, "log")
|
||||
data, err := yaml.Marshal(cfg)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(configPath, data, 0o600))
|
||||
|
||||
return cfg, configPath
|
||||
}
|
||||
|
||||
func TestSettingsHandler_GetStartupSettings_Desktop(t *testing.T) {
|
||||
cfg, configPath := createTestConfig(t)
|
||||
h := NewSettingsHandler(cfg, "desktop", true, configPath)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/api/settings/startup", nil)
|
||||
|
||||
h.GetStartupSettings(c)
|
||||
|
||||
assert.Equal(t, 200, w.Code)
|
||||
|
||||
var resp startupSettingsResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "desktop", resp.Mode)
|
||||
assert.True(t, resp.Editable)
|
||||
assert.True(t, resp.RestartRequired)
|
||||
assert.Equal(t, configPath, resp.ConfigPath)
|
||||
assert.Equal(t, cfg.Server.Port, resp.Config.Server.Port)
|
||||
assert.Equal(t, "30s", resp.Config.Server.ReadTimeout)
|
||||
assert.Equal(t, cfg.Database.Driver, resp.Config.Database.Driver)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_GetStartupSettings_Server(t *testing.T) {
|
||||
cfg, configPath := createTestConfig(t)
|
||||
h := NewSettingsHandler(cfg, "server", false, configPath)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/api/settings/startup", nil)
|
||||
|
||||
h.GetStartupSettings(c)
|
||||
|
||||
assert.Equal(t, 200, w.Code)
|
||||
|
||||
var resp startupSettingsResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "server", resp.Mode)
|
||||
assert.False(t, resp.Editable)
|
||||
assert.False(t, resp.RestartRequired)
|
||||
assert.Equal(t, configPath, resp.ConfigPath)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_SaveStartupSettings_Desktop(t *testing.T) {
|
||||
cfg, configPath := createTestConfig(t)
|
||||
h := NewSettingsHandler(cfg, "desktop", true, configPath)
|
||||
|
||||
newPort := 9999
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"config": map[string]interface{}{
|
||||
"server": map[string]interface{}{
|
||||
"port": newPort,
|
||||
"read_timeout": "30s",
|
||||
"write_timeout": "30s",
|
||||
},
|
||||
"database": map[string]interface{}{
|
||||
"driver": "sqlite",
|
||||
"path": filepath.Join(t.TempDir(), "new.db"),
|
||||
"port": 3306,
|
||||
"dbname": "nex",
|
||||
"max_idle_conns": 10,
|
||||
"max_open_conns": 100,
|
||||
"conn_max_lifetime": "1h",
|
||||
},
|
||||
"log": map[string]interface{}{
|
||||
"level": "info",
|
||||
"path": filepath.Join(t.TempDir(), "log"),
|
||||
"max_size": 100,
|
||||
"max_backups": 10,
|
||||
"max_age": 30,
|
||||
"compress": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.SaveStartupSettings(c)
|
||||
|
||||
assert.Equal(t, 200, w.Code)
|
||||
|
||||
var resp startupSettingsResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, newPort, resp.Config.Server.Port)
|
||||
assert.True(t, resp.Editable)
|
||||
assert.True(t, resp.RestartRequired)
|
||||
|
||||
savedCfg, err := config.LoadDesktopConfigAtPath(configPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newPort, savedCfg.Server.Port)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_SaveStartupSettings_Server_Forbidden(t *testing.T) {
|
||||
cfg, configPath := createTestConfig(t)
|
||||
h := NewSettingsHandler(cfg, "server", false, configPath)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"config": map[string]interface{}{
|
||||
"server": map[string]interface{}{
|
||||
"port": 9999,
|
||||
"read_timeout": "30s",
|
||||
"write_timeout": "30s",
|
||||
},
|
||||
"database": map[string]interface{}{
|
||||
"driver": "sqlite",
|
||||
"path": "/tmp/test.db",
|
||||
"port": 3306,
|
||||
"dbname": "nex",
|
||||
"max_idle_conns": 10,
|
||||
"max_open_conns": 100,
|
||||
"conn_max_lifetime": "1h",
|
||||
},
|
||||
"log": map[string]interface{}{
|
||||
"level": "info",
|
||||
"path": "/tmp/log",
|
||||
"max_size": 100,
|
||||
"max_backups": 10,
|
||||
"max_age": 30,
|
||||
"compress": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.SaveStartupSettings(c)
|
||||
|
||||
assert.Equal(t, 403, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "不允许保存")
|
||||
}
|
||||
|
||||
func TestSettingsHandler_SaveStartupSettings_Desktop_InvalidConfig(t *testing.T) {
|
||||
cfg, configPath := createTestConfig(t)
|
||||
h := NewSettingsHandler(cfg, "desktop", true, configPath)
|
||||
|
||||
originalData, err := os.ReadFile(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"config": map[string]interface{}{
|
||||
"server": map[string]interface{}{
|
||||
"port": 0,
|
||||
"read_timeout": "30s",
|
||||
"write_timeout": "30s",
|
||||
},
|
||||
"database": map[string]interface{}{
|
||||
"driver": "sqlite",
|
||||
"path": "/tmp/test.db",
|
||||
"port": 3306,
|
||||
"dbname": "nex",
|
||||
"max_idle_conns": 10,
|
||||
"max_open_conns": 100,
|
||||
"conn_max_lifetime": "1h",
|
||||
},
|
||||
"log": map[string]interface{}{
|
||||
"level": "info",
|
||||
"path": "/tmp/log",
|
||||
"max_size": 100,
|
||||
"max_backups": 10,
|
||||
"max_age": 30,
|
||||
"compress": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.SaveStartupSettings(c)
|
||||
|
||||
assert.Equal(t, 400, w.Code)
|
||||
|
||||
currentData, err := os.ReadFile(configPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, originalData, currentData)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_SaveStartupSettings_Desktop_InvalidDuration(t *testing.T) {
|
||||
cfg, configPath := createTestConfig(t)
|
||||
h := NewSettingsHandler(cfg, "desktop", true, configPath)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"config": map[string]interface{}{
|
||||
"server": map[string]interface{}{
|
||||
"port": 9826,
|
||||
"read_timeout": "not-a-duration",
|
||||
"write_timeout": "30s",
|
||||
},
|
||||
"database": map[string]interface{}{
|
||||
"driver": "sqlite",
|
||||
"path": "/tmp/test.db",
|
||||
"port": 3306,
|
||||
"dbname": "nex",
|
||||
"max_idle_conns": 10,
|
||||
"max_open_conns": 100,
|
||||
"conn_max_lifetime": "1h",
|
||||
},
|
||||
"log": map[string]interface{}{
|
||||
"level": "info",
|
||||
"path": "/tmp/log",
|
||||
"max_size": 100,
|
||||
"max_backups": 10,
|
||||
"max_age": 30,
|
||||
"compress": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.SaveStartupSettings(c)
|
||||
|
||||
assert.Equal(t, 400, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "read_timeout")
|
||||
}
|
||||
|
||||
func TestSettingsHandler_SaveStartupSettings_Desktop_CreatesConfigFile(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "nex", "config.yaml")
|
||||
|
||||
_, err := os.Stat(configPath)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
|
||||
h := NewSettingsHandler(cfg, "desktop", true, configPath)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"config": map[string]interface{}{
|
||||
"server": map[string]interface{}{
|
||||
"port": 9826,
|
||||
"read_timeout": "30s",
|
||||
"write_timeout": "30s",
|
||||
},
|
||||
"database": map[string]interface{}{
|
||||
"driver": "sqlite",
|
||||
"path": filepath.Join(dir, "test.db"),
|
||||
"port": 3306,
|
||||
"dbname": "nex",
|
||||
"max_idle_conns": 10,
|
||||
"max_open_conns": 100,
|
||||
"conn_max_lifetime": "1h",
|
||||
},
|
||||
"log": map[string]interface{}{
|
||||
"level": "info",
|
||||
"path": filepath.Join(dir, "log"),
|
||||
"max_size": 100,
|
||||
"max_backups": 10,
|
||||
"max_age": 30,
|
||||
"compress": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.SaveStartupSettings(c)
|
||||
|
||||
assert.Equal(t, 200, w.Code)
|
||||
|
||||
_, err = os.Stat(configPath)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_SaveStartupSettings_Desktop_PasswordIncluded(t *testing.T) {
|
||||
cfg, configPath := createTestConfig(t)
|
||||
h := NewSettingsHandler(cfg, "desktop", true, configPath)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"config": map[string]interface{}{
|
||||
"server": map[string]interface{}{
|
||||
"port": 9826,
|
||||
"read_timeout": "30s",
|
||||
"write_timeout": "30s",
|
||||
},
|
||||
"database": map[string]interface{}{
|
||||
"driver": "mysql",
|
||||
"host": "localhost",
|
||||
"port": 3306,
|
||||
"user": "root",
|
||||
"password": "secret123",
|
||||
"dbname": "nex",
|
||||
"path": "",
|
||||
"max_idle_conns": 10,
|
||||
"max_open_conns": 100,
|
||||
"conn_max_lifetime": "1h",
|
||||
},
|
||||
"log": map[string]interface{}{
|
||||
"level": "info",
|
||||
"path": filepath.Join(t.TempDir(), "log"),
|
||||
"max_size": 100,
|
||||
"max_backups": 10,
|
||||
"max_age": 30,
|
||||
"compress": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.SaveStartupSettings(c)
|
||||
|
||||
assert.Equal(t, 200, w.Code)
|
||||
|
||||
var resp startupSettingsResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "secret123", resp.Config.Database.Password)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_GetStartupSettings_DesktopReadsConfigFile(t *testing.T) {
|
||||
cfg, configPath := createTestConfig(t)
|
||||
|
||||
savedCfg := config.DefaultConfig()
|
||||
savedCfg.Server.Port = 5555
|
||||
data, err := yaml.Marshal(savedCfg)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(configPath, data, 0o600))
|
||||
|
||||
h := NewSettingsHandler(cfg, "desktop", true, configPath)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/api/settings/startup", nil)
|
||||
|
||||
h.GetStartupSettings(c)
|
||||
|
||||
assert.Equal(t, 200, w.Code)
|
||||
|
||||
var resp startupSettingsResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, 5555, resp.Config.Server.Port)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_GetStartupSettings_ServerReturnsRuntime(t *testing.T) {
|
||||
cfg, configPath := createTestConfig(t)
|
||||
cfg.Server.Port = 7777
|
||||
|
||||
h := NewSettingsHandler(cfg, "server", false, configPath)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/api/settings/startup", nil)
|
||||
|
||||
h.GetStartupSettings(c)
|
||||
|
||||
assert.Equal(t, 200, w.Code)
|
||||
|
||||
var resp startupSettingsResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, 7777, resp.Config.Server.Port)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_SaveStartupSettings_InvalidJSON(t *testing.T) {
|
||||
cfg, configPath := createTestConfig(t)
|
||||
h := NewSettingsHandler(cfg, "desktop", true, configPath)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("PUT", "/api/settings/startup", bytes.NewReader([]byte("{invalid")))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.SaveStartupSettings(c)
|
||||
|
||||
assert.Equal(t, 400, w.Code)
|
||||
}
|
||||
26
backend/internal/handler/version_handler.go
Normal file
26
backend/internal/handler/version_handler.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"nex/backend/pkg/buildinfo"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// VersionHandler 提供后端构建版本信息。
|
||||
type VersionHandler struct{}
|
||||
|
||||
// NewVersionHandler 创建版本信息处理器。
|
||||
func NewVersionHandler() *VersionHandler {
|
||||
return &VersionHandler{}
|
||||
}
|
||||
|
||||
// GetVersion 返回构建注入的版本元数据。
|
||||
func (h *VersionHandler) GetVersion(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"version": buildinfo.Version(),
|
||||
"commit": buildinfo.Commit(),
|
||||
"build_time": buildinfo.BuildTime(),
|
||||
})
|
||||
}
|
||||
31
backend/internal/handler/version_handler_test.go
Normal file
31
backend/internal/handler/version_handler_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestVersionHandler_GetVersion(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := NewVersionHandler()
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/version", nil)
|
||||
|
||||
h.GetVersion(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var result map[string]string
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result))
|
||||
assert.Equal(t, "dev", result["version"])
|
||||
assert.Equal(t, "unknown", result["commit"])
|
||||
assert.Equal(t, "unknown", result["build_time"])
|
||||
}
|
||||
31
backend/migrations/embed.go
Normal file
31
backend/migrations/embed.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
//go:embed sqlite/*.sql mysql/*.sql
|
||||
var FS embed.FS
|
||||
|
||||
func ForDriver(driver string) (goose.Dialect, fs.FS, error) {
|
||||
switch driver {
|
||||
case "sqlite":
|
||||
subFS, err := fs.Sub(FS, "sqlite")
|
||||
if err != nil {
|
||||
return goose.DialectSQLite3, nil, fmt.Errorf("SQLite 迁移资源不可用: %w", err)
|
||||
}
|
||||
return goose.DialectSQLite3, subFS, nil
|
||||
case "mysql":
|
||||
subFS, err := fs.Sub(FS, "mysql")
|
||||
if err != nil {
|
||||
return goose.DialectMySQL, nil, fmt.Errorf("MySQL 迁移资源不可用: %w", err)
|
||||
}
|
||||
return goose.DialectMySQL, subFS, nil
|
||||
default:
|
||||
return goose.DialectSQLite3, nil, fmt.Errorf("不支持的数据库驱动: %s,仅支持 sqlite 或 mysql", driver)
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package projectversion
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
t.Run("valid", func(t *testing.T) {
|
||||
version, err := Parse("1.2.3")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Version{Major: 1, Minor: 2, Patch: 3}, version)
|
||||
assert.Equal(t, "1.2.3", version.String())
|
||||
})
|
||||
|
||||
t.Run("invalid", func(t *testing.T) {
|
||||
invalidValues := []string{"", "1.2", "1.2.3.4", "v1.2.3", "01.2.3", "1.02.3"}
|
||||
for _, tc := range invalidValues {
|
||||
_, err := Parse(tc)
|
||||
assert.Error(t, err, "%q 应校验失败", tc)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdatePackageJSONVersion(t *testing.T) {
|
||||
content := "{\n \"name\": \"frontend\",\n \"version\": \"0.0.0\"\n}\n"
|
||||
updated, err := UpdatePackageJSONVersion(content, "1.2.3")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, updated, `"version": "1.2.3"`)
|
||||
|
||||
version, err := ReadPackageJSONVersion(updated)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "1.2.3", version)
|
||||
}
|
||||
|
||||
func TestUpsertEnvVar(t *testing.T) {
|
||||
updated := UpsertEnvVar("VITE_API_BASE=/api\n", "VITE_APP_VERSION", "1.2.3")
|
||||
assert.Contains(t, updated, "VITE_API_BASE=/api\n")
|
||||
assert.Contains(t, updated, "VITE_APP_VERSION=1.2.3\n")
|
||||
|
||||
updated = UpsertEnvVar(updated, "VITE_APP_VERSION", "2.0.0")
|
||||
value, ok := ReadEnvVar(updated, "VITE_APP_VERSION")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "2.0.0", value)
|
||||
assert.Equal(t, 1, strings.Count(updated, "VITE_APP_VERSION="))
|
||||
}
|
||||
|
||||
func TestSyncAndCheck(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "VERSION"), []byte("1.2.3\n"), 0o644))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "frontend"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "frontend", "package.json"), []byte("{\n \"name\": \"frontend\",\n \"version\": \"0.0.0\"\n}\n"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "frontend", ".env.production"), []byte("VITE_API_BASE=/api\n"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "frontend", ".env.development"), []byte("VITE_API_BASE=\n"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "frontend", ".env.desktop"), []byte("VITE_API_BASE=\n"), 0o644))
|
||||
|
||||
require.NoError(t, Sync(root))
|
||||
require.NoError(t, Check(root))
|
||||
|
||||
packageJSONContent, err := os.ReadFile(filepath.Join(root, "frontend", "package.json"))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(packageJSONContent), `"version": "1.2.3"`)
|
||||
|
||||
for _, relPath := range frontendVersionFiles {
|
||||
content, readErr := os.ReadFile(filepath.Join(root, relPath))
|
||||
require.NoError(t, readErr)
|
||||
assert.Contains(t, string(content), "VITE_APP_VERSION=1.2.3\n")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyTag(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "VERSION"), []byte("1.2.3\n"), 0o644))
|
||||
|
||||
require.NoError(t, VerifyTag(root, "v1.2.3"))
|
||||
assert.Error(t, VerifyTag(root, "1.2.3"))
|
||||
assert.Error(t, VerifyTag(root, "v1.2.4"))
|
||||
}
|
||||
|
||||
func TestAssetNames(t *testing.T) {
|
||||
linuxServer, err := ServerAssetName("1.2.3", "linux", "amd64")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "nex-server_1.2.3_linux_amd64.tar.gz", linuxServer)
|
||||
|
||||
macServer, err := ServerAssetName("1.2.3", "darwin", "arm64")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "nex-server_1.2.3_darwin_arm64.tar.gz", macServer)
|
||||
|
||||
macDesktop, err := DesktopAssetName("1.2.3", "macos")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Nex_1.2.3_macOS_universal.zip", macDesktop)
|
||||
|
||||
_, err = DesktopAssetName("1.2.3", "ios")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDesktopInfoPlist(t *testing.T) {
|
||||
plist, err := DesktopInfoPlist("1.2.3", "13.0")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, plist, "<key>CFBundleShortVersionString</key>\n <string>1.2.3</string>")
|
||||
assert.Contains(t, plist, "<key>CFBundleVersion</key>\n <string>1.2.3</string>")
|
||||
assert.Contains(t, plist, "<key>LSMinimumSystemVersion</key>\n <string>13.0</string>")
|
||||
|
||||
_, err = DesktopInfoPlist("1.2", "13.0")
|
||||
assert.Error(t, err)
|
||||
_, err = DesktopInfoPlist("1.2.3", "")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -120,7 +120,7 @@ log:
|
||||
assert.Equal(t, "warn", cfg.Log.Level, "YAML value should be used when no CLI/ENV override")
|
||||
}
|
||||
|
||||
func TestLoadConfig_AutoCreate(t *testing.T) {
|
||||
func TestLoadConfig_NoAutoCreate(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
|
||||
@@ -132,6 +132,9 @@ func TestLoadConfig_AutoCreate(t *testing.T) {
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.Equal(t, 9826, cfg.Server.Port, "should load with default values")
|
||||
|
||||
_, err = os.Stat(configPath)
|
||||
assert.True(t, os.IsNotExist(err), "config file should not be auto-created")
|
||||
}
|
||||
|
||||
func TestSaveAndLoadConfig(t *testing.T) {
|
||||
@@ -184,3 +187,124 @@ func TestSaveAndLoadConfig(t *testing.T) {
|
||||
assert.Equal(t, cfg.Log.MaxAge, loaded.Log.MaxAge)
|
||||
assert.Equal(t, cfg.Log.Compress, loaded.Log.Compress)
|
||||
}
|
||||
|
||||
func TestLoadDesktopConfig_FileOnly(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
|
||||
yamlContent := `
|
||||
server:
|
||||
port: 8080
|
||||
log:
|
||||
level: debug
|
||||
`
|
||||
err := os.WriteFile(configPath, []byte(yamlContent), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := config.LoadDesktopConfigAtPath(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 8080, cfg.Server.Port)
|
||||
assert.Equal(t, "debug", cfg.Log.Level)
|
||||
}
|
||||
|
||||
func TestLoadDesktopConfig_IgnoresCLI(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
|
||||
err := os.WriteFile(configPath, []byte("server:\n port: 8080\n"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
originalArgs := os.Args
|
||||
defer func() { os.Args = originalArgs }()
|
||||
os.Args = []string{"nex", "--server-port", "9999"}
|
||||
|
||||
cfg, err := config.LoadDesktopConfigAtPath(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 8080, cfg.Server.Port, "desktop should ignore CLI args and use config file")
|
||||
}
|
||||
|
||||
func TestLoadDesktopConfig_IgnoresEnv(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
|
||||
err := os.WriteFile(configPath, []byte("server:\n port: 8080\n"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Setenv("NEX_SERVER_PORT", "9000")
|
||||
t.Setenv("NEX_LOG_LEVEL", "debug")
|
||||
|
||||
cfg, err := config.LoadDesktopConfigAtPath(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 8080, cfg.Server.Port, "desktop should ignore env vars and use config file")
|
||||
assert.Equal(t, "info", cfg.Log.Level, "desktop should ignore env vars and use default")
|
||||
}
|
||||
|
||||
func TestLoadDesktopConfig_IgnoresUnknownArgs(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
|
||||
err := os.WriteFile(configPath, []byte("server:\n port: 8080\n"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
originalArgs := os.Args
|
||||
defer func() { os.Args = originalArgs }()
|
||||
os.Args = []string{"nex", "--unknown-flag", "value", "--another-unknown"}
|
||||
|
||||
cfg, err := config.LoadDesktopConfigAtPath(configPath)
|
||||
require.NoError(t, err, "desktop should not fail on unknown CLI args")
|
||||
|
||||
assert.Equal(t, 8080, cfg.Server.Port)
|
||||
}
|
||||
|
||||
func TestLoadDesktopConfig_Snapshot(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
|
||||
err := os.WriteFile(configPath, []byte("server:\n port: 8080\n"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := config.LoadDesktopConfigAtPath(configPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 8080, cfg.Server.Port)
|
||||
|
||||
err = os.WriteFile(configPath, []byte("server:\n port: 9999\n"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 8080, cfg.Server.Port, "loaded config snapshot should not change when file changes")
|
||||
|
||||
cfg2, err := config.LoadDesktopConfigAtPath(configPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 9999, cfg2.Server.Port, "reload should pick up new config values")
|
||||
}
|
||||
|
||||
func TestLoadDesktopConfig_InvalidFileFails(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{
|
||||
name: "invalid yaml",
|
||||
content: "server:\n port: [\n",
|
||||
},
|
||||
{
|
||||
name: "validation failure",
|
||||
content: "server:\n port: 70000\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
|
||||
err := os.WriteFile(configPath, []byte(tt.content), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = config.LoadDesktopConfigAtPath(configPath)
|
||||
require.Error(t, err, "desktop should not silently fall back to defaults for invalid config files")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
VITE_API_BASE=
|
||||
VITE_APP_VERSION=0.1.0
|
||||
VITE_APP_VERSION=0.1.7
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
VITE_API_BASE=
|
||||
VITE_APP_VERSION=0.1.0
|
||||
VITE_APP_VERSION=0.1.7
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
VITE_API_BASE=/api
|
||||
VITE_APP_VERSION=0.1.0
|
||||
VITE_APP_VERSION=0.1.7
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# AI Gateway Frontend
|
||||
# Nex Frontend
|
||||
|
||||
AI 网关管理前端,提供供应商配置和用量统计界面。
|
||||
|
||||
@@ -11,7 +11,7 @@ AI 网关管理前端,提供供应商配置和用量统计界面。
|
||||
- **UI 组件库**: TDesign
|
||||
- **路由**: React Router v7
|
||||
- **数据获取**: TanStack Query v5
|
||||
- **样式**: SCSS Modules(禁止使用纯 CSS)
|
||||
- **样式**: TDesign 组件 props 优先,TDesign tokens 次之,SCSS 作为兜底补充
|
||||
- **测试**: Vitest + React Testing Library + Playwright
|
||||
- **代码格式化**: Prettier
|
||||
|
||||
@@ -86,17 +86,20 @@ frontend/
|
||||
│ │ ├── client.ts # 统一 request<T>() + 字段转换
|
||||
│ │ ├── providers.ts # Provider CRUD
|
||||
│ │ ├── models.ts # Model CRUD
|
||||
│ │ └── stats.ts # Stats 查询
|
||||
│ │ ├── stats.ts # Stats 查询
|
||||
│ │ └── version.ts # 后端版本查询
|
||||
│ ├── components/
|
||||
│ │ └── AppLayout/ # 侧边栏导航布局
|
||||
│ ├── hooks/ # TanStack Query hooks
|
||||
│ │ ├── useProviders.ts
|
||||
│ │ ├── useModels.ts
|
||||
│ │ └── useStats.ts
|
||||
│ │ ├── useStats.ts
|
||||
│ │ └── useVersion.ts
|
||||
│ ├── pages/
|
||||
│ │ ├── Providers/ # 供应商管理(含内嵌模型管理)
|
||||
│ │ ├── Stats/ # 用量统计
|
||||
│ │ ├── Settings/ # 设置(开发中)
|
||||
│ │ ├── About/ # 关于页面(品牌与版本信息)
|
||||
│ │ └── NotFound.tsx
|
||||
│ ├── routes/
|
||||
│ │ └── index.tsx # 路由配置
|
||||
@@ -111,6 +114,7 @@ frontend/
|
||||
│ ├── main.tsx
|
||||
│ └── index.scss
|
||||
├── e2e/ # Playwright E2E 测试
|
||||
├── public/ # 静态资源(icon.png 来源于 ../assets/icon.png)
|
||||
├── vitest.config.ts
|
||||
├── playwright.config.ts
|
||||
├── tsconfig.json
|
||||
@@ -145,7 +149,8 @@ bun run build
|
||||
```bash
|
||||
bun run lint # ESLint 检查
|
||||
bun run format:check # Prettier 格式检查
|
||||
bun run check # 同时检查 lint 和格式
|
||||
bun run typecheck # TypeScript 类型检查
|
||||
bun run check # 同时检查类型、lint 和格式
|
||||
```
|
||||
|
||||
### 代码格式化
|
||||
@@ -184,13 +189,14 @@ bun run test:e2e
|
||||
- API Key 脱敏显示
|
||||
- 启用/禁用状态标签
|
||||
- **协议字段**:支持 OpenAI 和 Anthropic 协议选择
|
||||
- **一键复制**:Base URL 和 API Key 支持一键复制到剪贴板
|
||||
|
||||
### 模型管理
|
||||
|
||||
- 展开供应商行查看关联模型
|
||||
- 添加/编辑/删除模型
|
||||
- 按供应商筛选模型
|
||||
- **统一模型 ID**:显示格式为 `provider_id/model_name`,用于跨协议模型识别
|
||||
- **统一模型 ID**:显示格式为 `provider_id/model_name`,用于跨协议模型识别,支持一键复制
|
||||
- **UUID 自动生成**:创建模型时后端自动生成 UUID,无需手动输入 ID
|
||||
|
||||
### 用量统计
|
||||
@@ -200,6 +206,12 @@ bun run test:e2e
|
||||
- 按模型筛选
|
||||
- 按日期范围筛选(DatePicker.RangePicker)
|
||||
|
||||
### 关于页面
|
||||
|
||||
- 展示应用名称、产品描述和项目链接
|
||||
- 展示前端版本、后端版本、后端 commit 和构建时间
|
||||
- 根据 `VITE_APP_VERSION` 与 `GET /api/version` 返回值提示前后端版本是否一致
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 目录结构
|
||||
@@ -231,9 +243,10 @@ __tests__/
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 开发环境 | 生产环境 | 说明 |
|
||||
| --------------- | -------- | -------- | ------------------------------- |
|
||||
| `VITE_API_BASE` | (空) | `/api` | API 基础路径,空则走 Vite proxy |
|
||||
| 变量 | 开发环境 | 生产环境 | 说明 |
|
||||
| ------------------ | -------- | -------- | ----------------------------------------- |
|
||||
| `VITE_API_BASE` | (空) | `/api` | API 基础路径,空则走 Vite proxy |
|
||||
| `VITE_APP_VERSION` | `0.1.0` | `0.1.0` | 前端构建版本,由 `make version-sync` 同步 |
|
||||
|
||||
**E2E 测试特有**:
|
||||
|
||||
@@ -242,9 +255,11 @@ __tests__/
|
||||
|
||||
## 开发规范
|
||||
|
||||
- 所有样式使用 SCSS,禁止使用纯 CSS 文件
|
||||
- 组件级样式使用 SCSS Modules(\*.module.scss)
|
||||
- 样式优先使用 TDesign 组件 props(如 `hoverShadow`、`headerBordered`、`variant`、`shape`、`gutter`)
|
||||
- 组件 props 无法表达时使用 TDesign tokens(`var(--td-*)`)
|
||||
- 仅当 props 和 tokens 无法满足布局、响应式或品牌视觉需求时使用 SCSS,禁止使用纯 CSS 文件
|
||||
- 图标优先使用 TDesign 图标(tdesign-icons-react)
|
||||
- 应用 favicon 使用 `frontend/public/icon.png`,该文件来源于仓库根目录 `assets/icon.png`
|
||||
- TypeScript strict 模式,禁止 any 类型
|
||||
- API 层自动处理 snake_case ↔ camelCase 字段转换
|
||||
- 使用路径别名 `@/` 引用 src 目录
|
||||
|
||||
@@ -12,7 +12,7 @@ test.describe('侧边栏', () => {
|
||||
})
|
||||
|
||||
test('应显示应用名称', async ({ page }) => {
|
||||
await expect(page.locator('aside').getByText('AI Gateway')).toBeVisible()
|
||||
await expect(page.locator('aside').getByText('Nex')).toBeVisible()
|
||||
})
|
||||
|
||||
test('应显示导航菜单项', async ({ page }) => {
|
||||
@@ -41,6 +41,15 @@ test.describe('页面导航', () => {
|
||||
await expect(page.getByRole('heading', { name: '供应商管理' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('应能切换到关于页面并显示版本信息', async ({ page }) => {
|
||||
await page.locator('aside').getByText('关于').click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: '关于' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Nex' })).toBeVisible()
|
||||
await expect(page.getByText('前端版本')).toBeVisible()
|
||||
await expect(page.getByText('后端版本')).toBeVisible()
|
||||
})
|
||||
|
||||
test('应在刷新后保持当前页面', async ({ page }) => {
|
||||
await page.locator('aside').getByText('用量统计').click()
|
||||
await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible()
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI Gateway</title>
|
||||
<title>Nex</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.7",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && bun run check && vite build",
|
||||
"build": "bun run check && vite build",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"check": "bun run lint && bun run format:check",
|
||||
"typecheck": "tsc -b",
|
||||
"check": "bun run typecheck && bun run lint && bun run format:check",
|
||||
"fix": "bun run lint:fix && bun run format",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB |
BIN
frontend/public/icon.png
LFS
Normal file
BIN
frontend/public/icon.png
LFS
Normal file
Binary file not shown.
@@ -1,24 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
30
frontend/src/__tests__/api/version.test.ts
Normal file
30
frontend/src/__tests__/api/version.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'
|
||||
import { getBackendVersion } from '@/api/version'
|
||||
|
||||
describe('version API', () => {
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }))
|
||||
afterEach(() => server.resetHandlers())
|
||||
afterAll(() => server.close())
|
||||
|
||||
it('fetches backend version and converts build_time to buildTime', async () => {
|
||||
server.use(
|
||||
http.get('http://localhost:3000/api/version', () => {
|
||||
return HttpResponse.json({
|
||||
version: '0.1.0',
|
||||
commit: 'abc1234',
|
||||
build_time: '2026-05-05T00:00:00Z',
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
await expect(getBackendVersion()).resolves.toEqual({
|
||||
version: '0.1.0',
|
||||
commit: 'abc1234',
|
||||
buildTime: '2026-05-05T00:00:00Z',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,25 +1,37 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { BrowserRouter } from 'react-router'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
|
||||
const renderWithRouter = (component: React.ReactNode) => {
|
||||
return render(<BrowserRouter>{component}</BrowserRouter>)
|
||||
return render(<MemoryRouter initialEntries={['/providers']}>{component}</MemoryRouter>)
|
||||
}
|
||||
|
||||
describe('AppLayout', () => {
|
||||
it('renders sidebar with app name', () => {
|
||||
renderWithRouter(<AppLayout />)
|
||||
|
||||
const appNames = screen.getAllByText('AI Gateway')
|
||||
expect(appNames.length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Nex')).toBeInTheDocument()
|
||||
expect(screen.getByAltText('Nex logo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps logo visible when sidebar is collapsed', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithRouter(<AppLayout />)
|
||||
|
||||
await user.click(screen.getByLabelText('收起侧边栏'))
|
||||
|
||||
expect(screen.getByAltText('Nex logo')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Nex')).not.toBeInTheDocument()
|
||||
expect(screen.getByLabelText('展开侧边栏')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders navigation menu items', () => {
|
||||
renderWithRouter(<AppLayout />)
|
||||
|
||||
expect(screen.getByText('供应商管理')).toBeInTheDocument()
|
||||
expect(screen.getByText('用量统计')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('供应商管理').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('用量统计').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('renders settings menu item', () => {
|
||||
@@ -28,6 +40,12 @@ describe('AppLayout', () => {
|
||||
expect(screen.getByText('设置')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders about menu item', () => {
|
||||
renderWithRouter(<AppLayout />)
|
||||
|
||||
expect(screen.getByText('关于')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders content outlet', () => {
|
||||
const { container } = renderWithRouter(<AppLayout />)
|
||||
|
||||
|
||||
@@ -4,6 +4,21 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ModelTable } from '@/pages/Providers/ModelTable'
|
||||
import type { Model } from '@/types'
|
||||
|
||||
const { mockMessagePluginSuccess } = vi.hoisted(() => ({
|
||||
mockMessagePluginSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('tdesign-react', async () => {
|
||||
const actual = await vi.importActual('tdesign-react')
|
||||
return {
|
||||
...actual,
|
||||
MessagePlugin: {
|
||||
success: mockMessagePluginSuccess,
|
||||
error: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const mockModels: Model[] = [
|
||||
{
|
||||
id: 'model-1',
|
||||
@@ -44,6 +59,7 @@ const defaultProps = {
|
||||
describe('ModelTable', () => {
|
||||
beforeEach(() => {
|
||||
mockMutate.mockClear()
|
||||
mockMessagePluginSuccess.mockClear()
|
||||
})
|
||||
|
||||
it('renders model list with unified ID and model name', () => {
|
||||
@@ -120,4 +136,19 @@ describe('ModelTable', () => {
|
||||
|
||||
expect(screen.queryByRole('button', { name: /编 ?辑/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders unified model ID with copy button and copies on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(<ModelTable {...defaultProps} />)
|
||||
|
||||
const allCells = container.querySelectorAll('td')
|
||||
const modelIdCell = Array.from(allCells).find((td) => td.textContent?.includes('openai/gpt-4o'))
|
||||
expect(modelIdCell).toBeTruthy()
|
||||
|
||||
const buttons = modelIdCell!.querySelectorAll('button')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
|
||||
await user.click(buttons[0]!)
|
||||
expect(mockMessagePluginSuccess).toHaveBeenCalledWith('已复制统一模型 ID')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ProviderTable } from '@/pages/Providers/ProviderTable'
|
||||
import type { Provider } from '@/types'
|
||||
|
||||
const { mockMessagePluginSuccess } = vi.hoisted(() => ({
|
||||
mockMessagePluginSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('tdesign-react', async () => {
|
||||
const actual = await vi.importActual('tdesign-react')
|
||||
return {
|
||||
...actual,
|
||||
MessagePlugin: {
|
||||
success: mockMessagePluginSuccess,
|
||||
error: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const mockModelsData = [
|
||||
{ id: 'model-1', providerId: 'openai', modelName: 'gpt-4o', enabled: true, unifiedId: 'openai/gpt-4o' },
|
||||
{
|
||||
@@ -54,6 +69,9 @@ const defaultProps = {
|
||||
}
|
||||
|
||||
describe('ProviderTable', () => {
|
||||
beforeEach(() => {
|
||||
mockMessagePluginSuccess.mockClear()
|
||||
})
|
||||
it('renders provider list with name, baseUrl, apiKey, and status tags', () => {
|
||||
render(<ProviderTable {...defaultProps} />)
|
||||
|
||||
@@ -203,4 +221,66 @@ describe('ProviderTable', () => {
|
||||
const protocolCell = container.querySelector('[data-colkey="protocol"]')
|
||||
expect(protocolCell).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders Base URL with copy button and copies on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(<ProviderTable {...defaultProps} />)
|
||||
|
||||
const baseUrlCells = container.querySelectorAll('td')
|
||||
const baseUrlCellWithContent = Array.from(baseUrlCells).find((td) =>
|
||||
td.textContent?.includes('https://api.openai.com/v1')
|
||||
)
|
||||
expect(baseUrlCellWithContent).toBeTruthy()
|
||||
|
||||
const buttons = baseUrlCellWithContent!.querySelectorAll('button')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
|
||||
await user.click(buttons[0]!)
|
||||
expect(mockMessagePluginSuccess).toHaveBeenCalledWith('已复制 Base URL')
|
||||
})
|
||||
|
||||
it('renders API Key with copy button and copies on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(<ProviderTable {...defaultProps} />)
|
||||
|
||||
const allCells = container.querySelectorAll('td')
|
||||
const apiKeyCell = Array.from(allCells).find((td) => td.textContent?.includes('sk-abcdefgh12345678'))
|
||||
expect(apiKeyCell).toBeTruthy()
|
||||
|
||||
const buttons = apiKeyCell!.querySelectorAll('button')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
|
||||
await user.click(buttons[0]!)
|
||||
expect(mockMessagePluginSuccess).toHaveBeenCalledWith('已复制 API Key')
|
||||
})
|
||||
|
||||
it('does not render copy button when Base URL is empty', () => {
|
||||
const emptyUrlProvider: Provider[] = [
|
||||
{
|
||||
...mockProviders[0],
|
||||
id: 'empty-url',
|
||||
baseUrl: '',
|
||||
},
|
||||
]
|
||||
const { container } = render(<ProviderTable {...defaultProps} providers={emptyUrlProvider} />)
|
||||
|
||||
const allCells = container.querySelectorAll('td')
|
||||
const baseUrlCells = Array.from(allCells).filter((td) => td.textContent === '')
|
||||
expect(baseUrlCells.length).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('does not render copy button when API Key is empty', () => {
|
||||
const emptyKeyProvider: Provider[] = [
|
||||
{
|
||||
...mockProviders[0],
|
||||
id: 'empty-key',
|
||||
apiKey: '',
|
||||
},
|
||||
]
|
||||
const { container } = render(<ProviderTable {...defaultProps} providers={emptyKeyProvider} />)
|
||||
|
||||
const allCells = container.querySelectorAll('td')
|
||||
const apiKeyCells = Array.from(allCells).filter((td) => td.textContent === '')
|
||||
expect(apiKeyCells.length).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
106
frontend/src/__tests__/hooks/useSettings.test.tsx
Normal file
106
frontend/src/__tests__/hooks/useSettings.test.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import React from 'react'
|
||||
import { MessagePlugin } from 'tdesign-react'
|
||||
import { useStartupSettings, useSaveStartupSettings } from '@/hooks/useSettings'
|
||||
import type { StartupSettings } from '@/types'
|
||||
|
||||
vi.mock('tdesign-react', () => ({
|
||||
MessagePlugin: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockDesktopSettings: StartupSettings = {
|
||||
mode: 'desktop',
|
||||
editable: true,
|
||||
configPath: '/home/user/.nex/config.yaml',
|
||||
restartRequired: true,
|
||||
config: {
|
||||
server: { port: 9826, readTimeout: '30s', writeTimeout: '30s' },
|
||||
database: {
|
||||
driver: 'sqlite',
|
||||
path: '/home/user/.nex/config.db',
|
||||
host: '',
|
||||
port: 3306,
|
||||
user: '',
|
||||
password: '',
|
||||
dbname: 'nex',
|
||||
maxIdleConns: 10,
|
||||
maxOpenConns: 100,
|
||||
connMaxLifetime: '1h',
|
||||
},
|
||||
log: { level: 'info', path: '/home/user/.nex/log', maxSize: 100, maxBackups: 10, maxAge: 30, compress: true },
|
||||
},
|
||||
}
|
||||
|
||||
const handlers = [
|
||||
http.get('/api/settings/startup', () => {
|
||||
return HttpResponse.json(mockDesktopSettings)
|
||||
}),
|
||||
http.put('/api/settings/startup', async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>
|
||||
return HttpResponse.json({
|
||||
...mockDesktopSettings,
|
||||
config: (body as Record<string, unknown>).config,
|
||||
})
|
||||
}),
|
||||
]
|
||||
|
||||
const server = setupServer(...handlers)
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }))
|
||||
afterEach(() => server.resetHandlers())
|
||||
afterAll(() => server.close())
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('useStartupSettings', () => {
|
||||
it('fetches startup settings', async () => {
|
||||
const { result } = renderHook(() => useStartupSettings(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(result.current.data?.mode).toBe('desktop')
|
||||
expect(result.current.data?.editable).toBe(true)
|
||||
expect(result.current.data?.configPath).toBe('/home/user/.nex/config.yaml')
|
||||
expect(result.current.data?.restartRequired).toBe(true)
|
||||
expect(result.current.data?.config.server.port).toBe(9826)
|
||||
expect(result.current.data?.config.database.driver).toBe('sqlite')
|
||||
expect(result.current.data?.config.log.level).toBe('info')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSaveStartupSettings', () => {
|
||||
it('saves settings and shows success message for desktop', async () => {
|
||||
const { result } = renderHook(() => useSaveStartupSettings(), { wrapper: createWrapper() })
|
||||
|
||||
result.current.mutate({ config: mockDesktopSettings.config })
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(MessagePlugin.success).toHaveBeenCalledWith(
|
||||
'配置已保存到配置文件。当前运行中的服务仍使用启动时配置,重启 Desktop 后生效'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows error message on failure', async () => {
|
||||
server.use(
|
||||
http.put('/api/settings/startup', () => {
|
||||
return HttpResponse.json({ error: '保存失败' }, { status: 500 })
|
||||
})
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useSaveStartupSettings(), { wrapper: createWrapper() })
|
||||
|
||||
result.current.mutate({ config: mockDesktopSettings.config })
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||
expect(MessagePlugin.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
33
frontend/src/__tests__/hooks/useVersion.test.tsx
Normal file
33
frontend/src/__tests__/hooks/useVersion.test.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import React from 'react'
|
||||
import { useBackendVersion } from '@/hooks/useVersion'
|
||||
|
||||
const server = setupServer(
|
||||
http.get('/api/version', () => {
|
||||
return HttpResponse.json({ version: '0.1.0', commit: 'abc1234', build_time: '2026-05-05T00:00:00Z' })
|
||||
})
|
||||
)
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(() => server.listen())
|
||||
afterEach(() => server.resetHandlers())
|
||||
afterAll(() => server.close())
|
||||
|
||||
describe('useBackendVersion', () => {
|
||||
it('fetches backend version', async () => {
|
||||
const { result } = renderHook(() => useBackendVersion(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
|
||||
expect(result.current.data).toEqual({ version: '0.1.0', commit: 'abc1234', buildTime: '2026-05-05T00:00:00Z' })
|
||||
})
|
||||
})
|
||||
88
frontend/src/__tests__/pages/About.test.tsx
Normal file
88
frontend/src/__tests__/pages/About.test.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useBackendVersion } from '@/hooks/useVersion'
|
||||
import AboutPage from '@/pages/About'
|
||||
|
||||
vi.mock('@/hooks/useVersion', () => ({
|
||||
useBackendVersion: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/constants/app', () => ({
|
||||
APP_NAME: 'Nex',
|
||||
APP_DESCRIPTION: 'AI Gateway - 统一的大模型 API 网关',
|
||||
APP_WEBSITE: 'https://github.com/nex/gateway',
|
||||
APP_VERSION: '0.1.0',
|
||||
}))
|
||||
|
||||
const mockUseBackendVersion = useBackendVersion as ReturnType<typeof vi.fn>
|
||||
|
||||
describe('AboutPage', () => {
|
||||
beforeEach(() => {
|
||||
mockUseBackendVersion.mockReturnValue({
|
||||
data: { version: '0.1.0', commit: 'abc1234', buildTime: '2026-05-05T00:00:00Z' },
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useBackendVersion>)
|
||||
})
|
||||
|
||||
it('renders brand, description and links', () => {
|
||||
render(<AboutPage />)
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Nex' })).toBeInTheDocument()
|
||||
expect(screen.getByText('AI Gateway - 统一的大模型 API 网关')).toBeInTheDocument()
|
||||
expect(screen.getByText('GitHub')).toHaveAttribute('href', 'https://github.com/nex/gateway')
|
||||
})
|
||||
|
||||
it('shows frontend and backend versions', () => {
|
||||
render(<AboutPage />)
|
||||
|
||||
expect(screen.getByText('前端版本')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('0.1.0').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('abc1234')).toBeInTheDocument()
|
||||
expect(screen.getByText('2026-05-05T00:00:00Z')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows matched status', () => {
|
||||
render(<AboutPage />)
|
||||
|
||||
expect(screen.getByText('版本一致')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows mismatched status', () => {
|
||||
mockUseBackendVersion.mockReturnValue({
|
||||
data: { version: '0.2.0', commit: 'abc1234', buildTime: '2026-05-05T00:00:00Z' },
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useBackendVersion>)
|
||||
|
||||
render(<AboutPage />)
|
||||
|
||||
expect(screen.getByText('版本不一致')).toBeInTheDocument()
|
||||
expect(screen.getByText(/用于部署诊断/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows unknown status for dev backend version', () => {
|
||||
mockUseBackendVersion.mockReturnValue({
|
||||
data: { version: 'dev', commit: 'unknown', buildTime: 'unknown' },
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useBackendVersion>)
|
||||
|
||||
render(<AboutPage />)
|
||||
|
||||
expect(screen.getByText('无法判断版本')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows unavailable status on backend request failure', () => {
|
||||
mockUseBackendVersion.mockReturnValue({
|
||||
data: undefined,
|
||||
isError: true,
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useBackendVersion>)
|
||||
|
||||
render(<AboutPage />)
|
||||
|
||||
expect(screen.getByText('无法获取后端版本')).toBeInTheDocument()
|
||||
expect(screen.getByText(/后端版本接口暂时不可用/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
169
frontend/src/__tests__/pages/Settings.test.tsx
Normal file
169
frontend/src/__tests__/pages/Settings.test.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import React from 'react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { MessagePlugin } from 'tdesign-react'
|
||||
import SettingsPage from '@/pages/Settings'
|
||||
import type { StartupSettings } from '@/types'
|
||||
|
||||
vi.mock('tdesign-react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('tdesign-react')>()
|
||||
return {
|
||||
...actual,
|
||||
MessagePlugin: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const mockDesktopSettings: StartupSettings = {
|
||||
mode: 'desktop',
|
||||
editable: true,
|
||||
configPath: '/home/user/.nex/config.yaml',
|
||||
restartRequired: true,
|
||||
config: {
|
||||
server: {
|
||||
port: 9826,
|
||||
readTimeout: '30s',
|
||||
writeTimeout: '30s',
|
||||
},
|
||||
database: {
|
||||
driver: 'sqlite',
|
||||
path: '/home/user/.nex/config.db',
|
||||
host: '',
|
||||
port: 3306,
|
||||
user: '',
|
||||
password: '',
|
||||
dbname: 'nex',
|
||||
maxIdleConns: 10,
|
||||
maxOpenConns: 100,
|
||||
connMaxLifetime: '1h',
|
||||
},
|
||||
log: {
|
||||
level: 'info',
|
||||
path: '/home/user/.nex/log',
|
||||
maxSize: 100,
|
||||
maxBackups: 10,
|
||||
maxAge: 30,
|
||||
compress: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const mockServerSettings: StartupSettings = {
|
||||
...mockDesktopSettings,
|
||||
mode: 'server',
|
||||
editable: false,
|
||||
restartRequired: false,
|
||||
configPath: '/etc/nex/config.yaml',
|
||||
}
|
||||
|
||||
const desktopHandlers = [
|
||||
http.get('/api/settings/startup', () => HttpResponse.json(mockDesktopSettings)),
|
||||
http.put('/api/settings/startup', async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>
|
||||
return HttpResponse.json({ ...mockDesktopSettings, config: (body as Record<string, unknown>).config })
|
||||
}),
|
||||
]
|
||||
|
||||
const serverHandlers = [http.get('/api/settings/startup', () => HttpResponse.json(mockServerSettings))]
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
describe('SettingsPage', () => {
|
||||
it('renders startup settings card', async () => {
|
||||
const mswServer = setupServer(...desktopHandlers)
|
||||
mswServer.listen({ onUnhandledRequest: 'bypass' })
|
||||
|
||||
render(<SettingsPage />, { wrapper: createWrapper() })
|
||||
|
||||
expect(await screen.findByText('服务配置')).toBeInTheDocument()
|
||||
expect(screen.getByText('启动参数设置')).toBeInTheDocument()
|
||||
|
||||
mswServer.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('StartupSettingsCard - Desktop mode', () => {
|
||||
const mswServer = setupServer(...desktopHandlers)
|
||||
|
||||
beforeAll(() => mswServer.listen({ onUnhandledRequest: 'bypass' }))
|
||||
afterEach(() => mswServer.resetHandlers())
|
||||
afterAll(() => mswServer.close())
|
||||
|
||||
it('shows editable form with save button in desktop mode', async () => {
|
||||
render(<SettingsPage />, { wrapper: createWrapper() })
|
||||
|
||||
expect(
|
||||
await screen.findByText('Desktop 模式下此页面编辑的是启动配置文件,保存后重启 Desktop 生效')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '保存' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows form fields for server, database, and log', async () => {
|
||||
render(<SettingsPage />, { wrapper: createWrapper() })
|
||||
|
||||
expect(await screen.findByText('服务配置')).toBeInTheDocument()
|
||||
expect(screen.getByText('数据库配置')).toBeInTheDocument()
|
||||
expect(screen.getByText('日志配置')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows success message on save', async () => {
|
||||
render(<SettingsPage />, { wrapper: createWrapper() })
|
||||
|
||||
expect(await screen.findByText('服务配置')).toBeInTheDocument()
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: '保存' })
|
||||
await userEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MessagePlugin.success).toHaveBeenCalledWith(
|
||||
'配置已保存到配置文件。当前运行中的服务仍使用启动时配置,重启 Desktop 后生效'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('StartupSettingsCard - Server mode', () => {
|
||||
const mswServer = setupServer(...serverHandlers)
|
||||
|
||||
beforeAll(() => mswServer.listen({ onUnhandledRequest: 'bypass' }))
|
||||
afterEach(() => mswServer.resetHandlers())
|
||||
afterAll(() => mswServer.close())
|
||||
|
||||
it('shows read-only form with server-only warning', async () => {
|
||||
render(<SettingsPage />, { wrapper: createWrapper() })
|
||||
|
||||
expect(await screen.findByText('Server 模式下启动参数仅支持查看,不支持从前端编辑')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '保存' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
function waitFor(fn: () => void, opts?: { timeout?: number }) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const start = Date.now()
|
||||
const interval = setInterval(() => {
|
||||
try {
|
||||
fn()
|
||||
clearInterval(interval)
|
||||
resolve()
|
||||
} catch {
|
||||
if (Date.now() - start > (opts?.timeout ?? 3000)) {
|
||||
clearInterval(interval)
|
||||
reject(new Error('waitFor timeout'))
|
||||
}
|
||||
}
|
||||
}, 50)
|
||||
})
|
||||
}
|
||||
21
frontend/src/__tests__/utils/version.test.ts
Normal file
21
frontend/src/__tests__/utils/version.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getVersionStatus } from '@/utils/version'
|
||||
|
||||
describe('getVersionStatus', () => {
|
||||
it('returns matched when versions are equal', () => {
|
||||
expect(getVersionStatus('1.2.3', { version: '1.2.3', commit: 'abc', buildTime: 'now' }).kind).toBe('matched')
|
||||
})
|
||||
|
||||
it('returns mismatched when release versions differ', () => {
|
||||
expect(getVersionStatus('1.2.3', { version: '1.2.4', commit: 'abc', buildTime: 'now' }).kind).toBe('mismatched')
|
||||
})
|
||||
|
||||
it('returns unknown for dev or unknown versions', () => {
|
||||
expect(getVersionStatus('dev', { version: '1.2.3', commit: 'abc', buildTime: 'now' }).kind).toBe('unknown')
|
||||
expect(getVersionStatus('1.2.3', { version: 'unknown', commit: 'abc', buildTime: 'now' }).kind).toBe('unknown')
|
||||
})
|
||||
|
||||
it('returns unavailable on request failure', () => {
|
||||
expect(getVersionStatus('1.2.3', undefined, true).kind).toBe('unavailable')
|
||||
})
|
||||
})
|
||||
10
frontend/src/api/settings.ts
Normal file
10
frontend/src/api/settings.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { StartupSettings, SaveStartupSettingsInput } from '@/types'
|
||||
import { request } from './client'
|
||||
|
||||
export async function getStartupSettings(): Promise<StartupSettings> {
|
||||
return request<StartupSettings>('GET', '/api/settings/startup')
|
||||
}
|
||||
|
||||
export async function saveStartupSettings(input: SaveStartupSettingsInput): Promise<StartupSettings> {
|
||||
return request<StartupSettings>('PUT', '/api/settings/startup', input)
|
||||
}
|
||||
6
frontend/src/api/version.ts
Normal file
6
frontend/src/api/version.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { BackendVersion } from '@/types'
|
||||
import { request } from './client'
|
||||
|
||||
export async function getBackendVersion(): Promise<BackendVersion> {
|
||||
return request<BackendVersion>('GET', '/api/version')
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ChevronRightIcon,
|
||||
} from 'tdesign-icons-react'
|
||||
import { Layout, Menu, Button } from 'tdesign-react'
|
||||
import { APP_NAME } from '@/constants/app'
|
||||
|
||||
const { MenuItem } = Menu
|
||||
|
||||
@@ -22,7 +23,7 @@ export function AppLayout() {
|
||||
if (location.pathname === '/stats') return '用量统计'
|
||||
if (location.pathname === '/settings') return '设置'
|
||||
if (location.pathname === '/about') return '关于'
|
||||
return 'AI Gateway'
|
||||
return APP_NAME
|
||||
}
|
||||
|
||||
const asideWidth = collapsed ? '64px' : '232px'
|
||||
@@ -52,15 +53,18 @@ export function AppLayout() {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{!collapsed && 'AI Gateway'}
|
||||
<img src='/icon.png' alt={`${APP_NAME} logo`} style={{ width: 28, height: 28 }} />
|
||||
{!collapsed && APP_NAME}
|
||||
</div>
|
||||
}
|
||||
operations={
|
||||
<Button
|
||||
aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
|
||||
variant='text'
|
||||
shape='square'
|
||||
icon={collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
|
||||
|
||||
4
frontend/src/constants/app.ts
Normal file
4
frontend/src/constants/app.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const APP_NAME = 'Nex'
|
||||
export const APP_DESCRIPTION = 'AI Gateway - 统一的大模型 API 网关'
|
||||
export const APP_WEBSITE = 'https://github.com/nex/gateway'
|
||||
export const APP_VERSION = import.meta.env.VITE_APP_VERSION || 'dev'
|
||||
33
frontend/src/hooks/useSettings.ts
Normal file
33
frontend/src/hooks/useSettings.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { MessagePlugin } from 'tdesign-react'
|
||||
import * as api from '@/api/settings'
|
||||
import type { SaveStartupSettingsInput, ApiError } from '@/types'
|
||||
|
||||
export const settingsKeys = {
|
||||
startup: ['settings', 'startup'] as const,
|
||||
}
|
||||
|
||||
export function useStartupSettings() {
|
||||
return useQuery({
|
||||
queryKey: settingsKeys.startup,
|
||||
queryFn: api.getStartupSettings,
|
||||
staleTime: 0,
|
||||
})
|
||||
}
|
||||
|
||||
export function useSaveStartupSettings() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: SaveStartupSettingsInput) => api.saveStartupSettings(input),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: settingsKeys.startup })
|
||||
if (data.mode === 'desktop') {
|
||||
MessagePlugin.success('配置已保存到配置文件。当前运行中的服务仍使用启动时配置,重启 Desktop 后生效')
|
||||
}
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
MessagePlugin.error(error.message)
|
||||
},
|
||||
})
|
||||
}
|
||||
13
frontend/src/hooks/useVersion.ts
Normal file
13
frontend/src/hooks/useVersion.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import * as api from '@/api/version'
|
||||
|
||||
export const versionKeys = {
|
||||
backend: ['version', 'backend'] as const,
|
||||
}
|
||||
|
||||
export function useBackendVersion() {
|
||||
return useQuery({
|
||||
queryKey: versionKeys.backend,
|
||||
queryFn: api.getBackendVersion,
|
||||
})
|
||||
}
|
||||
@@ -1,30 +1,77 @@
|
||||
import { Card } from 'tdesign-react'
|
||||
import { Alert, Card, Descriptions, Link, Space, Tag } from 'tdesign-react'
|
||||
import { APP_DESCRIPTION, APP_NAME, APP_VERSION, APP_WEBSITE } from '@/constants/app'
|
||||
import { useBackendVersion } from '@/hooks/useVersion'
|
||||
import type { VersionStatusKind } from '@/types'
|
||||
import { getVersionStatus } from '@/utils/version'
|
||||
|
||||
const statusTheme: Record<VersionStatusKind, 'success' | 'warning' | 'default'> = {
|
||||
matched: 'success',
|
||||
mismatched: 'warning',
|
||||
unknown: 'default',
|
||||
unavailable: 'warning',
|
||||
}
|
||||
|
||||
export default function AboutPage() {
|
||||
const { data: backendVersion, isError, isLoading } = useBackendVersion()
|
||||
const versionStatus = getVersionStatus(APP_VERSION, backendVersion, isError)
|
||||
|
||||
return (
|
||||
<Card bordered={false}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '4rem 0',
|
||||
}}
|
||||
>
|
||||
<h1 style={{ margin: 0, fontSize: '2rem' }}>Nex</h1>
|
||||
<p style={{ margin: '0.5rem 0 0', color: 'var(--td-text-color-secondary)', fontSize: '1rem' }}>
|
||||
AI Gateway - 统一的大模型 API 网关
|
||||
</p>
|
||||
<a
|
||||
href='https://github.com/nex/gateway'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
style={{ marginTop: '1rem', color: 'var(--td-brand-color)' }}
|
||||
<div style={{ display: 'grid', gap: 'var(--td-comp-margin-l)' }}>
|
||||
<Card bordered={false} hoverShadow>
|
||||
<Space align='center' size='large'>
|
||||
<img src='/icon.png' alt={`${APP_NAME} logo`} style={{ width: 56, height: 56 }} />
|
||||
<div>
|
||||
<h1 style={{ margin: 0, fontSize: '2rem' }}>{APP_NAME}</h1>
|
||||
<p style={{ margin: '0.5rem 0 0', color: 'var(--td-text-color-secondary)', fontSize: '1rem' }}>
|
||||
{APP_DESCRIPTION}
|
||||
</p>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Card bordered={false} hoverShadow>
|
||||
{(versionStatus.kind === 'mismatched' || versionStatus.kind === 'unavailable') && (
|
||||
<Alert
|
||||
theme='warning'
|
||||
message={versionStatus.description}
|
||||
style={{ marginBottom: 'var(--td-comp-margin-l)' }}
|
||||
/>
|
||||
)}
|
||||
<Descriptions
|
||||
column={2}
|
||||
itemLayout='vertical'
|
||||
items={[
|
||||
{ label: '前端版本', content: APP_VERSION },
|
||||
{ label: '后端版本', content: isLoading ? '加载中' : backendVersion?.version || 'unknown' },
|
||||
{ label: '后端提交', content: isLoading ? '加载中' : backendVersion?.commit || 'unknown' },
|
||||
{ label: '后端构建时间', content: isLoading ? '加载中' : backendVersion?.buildTime || 'unknown' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
<Tag
|
||||
theme={statusTheme[versionStatus.kind]}
|
||||
variant='light'
|
||||
shape='round'
|
||||
style={{ position: 'absolute', top: 'var(--td-comp-paddingLR-l)', right: 'var(--td-comp-paddingLR-l)' }}
|
||||
>
|
||||
https://github.com/nex/gateway
|
||||
</a>
|
||||
{versionStatus.label}
|
||||
</Tag>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card bordered={false} hoverShadow>
|
||||
<Space breakLine size='large'>
|
||||
<Link href={APP_WEBSITE} target='_blank' theme='primary'>
|
||||
GitHub
|
||||
</Link>
|
||||
<Link href={APP_WEBSITE} target='_blank' theme='primary'>
|
||||
文档
|
||||
</Link>
|
||||
<Link href={`${APP_WEBSITE}/blob/main/LICENSE`} target='_blank' theme='primary'>
|
||||
License
|
||||
</Link>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Table, Tag, Popconfirm, Space } from 'tdesign-react'
|
||||
import { Button, Table, Tag, Popconfirm, Space, Typography, MessagePlugin } from 'tdesign-react'
|
||||
import { useModels, useDeleteModel } from '@/hooks/useModels'
|
||||
import type { Model } from '@/types'
|
||||
import type { PrimaryTableCol } from 'tdesign-react/es/table/type'
|
||||
@@ -18,8 +18,32 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
|
||||
title: '统一模型 ID',
|
||||
colKey: 'unifiedId',
|
||||
width: 250,
|
||||
ellipsis: true,
|
||||
cell: ({ row }) => row.unifiedId || `${row.providerId}/${row.modelName}`,
|
||||
cell: ({ row }) => {
|
||||
const id = row.unifiedId || `${row.providerId}/${row.modelName}`
|
||||
return id ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, maxWidth: '100%' }}>
|
||||
<span
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{id}
|
||||
</span>
|
||||
<Typography.Text
|
||||
style={{ flexShrink: 0 }}
|
||||
copyable={{
|
||||
text: id,
|
||||
onCopy: () => MessagePlugin.success('已复制统一模型 ID'),
|
||||
}}
|
||||
>
|
||||
{''}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
) : null
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '模型名称',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Table, Tag, Popconfirm, Space, Card } from 'tdesign-react'
|
||||
import { Button, Table, Tag, Popconfirm, Space, Card, Typography, MessagePlugin } from 'tdesign-react'
|
||||
import type { Provider, Model } from '@/types'
|
||||
import { ModelTable } from './ModelTable'
|
||||
import type { PrimaryTableCol } from 'tdesign-react/es/table/type'
|
||||
@@ -32,7 +32,30 @@ export function ProviderTable({
|
||||
{
|
||||
title: 'Base URL',
|
||||
colKey: 'baseUrl',
|
||||
ellipsis: true,
|
||||
cell: ({ row }) =>
|
||||
row.baseUrl ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, maxWidth: '100%' }}>
|
||||
<span
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{row.baseUrl}
|
||||
</span>
|
||||
<Typography.Text
|
||||
style={{ flexShrink: 0 }}
|
||||
copyable={{
|
||||
text: row.baseUrl,
|
||||
onCopy: () => MessagePlugin.success('已复制 Base URL'),
|
||||
}}
|
||||
>
|
||||
{''}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
title: '协议',
|
||||
@@ -47,7 +70,30 @@ export function ProviderTable({
|
||||
{
|
||||
title: 'API Key',
|
||||
colKey: 'apiKey',
|
||||
ellipsis: true,
|
||||
cell: ({ row }) =>
|
||||
row.apiKey ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, maxWidth: '100%' }}>
|
||||
<span
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{row.apiKey}
|
||||
</span>
|
||||
<Typography.Text
|
||||
style={{ flexShrink: 0 }}
|
||||
copyable={{
|
||||
text: row.apiKey,
|
||||
onCopy: () => MessagePlugin.success('已复制 API Key'),
|
||||
}}
|
||||
>
|
||||
{''}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
|
||||
275
frontend/src/pages/Settings/StartupSettingsCard.tsx
Normal file
275
frontend/src/pages/Settings/StartupSettingsCard.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, Form, Input, InputNumber, Select, Switch, Button, Alert, Divider, Space, Loading } from 'tdesign-react'
|
||||
import { useStartupSettings, useSaveStartupSettings } from '@/hooks/useSettings'
|
||||
import type { StartupConfig } from '@/types'
|
||||
import type { SubmitContext } from 'tdesign-react/es/form/type'
|
||||
|
||||
const DURATION_PLACEHOLDERS = {
|
||||
readTimeout: '例如 30s',
|
||||
writeTimeout: '例如 30s',
|
||||
connMaxLifetime: '例如 1h',
|
||||
}
|
||||
|
||||
function flattenConfig(c: StartupConfig): Record<string, unknown> {
|
||||
return {
|
||||
'server.port': c.server.port,
|
||||
'server.readTimeout': c.server.readTimeout,
|
||||
'server.writeTimeout': c.server.writeTimeout,
|
||||
'database.driver': c.database.driver,
|
||||
'database.path': c.database.path,
|
||||
'database.host': c.database.host,
|
||||
'database.port': c.database.port,
|
||||
'database.user': c.database.user,
|
||||
'database.password': c.database.password,
|
||||
'database.dbname': c.database.dbname,
|
||||
'database.maxIdleConns': c.database.maxIdleConns,
|
||||
'database.maxOpenConns': c.database.maxOpenConns,
|
||||
'database.connMaxLifetime': c.database.connMaxLifetime,
|
||||
'log.level': c.log.level,
|
||||
'log.path': c.log.path,
|
||||
'log.maxSize': c.log.maxSize,
|
||||
'log.maxBackups': c.log.maxBackups,
|
||||
'log.maxAge': c.log.maxAge,
|
||||
'log.compress': c.log.compress,
|
||||
}
|
||||
}
|
||||
|
||||
export function StartupSettingsCard() {
|
||||
const { data: settings, isLoading, isError } = useStartupSettings()
|
||||
const saveMutation = useSaveStartupSettings()
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const isDesktop = settings?.mode === 'desktop'
|
||||
const editable = settings?.editable ?? false
|
||||
|
||||
const [driver, setDriver] = useState<string>(settings?.config.database.driver ?? 'sqlite')
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.config && form) {
|
||||
form.setFieldsValue(flattenConfig(settings.config))
|
||||
}
|
||||
}, [form, settings?.config])
|
||||
|
||||
const handleDriverChange = (changedValues: Record<string, unknown>) => {
|
||||
if ('database.driver' in changedValues) {
|
||||
setDriver(changedValues['database.driver'] as string)
|
||||
}
|
||||
}
|
||||
|
||||
const isSqlite = driver === 'sqlite'
|
||||
const isMysql = driver === 'mysql'
|
||||
|
||||
const handleSubmit = (context: SubmitContext) => {
|
||||
if (context.validateResult !== true || !form) return
|
||||
const values = form.getFieldsValue(true) as Record<string, unknown>
|
||||
const config: StartupConfig = {
|
||||
server: {
|
||||
port: values['server.port'] as number,
|
||||
readTimeout: values['server.readTimeout'] as string,
|
||||
writeTimeout: values['server.writeTimeout'] as string,
|
||||
},
|
||||
database: {
|
||||
driver: values['database.driver'] as string,
|
||||
path: values['database.path'] as string,
|
||||
host: values['database.host'] as string,
|
||||
port: values['database.port'] as number,
|
||||
user: values['database.user'] as string,
|
||||
password: values['database.password'] as string,
|
||||
dbname: values['database.dbname'] as string,
|
||||
maxIdleConns: values['database.maxIdleConns'] as number,
|
||||
maxOpenConns: values['database.maxOpenConns'] as number,
|
||||
connMaxLifetime: values['database.connMaxLifetime'] as string,
|
||||
},
|
||||
log: {
|
||||
level: values['log.level'] as string,
|
||||
path: values['log.path'] as string,
|
||||
maxSize: values['log.maxSize'] as number,
|
||||
maxBackups: values['log.maxBackups'] as number,
|
||||
maxAge: values['log.maxAge'] as number,
|
||||
compress: values['log.compress'] as boolean,
|
||||
},
|
||||
}
|
||||
saveMutation.mutate({ config })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card title='启动参数设置'>
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
<Loading text='加载中...' />
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !settings) {
|
||||
return (
|
||||
<Card title='启动参数设置'>
|
||||
<Alert theme='error' message='加载启动参数失败,请刷新页面重试' />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title='启动参数设置'>
|
||||
{isDesktop && (
|
||||
<Alert
|
||||
theme='info'
|
||||
message='Desktop 模式下此页面编辑的是启动配置文件,保存后重启 Desktop 生效'
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
{!editable && (
|
||||
<Alert
|
||||
theme='warning'
|
||||
message='Server 模式下启动参数仅支持查看,不支持从前端编辑'
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout='vertical'
|
||||
labelWidth={140}
|
||||
initialData={flattenConfig(settings.config)}
|
||||
onSubmit={handleSubmit}
|
||||
onValuesChange={handleDriverChange}
|
||||
disabled={!editable}
|
||||
>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>服务配置</div>
|
||||
<Form.FormItem label='端口' name='server.port' rules={[{ required: true, message: '请输入端口' }]}>
|
||||
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
|
||||
</Form.FormItem>
|
||||
<Form.FormItem label='读超时' name='server.readTimeout' rules={[{ required: true, message: '请输入读超时' }]}>
|
||||
<Input placeholder={DURATION_PLACEHOLDERS.readTimeout} />
|
||||
</Form.FormItem>
|
||||
<Form.FormItem label='写超时' name='server.writeTimeout' rules={[{ required: true, message: '请输入写超时' }]}>
|
||||
<Input placeholder={DURATION_PLACEHOLDERS.writeTimeout} />
|
||||
</Form.FormItem>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>数据库配置</div>
|
||||
<Form.FormItem label='驱动' name='database.driver' rules={[{ required: true, message: '请选择数据库驱动' }]}>
|
||||
<Select>
|
||||
<Select.Option value='sqlite'>SQLite</Select.Option>
|
||||
<Select.Option value='mysql'>MySQL</Select.Option>
|
||||
</Select>
|
||||
</Form.FormItem>
|
||||
|
||||
{isSqlite && (
|
||||
<Form.FormItem
|
||||
label='数据库路径'
|
||||
name='database.path'
|
||||
rules={[{ required: true, message: '请输入数据库路径' }]}
|
||||
>
|
||||
<Input placeholder='例如 ~/.nex/config.db' />
|
||||
</Form.FormItem>
|
||||
)}
|
||||
|
||||
{isMysql && (
|
||||
<>
|
||||
<Form.FormItem
|
||||
label='主机地址'
|
||||
name='database.host'
|
||||
rules={[{ required: true, message: '请输入主机地址' }]}
|
||||
>
|
||||
<Input placeholder='例如 localhost' />
|
||||
</Form.FormItem>
|
||||
<Form.FormItem label='端口' name='database.port' rules={[{ required: true, message: '请输入端口' }]}>
|
||||
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
|
||||
</Form.FormItem>
|
||||
<Form.FormItem label='用户名' name='database.user' rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input placeholder='例如 root' />
|
||||
</Form.FormItem>
|
||||
<Form.FormItem label='密码' name='database.password'>
|
||||
<Input placeholder='MySQL 密码' />
|
||||
</Form.FormItem>
|
||||
<Form.FormItem
|
||||
label='数据库名'
|
||||
name='database.dbname'
|
||||
rules={[{ required: true, message: '请输入数据库名' }]}
|
||||
>
|
||||
<Input placeholder='例如 nex' />
|
||||
</Form.FormItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.FormItem
|
||||
label='最大空闲连接数'
|
||||
name='database.maxIdleConns'
|
||||
rules={[{ required: true, message: '请输入最大空闲连接数' }]}
|
||||
>
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.FormItem>
|
||||
<Form.FormItem
|
||||
label='最大打开连接数'
|
||||
name='database.maxOpenConns'
|
||||
rules={[{ required: true, message: '请输入最大打开连接数' }]}
|
||||
>
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.FormItem>
|
||||
<Form.FormItem
|
||||
label='连接最大生命周期'
|
||||
name='database.connMaxLifetime'
|
||||
rules={[{ required: true, message: '请输入连接最大生命周期' }]}
|
||||
>
|
||||
<Input placeholder={DURATION_PLACEHOLDERS.connMaxLifetime} />
|
||||
</Form.FormItem>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>日志配置</div>
|
||||
<Form.FormItem label='日志级别' name='log.level' rules={[{ required: true, message: '请选择日志级别' }]}>
|
||||
<Select>
|
||||
<Select.Option value='debug'>debug</Select.Option>
|
||||
<Select.Option value='info'>info</Select.Option>
|
||||
<Select.Option value='warn'>warn</Select.Option>
|
||||
<Select.Option value='error'>error</Select.Option>
|
||||
</Select>
|
||||
</Form.FormItem>
|
||||
<Form.FormItem label='日志路径' name='log.path' rules={[{ required: true, message: '请输入日志路径' }]}>
|
||||
<Input placeholder='例如 ~/.nex/log' />
|
||||
</Form.FormItem>
|
||||
<Form.FormItem
|
||||
label='单文件最大大小'
|
||||
name='log.maxSize'
|
||||
rules={[{ required: true, message: '请输入最大大小' }]}
|
||||
>
|
||||
<InputNumber min={1} suffix=' MB' style={{ width: '100%' }} />
|
||||
</Form.FormItem>
|
||||
<Form.FormItem
|
||||
label='最大备份数'
|
||||
name='log.maxBackups'
|
||||
rules={[{ required: true, message: '请输入最大备份数' }]}
|
||||
>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.FormItem>
|
||||
<Form.FormItem
|
||||
label='最大保留天数'
|
||||
name='log.maxAge'
|
||||
rules={[{ required: true, message: '请输入最大保留天数' }]}
|
||||
>
|
||||
<InputNumber min={0} suffix=' 天' style={{ width: '100%' }} />
|
||||
</Form.FormItem>
|
||||
<Form.FormItem label='压缩旧日志' name='log.compress'>
|
||||
<Switch />
|
||||
</Form.FormItem>
|
||||
|
||||
{editable && (
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
<Button
|
||||
theme='primary'
|
||||
loading={saveMutation.isPending}
|
||||
onClick={() => {
|
||||
form?.submit()
|
||||
}}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</Form>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,5 @@
|
||||
import { Card } from 'tdesign-react'
|
||||
import { StartupSettingsCard } from './StartupSettingsCard'
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<Card title='设置'>
|
||||
<div style={{ textAlign: 'center', padding: '40px 0', color: 'var(--td-text-color-placeholder)' }}>
|
||||
设置功能开发中...
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
return <StartupSettingsCard />
|
||||
}
|
||||
|
||||
@@ -62,6 +62,20 @@ export interface StatsQueryParams {
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
export interface BackendVersion {
|
||||
version: string
|
||||
commit: string
|
||||
buildTime: string
|
||||
}
|
||||
|
||||
export type VersionStatusKind = 'matched' | 'mismatched' | 'unknown' | 'unavailable'
|
||||
|
||||
export interface VersionStatus {
|
||||
kind: VersionStatusKind
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
code?: string
|
||||
@@ -78,3 +92,49 @@ export interface ApiErrorResponse {
|
||||
error: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
export interface StartupServerConfig {
|
||||
port: number
|
||||
readTimeout: string
|
||||
writeTimeout: string
|
||||
}
|
||||
|
||||
export interface StartupDatabaseConfig {
|
||||
driver: string
|
||||
path: string
|
||||
host: string
|
||||
port: number
|
||||
user: string
|
||||
password: string
|
||||
dbname: string
|
||||
maxIdleConns: number
|
||||
maxOpenConns: number
|
||||
connMaxLifetime: string
|
||||
}
|
||||
|
||||
export interface StartupLogConfig {
|
||||
level: string
|
||||
path: string
|
||||
maxSize: number
|
||||
maxBackups: number
|
||||
maxAge: number
|
||||
compress: boolean
|
||||
}
|
||||
|
||||
export interface StartupConfig {
|
||||
server: StartupServerConfig
|
||||
database: StartupDatabaseConfig
|
||||
log: StartupLogConfig
|
||||
}
|
||||
|
||||
export interface StartupSettings {
|
||||
mode: 'server' | 'desktop'
|
||||
editable: boolean
|
||||
configPath: string
|
||||
restartRequired: boolean
|
||||
config: StartupConfig
|
||||
}
|
||||
|
||||
export type SaveStartupSettingsInput = {
|
||||
config: StartupConfig
|
||||
}
|
||||
|
||||
42
frontend/src/utils/version.ts
Normal file
42
frontend/src/utils/version.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { BackendVersion, VersionStatus } from '@/types'
|
||||
|
||||
function isUnknownVersion(version: string | undefined): boolean {
|
||||
const normalized = version?.trim().toLowerCase()
|
||||
return !normalized || normalized === 'dev' || normalized === 'unknown'
|
||||
}
|
||||
|
||||
export function getVersionStatus(
|
||||
frontendVersion: string,
|
||||
backendVersion?: BackendVersion,
|
||||
hasError = false
|
||||
): VersionStatus {
|
||||
if (hasError) {
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
label: '无法获取后端版本',
|
||||
description: '后端版本接口暂时不可用,当前仅展示前端版本。',
|
||||
}
|
||||
}
|
||||
|
||||
if (!backendVersion || isUnknownVersion(frontendVersion) || isUnknownVersion(backendVersion.version)) {
|
||||
return {
|
||||
kind: 'unknown',
|
||||
label: '无法判断版本',
|
||||
description: '当前处于开发构建或版本信息不完整,不判定为版本错误。',
|
||||
}
|
||||
}
|
||||
|
||||
if (frontendVersion === backendVersion.version) {
|
||||
return {
|
||||
kind: 'matched',
|
||||
label: '版本一致',
|
||||
description: '前端和后端来自同一版本构建。',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'mismatched',
|
||||
label: '版本不一致',
|
||||
description: '前后端版本不同,该状态用于部署诊断,不影响当前功能使用。',
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQ
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=
|
||||
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
|
||||
cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.43.0/go.mod h1:o6jf7JM/zveWC/PP277BLxjHy5KjnGX/jfljhM4s34g=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
@@ -14,6 +12,7 @@ github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmO
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/cristalhq/acmd v0.12.0/go.mod h1:LG5oa43pE/BbxtfMoImHCQN++0Su7dzipdgBjMCBVDQ=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
@@ -26,8 +25,6 @@ github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6v
|
||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
@@ -48,6 +45,7 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA
|
||||
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
||||
github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
||||
@@ -66,8 +64,10 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v
|
||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||
github.com/phayes/checkstyle v0.0.0-20170904204023-bfd46e6a821d/go.mod h1:3OzsM7FXDQlpCiw2j81fOmAwQLnZnLGXVKUzeKQXIAw=
|
||||
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/quasilyte/go-ruleguard/rules v0.0.0-20211022131956-028d6511ab71/go.mod h1:4cgAphtvu7Ftv7vOT2ZOYhC6CvBxZixcasr8qIOTA50=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
pre-commit:
|
||||
commands:
|
||||
backend-lint:
|
||||
glob: "backend/**/*.go"
|
||||
run: cd backend && go tool golangci-lint run --new-from-rev HEAD ./...
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-04
|
||||
@@ -0,0 +1,105 @@
|
||||
## Context
|
||||
|
||||
Nex 已经具备统一版本源:仓库根目录 `VERSION` 是权威版本,`versionctl sync` 会同步 `frontend/package.json` 和前端 `.env.*` 中的 `VITE_APP_VERSION`,Go 二进制构建通过 `-ldflags` 注入 `backend/pkg/buildinfo`。当前缺口在运行时可见性:前端只能知道自身构建版本,无法从后端获取 server/desktop 二进制版本,也无法判断前后端是否来自同一版本。
|
||||
|
||||
前端侧边栏当前在 `AppLayout` 中以 `AI Gateway` 作为品牌文字,折叠后 logo 区域为空,HTML title 也仍为 `AI Gateway`。About 页面只展示名称、描述和链接,缺少版本、构建信息、状态反馈和现代化信息层次。图标资源方面,前端 public 目录维护独立 SVG favicon 和未使用的 `icons.svg`,而桌面托盘和打包资源已经使用仓库 `assets/icon.png`、`assets/icon.ico`、`assets/icon.icns`。desktop 代码中的 `appName/appTooltip` 已经是 `Nex`,但现有 `desktop-app` spec 仍保留旧 tooltip 文案,需要借本次 change 同步。
|
||||
|
||||
本变更跨越前端页面、前端 API、后端管理接口、desktop 静态资源服务、README 和 OpenSpec,因此需要先固定实现边界,避免实现阶段出现多个版本来源或重复图标资源。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 统一用户可见应用名称为 `Nex`,覆盖前端侧边栏、HTML title 和相关测试断言,`AI Gateway` 作为产品描述保留。
|
||||
- 侧边栏展开态显示统一图标和 `Nex`,折叠态仍显示统一图标。
|
||||
- 前端 favicon/public 图标复用仓库 `assets/icon.png`,清理不再使用的 SVG public 图标资源。
|
||||
- About 页面展示前端版本、后端版本、后端 commit、后端 build_time 和版本匹配状态。
|
||||
- 后端在 server 和 desktop 模式下都提供 `GET /api/version`。
|
||||
- 明确并落地前端样式优先级:TDesign 组件 props、TDesign tokens、SCSS。
|
||||
- 补充单元测试、组件测试、后端路由测试和必要的 E2E 覆盖。
|
||||
- 同步更新根 README、frontend README、backend README。
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不改变版本权威来源,仍以根目录 `VERSION` 为唯一版本源。
|
||||
- 不新增前端或后端依赖。
|
||||
- 不引入用户认证、权限控制或配置项开关。
|
||||
- 不让 About 页面阻断业务功能;版本不一致仅提示,不禁止使用。
|
||||
- 不重构所有已有内联样式,只处理本次触达的品牌和 About 页面相关样式。
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision: 后端版本信息复用 buildinfo 并通过 `/api/version` 暴露
|
||||
|
||||
后端 SHALL 新增 `GET /api/version` 管理接口,响应字段为 `version`、`commit`、`build_time`,字段值来自 `buildinfo.Version()`、`buildinfo.Commit()`、`buildinfo.BuildTime()`。
|
||||
|
||||
选择 `/api/version` 的原因:管理接口已经统一使用 `/api/*`,前端 API 客户端也以该路径族访问后端;`/health` 应保持存活检查语义,不混入构建元数据。选择 `buildinfo` 的原因:Makefile 已在 server 和 desktop 构建中注入版本、commit 和 buildTime,运行时读取 `VERSION` 会破坏发布产物独立性。
|
||||
|
||||
备选方案:扩展 `/health`。放弃原因是健康检查会被监控系统频繁调用,混入构建信息容易模糊职责,并且前端语义上需要的是管理信息而不是存活探针。
|
||||
|
||||
### Decision: server 和 desktop 分别注册同一个版本接口
|
||||
|
||||
server 和 desktop 当前拥有独立 `setupRoutes`,因此两个入口都 MUST 注册 `GET /api/version`。desktop 还需要确保该路径不会落入 SPA fallback 或协议代理路由。
|
||||
|
||||
备选方案:只在 server 注册。放弃原因是桌面应用使用独立路由装配和静态资源服务,用户在 desktop 模式下同样需要 About 页面展示后端版本。
|
||||
|
||||
### Decision: 前端版本使用 `VITE_APP_VERSION`,不运行时读取 package.json
|
||||
|
||||
前端 SHALL 使用 `import.meta.env.VITE_APP_VERSION` 作为自身版本,缺失时降级为 `dev` 或 `unknown` 显示。该值由版本同步工具写入 `.env.*`,符合现有构建版本注入规则。
|
||||
|
||||
备选方案:在前端运行时请求或打包 `package.json`。放弃原因是会产生第二个版本读取路径,并可能在 desktop 嵌入构建、生产构建和测试环境中产生不一致。
|
||||
|
||||
### Decision: About 页面只提示版本一致性,不阻断功能
|
||||
|
||||
About 页面 SHALL 根据前端版本和后端版本显示状态:一致、不一致、开发构建无法判断、后端版本获取失败。状态通过 TDesign `Tag` 和必要时的 `Alert` 展示。
|
||||
|
||||
判断规则:前端版本等于后端版本时为一致;任一版本为 `dev`、`unknown`、空值时为无法判断;请求失败时为后端版本获取失败;其余不相等时为不一致。
|
||||
|
||||
备选方案:版本不一致时阻断或弹窗提示。放弃原因是版本检查用于部署诊断,不能影响现有配置和代理能力。
|
||||
|
||||
### Decision: 图标资源统一到 `assets/icon.png` 并固定运行时路径
|
||||
|
||||
前端 public 图标 SHALL 由仓库 `assets/icon.png` 派生,实施时将根目录 `assets/icon.png` 复制为 `frontend/public/icon.png`,前端入口 SHALL 使用 `/icon.png` 作为 PNG favicon 路径。`frontend/public/favicon.svg` 不再作为 favicon 来源,`frontend/public/icons.svg` 经确认未被引用后 SHALL 删除。
|
||||
|
||||
Vite 会将 `frontend/public/icon.png` 输出到 dist 根目录,因此 desktop 静态服务 SHALL 显式服务 `/icon.png` 并读取嵌入 dist 中的 `icon.png`。README SHALL 标注 `frontend/public/icon.png` 来源于根目录 `assets/icon.png`,后续更新应用图标时应以根目录资源为准并同步 public 镜像。
|
||||
|
||||
备选方案:继续保留 SVG favicon。放弃原因是会继续存在两套应用图标来源,与用户要求的统一资源方向不一致。
|
||||
|
||||
### Decision: 前端样式优先级写入 README 并指导实现
|
||||
|
||||
实现视觉效果时,前端 SHALL 优先使用 TDesign 组件 props,例如 `Card` 的 `bordered`、`hoverShadow`、`headerBordered`,`Tag` 的 `theme`、`variant`、`shape`,`Row/Col` 的响应式 props。组件 props 不足时使用 TDesign tokens,例如 `var(--td-text-color-secondary)`、`var(--td-brand-color)`。只有 props 和 tokens 无法表达布局、响应式或品牌细节时才使用 SCSS。
|
||||
|
||||
备选方案:直接新增 SCSS Modules 完成全部视觉。放弃原因是项目已经在 OpenSpec 中要求优先使用 TDesign 样式体系,过多 SCSS 会增加覆盖组件内部样式的风险。
|
||||
|
||||
### Decision: About 页面采用信息面板布局
|
||||
|
||||
About 页面 SHALL 以三个独立 `Card` 垂直排列呈现:顶部品牌卡片展示图标、`Nex`、产品描述;中部版本信息卡片展示前端版本、后端版本、commit、build_time,版本状态 Tag 以绝对定位浮动在卡片右上角;下部链接卡片展示外部链接入口。
|
||||
|
||||
布局示意:
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Icon Nex │
|
||||
│ AI Gateway - 统一的大模型 API 网关 │
|
||||
└─────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 前端版本 后端版本 构建信息 [Tag] │
|
||||
│ v0.1.0 v0.1.0 commit/time │
|
||||
└─────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ GitHub / 文档 / License │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Decision: desktop-app spec 同步现有 Nex tooltip
|
||||
|
||||
desktop 代码已经通过 `appName = "Nex"` 和 `appTooltip = appName` 使用统一应用名称,现有 `desktop-app` spec 中 tooltip 仍写 `AI Gateway`。本次 change 已触达 `desktop-app` capability,因此 delta spec SHALL 同步修正托盘 tooltip 要求为 `Nex`,避免归档后主 spec 与当前代码继续漂移。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- `/api/version` 在未通过 Makefile 运行的本地 Go 进程中可能返回 `dev/unknown/unknown` → About 页面将其视为开发构建,显示“无法判断”而不是错误。
|
||||
- desktop 与 server 路由重复维护,可能漏注册 → 任务中明确分别添加路由测试,覆盖两个入口。
|
||||
- 前端 public PNG 与根 `assets/icon.png` 可能漂移 → README 明确来源;实现阶段复制 `assets/icon.png` 到 `frontend/public/icon.png`,并通过构建验证确认 `/icon.png` 可用。
|
||||
- 删除 `frontend/public/icons.svg` 可能影响隐藏引用 → 已通过全文搜索未发现引用;实现阶段删除前再次全文确认,删除后运行前端测试和构建验证。
|
||||
- 样式优先级与现有 README “SCSS Modules” 表述可能冲突 → 本变更同步更新 README,以 TDesign props/tokens 优先作为新的明确规则。
|
||||
- 版本不一致提示可能被误解为严重故障 → 文案应说明该状态用于部署诊断,不影响当前功能使用。
|
||||
@@ -0,0 +1,34 @@
|
||||
## Why
|
||||
|
||||
当前前端侧边栏仍显示旧的 `AI Gateway` 文案,折叠后品牌区域为空;About 页面信息展示较简陋,无法展示和判断前后端构建版本是否匹配。同时前端 public 图标资源与仓库统一的 `assets/icon.png` 不一致,并存在未使用的 SVG 图标资源,增加了品牌和资源维护成本。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 将前端侧边栏品牌统一为 `Nex`,展开时显示统一图标和应用名称,折叠时仍显示图标。
|
||||
- 将前端 favicon/public 图标统一复用仓库 `assets/icon.png`,运行时统一使用 `/icon.png`;替换当前 `/favicon.svg` 引用并清理未使用的 `frontend/public/icons.svg` 资源。
|
||||
- 将前端 HTML 标题等用户可见应用名称同步为 `Nex`,保留 `AI Gateway` 作为产品描述。
|
||||
- 重新设计 About 页面信息结构,使用三个独立卡片分别展示品牌、版本信息和外部链接,版本状态 Tag 浮动在版本信息卡片右上角。
|
||||
- 新增后端管理接口 `GET /api/version`,暴露构建注入的 `version`、`commit`、`build_time`,供前端判断前后端版本一致性。
|
||||
- 在 server 和 desktop 两种启动模式下都注册版本接口,并确保 desktop 静态资源路由支持新的 PNG 图标路径。
|
||||
- 明确前端样式优先级:TDesign 组件 props 优先,其次使用 TDesign tokens,最后才在无法通过前两者表达时使用 SCSS,并同步更新 README。
|
||||
- 为菜单品牌、About 页面、版本接口、版本匹配状态和资源清理补充测试。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
无。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `frontend`: 侧边栏品牌标识、折叠态 logo、HTML 标题、前端样式优先级和 public 图标资源使用规则发生变化。
|
||||
- `about-page`: About 页面从简单品牌信息扩展为现代化信息面板,并展示前后端版本与匹配状态。
|
||||
- `repository-versioning`: 后端需要通过管理接口暴露构建版本信息,前端需要使用构建注入版本与后端版本进行一致性判断。
|
||||
- `desktop-app`: desktop 模式需要支持新的 PNG 图标静态资源路径,保证版本接口作为 API 路由处理,并同步现有 `Nex` 托盘 tooltip 规范。
|
||||
|
||||
## Impact
|
||||
|
||||
- 前端:`frontend/src/components/AppLayout`、`frontend/src/pages/About`、`frontend/src/api`、`frontend/src/hooks`、`frontend/src/types`、`frontend/index.html`、`frontend/public`、前端测试和 E2E 测试。
|
||||
- 后端:`backend/internal/handler`、`backend/cmd/server`、`backend/cmd/desktop`、后端测试。
|
||||
- 文档与规范:根 `README.md`、`frontend/README.md`、`backend/README.md`、相关 OpenSpec specs。
|
||||
- API:新增 `GET /api/version` 管理接口;不引入新依赖。
|
||||
@@ -0,0 +1,78 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 关于页面
|
||||
|
||||
前端 SHALL 提供现代化关于页面,使用 TDesign 组件展示项目品牌信息、项目链接、前端版本、后端版本和版本匹配状态。
|
||||
|
||||
#### Scenario: 显示关于页面
|
||||
|
||||
- **WHEN** 用户访问 `/about` 路径
|
||||
- **THEN** 前端 SHALL 显示关于页面
|
||||
- **THEN** 页面 SHALL 展示应用名称 `Nex`
|
||||
- **THEN** 页面 SHALL 展示应用描述 `AI Gateway - 统一的大模型 API 网关`
|
||||
- **THEN** 页面 SHALL 展示项目链接 `https://github.com/nex/gateway`
|
||||
|
||||
#### Scenario: 页面布局
|
||||
|
||||
- **WHEN** 渲染关于页面
|
||||
- **THEN** 页面 SHALL 使用三个独立 TDesign Card 组件分别承载品牌区、版本信息区和链接区
|
||||
- **THEN** 三个 Card SHALL 使用 grid 布局垂直排列
|
||||
- **THEN** 每个 Card SHALL 设置 `bordered={false}` 和 `hoverShadow`
|
||||
- **THEN** Card SHALL 使用 TDesign 组件 props 和 tokens 完成主要视觉效果
|
||||
|
||||
#### Scenario: 品牌卡片
|
||||
|
||||
- **WHEN** 渲染品牌卡片
|
||||
- **THEN** 卡片 SHALL 展示应用图标、应用名称 `Nex` 和产品描述
|
||||
|
||||
#### Scenario: 版本信息卡片
|
||||
|
||||
- **WHEN** 渲染版本信息卡片
|
||||
- **THEN** 版本状态 Tag SHALL 以绝对定位浮动在卡片右上角,不占据内容布局空间
|
||||
- **THEN** 版本状态 Tag SHALL 使用 TDesign Tag 的 `theme`、`variant`、`shape` props
|
||||
- **THEN** 卡片 SHALL 展示前端版本、后端版本、后端提交和后端构建时间
|
||||
|
||||
#### Scenario: 链接卡片
|
||||
|
||||
- **WHEN** 渲染链接卡片
|
||||
- **THEN** 卡片 SHALL 展示项目外部链接
|
||||
|
||||
#### Scenario: 展示前端版本
|
||||
|
||||
- **WHEN** 渲染关于页面
|
||||
- **THEN** 页面 SHALL 显示前端版本号
|
||||
- **THEN** 前端版本号 SHALL 来源于构建注入的 `VITE_APP_VERSION`
|
||||
- **THEN** 当前端版本号缺失时页面 SHALL 显示开发或未知版本状态
|
||||
|
||||
#### Scenario: 展示后端版本
|
||||
|
||||
- **WHEN** 渲染关于页面且后端版本接口请求成功
|
||||
- **THEN** 页面 SHALL 显示后端 `version`
|
||||
- **THEN** 页面 SHALL 显示后端 `commit`
|
||||
- **THEN** 页面 SHALL 显示后端 `build_time`
|
||||
|
||||
#### Scenario: 判断版本一致
|
||||
|
||||
- **WHEN** 前端版本号与后端版本号相同
|
||||
- **THEN** 页面 SHALL 显示版本一致状态
|
||||
- **THEN** 版本状态 SHALL 使用 TDesign Tag 展示成功语义
|
||||
|
||||
#### Scenario: 判断版本不一致
|
||||
|
||||
- **WHEN** 前端版本号与后端版本号不同且两者均为可判断的发布版本
|
||||
- **THEN** 页面 SHALL 显示版本不一致状态
|
||||
- **THEN** 页面 SHALL 展示提示信息说明该状态用于部署诊断
|
||||
- **THEN** 页面 SHALL NOT 阻断用户使用其他功能
|
||||
|
||||
#### Scenario: 后端版本无法判断
|
||||
|
||||
- **WHEN** 后端版本号为 `dev`、`unknown` 或空值
|
||||
- **THEN** 页面 SHALL 显示开发构建或无法判断状态
|
||||
- **THEN** 页面 SHALL NOT 将该状态显示为版本错误
|
||||
|
||||
#### Scenario: 后端版本获取失败
|
||||
|
||||
- **WHEN** 请求后端版本接口失败
|
||||
- **THEN** 页面 SHALL 显示无法获取后端版本的状态
|
||||
- **THEN** 页面 SHALL 保留前端版本信息
|
||||
- **THEN** 页面 SHALL NOT 因版本接口失败而崩溃
|
||||
@@ -0,0 +1,91 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 系统托盘
|
||||
|
||||
系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择。
|
||||
|
||||
#### Scenario: 托盘图标显示
|
||||
|
||||
- **WHEN** 桌面应用启动成功
|
||||
- **THEN** 系统根据平台加载正确的图标格式
|
||||
- **AND** 在 Windows 上加载 ICO 格式图标(`assets/icon.ico`)
|
||||
- **AND** 在 macOS 和 Linux 上加载 PNG 格式图标(`assets/icon.png`)
|
||||
- **AND** 托盘图标 tooltip 显示 `Nex`
|
||||
|
||||
#### Scenario: 托盘菜单显示
|
||||
|
||||
- **WHEN** 用户点击托盘图标(左键或右键)
|
||||
- **THEN** 显示托盘菜单
|
||||
- **AND** 菜单包含"打开管理界面"选项
|
||||
- **AND** 菜单包含"状态: 运行中"选项(禁用状态)
|
||||
- **AND** 菜单包含"端口: 9826"选项(禁用状态)
|
||||
- **AND** 菜单包含"退出"选项
|
||||
|
||||
#### Scenario: 打开管理界面
|
||||
|
||||
- **WHEN** 用户点击托盘菜单"打开管理界面"
|
||||
- **THEN** 系统在浏览器中打开 `http://localhost:9826`
|
||||
|
||||
#### Scenario: 浏览器打开失败
|
||||
|
||||
- **WHEN** 系统无法打开浏览器(浏览器未安装等)
|
||||
- **THEN** 托盘菜单仍可正常使用
|
||||
- **AND** 用户可手动访问 `http://localhost:9826`
|
||||
|
||||
#### Scenario: 退出应用
|
||||
|
||||
- **WHEN** 用户点击托盘菜单"退出"
|
||||
- **THEN** 系统优雅关闭后端服务
|
||||
- **AND** 托盘图标消失
|
||||
- **AND** 应用进程退出
|
||||
|
||||
### Requirement: 静态文件服务
|
||||
|
||||
系统 SHALL 通过 Gin 同时服务 API、协议代理和前端静态资源。
|
||||
|
||||
#### Scenario: API 请求路由
|
||||
|
||||
- **WHEN** 请求路径以 `/api/` 或 `/health` 开头
|
||||
- **THEN** 请求由现有业务 handler 处理或返回 API 风格 404
|
||||
|
||||
#### Scenario: 版本接口路由
|
||||
|
||||
- **WHEN** desktop 模式收到 `GET /api/version` 请求
|
||||
- **THEN** 请求 SHALL 由版本信息 handler 处理
|
||||
- **THEN** 响应 SHALL 为 API JSON 响应
|
||||
- **THEN** 请求 SHALL NOT 返回前端 `index.html`
|
||||
|
||||
#### Scenario: 协议代理请求路由
|
||||
|
||||
- **WHEN** 请求路径以 `/openai/` 或 `/anthropic/` 开头
|
||||
- **THEN** 请求 SHALL 被视为协议代理请求或返回 API 风格 404
|
||||
- **THEN** 请求 SHALL NOT 返回前端 `index.html`
|
||||
|
||||
#### Scenario: OpenAI 代理路由
|
||||
|
||||
- **WHEN** desktop 模式收到 `/openai/v1/chat/completions` 请求
|
||||
- **THEN** 请求 SHALL 进入 ProxyHandler
|
||||
- **THEN** ProxyHandler SHALL 获取 clientProtocol 为 `openai`
|
||||
|
||||
#### Scenario: Anthropic 代理路由
|
||||
|
||||
- **WHEN** desktop 模式收到 `/anthropic/v1/messages` 请求
|
||||
- **THEN** 请求 SHALL 进入 ProxyHandler
|
||||
- **THEN** ProxyHandler SHALL 获取 clientProtocol 为 `anthropic`
|
||||
|
||||
#### Scenario: 静态资源路由
|
||||
|
||||
- **WHEN** 请求路径为 `/assets/*`
|
||||
- **THEN** 返回嵌入的前端静态资源文件
|
||||
- **THEN** 请求 SHALL NOT 被协议代理路由处理
|
||||
|
||||
#### Scenario: PNG Favicon 路由
|
||||
|
||||
- **WHEN** 请求路径为 `/icon.png`
|
||||
- **THEN** 返回来源于统一应用图标的 PNG favicon 资源
|
||||
- **THEN** 请求 SHALL NOT 被协议代理路由处理
|
||||
|
||||
#### Scenario: SPA 路由回退
|
||||
|
||||
- **WHEN** 请求路径不匹配任何 API、协议代理或静态资源路由
|
||||
- **THEN** 返回 `index.html`(支持前端 SPA 路由)
|
||||
@@ -0,0 +1,121 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 样式体系
|
||||
|
||||
前端样式 SHALL 优先使用 TDesign 组件 props,其次使用 TDesign tokens,最后在前两者无法表达所需效果时使用 SCSS 作为补充工具。
|
||||
|
||||
#### Scenario: TDesign 组件 Props 优先
|
||||
|
||||
- **WHEN** 实现组件视觉效果
|
||||
- **THEN** 前端 SHALL 优先使用 TDesign 组件的视觉增强 Props(如 color、trend、hoverShadow、stripe、variant、shape、headerBordered、gutter 等)
|
||||
- **THEN** 前端 SHALL NOT 通过 CSS 类名覆盖组件内部样式
|
||||
|
||||
#### Scenario: TDesign Tokens 作为第二优先级
|
||||
|
||||
- **WHEN** 组件 props 无法完整表达颜色、边框、背景、间距等视觉细节
|
||||
- **THEN** 前端 SHALL 使用 TDesign CSS Token 引用(`var(--td-*)`)表达样式
|
||||
- **THEN** 前端 SHALL NOT 在布局样式中硬编码 `#fff`、`#e7e7e7`、`#999` 等颜色值
|
||||
|
||||
#### Scenario: CSS Variables 主题微调
|
||||
|
||||
- **WHEN** 需要调整全局视觉风格
|
||||
- **THEN** 前端 SHALL 通过 `:root` 中声明 TDesign CSS Variables(`--td-*`)进行覆盖
|
||||
- **THEN** 前端 SHALL NOT 使用 `!important` 或高优先级选择器覆盖组件样式
|
||||
|
||||
#### Scenario: SCSS 兜底使用
|
||||
|
||||
- **WHEN** TDesign 组件 props 和 TDesign tokens 均无法满足布局、响应式或品牌视觉需求
|
||||
- **THEN** 前端 MAY 使用 SCSS 作为补充
|
||||
- **THEN** SCSS SHALL 只承载必要的补充样式
|
||||
- **THEN** 前端 SHALL NOT 使用纯 CSS 文件(*.css)
|
||||
|
||||
### Requirement: 提供响应式布局
|
||||
|
||||
前端 SHALL 使用 TDesign Layout 提供侧边栏导航布局。
|
||||
|
||||
#### Scenario: 桌面布局
|
||||
|
||||
- **WHEN** 在桌面屏幕上查看前端
|
||||
- **THEN** 布局 SHALL 使用 TDesign `Layout.Aside` + `Menu`
|
||||
- **THEN** 侧边栏 SHALL 显示导航菜单,包含图标和文字标签
|
||||
- **THEN** 侧边栏 SHALL 使用固定宽度 232px
|
||||
- **THEN** Menu 组件 SHALL 使用 `logo` prop 显示品牌标识
|
||||
- **THEN** Menu 组件 SHALL 使用 `operations` prop 在底部显示操作区域
|
||||
- **THEN** Menu 组件 SHALL 支持 `collapsed` 折叠功能
|
||||
|
||||
#### Scenario: 侧边栏折叠布局
|
||||
|
||||
- **WHEN** 用户折叠侧边栏
|
||||
- **THEN** 侧边栏 SHALL 使用折叠宽度 64px
|
||||
- **THEN** Menu logo 区域 SHALL 保留应用图标
|
||||
- **THEN** Menu logo 区域 SHALL 隐藏应用名称文字
|
||||
- **THEN** Menu logo 区域 SHALL NOT 显示为空白
|
||||
|
||||
#### Scenario: 页面内容区域
|
||||
|
||||
- **WHEN** 显示页面内容
|
||||
- **THEN** 内容区域 SHALL 在 `Layout.Content` 中渲染
|
||||
- **THEN** 页面之间 SHALL 通过 React Router Outlet 渲染
|
||||
|
||||
#### Scenario: Header 区域
|
||||
|
||||
- **WHEN** 渲染页面 Header
|
||||
- **THEN** Header SHALL 仅显示当前页面标题
|
||||
- **THEN** Header SHALL 不包含导航菜单
|
||||
- **THEN** Header 背景色 SHALL 使用 `var(--td-bg-color-container)` Token
|
||||
- **THEN** Header 底部分割线 SHALL 使用 `var(--td-component-stroke)` Token
|
||||
|
||||
### Requirement: 提供侧边栏导航
|
||||
|
||||
前端 SHALL 使用 TDesign `Layout.Aside` 提供侧边栏导航。
|
||||
|
||||
#### Scenario: 侧边栏内容
|
||||
|
||||
- **WHEN** 渲染侧边栏
|
||||
- **THEN** 侧边栏顶部 SHALL 显示统一应用图标和应用名称 `Nex`
|
||||
- **THEN** 侧边栏 SHALL NOT 显示旧品牌文字 `AI Gateway` 作为应用名称
|
||||
- **THEN** 侧边栏 SHALL 包含导航菜单
|
||||
- **THEN** 导航菜单项 SHALL 包含:供应商管理(ServerIcon 图标)、用量统计(ChartLineIcon 图标)、设置(SettingIcon 图标)、关于(InfoCircleIcon 图标)
|
||||
|
||||
#### Scenario: 侧边栏折叠品牌显示
|
||||
|
||||
- **WHEN** 侧边栏处于折叠状态
|
||||
- **THEN** 侧边栏顶部 SHALL 显示统一应用图标
|
||||
- **THEN** 侧边栏顶部 SHALL 隐藏 `Nex` 文案
|
||||
- **THEN** 侧边栏顶部 SHALL NOT 为空白
|
||||
|
||||
#### Scenario: 导航菜单交互
|
||||
|
||||
- **WHEN** 用户点击导航中的"供应商管理"
|
||||
- **THEN** 前端 SHALL 导航到 `/providers` 并高亮当前菜单项
|
||||
- **WHEN** 用户点击导航中的"用量统计"
|
||||
- **THEN** 前端 SHALL 导航到 `/stats` 并高亮当前菜单项
|
||||
- **WHEN** 用户点击导航中的"设置"
|
||||
- **THEN** 前端 SHALL 导航到 `/settings` 并高亮当前菜单项
|
||||
- **WHEN** 用户点击导航中的"关于"
|
||||
- **THEN** 前端 SHALL 导航到 `/about` 并高亮当前菜单项
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 统一 public 图标资源
|
||||
|
||||
前端 SHALL 使用仓库统一应用图标作为 public favicon 和品牌图标来源。
|
||||
|
||||
#### Scenario: 使用 PNG favicon
|
||||
|
||||
- **WHEN** 前端页面加载 HTML 入口
|
||||
- **THEN** 页面 SHALL 使用 `/icon.png` 作为 PNG favicon 路径
|
||||
- **THEN** `frontend/public/icon.png` SHALL 来源于仓库根目录 `assets/icon.png`
|
||||
- **THEN** 页面 SHALL NOT 引用独立维护的 SVG favicon
|
||||
|
||||
#### Scenario: HTML 标题使用统一应用名称
|
||||
|
||||
- **WHEN** 前端页面加载 HTML 入口
|
||||
- **THEN** 页面标题 SHALL 使用 `Nex` 作为应用名称
|
||||
- **THEN** 页面标题 SHALL NOT 使用旧应用名称 `AI Gateway`
|
||||
|
||||
#### Scenario: 清理未使用 public SVG 图标
|
||||
|
||||
- **WHEN** public 目录中的 SVG 图标资源没有被前端代码、HTML 或 desktop 静态服务引用
|
||||
- **THEN** 前端 SHALL 删除该未使用 SVG 图标资源
|
||||
- **THEN** 前端 SHALL NOT 保留未使用的 `frontend/public/icons.svg`
|
||||
@@ -0,0 +1,54 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 后端运行时版本查询
|
||||
|
||||
系统 SHALL 通过管理接口暴露后端运行时构建版本信息,供前端和用户诊断前后端版本一致性。
|
||||
|
||||
#### Scenario: 查询后端版本信息
|
||||
|
||||
- **WHEN** 客户端请求 `GET /api/version`
|
||||
- **THEN** 后端 SHALL 返回 HTTP 200
|
||||
- **THEN** 响应 JSON SHALL 包含 `version` 字段
|
||||
- **THEN** 响应 JSON SHALL 包含 `commit` 字段
|
||||
- **THEN** 响应 JSON SHALL 包含 `build_time` 字段
|
||||
|
||||
#### Scenario: 版本信息来源于构建注入
|
||||
|
||||
- **WHEN** 后端返回版本信息
|
||||
- **THEN** `version` SHALL 来源于 `buildinfo.Version()`
|
||||
- **THEN** `commit` SHALL 来源于 `buildinfo.Commit()`
|
||||
- **THEN** `build_time` SHALL 来源于 `buildinfo.BuildTime()`
|
||||
- **THEN** 后端 SHALL NOT 在运行时读取仓库 `VERSION` 文件作为接口响应来源
|
||||
|
||||
#### Scenario: 本地开发构建降级值
|
||||
|
||||
- **WHEN** 后端未通过构建参数注入版本元数据
|
||||
- **THEN** 后端版本接口 SHALL 返回 buildinfo 的默认降级值
|
||||
- **THEN** 前端 SHALL 能够展示该降级值而不崩溃
|
||||
|
||||
### Requirement: 前后端版本一致性诊断
|
||||
|
||||
系统 SHALL 支持前端使用自身构建版本和后端运行时版本进行一致性诊断。
|
||||
|
||||
#### Scenario: 前端读取构建版本
|
||||
|
||||
- **WHEN** 前端渲染版本信息
|
||||
- **THEN** 前端 SHALL 使用 `VITE_APP_VERSION` 作为前端版本号
|
||||
- **THEN** `VITE_APP_VERSION` SHALL 继续由版本同步流程保持与 `VERSION` 一致
|
||||
|
||||
#### Scenario: 诊断版本匹配
|
||||
|
||||
- **WHEN** 前端版本号和后端版本号均可判断且完全相同
|
||||
- **THEN** 前端 SHALL 将版本状态判定为一致
|
||||
|
||||
#### Scenario: 诊断版本不匹配
|
||||
|
||||
- **WHEN** 前端版本号和后端版本号均可判断且不相同
|
||||
- **THEN** 前端 SHALL 将版本状态判定为不一致
|
||||
- **THEN** 前端 SHALL 将该状态作为诊断提示展示
|
||||
|
||||
#### Scenario: 诊断版本不可判断
|
||||
|
||||
- **WHEN** 任一版本号为空、`dev` 或 `unknown`
|
||||
- **THEN** 前端 SHALL 将版本状态判定为无法判断
|
||||
- **THEN** 前端 SHALL NOT 将该状态判定为版本不一致
|
||||
@@ -0,0 +1,57 @@
|
||||
## 1. 后端版本接口
|
||||
|
||||
- [x] 1.1 新增后端版本信息 handler,响应 `version`、`commit`、`build_time` 并复用 `buildinfo`
|
||||
- [x] 1.2 在 server 入口注册 `GET /api/version`
|
||||
- [x] 1.3 在 desktop 入口注册 `GET /api/version`
|
||||
- [x] 1.4 为版本 handler 增加单元测试覆盖响应字段和默认降级值
|
||||
- [x] 1.5 为 server 路由增加测试覆盖 `GET /api/version`
|
||||
- [x] 1.6 为 desktop 路由增加测试覆盖 `GET /api/version` 不落入 SPA fallback
|
||||
|
||||
## 2. 前端版本 API 与状态判断
|
||||
|
||||
- [x] 2.1 新增前端版本信息类型定义,包含后端 `version`、`commit`、`buildTime`
|
||||
- [x] 2.2 新增前端版本 API 调用 `GET /api/version`
|
||||
- [x] 2.3 新增 TanStack Query hook 获取后端版本信息
|
||||
- [x] 2.4 集中定义前端 `APP_NAME`、产品描述和 `VITE_APP_VERSION` 降级值
|
||||
- [x] 2.5 实现前后端版本状态判断逻辑,覆盖一致、不一致、无法判断和请求失败
|
||||
- [x] 2.6 为版本 API 和版本状态判断增加前端单元测试
|
||||
|
||||
## 3. 品牌与图标资源
|
||||
|
||||
- [x] 3.1 将侧边栏品牌名称从 `AI Gateway` 替换为 `Nex`
|
||||
- [x] 3.2 使用统一应用图标渲染侧边栏 logo,折叠态保留图标并隐藏文字
|
||||
- [x] 3.3 为侧边栏折叠按钮补充清晰的无障碍标签
|
||||
- [x] 3.4 将根目录 `assets/icon.png` 复制为 `frontend/public/icon.png`
|
||||
- [x] 3.5 将前端 HTML favicon 路径改为 `/icon.png` 并将 HTML title 改为 `Nex`
|
||||
- [x] 3.6 将 desktop 静态文件服务的 `/favicon.svg` 路由替换为 `/icon.png` 路由
|
||||
- [x] 3.7 删除未使用的 `frontend/public/icons.svg` 和旧 SVG favicon 资源
|
||||
- [x] 3.8 全文确认不存在未处理的 `icons.svg`、`favicon.svg` 引用
|
||||
- [x] 3.9 更新 AppLayout 组件测试覆盖展开态 `Nex`、折叠态图标和 About 菜单项
|
||||
- [x] 3.10 更新导航 E2E 中旧 `AI Gateway` 品牌断言为 `Nex`
|
||||
|
||||
## 4. About 页面改造
|
||||
|
||||
- [x] 4.1 重构 About 页面为品牌卡片、版本信息卡片和链接卡片三卡布局
|
||||
- [x] 4.2 使用 TDesign Card、Tag、Descriptions、Alert、Row/Col 等组件 props 完成主要视觉结构
|
||||
- [x] 4.3 在 About 页面展示前端版本、后端版本、commit 和 build_time
|
||||
- [x] 4.4 在 About 页面展示版本一致、不一致、无法判断和请求失败状态
|
||||
- [x] 4.5 确保版本不一致或接口失败时 About 页面不崩溃且不阻断其他功能
|
||||
- [x] 4.6 为 About 页面增加组件测试覆盖布局、版本展示和所有版本状态
|
||||
- [x] 4.7 为 About 页面补充必要的 E2E 导航与版本区域可见性测试
|
||||
|
||||
## 5. 文档同步
|
||||
|
||||
- [x] 5.1 更新根 README 的管理接口列表,加入 `GET /api/version`
|
||||
- [x] 5.2 更新根 README 的前端样式技术栈说明,体现 TDesign props、TDesign tokens、SCSS 优先级
|
||||
- [x] 5.3 更新 backend README 的管理接口文档,说明版本响应字段来源
|
||||
- [x] 5.4 更新 frontend README 的环境变量说明,加入 `VITE_APP_VERSION`
|
||||
- [x] 5.5 更新 frontend README 的样式优先级为 TDesign props、TDesign tokens、SCSS
|
||||
- [x] 5.6 更新 frontend README 的项目结构、About 页面说明和 public 图标来源说明
|
||||
- [x] 5.7 更新 desktop 相关文档说明 PNG favicon、`/icon.png` 静态路由和 API 版本接口路由
|
||||
|
||||
## 6. 验证
|
||||
|
||||
- [x] 6.1 运行后端测试,确保 handler、server 和 desktop 路由测试通过
|
||||
- [x] 6.2 运行前端测试,确保 API、hook、AppLayout 和 About 页面测试通过
|
||||
- [x] 6.3 运行前端检查和构建,确保 Bun/Vite 构建能加载 PNG favicon
|
||||
- [x] 6.4 运行 OpenSpec 校验,确保 proposal、design、specs 和 tasks 状态可实施
|
||||
@@ -8,7 +8,7 @@ context: |
|
||||
- 新增代码优先复用已有组件、工具、依赖库,不引入新依赖
|
||||
- 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试
|
||||
- backend是使用go开发的后端,阅读backend/README.md了解项目架构,优先使用公共组件实现功能逻辑(优先级:官方库>主流三方库>项目公共工具>自行实现)
|
||||
- frontend是基于bun+vite+typescript开发的前端,使用bun作为唯一包管理器,严禁使用pnpm、npm
|
||||
- frontend是基于bun+vite+typescript开发的前端,使用bun作为唯一包管理器,严禁使用pnpm、npm,使用bunx运行工具,严禁使用npx、pnpx
|
||||
- Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明
|
||||
- 禁止创建git操作task
|
||||
- 积极使用subagents精心设计并行任务,节省上下文空间,加速任务执行
|
||||
|
||||
@@ -2,25 +2,83 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
TBD - 提供关于页面展示项目品牌信息
|
||||
TBD - 提供关于页面展示项目品牌信息、版本信息和外部链接
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 关于页面
|
||||
|
||||
前端 SHALL 提供关于页面,使用 TDesign Card 组件居中展示项目品牌信息(应用名称、描述、项目链接)。
|
||||
前端 SHALL 提供现代化关于页面,使用 TDesign 组件展示项目品牌信息、项目链接、前端版本、后端版本和版本匹配状态。
|
||||
|
||||
#### Scenario: 显示关于页面
|
||||
|
||||
- **WHEN** 用户访问 `/about` 路径
|
||||
- **THEN** 前端 SHALL 显示关于页面
|
||||
- **THEN** 页面 SHALL 展示应用名称"Nex"
|
||||
- **THEN** 页面 SHALL 展示应用描述"AI Gateway - 统一的大模型 API 网关"
|
||||
- **THEN** 页面 SHALL 展示项目链接"https://github.com/nex/gateway"
|
||||
- **THEN** 页面 SHALL 展示应用名称 `Nex`
|
||||
- **THEN** 页面 SHALL 展示应用描述 `AI Gateway - 统一的大模型 API 网关`
|
||||
- **THEN** 页面 SHALL 展示项目链接 `https://github.com/nex/gateway`
|
||||
|
||||
#### Scenario: 页面布局
|
||||
|
||||
- **WHEN** 渲染关于页面
|
||||
- **THEN** 页面 SHALL 使用 TDesign Card 组件作为容器
|
||||
- **THEN** Card SHALL 设置 `bordered={false}`
|
||||
- **THEN** 内容 SHALL 居中展示
|
||||
- **THEN** 页面 SHALL 使用三个独立 TDesign Card 组件分别承载品牌区、版本信息区和链接区
|
||||
- **THEN** 三个 Card SHALL 使用 grid 布局垂直排列
|
||||
- **THEN** 每个 Card SHALL 设置 `bordered={false}` 和 `hoverShadow`
|
||||
- **THEN** Card SHALL 使用 TDesign 组件 props 和 tokens 完成主要视觉效果
|
||||
|
||||
#### Scenario: 品牌卡片
|
||||
|
||||
- **WHEN** 渲染品牌卡片
|
||||
- **THEN** 卡片 SHALL 展示应用图标、应用名称 `Nex` 和产品描述
|
||||
|
||||
#### Scenario: 版本信息卡片
|
||||
|
||||
- **WHEN** 渲染版本信息卡片
|
||||
- **THEN** 版本状态 Tag SHALL 以绝对定位浮动在卡片右上角,不占据内容布局空间
|
||||
- **THEN** 版本状态 Tag SHALL 使用 TDesign Tag 的 `theme`、`variant`、`shape` props
|
||||
- **THEN** 卡片 SHALL 展示前端版本、后端版本、后端提交和后端构建时间
|
||||
|
||||
#### Scenario: 链接卡片
|
||||
|
||||
- **WHEN** 渲染链接卡片
|
||||
- **THEN** 卡片 SHALL 展示项目外部链接
|
||||
|
||||
#### Scenario: 展示前端版本
|
||||
|
||||
- **WHEN** 渲染关于页面
|
||||
- **THEN** 页面 SHALL 显示前端版本号
|
||||
- **THEN** 前端版本号 SHALL 来源于构建注入的 `VITE_APP_VERSION`
|
||||
- **THEN** 当前端版本号缺失时页面 SHALL 显示开发或未知版本状态
|
||||
|
||||
#### Scenario: 展示后端版本
|
||||
|
||||
- **WHEN** 渲染关于页面且后端版本接口请求成功
|
||||
- **THEN** 页面 SHALL 显示后端 `version`
|
||||
- **THEN** 页面 SHALL 显示后端 `commit`
|
||||
- **THEN** 页面 SHALL 显示后端 `build_time`
|
||||
|
||||
#### Scenario: 判断版本一致
|
||||
|
||||
- **WHEN** 前端版本号与后端版本号相同
|
||||
- **THEN** 页面 SHALL 显示版本一致状态
|
||||
- **THEN** 版本状态 SHALL 使用 TDesign Tag 展示成功语义
|
||||
|
||||
#### Scenario: 判断版本不一致
|
||||
|
||||
- **WHEN** 前端版本号与后端版本号不同且两者均为可判断的发布版本
|
||||
- **THEN** 页面 SHALL 显示版本不一致状态
|
||||
- **THEN** 页面 SHALL 展示提示信息说明该状态用于部署诊断
|
||||
- **THEN** 页面 SHALL NOT 阻断用户使用其他功能
|
||||
|
||||
#### Scenario: 后端版本无法判断
|
||||
|
||||
- **WHEN** 后端版本号为 `dev`、`unknown` 或空值
|
||||
- **THEN** 页面 SHALL 显示开发构建或无法判断状态
|
||||
- **THEN** 页面 SHALL NOT 将该状态显示为版本错误
|
||||
|
||||
#### Scenario: 后端版本获取失败
|
||||
|
||||
- **WHEN** 请求后端版本接口失败
|
||||
- **THEN** 页面 SHALL 显示无法获取后端版本的状态
|
||||
- **THEN** 页面 SHALL 保留前端版本信息
|
||||
- **THEN** 页面 SHALL NOT 因版本接口失败而崩溃
|
||||
|
||||
151
openspec/specs/ci-test-gate/spec.md
Normal file
151
openspec/specs/ci-test-gate/spec.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# CI Test Gate
|
||||
|
||||
## Purpose
|
||||
|
||||
定义 CI 全流程测试门禁,作为 release 和未来其他 CI 流程的前序质量检查,覆盖 lint、默认测试、MySQL 测试和 E2E 测试。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 独立可复用测试 workflow
|
||||
|
||||
系统 SHALL 提供独立的全流程测试 workflow(`test.yml`),使用 `workflow_call` 触发器,通过 `full` 布尔参数控制测试分层执行。
|
||||
|
||||
#### Scenario: workflow_call 触发器
|
||||
|
||||
- **WHEN** 查看 `.github/workflows/test.yml` 的触发器配置
|
||||
- **THEN** SHALL 使用 `on: workflow_call` 触发器
|
||||
- **THEN** SHALL 声明 `inputs.full` 布尔参数,默认值为 `false`
|
||||
- **THEN** SHALL NOT 使用 `push`、`pull_request` 等其他触发器
|
||||
|
||||
#### Scenario: 被其他 workflow 引用(快速模式)
|
||||
|
||||
- **WHEN** 其他 workflow 的 job 通过 `uses: ./.github/workflows/test.yml` 引用此 workflow 且未传 `full` 或传 `full: false`
|
||||
- **THEN** test workflow SHALL 仅执行 `check` job(lint + 全量测试)
|
||||
- **THEN** test workflow SHALL NOT 执行 MySQL 测试和 E2E 测试
|
||||
|
||||
#### Scenario: 被其他 workflow 引用(完整模式)
|
||||
|
||||
- **WHEN** 其他 workflow 的 job 引用此 workflow 且传 `full: true`
|
||||
- **THEN** test workflow SHALL 执行 `check`、`mysql`、`e2e` 三个 job
|
||||
- **THEN** `mysql` 和 `e2e` job SHALL 在 `check` job 成功后并行执行
|
||||
|
||||
### Requirement: 全流程测试步骤编排
|
||||
|
||||
测试 workflow SHALL 将测试步骤拆分为 `check`、`mysql`、`e2e` 三个独立 job,通过 `full` 参数和 `needs` 依赖控制执行。
|
||||
|
||||
#### Scenario: check job(始终执行)
|
||||
|
||||
- **WHEN** 测试 workflow 被调用(无论 `full` 值)
|
||||
- **THEN** `check` job SHALL 始终执行
|
||||
- **THEN** SHALL 在 `check` job 内按顺序执行:checkout(含 LFS)→ setup Go → setup Bun → `make lint` → `make test`
|
||||
- **THEN** `make lint` SHALL 覆盖 backend golangci-lint、frontend typecheck + eslint + prettier、versionctl golangci-lint
|
||||
- **THEN** `make test` SHALL 覆盖 backend 核心测试、frontend Vitest 单元/组件测试、desktop 测试和 versionctl 测试
|
||||
- **THEN** `make test` SHALL NOT 覆盖 MySQL 专项测试或 frontend E2E 测试
|
||||
- **THEN** lint 或测试失败时 SHALL 阻止后续步骤执行
|
||||
|
||||
#### Scenario: mysql job(仅 full=true)
|
||||
|
||||
- **WHEN** 测试 workflow 被调用且 `full=true`,且 `check` job 成功
|
||||
- **THEN** `mysql` job SHALL 执行
|
||||
- **THEN** SHALL checkout 仓库代码
|
||||
- **THEN** SHALL 安装 Go 工具链(使用 `go.work` 文件指定版本,缓存覆盖 `backend/go.sum` 和 `versionctl/go.sum`)
|
||||
- **THEN** SHALL 使用 GitHub Actions `services:` 声明 MySQL 8.0 容器
|
||||
- **THEN** MySQL 容器 SHALL 映射端口 `13306:3306`
|
||||
- **THEN** MySQL 容器 SHALL 配置 `MYSQL_DATABASE=nex_test`、`MYSQL_USER=nex_test`、`MYSQL_PASSWORD=testpass`
|
||||
- **THEN** SHALL 执行 `cd backend && go test -tags=mysql ./tests/mysql/... -v -count=1`
|
||||
|
||||
#### Scenario: mysql job 跳过
|
||||
|
||||
- **WHEN** 测试 workflow 被调用且 `full=false`
|
||||
- **THEN** `mysql` job SHALL NOT 执行
|
||||
|
||||
#### Scenario: e2e job(仅 full=true)
|
||||
|
||||
- **WHEN** 测试 workflow 被调用且 `full=true`,且 `check` job 成功
|
||||
- **THEN** `e2e` job SHALL 执行
|
||||
- **THEN** SHALL checkout 仓库代码(含 LFS)
|
||||
- **THEN** SHALL 安装 Go 工具链(使用 `go.work` 文件指定版本,缓存覆盖 `backend/go.sum` 和 `versionctl/go.sum`)
|
||||
- **THEN** SHALL 安装 Bun 运行时
|
||||
- **THEN** SHALL 安装 Playwright Chromium 浏览器:`cd frontend && bunx playwright install --with-deps chromium`
|
||||
- **THEN** SHALL 执行 `cd frontend && bun run test:e2e`
|
||||
- **THEN** Playwright SHALL 使用 CI 模式(`forbidOnly: true`、`retries: 2`)
|
||||
|
||||
#### Scenario: e2e job 跳过
|
||||
|
||||
- **WHEN** 测试 workflow 被调用且 `full=false`
|
||||
- **THEN** `e2e` job SHALL NOT 执行
|
||||
|
||||
#### Scenario: mysql 和 e2e 并行执行
|
||||
|
||||
- **WHEN** `full=true` 且 `check` job 成功
|
||||
- **THEN** `mysql` job 和 `e2e` job SHALL 并行执行
|
||||
- **THEN** 两个 job 之间 SHALL NOT 有 `needs` 依赖关系
|
||||
|
||||
#### Scenario: check 失败阻止后续 job
|
||||
|
||||
- **WHEN** `check` job 中 lint 或测试任一失败
|
||||
- **THEN** `mysql` 和 `e2e` job SHALL NOT 执行
|
||||
|
||||
### Requirement: 开发 CI 自动触发
|
||||
|
||||
系统 SHALL 在 `push`(`dev` 和 `main` 分支)和所有 `pull_request` 事件时自动触发快速质量检查。
|
||||
|
||||
#### Scenario: push 到 dev 分支触发 CI
|
||||
|
||||
- **WHEN** 代码推送到 `dev` 分支
|
||||
- **THEN** SHALL 触发 CI workflow
|
||||
- **THEN** CI workflow SHALL 调用 `test.yml`(`full=false`)
|
||||
- **THEN** SHALL 仅执行 lint 和全量单元/集成测试
|
||||
|
||||
#### Scenario: push 到 main 分支触发 CI
|
||||
|
||||
- **WHEN** 代码推送到 `main` 分支
|
||||
- **THEN** SHALL 触发 CI workflow
|
||||
- **THEN** CI workflow SHALL 调用 `test.yml`(`full=false`)
|
||||
|
||||
#### Scenario: Pull Request 触发 CI
|
||||
|
||||
- **WHEN** 创建或更新 Pull Request
|
||||
- **THEN** SHALL 触发 CI workflow
|
||||
- **THEN** CI workflow SHALL 调用 `test.yml`(`full=false`)
|
||||
|
||||
#### Scenario: CI workflow 极简设计
|
||||
|
||||
- **WHEN** 查看 `.github/workflows/ci.yml`
|
||||
- **THEN** SHALL 仅包含触发器配置和一个 job 引用 `test.yml`
|
||||
- **THEN** SHALL NOT 定义任何直接执行的步骤
|
||||
- **THEN** SHALL NOT 传递 `full: true`
|
||||
|
||||
### Requirement: 发布流水线使用完整测试模式
|
||||
|
||||
`release.yml` 调用 `test.yml` 时 SHALL 显式传递 `full: true`,确保发布流程执行完整测试。
|
||||
|
||||
#### Scenario: release 调用 test.yml 传 full: true
|
||||
|
||||
- **WHEN** 发布流水线的 `test-gate` job 引用 `test.yml`
|
||||
- **THEN** SHALL 传递 `with: full: true`
|
||||
- **THEN** 发布流水线 SHALL 执行 `check`、`mysql`、`e2e` 三个 job
|
||||
- **THEN** 测试行为 SHALL 与重构前一致
|
||||
|
||||
### Requirement: 测试 workflow 工具链依赖
|
||||
|
||||
测试 workflow SHALL 在单个 ubuntu runner 上准备完整的工具链环境。
|
||||
|
||||
#### Scenario: 工具链安装
|
||||
|
||||
- **WHEN** 测试 workflow 开始执行
|
||||
- **THEN** SHALL checkout 仓库代码并拉取 Git LFS 文件
|
||||
- **THEN** SHALL 安装 Go 工具链(使用 `go.work` 文件指定版本)
|
||||
- **THEN** SHALL 安装 Bun 运行时
|
||||
- **THEN** Go 模块缓存 SHALL 覆盖 `backend/go.sum` 和 `versionctl/go.sum`
|
||||
|
||||
### Requirement: 测试 workflow 资源隔离
|
||||
|
||||
测试 workflow 中的各测试步骤 SHALL 使用隔离的资源,不干扰主环境。
|
||||
|
||||
#### Scenario: E2E 临时资源隔离
|
||||
|
||||
- **WHEN** E2E 测试运行
|
||||
- **THEN** Go 后端 SHALL 使用临时目录的独立数据库文件(`/tmp/nex-e2e/test.db`)
|
||||
- **THEN** Go 后端 SHALL 使用临时目录的日志目录(`/tmp/nex-e2e/log/`)
|
||||
- **THEN** 临时资源 SHALL 在测试结束后自动清理
|
||||
@@ -8,20 +8,27 @@
|
||||
|
||||
### Requirement: 使用 YAML 配置文件
|
||||
|
||||
系统 SHALL 使用 YAML 格式的配置文件。
|
||||
系统 SHALL 使用 YAML 格式的配置文件,并按入口区分配置文件路径选择能力。
|
||||
|
||||
#### Scenario: 配置文件路径
|
||||
#### Scenario: Server 默认配置文件路径
|
||||
|
||||
- **WHEN** 应用启动且未指定 `--config` 参数
|
||||
- **WHEN** server 应用启动且未指定 `--config` 参数
|
||||
- **THEN** SHALL 从 `~/.nex/config.yaml` 加载配置
|
||||
- **THEN** SHALL 解析 YAML 格式
|
||||
|
||||
#### Scenario: 自定义配置文件路径
|
||||
#### Scenario: Server 自定义配置文件路径
|
||||
|
||||
- **WHEN** 应用启动且指定 `--config /path/to/custom.yaml`
|
||||
- **WHEN** server 应用启动且指定 `--config /path/to/custom.yaml`
|
||||
- **THEN** SHALL 从指定路径加载配置文件
|
||||
- **THEN** SHALL NOT 使用默认路径 `~/.nex/config.yaml`
|
||||
|
||||
#### Scenario: Desktop 固定配置文件路径
|
||||
|
||||
- **WHEN** desktop 应用启动
|
||||
- **THEN** SHALL 从 `~/.nex/config.yaml` 加载配置
|
||||
- **THEN** SHALL 解析 YAML 格式
|
||||
- **THEN** SHALL NOT 支持通过 `--config` 指定其他配置文件路径
|
||||
|
||||
#### Scenario: 配置文件结构
|
||||
|
||||
- **WHEN** 加载配置文件
|
||||
@@ -30,14 +37,14 @@
|
||||
|
||||
### Requirement: 自动生成默认配置
|
||||
|
||||
系统 SHALL 在首次使用时自动生成默认配置。
|
||||
系统 SHALL 在配置文件不存在时使用默认配置值,不自动创建配置文件。
|
||||
|
||||
#### Scenario: 配置文件不存在
|
||||
|
||||
- **WHEN** 应用启动且配置文件不存在
|
||||
- **THEN** SHALL 自动创建配置文件
|
||||
- **THEN** SHALL 写入默认配置值
|
||||
- **THEN** SHALL 记录日志提示已创建
|
||||
- **THEN** SHALL 使用默认配置值
|
||||
- **THEN** SHALL NOT 自动创建配置文件
|
||||
- **THEN** SHALL NOT 写入默认配置值到磁盘
|
||||
|
||||
#### Scenario: 配置文件已存在
|
||||
|
||||
@@ -163,22 +170,36 @@
|
||||
|
||||
### Requirement: 配置加载流程
|
||||
|
||||
系统 SHALL 实现标准化的配置加载流程。
|
||||
系统 SHALL 为 server 和 desktop 实现标准化且入口隔离的配置加载流程。
|
||||
|
||||
#### Scenario: 加载步骤
|
||||
#### Scenario: Server 加载步骤
|
||||
|
||||
- **WHEN** 应用启动
|
||||
- **WHEN** server 应用启动
|
||||
- **THEN** SHALL 按以下顺序加载配置:
|
||||
1. 解析 CLI 参数(获取 --config 路径)
|
||||
2. 初始化配置管理器
|
||||
3. 设置默认值
|
||||
4. 绑定 CLI 参数
|
||||
5. 绑定环境变量
|
||||
6. 读取配置文件(不存在时自动创建)
|
||||
6. 读取配置文件(不存在时使用默认值)
|
||||
7. 反序列化到结构体
|
||||
8. 验证配置
|
||||
9. 打印配置摘要
|
||||
|
||||
#### Scenario: Desktop 加载步骤
|
||||
|
||||
- **WHEN** desktop 应用启动
|
||||
- **THEN** SHALL 按以下顺序加载配置:
|
||||
1. 初始化配置管理器
|
||||
2. 设置默认值
|
||||
3. 读取默认配置文件 `~/.nex/config.yaml`(不存在时使用默认值)
|
||||
4. 反序列化到结构体
|
||||
5. 验证配置
|
||||
6. 打印配置摘要
|
||||
- **THEN** SHALL NOT 解析 CLI 参数
|
||||
- **THEN** SHALL NOT 绑定环境变量
|
||||
- **THEN** SHALL NOT 允许 CLI 参数覆盖配置文件路径
|
||||
|
||||
#### Scenario: 加载失败处理
|
||||
|
||||
- **WHEN** 配置加载过程中发生错误
|
||||
@@ -188,25 +209,25 @@
|
||||
|
||||
### Requirement: 配置优先级管理
|
||||
|
||||
系统 SHALL 实现明确的配置优先级机制。
|
||||
系统 SHALL 为不同入口实现明确的配置优先级机制。
|
||||
|
||||
#### Scenario: 优先级顺序
|
||||
#### Scenario: Server 优先级顺序
|
||||
|
||||
- **WHEN** 同一配置项在多个配置源中设置
|
||||
- **WHEN** 同一配置项在多个 server 配置源中设置
|
||||
- **THEN** SHALL 按以下优先级顺序(从高到低):
|
||||
1. CLI 参数
|
||||
2. 环境变量
|
||||
3. 配置文件
|
||||
4. 默认值
|
||||
|
||||
#### Scenario: CLI 参数最高优先级
|
||||
#### Scenario: Server CLI 参数最高优先级
|
||||
|
||||
- **WHEN** 配置文件设置 `server.port: 9826`
|
||||
- **AND** 环境变量设置 `NEX_SERVER_PORT=9000`
|
||||
- **AND** CLI 参数设置 `--server-port 8080`
|
||||
- **AND** server CLI 参数设置 `--server-port 8080`
|
||||
- **THEN** SHALL 使用 CLI 参数值 8080
|
||||
|
||||
#### Scenario: 环境变量次高优先级
|
||||
#### Scenario: Server 环境变量次高优先级
|
||||
|
||||
- **WHEN** 配置文件设置 `server.port: 9826`
|
||||
- **AND** 环境变量设置 `NEX_SERVER_PORT=9000`
|
||||
@@ -227,21 +248,35 @@
|
||||
- **AND** 未设置 CLI 参数
|
||||
- **THEN** SHALL 使用默认值
|
||||
|
||||
#### Scenario: 部分配置覆盖
|
||||
#### Scenario: Server 部分配置覆盖
|
||||
|
||||
- **WHEN** 配置文件设置完整配置
|
||||
- **AND** CLI 参数仅覆盖部分配置项
|
||||
- **AND** server CLI 参数仅覆盖部分配置项
|
||||
- **THEN** SHALL 合并所有配置源
|
||||
- **THEN** SHALL 使用高优先级源覆盖指定项
|
||||
- **THEN** SHALL 保留其他配置源中的未覆盖项
|
||||
|
||||
#### Scenario: 配置项独立覆盖
|
||||
#### Scenario: Server 配置项独立覆盖
|
||||
|
||||
- **WHEN** 仅通过 CLI 参数设置 `--server-port 9000`
|
||||
- **WHEN** 仅通过 server CLI 参数设置 `--server-port 9000`
|
||||
- **THEN** SHALL 仅覆盖 server.port 配置项
|
||||
- **THEN** SHALL NOT 影响其他配置项
|
||||
- **THEN** SHALL 其他配置项使用配置文件或默认值
|
||||
|
||||
#### Scenario: Desktop 优先级顺序
|
||||
|
||||
- **WHEN** 同一配置项存在于 desktop 默认配置文件和默认值中
|
||||
- **THEN** SHALL 使用 `~/.nex/config.yaml` 中的配置文件值
|
||||
- **THEN** SHALL 仅在配置文件未设置该配置项时使用默认值
|
||||
|
||||
#### Scenario: Desktop 忽略外部覆盖源
|
||||
|
||||
- **WHEN** desktop 启动时存在 `--server-port 9000` 参数
|
||||
- **AND** 存在 `NEX_SERVER_PORT=9001` 环境变量
|
||||
- **AND** `~/.nex/config.yaml` 设置 `server.port: 9826`
|
||||
- **THEN** SHALL 使用配置文件值 9826
|
||||
- **THEN** SHALL NOT 使用 CLI 参数或环境变量覆盖配置
|
||||
|
||||
#### Scenario: 启动后配置锁定
|
||||
|
||||
- **WHEN** 应用启动完成
|
||||
@@ -314,67 +349,79 @@
|
||||
|
||||
### Requirement: CLI 参数配置支持
|
||||
|
||||
系统 SHALL 支持通过命令行参数设置所有配置项。
|
||||
server 入口 SHALL 支持通过命令行参数设置所有配置项;desktop 入口 SHALL NOT 将命令行参数作为配置源。
|
||||
|
||||
#### Scenario: 基本参数解析
|
||||
#### Scenario: Server 基本参数解析
|
||||
|
||||
- **WHEN** 应用启动时传入命令行参数
|
||||
- **WHEN** server 应用启动时传入命令行参数
|
||||
- **THEN** SHALL 解析所有 CLI 参数
|
||||
- **THEN** SHALL 将参数值应用到对应配置项
|
||||
|
||||
#### Scenario: 参数命名规范
|
||||
|
||||
- **WHEN** 使用命令行参数
|
||||
- **WHEN** server 使用命令行参数
|
||||
- **THEN** SHALL 使用 kebab-case 命名(如 `--server-port`)
|
||||
- **THEN** SHALL 保持完整的层次结构(如 `--database-max-idle-conns`)
|
||||
- **THEN** SHALL 与配置文件路径对应(`database.max_idle_conns` → `--database-max-idle-conns`)
|
||||
|
||||
#### Scenario: 参数类型支持
|
||||
|
||||
- **WHEN** 解析不同类型的参数
|
||||
- **WHEN** server 解析不同类型的参数
|
||||
- **THEN** SHALL 支持 int 类型(如 `--server-port 9000`)
|
||||
- **THEN** SHALL 支持 string 类型(如 `--database-path /data/nex.db`)
|
||||
- **THEN** SHALL 支持 duration 类型(如 `--server-read-timeout 60s`)
|
||||
- **THEN** SHALL 支持 bool 类型(如 `--log-compress`)
|
||||
|
||||
#### Scenario: 完整配置覆盖
|
||||
#### Scenario: Server 完整配置覆盖
|
||||
|
||||
- **WHEN** 使用服务器相关参数
|
||||
- **WHEN** server 使用服务器相关参数
|
||||
- **THEN** SHALL 支持 `--server-port`、`--server-read-timeout`、`--server-write-timeout`
|
||||
- **WHEN** 使用数据库相关参数
|
||||
- **WHEN** server 使用数据库相关参数
|
||||
- **THEN** SHALL 支持 `--database-driver`、`--database-path`、`--database-host`、`--database-port`、`--database-user`、`--database-password`、`--database-dbname`、`--database-max-idle-conns`、`--database-max-open-conns`、`--database-conn-max-lifetime`
|
||||
- **WHEN** 使用日志相关参数
|
||||
- **WHEN** server 使用日志相关参数
|
||||
- **THEN** SHALL 支持 `--log-level`、`--log-path`、`--log-max-size`、`--log-max-backups`、`--log-max-age`、`--log-compress`
|
||||
|
||||
#### Scenario: 参数帮助信息
|
||||
#### Scenario: Server 参数帮助信息
|
||||
|
||||
- **WHEN** 使用 `--help` 参数
|
||||
- **WHEN** server 使用 `--help` 参数
|
||||
- **THEN** SHALL 显示所有支持的参数
|
||||
- **THEN** SHALL 按功能分组展示参数(服务器、数据库、日志)
|
||||
- **THEN** SHALL 显示每个参数的默认值和说明
|
||||
|
||||
#### Scenario: 参数错误处理
|
||||
#### Scenario: Server 参数错误处理
|
||||
|
||||
- **WHEN** 传入无效的参数值(如 `--server-port abc`)
|
||||
- **WHEN** server 传入无效的参数值(如 `--server-port abc`)
|
||||
- **THEN** SHALL 返回明确的错误信息,指示参数名称和期望类型
|
||||
- **THEN** SHALL NOT 启动应用
|
||||
- **WHEN** 传入未定义的参数(如 `--unknown-param value`)
|
||||
- **WHEN** server 传入未定义的参数(如 `--unknown-param value`)
|
||||
- **THEN** SHALL 返回错误信息,指示未知参数名称
|
||||
- **THEN** SHALL NOT 启动应用
|
||||
|
||||
#### Scenario: Desktop 忽略配置参数
|
||||
|
||||
- **WHEN** desktop 启动时传入 `--server-port 9000`、`--database-path /tmp/test.db` 或 `--config /tmp/custom.yaml`
|
||||
- **THEN** SHALL 忽略这些参数
|
||||
- **THEN** SHALL 从 `~/.nex/config.yaml` 和默认值加载配置
|
||||
|
||||
#### Scenario: Desktop 忽略未知参数
|
||||
|
||||
- **WHEN** desktop 启动时传入未知命令行参数
|
||||
- **THEN** SHALL NOT 因未知参数导致配置加载失败
|
||||
- **THEN** SHALL NOT 将未知参数应用为配置
|
||||
|
||||
### Requirement: 环境变量配置支持
|
||||
|
||||
系统 SHALL 支持通过环境变量设置所有配置项,符合 12-Factor App 原则。
|
||||
server 入口 SHALL 支持通过环境变量设置所有配置项,符合 server 部署场景的 12-Factor App 原则;desktop 入口 SHALL NOT 将 `NEX_*` 环境变量作为配置源。
|
||||
|
||||
#### Scenario: 环境变量读取
|
||||
#### Scenario: Server 环境变量读取
|
||||
|
||||
- **WHEN** 应用启动时存在环境变量
|
||||
- **WHEN** server 应用启动时存在环境变量
|
||||
- **THEN** SHALL 自动读取所有 `NEX_` 前缀的环境变量
|
||||
- **THEN** SHALL 将环境变量值应用到对应配置项
|
||||
|
||||
#### Scenario: 环境变量命名规范
|
||||
|
||||
- **WHEN** 使用环境变量配置
|
||||
- **WHEN** server 使用环境变量配置
|
||||
- **THEN** SHALL 使用 `NEX_` 前缀
|
||||
- **THEN** SHALL 使用大写字母和下划线分隔(如 `NEX_SERVER_PORT`)
|
||||
- **THEN** SHALL 保持完整层次结构(如 `NEX_DATABASE_MAX_IDLE_CONNS`)
|
||||
@@ -382,35 +429,143 @@
|
||||
|
||||
#### Scenario: 环境变量类型转换
|
||||
|
||||
- **WHEN** 解析不同类型的环境变量
|
||||
- **WHEN** server 解析不同类型的环境变量
|
||||
- **THEN** SHALL 支持 int 类型(如 `NEX_SERVER_PORT=9000`)
|
||||
- **THEN** SHALL 支持 string 类型(如 `NEX_DATABASE_PATH=/data/nex.db`)
|
||||
- **THEN** SHALL 支持 duration 类型(如 `NEX_SERVER_READ_TIMEOUT=60s`)
|
||||
- **THEN** SHALL 支持 bool 类型(如 `NEX_LOG_COMPRESS=true`)
|
||||
|
||||
#### Scenario: 完整环境变量覆盖
|
||||
#### Scenario: Server 完整环境变量覆盖
|
||||
|
||||
- **WHEN** 设置服务器相关环境变量
|
||||
- **WHEN** server 设置服务器相关环境变量
|
||||
- **THEN** SHALL 支持 `NEX_SERVER_PORT`、`NEX_SERVER_READ_TIMEOUT`、`NEX_SERVER_WRITE_TIMEOUT`
|
||||
- **WHEN** 设置数据库相关环境变量
|
||||
- **WHEN** server 设置数据库相关环境变量
|
||||
- **THEN** SHALL 支持 `NEX_DATABASE_DRIVER`、`NEX_DATABASE_PATH`、`NEX_DATABASE_HOST`、`NEX_DATABASE_PORT`、`NEX_DATABASE_USER`、`NEX_DATABASE_PASSWORD`、`NEX_DATABASE_DBNAME`、`NEX_DATABASE_MAX_IDLE_CONNS`、`NEX_DATABASE_MAX_OPEN_CONNS`、`NEX_DATABASE_CONN_MAX_LIFETIME`
|
||||
- **WHEN** 设置日志相关环境变量
|
||||
- **WHEN** server 设置日志相关环境变量
|
||||
- **THEN** SHALL 支持 `NEX_LOG_LEVEL`、`NEX_LOG_PATH`、`NEX_LOG_MAX_SIZE`、`NEX_LOG_MAX_BACKUPS`、`NEX_LOG_MAX_AGE`、`NEX_LOG_COMPRESS`
|
||||
|
||||
#### Scenario: 12-Factor App 合规
|
||||
#### Scenario: Server 12-Factor App 合规
|
||||
|
||||
- **WHEN** 应用部署到不同环境
|
||||
- **WHEN** server 部署到不同环境
|
||||
- **THEN** SHALL 通过环境变量区分环境配置
|
||||
- **THEN** SHALL NOT 修改代码或配置文件
|
||||
- **WHEN** 配置包含敏感信息(如密钥、密码)
|
||||
- **WHEN** server 配置包含敏感信息(如密钥、密码)
|
||||
- **THEN** SHALL 通过环境变量传递
|
||||
- **THEN** SHALL NOT 存储在配置文件中
|
||||
|
||||
#### Scenario: 环境变量错误处理
|
||||
#### Scenario: Server 环境变量错误处理
|
||||
|
||||
- **WHEN** 环境变量值格式无效(如 `NEX_SERVER_PORT=abc`)
|
||||
- **WHEN** server 环境变量值格式无效(如 `NEX_SERVER_PORT=abc`)
|
||||
- **THEN** SHALL 返回明确的错误信息,指示环境变量名称和期望类型
|
||||
- **THEN** SHALL NOT 启动应用
|
||||
- **WHEN** 必需配置项既无配置文件也无环境变量
|
||||
- **WHEN** server 必需配置项既无配置文件也无环境变量
|
||||
- **THEN** SHALL 使用默认值
|
||||
- **THEN** SHALL 正常启动应用
|
||||
|
||||
#### Scenario: Desktop 忽略环境变量
|
||||
|
||||
- **WHEN** desktop 启动时存在 `NEX_SERVER_PORT`、`NEX_DATABASE_PATH`、`NEX_LOG_LEVEL` 或其他 `NEX_*` 环境变量
|
||||
- **THEN** SHALL NOT 读取这些环境变量作为配置源
|
||||
- **THEN** SHALL 使用 `~/.nex/config.yaml` 和默认值加载配置
|
||||
|
||||
### Requirement: 启动参数设置查询
|
||||
|
||||
系统 SHALL 提供面向前端设置页的启动参数查询能力,按入口返回用于展示的启动参数设置视图和当前入口的可编辑状态。
|
||||
|
||||
#### Scenario: Desktop 查询配置文件编辑视图
|
||||
|
||||
- **WHEN** desktop 入口收到启动参数查询请求
|
||||
- **THEN** 后端 SHALL 使用 desktop 配置语义读取 `~/.nex/config.yaml` 和默认值
|
||||
- **THEN** 后端 SHALL 返回用于编辑配置文件的启动参数设置视图
|
||||
- **THEN** 后端 SHALL NOT 将查询结果应用到当前运行配置快照
|
||||
- **THEN** 返回配置 SHALL 包含 `server`、`database`、`log` 配置分组
|
||||
- **THEN** 返回配置 SHALL 覆盖现有配置结构中的全部启动参数字段
|
||||
- **THEN** 返回配置 SHALL 直接包含 `database.password` 字段值
|
||||
|
||||
#### Scenario: Server 查询当前有效启动参数
|
||||
|
||||
- **WHEN** server 入口收到启动参数查询请求
|
||||
- **THEN** 后端 SHALL 返回当前运行进程启动后使用的有效配置
|
||||
- **THEN** 返回配置 SHALL 包含 `server`、`database`、`log` 配置分组
|
||||
- **THEN** 返回配置 SHALL 覆盖现有配置结构中的全部启动参数字段
|
||||
- **THEN** 返回配置 SHALL 直接包含 `database.password` 字段值
|
||||
|
||||
#### Scenario: 查询返回入口模式元数据
|
||||
|
||||
- **WHEN** 前端请求启动参数设置
|
||||
- **THEN** 后端 SHALL 返回当前入口模式,取值为 `server` 或 `desktop`
|
||||
- **THEN** 后端 SHALL 返回 `editable` 表示当前入口是否允许前端保存启动参数
|
||||
- **THEN** 后端 SHALL 返回配置文件路径
|
||||
- **THEN** 后端 SHALL 返回 `restart_required` 表示保存后是否需要重启生效
|
||||
|
||||
#### Scenario: Desktop 查询返回可编辑元数据
|
||||
|
||||
- **WHEN** desktop 入口收到启动参数查询请求
|
||||
- **THEN** 后端 SHALL 返回 `mode` 为 `desktop`
|
||||
- **THEN** 后端 SHALL 返回 `editable` 为 true
|
||||
- **THEN** 后端 SHALL 返回配置文件路径为默认配置文件 `~/.nex/config.yaml`
|
||||
- **THEN** 后端 SHALL 返回 `restart_required` 为 true
|
||||
|
||||
#### Scenario: 查询不返回来源追踪信息
|
||||
|
||||
- **WHEN** 前端请求启动参数设置
|
||||
- **THEN** 后端 SHALL NOT 要求返回每个字段的配置来源标签
|
||||
- **THEN** 后端 SHALL NOT 要求区分当前运行值和配置文件值
|
||||
|
||||
### Requirement: Desktop 启动参数保存
|
||||
|
||||
desktop 入口 SHALL 允许前端通过设置页保存启动参数到默认配置文件,并保持当前运行时配置快照不变。
|
||||
|
||||
#### Scenario: Desktop 保存有效启动参数
|
||||
|
||||
- **WHEN** desktop 入口收到有效的启动参数保存请求
|
||||
- **THEN** 后端 SHALL 验证请求配置符合现有配置验证规则
|
||||
- **THEN** 后端 SHALL 将配置保存到 `~/.nex/config.yaml`
|
||||
- **THEN** 保存的配置文件权限 SHALL 符合现有配置文件安全要求
|
||||
- **THEN** 后端 SHALL 返回保存后的启动参数设置
|
||||
|
||||
#### Scenario: Desktop 保存时创建配置文件
|
||||
|
||||
- **WHEN** desktop 入口收到有效的启动参数保存请求
|
||||
- **AND** `~/.nex/config.yaml` 不存在
|
||||
- **THEN** 后端 SHALL 创建配置文件并写入提交的配置
|
||||
- **THEN** 后端 SHALL NOT 在查询启动参数时自动创建配置文件
|
||||
|
||||
#### Scenario: Desktop 保存不动态应用配置
|
||||
|
||||
- **WHEN** desktop 入口成功保存启动参数
|
||||
- **THEN** 当前运行中的配置快照 SHALL 保持不变
|
||||
- **THEN** 当前运行中的 HTTP server、数据库连接、日志器和请求处理 SHALL NOT 因保存操作而重建或中断
|
||||
- **THEN** 保存后的配置 SHALL 在下一次 desktop 启动时生效
|
||||
- **THEN** 后端 SHALL NOT 自动重启 desktop
|
||||
|
||||
#### Scenario: Desktop 拒绝无效启动参数
|
||||
|
||||
- **WHEN** desktop 入口收到无效的启动参数保存请求
|
||||
- **THEN** 后端 SHALL 返回验证错误
|
||||
- **THEN** 后端 SHALL NOT 写入无效配置到 `~/.nex/config.yaml`
|
||||
|
||||
### Requirement: Server 启动参数只读
|
||||
|
||||
server 入口 SHALL 允许前端查看当前有效启动参数,但 SHALL NOT 允许前端保存或修改启动参数。
|
||||
|
||||
#### Scenario: Server 查询只读元数据
|
||||
|
||||
- **WHEN** server 入口收到启动参数查询请求
|
||||
- **THEN** 后端 SHALL 返回 `mode` 为 `server`
|
||||
- **THEN** 后端 SHALL 返回 `editable` 为 false
|
||||
- **THEN** 后端 SHALL 返回 `restart_required` 为 false
|
||||
- **THEN** 后端 SHALL 返回 server 启动时实际解析到的配置文件路径
|
||||
|
||||
#### Scenario: Server 查询返回自定义配置文件路径
|
||||
|
||||
- **WHEN** server 入口使用 `--config /path/to/custom.yaml` 启动
|
||||
- **AND** server 入口收到启动参数查询请求
|
||||
- **THEN** 后端 SHALL 返回配置文件路径 `/path/to/custom.yaml`
|
||||
|
||||
#### Scenario: Server 拒绝保存启动参数
|
||||
|
||||
- **WHEN** server 入口收到启动参数保存请求
|
||||
- **THEN** 后端 SHALL 返回禁止修改错误
|
||||
- **THEN** 后端 SHALL NOT 写入配置文件
|
||||
- **THEN** 后端 SHALL NOT 修改当前运行配置
|
||||
|
||||
@@ -104,14 +104,14 @@
|
||||
|
||||
### Requirement: 应用启动时迁移
|
||||
|
||||
应用 SHALL 在启动时执行迁移。
|
||||
应用 SHALL 在启动时执行迁移,并 SHALL 使用随应用构建产物可用的打包迁移资源。
|
||||
|
||||
#### Scenario: 自动迁移
|
||||
|
||||
- **WHEN** 应用启动
|
||||
- **THEN** SHALL 根据 `database.driver` 配置选择对应的迁移目录和 goose dialect
|
||||
- **THEN** SHALL 在 `driver=sqlite` 时使用 `migrations/sqlite/` 目录,goose dialect 为 `sqlite3`
|
||||
- **THEN** SHALL 在 `driver=mysql` 时使用 `migrations/mysql/` 目录,goose dialect 为 `mysql`
|
||||
- **THEN** SHALL 根据 `database.driver` 配置选择对应的迁移资源和 goose dialect
|
||||
- **THEN** SHALL 在 `driver=sqlite` 时使用 SQLite 方言迁移资源,goose dialect 为 `sqlite3`
|
||||
- **THEN** SHALL 在 `driver=mysql` 时使用 MySQL 方言迁移资源,goose dialect 为 `mysql`
|
||||
- **THEN** SHALL 自动执行待执行的迁移
|
||||
- **THEN** SHALL 在迁移失败时拒绝启动
|
||||
- **THEN** SHALL 记录迁移日志
|
||||
@@ -122,6 +122,15 @@
|
||||
- **THEN** SHALL 检查数据库迁移版本
|
||||
- **THEN** SHALL 在版本不匹配时执行迁移
|
||||
|
||||
#### Scenario: 发布产物无源码目录时自动迁移
|
||||
|
||||
- **WHEN** 应用以发布产物形式运行,且安装环境不存在仓库源码目录
|
||||
- **THEN** 应用启动 SHALL 能找到迁移资源
|
||||
- **THEN** SHALL 自动执行待执行迁移
|
||||
- **THEN** SHALL NOT 依赖源码工作区中的 `backend/migrations/...` 文件系统路径
|
||||
- **THEN** SHALL NOT 通过 `runtime.Caller` 推导构建机或源码目录作为运行时迁移目录
|
||||
- **THEN** 日志中的迁移资源位置 SHALL NOT 指向构建机路径,如 `/Users/runner/work/...`
|
||||
|
||||
### Requirement: 连接池配置
|
||||
|
||||
系统 SHALL 配置数据库连接池。
|
||||
@@ -157,7 +166,7 @@
|
||||
|
||||
### Requirement: 迁移文件管理
|
||||
|
||||
迁移文件 SHALL 版本化管理。
|
||||
迁移文件 SHALL 版本化管理,并 SHALL 在构建发布产物时作为运行时迁移资源打包。
|
||||
|
||||
#### Scenario: 迁移文件命名
|
||||
|
||||
@@ -171,3 +180,10 @@
|
||||
- **WHEN** 创建迁移文件
|
||||
- **THEN** SHALL 按 SQL 方言存储在对应子目录(`migrations/sqlite/` 或 `migrations/mysql/`)
|
||||
- **THEN** SHALL 提交到版本控制系统
|
||||
|
||||
#### Scenario: 迁移文件打包
|
||||
|
||||
- **WHEN** 构建 server 或 desktop 二进制
|
||||
- **THEN** SQLite 和 MySQL 迁移文件 SHALL 被作为运行时迁移资源打包进二进制或等效发布资源
|
||||
- **THEN** 应用启动迁移 SHALL 使用该打包资源
|
||||
- **THEN** backend Makefile 的 goose CLI 迁移命令 MAY 继续使用文件系统中的 `migrations/<dialect>/` 目录
|
||||
|
||||
@@ -8,16 +8,18 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
||||
|
||||
### Requirement: 桌面应用启动
|
||||
|
||||
系统 SHALL 支持作为桌面应用启动,将后端服务与前端静态资源打包为单一可执行文件。
|
||||
系统 SHALL 支持作为桌面应用启动,将后端服务与前端静态资源打包为单一可执行文件,并使用启动配置中的 `server.port` 作为本次运行端口。
|
||||
|
||||
#### Scenario: 双击启动
|
||||
|
||||
- **WHEN** 用户双击桌面应用可执行文件
|
||||
- **THEN** 系统使用 `gofrs/flock` 尝试获取排他文件锁
|
||||
- **THEN** 系统从 `~/.nex/config.yaml` 和默认值加载启动配置快照
|
||||
- **AND** 系统使用 `gofrs/flock` 尝试获取排他文件锁
|
||||
- **AND** 锁文件路径为系统临时目录下的 `nex-gateway.lock`
|
||||
- **AND** 系统启动后端服务
|
||||
- **AND** 系统使用启动配置中的 `server.port` 启动后端服务
|
||||
- **AND** 未配置 `server.port` 时默认端口为 9826
|
||||
- **AND** 系统托盘图标出现
|
||||
- **AND** 浏览器自动打开 `http://localhost:9826` 显示管理界面
|
||||
- **AND** 浏览器自动打开 `http://localhost:<server.port>` 显示管理界面
|
||||
|
||||
#### Scenario: 单实例检查
|
||||
|
||||
@@ -34,7 +36,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
||||
|
||||
### Requirement: 系统托盘
|
||||
|
||||
系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择。
|
||||
系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择,端口显示 SHALL 使用启动配置中的 `server.port`。
|
||||
|
||||
#### Scenario: 托盘图标显示
|
||||
|
||||
@@ -42,7 +44,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
||||
- **THEN** 系统根据平台加载正确的图标格式
|
||||
- **AND** 在 Windows 上加载 ICO 格式图标(`assets/icon.ico`)
|
||||
- **AND** 在 macOS 和 Linux 上加载 PNG 格式图标(`assets/icon.png`)
|
||||
- **AND** 托盘图标 tooltip 显示"AI Gateway"
|
||||
- **AND** 托盘图标 tooltip 显示 `Nex`
|
||||
|
||||
#### Scenario: 托盘菜单显示
|
||||
|
||||
@@ -50,19 +52,19 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
||||
- **THEN** 显示托盘菜单
|
||||
- **AND** 菜单包含"打开管理界面"选项
|
||||
- **AND** 菜单包含"状态: 运行中"选项(禁用状态)
|
||||
- **AND** 菜单包含"端口: 9826"选项(禁用状态)
|
||||
- **AND** 菜单包含"端口: <server.port>"选项(禁用状态)
|
||||
- **AND** 菜单包含"退出"选项
|
||||
|
||||
#### Scenario: 打开管理界面
|
||||
|
||||
- **WHEN** 用户点击托盘菜单"打开管理界面"
|
||||
- **THEN** 系统在浏览器中打开 `http://localhost:9826`
|
||||
- **THEN** 系统在浏览器中打开 `http://localhost:<server.port>`
|
||||
|
||||
#### Scenario: 浏览器打开失败
|
||||
|
||||
- **WHEN** 系统无法打开浏览器(浏览器未安装等)
|
||||
- **THEN** 托盘菜单仍可正常使用
|
||||
- **AND** 用户可手动访问 `http://localhost:9826`
|
||||
- **AND** 用户可手动访问 `http://localhost:<server.port>`
|
||||
|
||||
#### Scenario: 退出应用
|
||||
|
||||
@@ -80,6 +82,13 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
||||
- **WHEN** 请求路径以 `/api/` 或 `/health` 开头
|
||||
- **THEN** 请求由现有业务 handler 处理或返回 API 风格 404
|
||||
|
||||
#### Scenario: 版本接口路由
|
||||
|
||||
- **WHEN** desktop 模式收到 `GET /api/version` 请求
|
||||
- **THEN** 请求 SHALL 由版本信息 handler 处理
|
||||
- **THEN** 响应 SHALL 为 API JSON 响应
|
||||
- **THEN** 请求 SHALL NOT 返回前端 `index.html`
|
||||
|
||||
#### Scenario: 协议代理请求路由
|
||||
|
||||
- **WHEN** 请求路径以 `/openai/` 或 `/anthropic/` 开头
|
||||
@@ -104,10 +113,10 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
||||
- **THEN** 返回嵌入的前端静态资源文件
|
||||
- **THEN** 请求 SHALL NOT 被协议代理路由处理
|
||||
|
||||
#### Scenario: Favicon 路由
|
||||
#### Scenario: PNG Favicon 路由
|
||||
|
||||
- **WHEN** 请求路径为 `/favicon.svg`
|
||||
- **THEN** 返回嵌入的前端 favicon 资源
|
||||
- **WHEN** 请求路径为 `/icon.png`
|
||||
- **THEN** 返回来源于统一应用图标的 PNG favicon 资源
|
||||
- **THEN** 请求 SHALL NOT 被协议代理路由处理
|
||||
|
||||
#### Scenario: SPA 路由回退
|
||||
@@ -117,44 +126,169 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
||||
|
||||
### Requirement: 端口冲突检测
|
||||
|
||||
系统 SHALL 在启动前检测端口是否可用。
|
||||
系统 SHALL 在启动前检测启动配置中的 `server.port` 是否可用。
|
||||
|
||||
#### Scenario: 端口可用
|
||||
#### Scenario: 配置端口可用
|
||||
|
||||
- **WHEN** 端口 9826 未被占用
|
||||
- **WHEN** 启动配置中的 `server.port` 未被占用
|
||||
- **THEN** 服务正常启动
|
||||
|
||||
#### Scenario: 端口被占用
|
||||
#### Scenario: 配置端口被占用
|
||||
|
||||
- **WHEN** 端口 9826 已被其他程序占用
|
||||
- **THEN** 显示错误提示"端口 9826 已被占用"
|
||||
- **WHEN** 启动配置中的 `server.port` 已被其他程序占用
|
||||
- **THEN** 显示错误提示"端口 <server.port> 已被占用"
|
||||
- **AND** 应用退出
|
||||
|
||||
### Requirement: 桌面配置源隔离和启动快照
|
||||
|
||||
desktop SHALL 仅在启动时从默认配置文件 `~/.nex/config.yaml` 和默认值加载运行时配置,并将加载结果作为本次运行的 immutable runtime snapshot。
|
||||
|
||||
#### Scenario: Desktop 仅使用默认配置文件
|
||||
|
||||
- **WHEN** desktop 启动
|
||||
- **THEN** SHALL 从 `~/.nex/config.yaml` 加载配置
|
||||
- **THEN** SHALL 在配置文件不存在时使用默认值
|
||||
- **THEN** SHALL 使用默认值补齐配置文件未设置的配置项
|
||||
|
||||
#### Scenario: Desktop 不支持 CLI 配置源
|
||||
|
||||
- **WHEN** desktop 启动时传入 `--server-port 9000`、`--database-path /tmp/test.db` 或 `--config /tmp/custom.yaml`
|
||||
- **THEN** SHALL 忽略这些参数
|
||||
- **THEN** SHALL NOT 将这些参数应用到运行时配置
|
||||
- **THEN** SHALL NOT 使用 `--config` 指定的配置文件路径
|
||||
|
||||
#### Scenario: Desktop 不支持环境变量配置源
|
||||
|
||||
- **WHEN** desktop 启动环境中存在 `NEX_SERVER_PORT`、`NEX_DATABASE_PATH`、`NEX_LOG_LEVEL` 或其他 `NEX_*` 环境变量
|
||||
- **THEN** SHALL NOT 将这些环境变量应用到运行时配置
|
||||
- **THEN** SHALL 使用默认配置文件和默认值确定运行时配置
|
||||
|
||||
#### Scenario: Desktop 忽略未知启动参数
|
||||
|
||||
- **WHEN** desktop 启动时传入未知命令行参数
|
||||
- **THEN** SHALL NOT 因未知参数导致配置加载失败
|
||||
- **THEN** SHALL 继续使用默认配置文件和默认值加载配置
|
||||
|
||||
#### Scenario: 配置文件修改仅下次启动生效
|
||||
|
||||
- **WHEN** desktop 已启动并正在处理请求
|
||||
- **AND** 用户修改 `~/.nex/config.yaml` 中的 `server.port`、`database.*`、`log.*` 或 timeout 配置
|
||||
- **THEN** 当前运行中的 desktop SHALL NOT 重新加载配置文件
|
||||
- **THEN** 当前运行中的 HTTP server、数据库连接、日志器和请求处理 SHALL NOT 因配置文件修改而重建或中断
|
||||
- **THEN** 修改后的配置 SHALL 在下一次 desktop 启动时生效
|
||||
|
||||
#### Scenario: 配置文件无效
|
||||
|
||||
- **WHEN** desktop 启动时 `~/.nex/config.yaml` 存在但内容无法解析或验证失败
|
||||
- **THEN** SHALL 显示包含配置文件路径和失败原因的错误提示
|
||||
- **THEN** SHALL 退出应用
|
||||
- **THEN** SHALL NOT 静默回退默认配置继续启动
|
||||
|
||||
### Requirement: Desktop 前端同源 API 访问
|
||||
|
||||
desktop 内嵌前端 SHALL 使用同源相对路径访问后端 API,不主动发现、缓存或覆盖 desktop 端口。
|
||||
|
||||
#### Scenario: 同源 API 请求
|
||||
|
||||
- **WHEN** desktop 浏览器页面打开在 `http://localhost:<server.port>`
|
||||
- **THEN** 前端 SHALL 使用 `/api/*`、`/openai/*` 和 `/anthropic/*` 等相对路径访问同一 origin
|
||||
- **THEN** 前端 SHALL NOT 硬编码 desktop 端口
|
||||
|
||||
#### Scenario: 重启后新端口访问
|
||||
|
||||
- **WHEN** 用户修改配置文件中的 `server.port` 并重启 desktop
|
||||
- **THEN** desktop SHALL 按新端口启动并打开 `http://localhost:<new-port>`
|
||||
- **THEN** 前端 SHALL 继续使用当前 origin 的相对路径访问 API
|
||||
- **THEN** 前端 SHALL NOT 扫描端口、读取本地缓存端口或请求端口发现接口
|
||||
|
||||
### Requirement: 跨平台构建
|
||||
|
||||
系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台分离,中间构建产物文件名 SHALL 保持 `nex-{os}-{arch}[.exe]` 格式,最终桌面发布资产文件名 SHALL 包含统一版本号和平台标识。
|
||||
系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台和架构分离或参数化,中间构建产物文件名 SHALL 保持可区分目标平台和架构,最终桌面发布资产文件名 SHALL 包含统一版本号、组件、平台、架构和格式信息。
|
||||
|
||||
#### Scenario: macOS 构建
|
||||
|
||||
- **WHEN** 执行 `desktop-build-mac` 构建命令且当前版本为 `1.2.3`
|
||||
- **THEN** 生成 `nex-mac-arm64` 和 `nex-mac-amd64` 可执行文件
|
||||
- **AND** 系统生成可打包为 `.app` bundle 的 macOS 桌面产物
|
||||
- **AND** 最终发布资产文件名 SHALL 包含 `1.2.3` 和 `macOS` 平台标识
|
||||
- **WHEN** 执行 macOS desktop 构建命令且当前版本为 `1.2.3`
|
||||
- **THEN** 系统 SHALL 生成 macOS arm64 和 amd64 桌面可执行文件
|
||||
- **AND** 系统 SHALL 使用 `lipo` 生成 macOS universal 桌面可执行文件
|
||||
- **AND** 系统 SHALL 生成可打包为 `.app` bundle 的 macOS desktop 产物
|
||||
- **AND** 最终 macOS desktop 发布资产文件名 SHALL 包含 `1.2.3`、`macos` 和 `universal`
|
||||
|
||||
#### Scenario: Windows 构建
|
||||
|
||||
- **WHEN** 执行 `desktop-build-win` 构建命令且当前版本为 `1.2.3`
|
||||
- **THEN** 系统生成 Windows 桌面可执行文件
|
||||
- **AND** 生成 `nex-win-amd64.exe` 可执行文件
|
||||
- **AND** 使用 `-H=windowsgui` linker flag 隐藏控制台窗口
|
||||
- **AND** 最终发布资产文件名 SHALL 包含 `1.2.3` 和 `windows` 平台标识
|
||||
- **WHEN** 执行 Windows desktop 构建命令且当前版本为 `1.2.3`
|
||||
- **THEN** 系统 SHALL 生成 Windows amd64 desktop 可执行文件
|
||||
- **AND** Windows desktop 构建 SHALL 使用 `-H=windowsgui` linker flag 隐藏控制台窗口
|
||||
- **AND** 最终 Windows desktop 发布资产文件名 SHALL 包含 `1.2.3`、`windows` 和 `amd64`
|
||||
|
||||
#### Scenario: Linux 构建
|
||||
|
||||
- **WHEN** 执行 `desktop-build-linux` 构建命令且当前版本为 `1.2.3`
|
||||
- **THEN** 系统生成 Linux 桌面可执行文件
|
||||
- **AND** 生成 `nex-linux-amd64` 可执行文件
|
||||
- **AND** 最终发布资产文件名 SHALL 包含 `1.2.3` 和 `linux` 平台标识
|
||||
- **WHEN** 执行 Linux desktop 构建命令且当前版本为 `1.2.3`
|
||||
- **THEN** 系统 SHALL 生成 Linux amd64 和 arm64 desktop 可执行文件
|
||||
- **AND** Linux desktop 构建 SHALL 使用 CGO 和 GTK/AppIndicator 构建依赖
|
||||
- **AND** 最终 Linux desktop 发布资产文件名 SHALL 包含 `1.2.3`、`linux` 和对应架构标识
|
||||
|
||||
### Requirement: Linux 桌面发布安装包
|
||||
|
||||
系统 SHALL 为 Linux desktop amd64 和 arm64 生成 tar.gz、AppImage、deb 和 rpm 发布安装包,并 SHALL 在安装包中包含标准桌面集成元数据。
|
||||
|
||||
#### Scenario: Linux desktop tar.gz 裸包
|
||||
|
||||
- **WHEN** 构建 Linux desktop 发布资产
|
||||
- **THEN** 系统 SHALL 为 amd64 生成 `nex-desktop_<version>_linux_amd64.tar.gz`
|
||||
- **AND** 系统 SHALL 为 arm64 生成 `nex-desktop_<version>_linux_arm64.tar.gz`
|
||||
|
||||
#### Scenario: Linux desktop AppImage 包
|
||||
|
||||
- **WHEN** 构建 Linux desktop AppImage 发布资产
|
||||
- **THEN** 系统 SHALL 为 amd64 生成 `nex-desktop_<version>_linux_amd64.AppImage`
|
||||
- **AND** 系统 SHALL 为 arm64 生成 `nex-desktop_<version>_linux_arm64.AppImage`
|
||||
- **AND** AppImage SHALL 包含 desktop entry、应用图标和 desktop 可执行文件
|
||||
- **AND** AppImage SHALL 依赖目标系统提供 GTK3、Ayatana AppIndicator 和运行 AppImage 所需的 runtime/FUSE 能力
|
||||
|
||||
#### Scenario: Linux desktop deb 包
|
||||
|
||||
- **WHEN** 构建 Linux desktop deb 发布资产
|
||||
- **THEN** 系统 SHALL 为 amd64 生成 `nex-desktop_<version>_linux_amd64.deb`
|
||||
- **AND** 系统 SHALL 为 arm64 生成 `nex-desktop_<version>_linux_arm64.deb`
|
||||
- **AND** deb 包 SHALL 将可执行文件安装到 `/usr/bin/nex`
|
||||
- **AND** deb 包 SHALL 将 desktop entry 安装到 `/usr/share/applications/nex.desktop`
|
||||
- **AND** deb 包 SHALL 将 hicolor 图标安装到 `/usr/share/icons/hicolor/.../apps/nex.png`
|
||||
- **AND** deb 包 SHALL 声明 `libgtk-3-0`、`libayatana-appindicator3-1` 和 `xdg-utils` 运行时依赖
|
||||
- **AND** deb 包 metadata 的架构字段 SHALL 使用 `amd64` 或 `arm64`
|
||||
|
||||
#### Scenario: Linux desktop rpm 包
|
||||
|
||||
- **WHEN** 构建 Linux desktop rpm 发布资产
|
||||
- **THEN** 系统 SHALL 为 amd64 生成 `nex-desktop_<version>_linux_amd64.rpm`
|
||||
- **AND** 系统 SHALL 为 arm64 生成 `nex-desktop_<version>_linux_arm64.rpm`
|
||||
- **AND** rpm 包 SHALL 将可执行文件安装到 `/usr/bin/nex`
|
||||
- **AND** rpm 包 SHALL 将 desktop entry 安装到 `/usr/share/applications/nex.desktop`
|
||||
- **AND** rpm 包 SHALL 将 hicolor 图标安装到 `/usr/share/icons/hicolor/.../apps/nex.png`
|
||||
- **AND** rpm 包 SHALL 声明 `gtk3`、`libayatana-appindicator-gtk3` 和 `xdg-utils` 运行时依赖
|
||||
- **AND** rpm 包 metadata 的架构字段 SHALL 使用 `x86_64` 或 `aarch64`
|
||||
|
||||
### Requirement: macOS DMG 打包
|
||||
|
||||
系统 SHALL 为 macOS desktop universal `.app` 生成 unsigned DMG 安装包,并 SHALL 保留 universal zip 发布资产。
|
||||
|
||||
#### Scenario: macOS universal zip 包
|
||||
|
||||
- **WHEN** 构建 macOS desktop 发布资产且当前版本为 `1.2.3`
|
||||
- **THEN** 系统 SHALL 生成 `nex-desktop_1.2.3_macos_universal.zip`
|
||||
- **AND** zip 包 SHALL 包含 `Nex.app`
|
||||
|
||||
#### Scenario: macOS universal DMG 包
|
||||
|
||||
- **WHEN** 构建 macOS desktop DMG 发布资产且当前版本为 `1.2.3`
|
||||
- **THEN** 系统 SHALL 生成 `nex-desktop_1.2.3_macos_universal.dmg`
|
||||
- **AND** DMG SHALL 包含 `Nex.app`
|
||||
- **AND** DMG SHALL 包含指向 `/Applications` 的快捷方式
|
||||
- **AND** DMG SHALL NOT 要求 macOS 签名或 notarization 才能完成构建
|
||||
|
||||
#### Scenario: macOS universal 架构校验
|
||||
|
||||
- **WHEN** macOS desktop universal 可执行文件生成完成
|
||||
- **THEN** 系统 SHALL 验证该可执行文件包含 amd64 和 arm64 架构
|
||||
|
||||
### Requirement: macOS .app 打包
|
||||
|
||||
@@ -246,3 +380,28 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
||||
#### Scenario: 多行文本处理
|
||||
- **WHEN** 对话框消息包含换行符 `\n`
|
||||
- **THEN** AppleScript 正确显示多行文本
|
||||
|
||||
### Requirement: 桌面应用打包迁移资源
|
||||
|
||||
桌面应用 SHALL 在打包安装后仍能访问数据库迁移资源,并 SHALL 在首次启动时完成数据库初始化和迁移。
|
||||
|
||||
#### Scenario: 打包安装后首次启动执行迁移
|
||||
|
||||
- **WHEN** 用户从 macOS DMG 安装并首次启动 `Nex.app`
|
||||
- **THEN** 系统 SHALL 初始化默认配置和数据库
|
||||
- **THEN** 系统 SHALL 使用打包在应用内的迁移资源执行 SQLite 迁移
|
||||
- **THEN** 系统 SHALL NOT 尝试访问构建机源码路径或仓库源码路径
|
||||
- **THEN** 系统 SHALL 成功启动后端服务、托盘和管理界面
|
||||
|
||||
#### Scenario: .app 包含运行时必需迁移资源
|
||||
|
||||
- **WHEN** 执行 macOS 桌面打包脚本
|
||||
- **THEN** `Nex.app` SHALL 包含启动后端服务所需的数据库迁移资源
|
||||
- **THEN** 迁移资源 SHALL 随应用移动到任意安装位置后仍可用
|
||||
- **THEN** `.app` SHALL NOT 依赖构建目录、源码目录或 GitHub Actions runner 路径
|
||||
|
||||
#### Scenario: DMG 安装后运行时资源完整
|
||||
|
||||
- **WHEN** 用户从 DMG 将 `Nex.app` 拖入 `/Applications` 并启动
|
||||
- **THEN** 应用 SHALL 能访问数据库迁移资源
|
||||
- **THEN** 应用 SHALL NOT 因 `migrations/sqlite` 或 `migrations/mysql` 文件系统目录不存在而启动失败
|
||||
|
||||
@@ -49,13 +49,14 @@ TBD - 定义前端 ESLint 规则配置、构建集成 lint 检查、以及自定
|
||||
|
||||
### Requirement: 构建集成 lint 检查
|
||||
|
||||
前端 SHALL 在 `build` 命令中集成 ESLint 检查和 Prettier 格式检查。
|
||||
前端 SHALL 在 `build` 命令中集成 TypeScript 类型检查、ESLint 检查和 Prettier 格式检查。
|
||||
|
||||
#### Scenario: 构建时执行 lint 和格式检查
|
||||
#### Scenario: 构建时执行类型检查、lint 和格式检查
|
||||
|
||||
- **WHEN** 执行 `bun run build`
|
||||
- **THEN** 构建 SHALL 依次执行 `tsc -b`、`bun run check`、`vite build`
|
||||
- **THEN** `bun run check` SHALL 执行 `bun run lint && bun run format:check`
|
||||
- **THEN** 构建 SHALL 依次执行 `bun run check`、`vite build`
|
||||
- **THEN** `bun run check` SHALL 依次执行 `bun run typecheck`、`bun run lint`、`bun run format:check`
|
||||
- **THEN** 若 `tsc -b` 报告类型错误,构建 SHALL 中断
|
||||
- **THEN** 若 `eslint .` 报告任何错误,构建 SHALL 中断
|
||||
- **THEN** 若 `prettier --check .` 报告任何格式问题,构建 SHALL 中断
|
||||
|
||||
@@ -77,8 +78,13 @@ TBD - 定义前端 ESLint 规则配置、构建集成 lint 检查、以及自定
|
||||
#### Scenario: 统一检查命令
|
||||
|
||||
- **WHEN** 执行 `bun run check`
|
||||
- **THEN** SHALL 运行 `bun run lint && bun run format:check`
|
||||
- **THEN** lint 错误和格式问题 SHALL 都被检查
|
||||
- **THEN** SHALL 依次运行 `bun run typecheck`、`bun run lint`、`bun run format:check`
|
||||
- **THEN** 类型错误、lint 错误和格式问题 SHALL 都被检查
|
||||
|
||||
#### Scenario: 单独执行类型检查
|
||||
|
||||
- **WHEN** 执行 `bun run typecheck`
|
||||
- **THEN** SHALL 运行 `tsc -b`
|
||||
|
||||
#### Scenario: 统一修复命令
|
||||
|
||||
|
||||
@@ -8,31 +8,31 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
||||
|
||||
### Requirement: 样式体系
|
||||
|
||||
前端样式 SHALL 优先使用 TDesign 样式体系,SCSS 作为补充工具。
|
||||
前端样式 SHALL 优先使用 TDesign 组件 props,其次使用 TDesign tokens,最后在前两者无法表达所需效果时使用 SCSS 作为补充工具。
|
||||
|
||||
#### Scenario: TDesign 组件 Props 优先
|
||||
|
||||
- **WHEN** 实现组件视觉效果
|
||||
- **THEN** 前端 SHALL 优先使用 TDesign 组件的视觉增强 Props(如 color、trend、hoverShadow、stripe、variant、shape 等)
|
||||
- **THEN** 前端 SHALL 优先使用 TDesign 组件的视觉增强 Props(如 color、trend、hoverShadow、stripe、variant、shape、headerBordered、gutter 等)
|
||||
- **THEN** 前端 SHALL NOT 通过 CSS 类名覆盖组件内部样式
|
||||
|
||||
#### Scenario: TDesign Tokens 作为第二优先级
|
||||
|
||||
- **WHEN** 组件 props 无法完整表达颜色、边框、背景、间距等视觉细节
|
||||
- **THEN** 前端 SHALL 使用 TDesign CSS Token 引用(`var(--td-*)`)表达样式
|
||||
- **THEN** 前端 SHALL NOT 在布局样式中硬编码 `#fff`、`#e7e7e7`、`#999` 等颜色值
|
||||
|
||||
#### Scenario: CSS Variables 主题微调
|
||||
|
||||
- **WHEN** 需要调整全局视觉风格
|
||||
- **THEN** 前端 SHALL 通过 \`:root\` 中声明 TDesign CSS Variables(\`--td-*\`)进行覆盖
|
||||
- **THEN** 前端 SHALL NOT 使用 \`!important\` 或高优先级选择器覆盖组件样式
|
||||
- **THEN** 前端 SHALL 通过 `:root` 中声明 TDesign CSS Variables(`--td-*`)进行覆盖
|
||||
- **THEN** 前端 SHALL NOT 使用 `!important` 或高优先级选择器覆盖组件样式
|
||||
|
||||
#### Scenario: 布局样式 Token 化
|
||||
#### Scenario: SCSS 兜底使用
|
||||
|
||||
- **WHEN** 编写布局级 inline style
|
||||
- **THEN** 前端 SHALL 使用 TDesign CSS Token 引用(\`var(--td-*)\`)替代硬编码颜色值
|
||||
- **THEN** 前端 SHALL NOT 在布局样式中硬编码 \`#fff\`、\`#e7e7e7\`、\`#999\` 等颜色值
|
||||
|
||||
#### Scenario: SCSS 补充使用
|
||||
|
||||
- **WHEN** TDesign 样式体系无法满足需求
|
||||
- **WHEN** TDesign 组件 props 和 TDesign tokens 均无法满足布局、响应式或品牌视觉需求
|
||||
- **THEN** 前端 MAY 使用 SCSS 作为补充
|
||||
- **THEN** SCSS 文件 SHALL 仅用于 \`:root\` 级别的 CSS Variables 声明和全局 reset
|
||||
- **THEN** SCSS SHALL 只承载必要的补充样式
|
||||
- **THEN** 前端 SHALL NOT 使用纯 CSS 文件(*.css)
|
||||
|
||||
|
||||
@@ -125,6 +125,26 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
||||
- **WHEN** 供应商列表为空
|
||||
- **THEN** 表格 SHALL 显示自定义空状态文案 "暂无供应商,点击上方按钮添加"
|
||||
|
||||
#### Scenario: Base URL 一键复制
|
||||
|
||||
- **WHEN** 供应商表格渲染 Base URL 列
|
||||
- **THEN** Base URL 文本右侧 SHALL 显示复制图标按钮
|
||||
- **THEN** 复制图标 SHALL 使用 TDesign `Typography.Text` 的 `copyable` 属性
|
||||
- **WHEN** 用户点击 Base URL 的复制按钮
|
||||
- **THEN** 系统 SHALL 将完整 Base URL 写入剪贴板
|
||||
- **THEN** 系统 SHALL 显示 `已复制 Base URL` 成功提示
|
||||
- **THEN** 当 Base URL 为空时,复制按钮 SHALL 禁用
|
||||
|
||||
#### Scenario: API Key 一键复制
|
||||
|
||||
- **WHEN** 供应商表格渲染 API Key 列
|
||||
- **THEN** API Key 文本右侧 SHALL 显示复制图标按钮
|
||||
- **THEN** 复制图标 SHALL 使用 TDesign `Typography.Text` 的 `copyable` 属性
|
||||
- **WHEN** 用户点击 API Key 的复制按钮
|
||||
- **THEN** 系统 SHALL 将完整 API Key 写入剪贴板
|
||||
- **THEN** 系统 SHALL 显示 `已复制 API Key` 成功提示
|
||||
- **THEN** 当 API Key 为空时,复制按钮 SHALL 禁用
|
||||
|
||||
#### Scenario: 添加新供应商
|
||||
|
||||
- **WHEN** 用户点击"添加供应商"按钮
|
||||
@@ -184,6 +204,16 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
||||
- **WHEN** 模型列表为空
|
||||
- **THEN** 表格 SHALL 显示自定义空状态文案 "暂无模型,点击上方按钮添加"
|
||||
|
||||
#### Scenario: 统一模型 ID 一键复制
|
||||
|
||||
- **WHEN** 模型表格渲染统一模型 ID 列
|
||||
- **THEN** 统一模型 ID 文本右侧 SHALL 显示复制图标按钮
|
||||
- **THEN** 复制图标 SHALL 使用 TDesign `Typography.Text` 的 `copyable` 属性
|
||||
- **WHEN** 用户点击统一模型 ID 的复制按钮
|
||||
- **THEN** 系统 SHALL 将完整统一模型 ID 写入剪贴板
|
||||
- **THEN** 系统 SHALL 显示 `已复制统一模型 ID` 成功提示
|
||||
- **THEN** 当统一模型 ID 为空时,复制按钮 SHALL 禁用
|
||||
|
||||
#### Scenario: 为供应商添加模型
|
||||
|
||||
- **WHEN** 用户在展开行中点击"添加模型"
|
||||
@@ -231,12 +261,20 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
||||
#### Scenario: 桌面布局
|
||||
|
||||
- **WHEN** 在桌面屏幕上查看前端
|
||||
- **THEN** 布局 SHALL 使用 TDesign \`Layout.Aside\` + \`Menu\`
|
||||
- **THEN** 布局 SHALL 使用 TDesign `Layout.Aside` + `Menu`
|
||||
- **THEN** 侧边栏 SHALL 显示导航菜单,包含图标和文字标签
|
||||
- **THEN** 侧边栏 SHALL 使用固定宽度 232px
|
||||
- **THEN** Menu 组件 SHALL 使用 \`logo\` prop 显示品牌标识
|
||||
- **THEN** Menu 组件 SHALL 使用 \`operations\` prop 在底部显示操作区域
|
||||
- **THEN** Menu 组件 SHALL 支持 \`collapsed\` 折叠功能
|
||||
- **THEN** Menu 组件 SHALL 使用 `logo` prop 显示品牌标识
|
||||
- **THEN** Menu 组件 SHALL 使用 `operations` prop 在底部显示操作区域
|
||||
- **THEN** Menu 组件 SHALL 支持 `collapsed` 折叠功能
|
||||
|
||||
#### Scenario: 侧边栏折叠布局
|
||||
|
||||
- **WHEN** 用户折叠侧边栏
|
||||
- **THEN** 侧边栏 SHALL 使用折叠宽度 64px
|
||||
- **THEN** Menu logo 区域 SHALL 保留应用图标
|
||||
- **THEN** Menu logo 区域 SHALL 隐藏应用名称文字
|
||||
- **THEN** Menu logo 区域 SHALL NOT 显示为空白
|
||||
|
||||
#### Scenario: 页面内容区域
|
||||
|
||||
@@ -336,13 +374,54 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
||||
|
||||
### Requirement: 提供设置页面
|
||||
|
||||
前端 SHALL 提供设置页面。
|
||||
前端 SHALL 提供设置页面,并在设置页面中以独立 Card 展示启动参数设置。
|
||||
|
||||
#### Scenario: 显示设置页面
|
||||
|
||||
- **WHEN** 用户访问设置页面
|
||||
- **THEN** 前端 SHALL 显示设置页面
|
||||
- **THEN** 开发中提示文字颜色 SHALL 使用 \`var(--td-text-color-placeholder)\` Token
|
||||
- **THEN** 前端 SHALL 显示标题为“启动参数设置”的 Card
|
||||
- **THEN** 启动参数设置 Card SHALL 与未来其他设置 Card 在视觉结构上保持独立
|
||||
|
||||
#### Scenario: Desktop 模式显示可编辑启动参数
|
||||
|
||||
- **WHEN** 后端返回启动参数设置 `editable` 为 true
|
||||
- **THEN** 前端 SHALL 在“启动参数设置” Card 中显示可编辑表单
|
||||
- **THEN** 表单 SHALL 覆盖 `server`、`database`、`log` 配置分组
|
||||
- **THEN** 前端 SHALL 提示“Desktop 模式下此页面编辑的是启动配置文件,保存后重启 Desktop 生效”
|
||||
- **THEN** 前端 SHALL 显示保存按钮
|
||||
- **THEN** 前端 SHALL 在保存成功后提示“配置已保存到配置文件。当前运行中的服务仍使用启动时配置,重启 Desktop 后生效”
|
||||
|
||||
#### Scenario: Server 模式显示只读启动参数
|
||||
|
||||
- **WHEN** 后端返回启动参数设置 `editable` 为 false
|
||||
- **THEN** 前端 SHALL 在“启动参数设置” Card 中显示只读表单
|
||||
- **THEN** 所有启动参数字段 SHALL 不可编辑
|
||||
- **THEN** 前端 SHALL 隐藏或禁用保存按钮
|
||||
- **THEN** 前端 SHALL 提示“Server 模式下启动参数仅支持查看,不支持从前端编辑”
|
||||
|
||||
#### Scenario: 启动参数展示内容
|
||||
|
||||
- **WHEN** 前端渲染启动参数设置表单
|
||||
- **THEN** 前端 SHALL 直接展示后端返回的启动参数设置值
|
||||
- **THEN** 前端 SHALL NOT 区分当前运行值和配置文件值
|
||||
- **THEN** 前端 SHALL NOT 展示配置来源标签
|
||||
- **THEN** 前端 SHALL 直接展示 `database.password` 字段值
|
||||
|
||||
#### Scenario: 数据库驱动表单切换
|
||||
|
||||
- **WHEN** 启动参数设置中的 `database.driver` 为 `sqlite`
|
||||
- **THEN** 前端 SHALL 允许配置 SQLite 数据库路径
|
||||
- **THEN** 前端 SHALL 弱化或禁用 MySQL 专属字段
|
||||
- **WHEN** 启动参数设置中的 `database.driver` 为 `mysql`
|
||||
- **THEN** 前端 SHALL 允许配置 MySQL host、port、user、password、dbname 字段
|
||||
- **THEN** 前端 SHALL 弱化或禁用 SQLite 专属路径字段
|
||||
|
||||
#### Scenario: 启动参数保存失败
|
||||
|
||||
- **WHEN** 用户保存启动参数且后端返回验证错误或保存错误
|
||||
- **THEN** 前端 SHALL 显示用户可理解的错误提示
|
||||
- **THEN** 前端 SHALL 保持用户当前填写内容,便于修正后重新保存
|
||||
|
||||
|
||||
### Requirement: 显示统一模型 ID
|
||||
@@ -402,21 +481,29 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
||||
|
||||
### Requirement: 提供侧边栏导航
|
||||
|
||||
前端 SHALL 使用 TDesign \`Layout.Aside\` 提供侧边栏导航。
|
||||
前端 SHALL 使用 TDesign `Layout.Aside` 提供侧边栏导航。
|
||||
|
||||
#### Scenario: 侧边栏内容
|
||||
|
||||
- **WHEN** 渲染侧边栏
|
||||
- **THEN** 侧边栏顶部 SHALL 显示应用名称/Logo
|
||||
- **THEN** 侧边栏顶部 SHALL 显示统一应用图标和应用名称 `Nex`
|
||||
- **THEN** 侧边栏 SHALL NOT 显示旧品牌文字 `AI Gateway` 作为应用名称
|
||||
- **THEN** 侧边栏 SHALL 包含导航菜单
|
||||
- **THEN** 导航菜单项 SHALL 包含:供应商管理(ServerIcon 图标)、用量统计(ChartLineIcon 图标)、设置(SettingIcon 图标)、关于(InfoCircleIcon 图标)
|
||||
|
||||
#### Scenario: 侧边栏折叠品牌显示
|
||||
|
||||
- **WHEN** 侧边栏处于折叠状态
|
||||
- **THEN** 侧边栏顶部 SHALL 显示统一应用图标
|
||||
- **THEN** 侧边栏顶部 SHALL 隐藏 `Nex` 文案
|
||||
- **THEN** 侧边栏顶部 SHALL NOT 为空白
|
||||
|
||||
#### Scenario: 导航菜单交互
|
||||
|
||||
- **WHEN** 用户点击导航中的"供应商管理"
|
||||
- **THEN** 前端 SHALL 导航到 \`/providers\` 并高亮当前菜单项
|
||||
- **THEN** 前端 SHALL 导航到 `/providers` 并高亮当前菜单项
|
||||
- **WHEN** 用户点击导航中的"用量统计"
|
||||
- **THEN** 前端 SHALL 导航到 \`/stats\` 并高亮当前菜单项
|
||||
- **THEN** 前端 SHALL 导航到 `/stats` 并高亮当前菜单项
|
||||
- **WHEN** 用户点击导航中的"设置"
|
||||
- **THEN** 前端 SHALL 导航到 `/settings` 并高亮当前菜单项
|
||||
- **WHEN** 用户点击导航中的"关于"
|
||||
@@ -583,3 +670,26 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
||||
- **THEN** 前端 SHALL 显示全局错误提示
|
||||
- **THEN** 错误消息 SHALL 具有描述性
|
||||
|
||||
### Requirement: 统一 public 图标资源
|
||||
|
||||
前端 SHALL 使用仓库统一应用图标作为 public favicon 和品牌图标来源。
|
||||
|
||||
#### Scenario: 使用 PNG favicon
|
||||
|
||||
- **WHEN** 前端页面加载 HTML 入口
|
||||
- **THEN** 页面 SHALL 使用 `/icon.png` 作为 PNG favicon 路径
|
||||
- **THEN** `frontend/public/icon.png` SHALL 来源于仓库根目录 `assets/icon.png`
|
||||
- **THEN** 页面 SHALL NOT 引用独立维护的 SVG favicon
|
||||
|
||||
#### Scenario: HTML 标题使用统一应用名称
|
||||
|
||||
- **WHEN** 前端页面加载 HTML 入口
|
||||
- **THEN** 页面标题 SHALL 使用 `Nex` 作为应用名称
|
||||
- **THEN** 页面标题 SHALL NOT 使用旧应用名称 `AI Gateway`
|
||||
|
||||
#### Scenario: 清理未使用 public SVG 图标
|
||||
|
||||
- **WHEN** public 目录中的 SVG 图标资源没有被前端代码、HTML 或 desktop 静态服务引用
|
||||
- **THEN** 前端 SHALL 删除该未使用 SVG 图标资源
|
||||
- **THEN** 前端 SHALL NOT 保留未使用的 `frontend/public/icons.svg`
|
||||
|
||||
|
||||
196
openspec/specs/git-hooks/spec.md
Normal file
196
openspec/specs/git-hooks/spec.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# git-hooks
|
||||
|
||||
## Purpose
|
||||
|
||||
定义仓库原生 Git hooks 的安装、校验、测试与跨平台执行规则,确保提交前快速检查和提交信息格式校验符合项目规范。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: pre-commit hook 快速检查
|
||||
|
||||
pre-commit hook SHALL 在 `git commit` 执行前对 staged files 进行快速检查。非代码检查(冲突标记、大文件告警、LFS 指针)SHALL 在 `_hooks-pre-commit` 中直接实现;代码检查(Go 后端、Go versionctl、前端)SHALL 根据 staged 文件类型有条件地委托给已有 Makefile target(`_backend-lint`、`_versionctl-lint`、`_frontend-check`),不再内联独立的 lint 命令。
|
||||
|
||||
#### Scenario: 无 Go 和前端文件变更时跳过代码检查
|
||||
|
||||
- **WHEN** staged files 中既无 `.go` 文件也无 `.ts`/`.tsx`/`.scss` 文件
|
||||
- **THEN** pre-commit hook SHALL 跳过代码检查委托,仅执行非代码检查
|
||||
|
||||
#### Scenario: Go 文件变更时委托后端 lint
|
||||
|
||||
- **WHEN** staged files 中包含 `backend/*.go` 文件
|
||||
- **THEN** pre-commit hook SHALL 委托 `_backend-lint` target 进行 Go 代码检查
|
||||
- **THEN** `_backend-lint` SHALL 复用 `backend/.golangci.yml` 配置
|
||||
- **THEN** 若 lint 报告任何错误,commit SHALL 被阻止
|
||||
|
||||
#### Scenario: versionctl Go 文件变更时委托 versionctl lint
|
||||
|
||||
- **WHEN** staged files 中包含 `versionctl/*.go` 文件
|
||||
- **THEN** pre-commit hook SHALL 委托 `_versionctl-lint` target 进行 Go 代码检查
|
||||
- **THEN** `_versionctl-lint` SHALL 复用 `versionctl/.golangci.yml` 配置
|
||||
- **THEN** 若 lint 报告任何错误,commit SHALL 被阻止
|
||||
|
||||
#### Scenario: 前端文件变更时委托前端检查
|
||||
|
||||
- **WHEN** staged files 中包含 `.ts`、`.tsx` 或 `.scss` 文件
|
||||
- **THEN** pre-commit hook SHALL 委托 `_frontend-check` target 进行前端代码检查
|
||||
- **THEN** `_frontend-check` SHALL 运行 `bun run check`(包含 `tsc -b` TypeScript 类型检查、ESLint 和 Prettier 格式检查)
|
||||
- **THEN** 若检查报告任何错误,commit SHALL 被阻止
|
||||
|
||||
#### Scenario: 冲突标记检测
|
||||
|
||||
- **WHEN** staged files 中包含 `<<<<<<<`、`=======` 或 `>>>>>>>` 冲突标记
|
||||
- **THEN** pre-commit hook SHALL 报告错误并列出包含冲突的文件名
|
||||
- **THEN** commit SHALL 被阻止
|
||||
|
||||
#### Scenario: 大文件告警
|
||||
|
||||
- **WHEN** staged files 中存在超过 500KB 的文本文件
|
||||
- **THEN** pre-commit hook SHALL 输出警告信息(不阻止提交),提示检查是否误提交
|
||||
|
||||
#### Scenario: LFS 指针校验
|
||||
|
||||
- **WHEN** staged files 匹配 `.gitattributes` 中 `filter=lfs` 的路径模式
|
||||
- **THEN** pre-commit hook SHALL 检查 staged 内容是否为 LFS 指针格式(`version https://git-lfs.github.com/spec/v1`)
|
||||
- **THEN** 若内容不是 LFS 指针格式,commit SHALL 被阻止,并提示安装 git-lfs
|
||||
- **THEN** 若 staged files 不匹配任何 `filter=lfs` 路径模式,SHALL 跳过此检查
|
||||
|
||||
#### Scenario: commit 被阻止时显示修复提示
|
||||
|
||||
- **WHEN** pre-commit hook 检查失败
|
||||
- **THEN** hook SHALL 输出明确的修复提示(如 `make lint` 修复代码问题、手动解决冲突标记等)
|
||||
|
||||
### Requirement: commit-msg hook 校验提交信息格式
|
||||
|
||||
commit-msg hook SHALL 在 `git commit` 输入提交信息后校验格式,确保首行符合项目规范。提交描述按项目规范应使用中文,但 hook SHALL NOT 通过 Python/CJK 字符集检测强制判断描述语言,以避免引入新的运行时依赖。
|
||||
|
||||
#### Scenario: 合法格式通过
|
||||
|
||||
- **WHEN** 提交信息首行格式为 `<类型>: <描述>`,类型为 `feat`、`fix`、`refactor`、`docs`、`style`、`test`、`chore` 之一
|
||||
- **THEN** commit-msg hook SHALL 通过,commit 正常执行
|
||||
|
||||
#### Scenario: 非法类型被拒绝
|
||||
|
||||
- **WHEN** 提交信息首行使用的类型不在允许列表中(如 `update: xxx`)
|
||||
- **THEN** commit-msg hook SHALL 报告错误,显示允许的类型列表,commit SHALL 被阻止
|
||||
|
||||
#### Scenario: 缺少冒号空格被拒绝
|
||||
|
||||
- **WHEN** 提交信息首行为 `feat:xxx`(冒号后无空格)或 `feat xxx`
|
||||
- **THEN** commit-msg hook SHALL 报告格式错误,commit SHALL 被阻止
|
||||
|
||||
#### Scenario: 首行过长告警
|
||||
|
||||
- **WHEN** 提交信息首行超过 72 个字符
|
||||
- **THEN** commit-msg hook SHALL 输出警告(不阻止提交),提示首行应简短
|
||||
|
||||
#### Scenario: Merge commit 自动放行
|
||||
|
||||
- **WHEN** 提交信息首行以 `Merge` 开头
|
||||
- **THEN** commit-msg hook SHALL 直接通过,不进行格式校验
|
||||
|
||||
#### Scenario: 格式错误时显示示例
|
||||
|
||||
- **WHEN** commit-msg hook 检查失败
|
||||
- **THEN** hook SHALL 输出包含正确格式示例的错误信息(如 `feat: 添加供应商批量管理功能`)
|
||||
|
||||
#### Scenario: 不执行字符集检测
|
||||
|
||||
- **WHEN** 提交信息首行格式合法且类型合法,但描述部分不包含 CJK 字符(如 `feat: add hook tests`)
|
||||
- **THEN** commit-msg hook SHALL 通过
|
||||
- **THEN** hook SHALL NOT 调用 `python3` 或其他额外运行时做 Unicode/CJK 检测
|
||||
|
||||
#### Scenario: 多行格式校验
|
||||
|
||||
- **WHEN** 提交信息忽略 `#` 注释行后,第三行及之后存在任一非空详细说明行
|
||||
- **THEN** commit-msg hook SHALL 检查第二行是否为空行
|
||||
- **THEN** 若第二行非空行,commit SHALL 被阻止,提示首行后应空行再写详细描述
|
||||
|
||||
#### Scenario: 模板注释不参与校验
|
||||
|
||||
- **WHEN** 提交信息文件中包含 prepare-commit-msg 写入的 `#` 注释模板
|
||||
- **THEN** commit-msg hook SHALL 忽略这些注释行
|
||||
- **THEN** 注释行 SHALL NOT 导致首行格式、多行空行分隔校验失败
|
||||
|
||||
### Requirement: hooks-install 安装命令
|
||||
|
||||
`make hooks-install` SHALL 将 `scripts/git-hooks/` 下的 hook 脚本安装到 `.git/hooks/`,不覆盖 Git LFS 管理的 hook。
|
||||
|
||||
#### Scenario: 安装所有 hook 脚本
|
||||
|
||||
- **WHEN** 执行 `make hooks-install`
|
||||
- **THEN** `scripts/git-hooks/pre-commit` SHALL 被复制到 `.git/hooks/pre-commit`
|
||||
- **THEN** `scripts/git-hooks/commit-msg` SHALL 被复制到 `.git/hooks/commit-msg`
|
||||
- **THEN** `scripts/git-hooks/prepare-commit-msg` SHALL 被复制到 `.git/hooks/prepare-commit-msg`
|
||||
- **THEN** 所有复制文件 SHALL 被设置为可执行(`chmod +x`)
|
||||
|
||||
#### Scenario: 不覆盖 LFS 管理的 hook
|
||||
|
||||
- **WHEN** `.git/hooks/post-checkout`、`.git/hooks/post-commit`、`.git/hooks/post-merge`、`.git/hooks/pre-push` 已由 Git LFS 管理
|
||||
- **THEN** `make hooks-install` SHALL NOT 覆盖或修改这些文件
|
||||
|
||||
#### Scenario: 重复安装幂等
|
||||
|
||||
- **WHEN** `make hooks-install` 被执行多次
|
||||
- **THEN** hook 文件 SHALL 被正确覆盖更新,不会产生重复或损坏
|
||||
|
||||
#### Scenario: hooks-check 验证安装状态
|
||||
|
||||
- **WHEN** 执行 `make hooks-check`
|
||||
- **THEN** 命令 SHALL 检查 `.git/hooks/pre-commit`、`.git/hooks/commit-msg`、`.git/hooks/prepare-commit-msg` 是否存在且可执行
|
||||
- **THEN** SHALL 输出每个 hook 的安装状态
|
||||
|
||||
#### Scenario: 安装前验证 source 文件存在
|
||||
|
||||
- **WHEN** 执行 `make hooks-install`
|
||||
- **THEN** 命令 SHALL 在复制前验证每个 source 文件(`scripts/git-hooks/<hook-name>`)是否存在
|
||||
- **THEN** 若 source 文件不存在,命令 SHALL 报告错误并返回非零退出码
|
||||
|
||||
### Requirement: hooks-test 回归测试命令
|
||||
|
||||
`make hooks-test` SHALL 运行仓库内 hook 回归测试,覆盖 commit-msg 格式校验和 pre-commit staged-file 检查,不污染真实 git index。
|
||||
|
||||
#### Scenario: 运行 hook 回归测试
|
||||
|
||||
- **WHEN** 执行 `make hooks-test`
|
||||
- **THEN** SHALL 运行 `scripts/git-hooks/test-hooks.sh`
|
||||
- **THEN** 测试 SHALL 使用临时 `GIT_INDEX_FILE` 构造 staged fixture
|
||||
- **THEN** 若任一 hook 行为不符合预期,命令 SHALL 返回非零退出码
|
||||
|
||||
### Requirement: 跨平台可用
|
||||
|
||||
pre-commit 和 commit-msg hook 脚本 SHALL 可在 macOS 和 Windows(Git Bash)上正常执行。
|
||||
|
||||
#### Scenario: macOS 上正常执行
|
||||
|
||||
- **WHEN** hook 脚本在 macOS 上被 git 调用
|
||||
- **THEN** `#!/bin/sh` shebang SHALL 被系统正确解析
|
||||
- **THEN** `exec make` SHALL 正确调用 Makefile target
|
||||
|
||||
#### Scenario: Windows Git Bash 上正常执行
|
||||
|
||||
- **WHEN** hook 脚本在 Windows 的 Git Bash 环境中被 git 调用
|
||||
- **THEN** Git for Windows 自带的 sh.exe SHALL 正确解析 `#!/bin/sh`
|
||||
- **THEN** `exec make` SHALL 正确调用 Makefile target(依赖 Git Bash/MINGW64 环境中 `make` 可用)
|
||||
- **THEN** Go 和 Bun 工具链 SHALL 通过 PATH 可被 Makefile 调用
|
||||
|
||||
### Requirement: pre-commit 核心逻辑在 Makefile 中复用
|
||||
|
||||
pre-commit hook 的检查逻辑 SHALL 通过 Makefile target 调用项目已有工具链,不重复实现。非代码检查(冲突标记、大文件、LFS 指针)SHALL 在 `_hooks-pre-commit` 中直接实现;代码检查 SHALL 委托 `_backend-lint`、`_versionctl-lint`、`_frontend-check` target。
|
||||
|
||||
#### Scenario: Go lint 委托后端 lint target
|
||||
|
||||
- **WHEN** pre-commit 需要检查 Go 文件
|
||||
- **THEN** SHALL 委托 `_backend-lint` 或 `_versionctl-lint` target(根据文件路径 `backend/` vs `versionctl/`)
|
||||
- **THEN** SHALL NOT 在 `_hooks-pre-commit` 中内联 `golangci-lint` 命令
|
||||
|
||||
#### Scenario: 前端检查委托前端 check target
|
||||
|
||||
- **WHEN** pre-commit 需要检查前端文件
|
||||
- **THEN** SHALL 委托 `_frontend-check` target
|
||||
- **THEN** SHALL NOT 在 `_hooks-pre-commit` 中内联 `eslint` 或 `prettier` 命令
|
||||
|
||||
#### Scenario: 终端直接调试
|
||||
|
||||
- **WHEN** 开发者执行 `make _hooks-pre-commit`
|
||||
- **THEN** SHALL 执行与 pre-commit hook 完全相同的检查逻辑
|
||||
- **THEN** 输出 SHALL 与 hook 触发时一致
|
||||
@@ -29,13 +29,13 @@
|
||||
#### Scenario: 记录请求开始
|
||||
|
||||
- **WHEN** 收到 HTTP 请求
|
||||
- **THEN** SHALL 记录请求开始日志
|
||||
- **THEN** SHALL 以 debug 级别记录请求开始日志
|
||||
- **THEN** SHALL 包含请求方法、路径、客户端 IP、请求 ID
|
||||
|
||||
#### Scenario: 记录请求结束
|
||||
|
||||
- **WHEN** HTTP 请求处理完成
|
||||
- **THEN** SHALL 记录请求结束日志
|
||||
- **THEN** SHALL 以 debug 级别记录请求结束日志
|
||||
- **THEN** SHALL 包含响应状态码、响应大小、请求耗时、请求 ID
|
||||
|
||||
#### Scenario: 记录错误
|
||||
@@ -44,6 +44,12 @@
|
||||
- **THEN** SHALL 记录错误日志
|
||||
- **THEN** SHALL 包含错误详情和请求 ID
|
||||
|
||||
#### Scenario: Info 级别过滤请求生命周期日志
|
||||
|
||||
- **WHEN** 日志中间件使用 info 级别 logger
|
||||
- **THEN** SHALL NOT 输出“请求开始”日志
|
||||
- **THEN** SHALL NOT 输出“请求结束”日志
|
||||
|
||||
### Requirement: 实现错误恢复中间件
|
||||
|
||||
系统 SHALL 实现错误恢复中间件。
|
||||
|
||||
@@ -54,13 +54,13 @@
|
||||
|
||||
### Requirement: 数据库初始化公共包
|
||||
|
||||
系统 SHALL 提供 `internal/database` 公共包,封装数据库初始化、迁移执行和连接关闭逻辑,供 `cmd/server` 和 `cmd/desktop` 共同调用。
|
||||
系统 SHALL 提供 `internal/database` 公共包,封装数据库初始化、迁移执行和连接关闭逻辑,供 `cmd/server` 和 `cmd/desktop` 共同调用,并 SHALL 使用随应用构建产物打包的迁移资源执行运行时迁移。
|
||||
|
||||
#### Scenario: 公共包 Init 函数
|
||||
|
||||
- **WHEN** 调用 `database.Init(cfg, logger)`
|
||||
- **THEN** SHALL 根据 `cfg.Driver` 选择对应的 GORM 驱动打开连接
|
||||
- **THEN** SHALL 执行对应方言的 goose 迁移
|
||||
- **THEN** SHALL 使用随应用构建产物打包的迁移资源执行对应方言的 goose 迁移
|
||||
- **THEN** SHALL 配置连接池参数
|
||||
- **THEN** SHALL 在 `driver=sqlite` 时执行 `PRAGMA journal_mode=WAL`
|
||||
- **THEN** SHALL 在 `driver=mysql` 时跳过 SQLite 专有 PRAGMA
|
||||
@@ -71,11 +71,20 @@
|
||||
- **WHEN** 调用 `database.Close(db)`
|
||||
- **THEN** SHALL 获取底层 `sql.DB` 并关闭连接
|
||||
|
||||
#### Scenario: 迁移目录选择
|
||||
#### Scenario: 迁移方言资源选择
|
||||
|
||||
- **WHEN** 执行迁移
|
||||
- **THEN** SHALL 在 `driver=sqlite` 时使用 `migrations/sqlite/` 目录,goose dialect 为 `sqlite3`
|
||||
- **THEN** SHALL 在 `driver=mysql` 时使用 `migrations/mysql/` 目录,goose dialect 为 `mysql`
|
||||
- **WHEN** 执行运行时迁移
|
||||
- **THEN** SHALL 在 `driver=sqlite` 时选择 SQLite 方言迁移资源,goose dialect 为 `sqlite3`
|
||||
- **THEN** SHALL 在 `driver=mysql` 时选择 MySQL 方言迁移资源,goose dialect 为 `mysql`
|
||||
- **THEN** 运行时迁移资源 SHALL 来源于打包资源而非源码目录
|
||||
- **THEN** SHALL 在方言子资源解析失败时返回明确错误并拒绝启动
|
||||
|
||||
#### Scenario: 公共包迁移资源来源
|
||||
|
||||
- **WHEN** 调用 `database.Init(cfg, logger)` 且当前工作目录不是仓库根目录或 `backend/` 目录
|
||||
- **THEN** SHALL 仍能解析并执行对应方言的迁移资源
|
||||
- **THEN** SHALL NOT 要求当前进程工作目录位于仓库根目录或 `backend/` 目录
|
||||
- **THEN** SHALL NOT 依赖 `runtime.Caller` 推导源码路径
|
||||
|
||||
### Requirement: MySQL 方言迁移文件
|
||||
|
||||
|
||||
57
openspec/specs/prepare-commit-msg-hook/spec.md
Normal file
57
openspec/specs/prepare-commit-msg-hook/spec.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# prepare-commit-msg-hook
|
||||
|
||||
## Purpose
|
||||
|
||||
定义 prepare-commit-msg Git hook,在 `git commit` 编辑器打开时为开发者提供提交信息模板。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: prepare-commit-msg hook 提供提交信息模板
|
||||
|
||||
prepare-commit-msg hook SHALL 在 `git commit` 打开编辑器时,将规范格式的提交信息模板预填充到提交信息文件中,辅助开发者编写符合项目规范的多行提交信息。
|
||||
|
||||
#### Scenario: 模板预填充到提交信息文件
|
||||
|
||||
- **WHEN** `git commit` 被执行且编辑器打开提交信息文件
|
||||
- **THEN** prepare-commit-msg hook SHALL 在提交信息文件中写入模板内容
|
||||
- **THEN** 模板 SHALL 包含注释行(以 `#` 开头)引导开发者填写规范格式
|
||||
|
||||
#### Scenario: 模板包含格式引导
|
||||
|
||||
- **WHEN** 模板被写入提交信息文件
|
||||
- **THEN** 模板 SHALL 包含首行格式提示:`# <类型>: <简短中文描述>`
|
||||
- **THEN** 模板 SHALL 包含空行占位符
|
||||
- **THEN** 模板 SHALL 包含详细描述区:`# <详细说明>`
|
||||
- **THEN** 模板 SHALL 列出可用类型:`feat / fix / refactor / docs / style / test / chore`
|
||||
- **THEN** 模板 SHALL 包含示例:`feat: 添加供应商批量管理功能`
|
||||
|
||||
#### Scenario: 注释行不被提交
|
||||
|
||||
- **WHEN** 用户在编辑器中基于模板填写提交信息并保存
|
||||
- **THEN** 以 `#` 开头的模板注释行 SHALL 被 Git 作为注释过滤,不会成为提交信息的一部分
|
||||
|
||||
#### Scenario: 已有提交信息时跳过
|
||||
|
||||
- **WHEN** 提交信息文件已包含非注释内容(如 `-m` 参数指定、`git commit --amend`、merge commit、cherry-pick)
|
||||
- **THEN** prepare-commit-msg hook SHALL NOT 覆盖已有内容,直接退出
|
||||
|
||||
#### Scenario: Git 默认注释不阻止模板写入
|
||||
|
||||
- **WHEN** 提交信息文件只包含空行或 Git 默认生成的 `#` 注释行
|
||||
- **THEN** prepare-commit-msg hook SHALL 将其视为没有已有提交信息
|
||||
- **THEN** hook SHALL 在文件顶部写入模板,并保留 Git 原有注释内容
|
||||
|
||||
### Requirement: 通过 hooks-install 安装
|
||||
|
||||
prepare-commit-msg hook SHALL 随 `make hooks-install` 一起安装到 `.git/hooks/`。
|
||||
|
||||
#### Scenario: 安装 prepare-commit-msg
|
||||
|
||||
- **WHEN** 执行 `make hooks-install`
|
||||
- **THEN** `scripts/git-hooks/prepare-commit-msg` SHALL 被复制到 `.git/hooks/prepare-commit-msg`
|
||||
- **THEN** 该文件 SHALL 被设置为可执行(`chmod +x`)
|
||||
|
||||
#### Scenario: hooks-check 验证安装状态
|
||||
|
||||
- **WHEN** 执行 `make hooks-check`
|
||||
- **THEN** 命令 SHALL 检查 `.git/hooks/prepare-commit-msg` 是否存在且可执行
|
||||
@@ -189,7 +189,8 @@
|
||||
|
||||
- `format = "prettier --write ."` — 格式化所有文件
|
||||
- `format:check = "prettier --check ."` — 检查文件格式
|
||||
- `check = "bun run lint && bun run format:check"` — 检查 lint 和格式
|
||||
- `typecheck = "tsc -b"` — TypeScript 类型检查
|
||||
- `check = "bun run typecheck && bun run lint && bun run format:check"` — 检查类型、lint 和格式
|
||||
- `fix = "bun run lint:fix && bun run format"` — 修复 lint 问题并格式化
|
||||
|
||||
#### Scenario: 运行格式化命令
|
||||
@@ -207,8 +208,14 @@
|
||||
#### Scenario: 运行统一检查命令
|
||||
|
||||
- **WHEN** 执行 `bun run check`
|
||||
- **THEN** SHALL 运行 `bun run lint && bun run format:check`
|
||||
- **THEN** lint 错误和格式问题 SHALL 都被检查
|
||||
- **THEN** SHALL 依次运行 `bun run typecheck`、`bun run lint`、`bun run format:check`
|
||||
- **THEN** TypeScript 类型错误、lint 错误和格式问题 SHALL 都被检查
|
||||
|
||||
#### Scenario: 运行类型检查命令
|
||||
|
||||
- **WHEN** 执行 `bun run typecheck`
|
||||
- **THEN** SHALL 运行 `tsc -b`
|
||||
- **THEN** TypeScript 类型错误 SHALL 报告错误
|
||||
|
||||
#### Scenario: 运行统一修复命令
|
||||
|
||||
|
||||
@@ -8,71 +8,257 @@
|
||||
|
||||
### Requirement: Tag 驱动发布流水线
|
||||
|
||||
系统 SHALL 仅在符合 `vX.Y.Z` 格式的 Git tag 上触发发布流水线,普通分支 push SHALL NOT 创建发布。
|
||||
系统 SHALL 仅在符合 `vX.Y.Z` 格式的 Git tag 上触发发布流水线,普通分支 push SHALL NOT 创建发布。发布流水线 SHALL 使用 `./versionctl` 而非 `./backend/cmd/versionctl` 调用版本管理工具。发布流水线 SHALL 在进入构建阶段前完成全流程测试验证,测试未通过 SHALL NOT 执行任何构建。
|
||||
|
||||
#### Scenario: 有效发布 tag
|
||||
|
||||
- **WHEN** 仓库收到 `v1.2.3` tag push
|
||||
- **THEN** 发布流水线 SHALL 启动版本校验、构建和 Release 组装步骤
|
||||
- **THEN** 发布流水线 SHALL 启动版本校验、全流程测试、构建和 Release 组装步骤
|
||||
- **AND** 版本校验步骤 SHALL 使用 `go run ./versionctl print` 和 `go run ./versionctl verify-tag` 获取并验证版本
|
||||
|
||||
#### Scenario: 普通分支推送
|
||||
|
||||
- **WHEN** 仓库收到非 tag 的分支 push
|
||||
- **THEN** 系统 SHALL NOT 创建 GitHub Release
|
||||
|
||||
#### Scenario: 测试门禁阻止构建
|
||||
|
||||
- **WHEN** 发布流水线中全流程测试步骤(lint、默认测试、MySQL 测试、E2E 测试)任一失败
|
||||
- **THEN** 发布流水线 SHALL NOT 执行任何平台构建
|
||||
- **THEN** 发布流水线 SHALL NOT 创建 Draft Release
|
||||
- **AND** 系统 SHALL NOT 产生可直接公开的成功发布结果
|
||||
|
||||
#### Scenario: 测试通过后并行构建
|
||||
|
||||
- **WHEN** 全流程测试全部通过
|
||||
- **THEN** web、Linux、Windows、macOS 构建 SHALL 并行执行
|
||||
- **AND** 所有构建 job SHALL 依赖 `prepare` 和 `test-gate`
|
||||
|
||||
### Requirement: 发布流水线 Go 模块缓存覆盖
|
||||
|
||||
发布流水线 SHALL 在所有 Go module 的 go.sum 文件存在时正确设置 Go 模块缓存路径,确保新增的 `versionctl` module 依赖也被缓存。
|
||||
|
||||
#### Scenario: CI 缓存覆盖所有 module
|
||||
|
||||
- **WHEN** 发布流水线设置 Go 模块缓存
|
||||
- **THEN** `cache-dependency-path` SHALL 覆盖 `backend/go.sum` 和 `versionctl/go.sum`
|
||||
|
||||
### Requirement: 三平台发布构建
|
||||
|
||||
系统 SHALL 在发布流水线中构建 server 与 desktop 的 Linux、Windows、macOS 三个平台产物。
|
||||
系统 SHALL 在发布流水线中构建 server、web 与 desktop 的发布产物,并覆盖 Linux、Windows、macOS 的目标架构和格式矩阵。
|
||||
|
||||
#### Scenario: Linux 发布构建
|
||||
#### Scenario: server 发布构建
|
||||
|
||||
- **WHEN** 发布流水线执行 Linux 构建 job
|
||||
- **THEN** 系统 SHALL 生成 Linux server 发布资产
|
||||
- **AND** 系统 SHALL 生成 Linux desktop 发布资产
|
||||
- **WHEN** 发布流水线执行 server 发布构建
|
||||
- **THEN** 系统 SHALL 生成 `nex-server_<version>_linux_amd64.tar.gz`
|
||||
- **AND** 系统 SHALL 生成 `nex-server_<version>_linux_arm64.tar.gz`
|
||||
- **AND** 系统 SHALL 生成 `nex-server_<version>_macos_amd64.tar.gz`
|
||||
- **AND** 系统 SHALL 生成 `nex-server_<version>_macos_arm64.tar.gz`
|
||||
- **AND** 系统 SHALL 生成 `nex-server_<version>_macos_universal.tar.gz`
|
||||
- **AND** 系统 SHALL 生成 `nex-server_<version>_windows_amd64.zip`
|
||||
|
||||
#### Scenario: Windows 发布构建
|
||||
#### Scenario: web 发布构建
|
||||
|
||||
- **WHEN** 发布流水线执行 Windows 构建 job
|
||||
- **THEN** 系统 SHALL 生成 Windows server 发布资产
|
||||
- **AND** 系统 SHALL 生成 Windows desktop 发布资产
|
||||
- **WHEN** 发布流水线执行 web 发布构建
|
||||
- **THEN** 系统 SHALL 使用 Bun 构建 `frontend/dist`
|
||||
- **AND** 系统 SHALL 将前端静态资源打包为 `nex-web_<version>.tar.gz`
|
||||
- **AND** server 发布资产 SHALL NOT 内置 Web 管理界面静态资源
|
||||
|
||||
#### Scenario: macOS 发布构建
|
||||
#### Scenario: Linux desktop 发布构建
|
||||
|
||||
- **WHEN** 发布流水线执行 macOS 构建 job
|
||||
- **THEN** 系统 SHALL 生成 darwin-amd64 server 发布资产
|
||||
- **AND** 系统 SHALL 生成 darwin-arm64 server 发布资产
|
||||
- **AND** 系统 SHALL 生成 macOS desktop universal 发布资产
|
||||
- **WHEN** 发布流水线执行 Linux desktop 发布构建
|
||||
- **THEN** 系统 SHALL 在可访问 Go、Bun、CGO、GTK3、Ayatana AppIndicator 和 Linux 打包工具链的环境中构建
|
||||
- **AND** 系统 SHALL 为 `amd64` 和 `arm64` 分别生成 tar.gz、AppImage、deb 和 rpm desktop 发布资产
|
||||
- **AND** Linux amd64 desktop 发布构建 SHALL 在 `ubuntu-latest` runner 上执行
|
||||
- **AND** Linux arm64 desktop 发布构建 SHALL 在 `ubuntu-24.04-arm` runner 上执行
|
||||
- **AND** 系统 SHALL NOT 在构建步骤中显式传递 TARGET_ARCH 参数
|
||||
|
||||
#### Scenario: Windows desktop 发布构建
|
||||
|
||||
- **WHEN** 发布流水线执行 Windows desktop 发布构建
|
||||
- **THEN** 系统 SHALL 在包含对应架构 MSYS2/MinGW 或等价 CGO 工具链的环境中构建
|
||||
- **AND** Windows amd64 desktop 发布构建 SHALL 在 `windows-latest` runner 上的 MSYS2 MINGW64 环境中执行
|
||||
- **AND** 系统 SHALL 生成 `nex-desktop_<version>_windows_amd64.zip`
|
||||
- **AND** 系统 SHALL NOT 在构建步骤中显式传递 TARGET_ARCH 参数
|
||||
|
||||
#### Scenario: macOS desktop 发布构建
|
||||
|
||||
- **WHEN** 发布流水线执行 macOS desktop 发布构建
|
||||
- **THEN** 系统 SHALL 在可访问 Go、Bun、Xcode 命令行工具、`lipo`、`hdiutil` 和 zip 打包工具的 macOS 环境中构建
|
||||
- **AND** 系统 SHALL 在 ARM64 macOS runner 上编译 amd64 和 arm64 双架构二进制并使用 `lipo` 合并为 universal binary
|
||||
- **AND** 系统 SHALL 生成 `nex-desktop_<version>_macos_universal.zip`
|
||||
- **AND** 系统 SHALL 生成 `nex-desktop_<version>_macos_universal.dmg`
|
||||
|
||||
#### Scenario: 原生架构构建
|
||||
|
||||
- **WHEN** 发布流水线执行 Linux 或 Windows 的 server/desktop 构建步骤
|
||||
- **THEN** 系统 SHALL NOT 显式传递 TARGET_ARCH 参数
|
||||
- **AND** Makefile SHALL 通过 `go env GOARCH` 自动检测目标架构
|
||||
- **AND** 原生 runner 的实际架构 SHALL 与 `go env GOARCH` 返回值一致
|
||||
|
||||
### Requirement: 三平台发布构建预检
|
||||
|
||||
系统 SHALL 在正式执行各平台 release 构建前验证对应 job 的关键工具链可用性,并在环境不完整时快速失败且输出明确诊断。
|
||||
|
||||
#### Scenario: Linux 预检通过后开始构建
|
||||
|
||||
- **WHEN** Linux 发布 job 中的 `go`、`bun`、`gcc`、`pkg-config`、GTK3、Ayatana AppIndicator 和 Linux 打包工具均可用
|
||||
- **THEN** 系统 SHALL 输出关键工具的版本信息或解析路径
|
||||
- **AND** 系统 SHALL 继续执行对应 Linux release 构建
|
||||
|
||||
#### Scenario: Windows 预检通过后开始构建
|
||||
|
||||
- **WHEN** Windows 发布 job 中的 `go`、`bun`、`make`、对应架构 CGO 编译器和 resource 生成工具均可用
|
||||
- **THEN** 系统 SHALL 输出关键工具的版本信息或解析路径
|
||||
- **AND** 系统 SHALL 继续执行对应 Windows release 构建
|
||||
|
||||
#### Scenario: macOS 预检通过后开始构建
|
||||
|
||||
- **WHEN** macOS 发布 job 中的 `go`、`bun`、`ditto`、`lipo`、`vtool` 和 `hdiutil` 均可用
|
||||
- **THEN** 系统 SHALL 输出关键工具的版本信息或解析路径
|
||||
- **AND** 系统 SHALL 继续执行对应 macOS release 构建
|
||||
|
||||
#### Scenario: web 预检通过后开始构建
|
||||
|
||||
- **WHEN** web 发布 job 中的 `bun` 和前端构建依赖均可用
|
||||
- **THEN** 系统 SHALL 输出 Bun 版本信息
|
||||
- **AND** 系统 SHALL 继续执行 web release 构建
|
||||
|
||||
#### Scenario: 任一预检发现工具缺失
|
||||
|
||||
- **WHEN** 任一发布 job 中存在关键工具不可用
|
||||
- **THEN** 发布流水线 SHALL 在正式构建前失败
|
||||
- **AND** 系统 SHALL 在日志中标识缺失的工具链名称
|
||||
|
||||
### Requirement: 发布流水线 LFS 资产拉取
|
||||
|
||||
发布流水线 SHALL 在所有会 checkout 仓库并参与版本校验、web 构建、server 构建或 desktop 构建的 job 中拉取 Git LFS 真实文件,确保发布构建读取到真实二进制资产而非 LFS pointer 文本。
|
||||
|
||||
#### Scenario: 发布 job 获取真实 LFS 图标资产
|
||||
|
||||
- **WHEN** 发布流水线执行任一参与版本校验、web 构建、server 构建或 desktop 构建的 job 的 checkout 步骤
|
||||
- **THEN** checkout 步骤 SHALL 拉取 Git LFS 文件
|
||||
- **AND** `assets/icon.ico`、`assets/icon.icns`、`assets/icon.png` 和 `frontend/public/icon.png` SHALL 在后续步骤中表现为真实图标文件而非 LFS pointer 文本
|
||||
|
||||
#### Scenario: 新增矩阵 job 获取真实 LFS 资产
|
||||
|
||||
- **WHEN** 发布流水线新增 server、web、desktop、platform 或 arch 矩阵 job
|
||||
- **THEN** 该 job 的 checkout 步骤 SHALL 使用与现有发布 job 一致的 Git LFS 拉取配置
|
||||
|
||||
### Requirement: 发布资产图标预检
|
||||
|
||||
发布流水线 SHALL 在正式执行任何需要图标资产、前端 public 图标或 desktop 打包资源的发布构建前校验关键图标资产可用,并在检测到 LFS pointer 或错误格式时快速失败且输出明确诊断。
|
||||
|
||||
#### Scenario: 图标资产为 LFS pointer
|
||||
|
||||
- **WHEN** 发布资产预检发现关键图标文件内容为 Git LFS pointer 文本
|
||||
- **THEN** 发布流水线 SHALL 在执行对应 release 构建前失败
|
||||
- **AND** 系统 SHALL 在日志中标识对应图标文件需要拉取 Git LFS 真实内容
|
||||
|
||||
#### Scenario: 图标资产格式无效
|
||||
|
||||
- **WHEN** 发布资产预检发现关键图标文件不是对应格式的有效资源
|
||||
- **THEN** 发布流水线 SHALL 在执行对应 release 构建前失败
|
||||
- **AND** 系统 SHALL 在日志中标识格式无效的图标文件路径
|
||||
|
||||
#### Scenario: 图标资产预检通过
|
||||
|
||||
- **WHEN** `assets/icon.ico`、`assets/icon.icns`、`assets/icon.png` 和 `frontend/public/icon.png` 均为真实且格式可用的图标资产
|
||||
- **THEN** 发布流水线 SHALL 继续执行依赖这些资产的 release 构建
|
||||
|
||||
### Requirement: 发布流水线运行时兼容性
|
||||
|
||||
系统 SHALL 保持与 GitHub-hosted runner 当前受支持的 workflow runtime 约束兼容,避免发布流程依赖已声明弃用的 runtime 或执行约束。
|
||||
|
||||
#### Scenario: runner runtime 升级前完成兼容更新
|
||||
|
||||
- **WHEN** GitHub-hosted runner 宣布 workflow runtime 或关键执行约束将从旧版本迁移到新版本
|
||||
- **THEN** 发布流水线 SHALL 在旧约束移除前完成兼容性更新
|
||||
|
||||
#### Scenario: 发布流水线执行时不依赖已弃用 runtime
|
||||
|
||||
- **WHEN** 发布流水线运行 release workflow
|
||||
- **THEN** 关键发布步骤 SHALL NOT 依赖已被 GitHub-hosted runner 标记为待移除的 runtime 或执行约束
|
||||
|
||||
### Requirement: 版本化发布资产命名
|
||||
|
||||
系统 SHALL 为 server 与 desktop 的发布资产使用包含统一版本号和目标平台信息的文件名,确保 Release 页面可直接区分产物用途与平台。
|
||||
系统 SHALL 为 server、web 与 desktop 发布资产使用包含统一版本号、组件、目标平台和目标架构信息的文件名,确保 Release 页面可直接区分产物用途、平台、架构和格式。
|
||||
|
||||
#### Scenario: server 资产命名
|
||||
|
||||
- **WHEN** 当前发布版本为 `1.2.3`
|
||||
- **THEN** Linux server 发布资产文件名 SHALL 包含 `1.2.3`、`linux` 和 `amd64`
|
||||
- **AND** Windows server 发布资产文件名 SHALL 包含 `1.2.3`、`windows` 和 `amd64`
|
||||
- **AND** macOS server 发布资产文件名 SHALL 分别包含 `1.2.3`、`darwin`、`amd64` 与 `1.2.3`、`darwin`、`arm64`
|
||||
- **THEN** Linux server 发布资产文件名 SHALL 为 `nex-server_1.2.3_linux_amd64.tar.gz` 和 `nex-server_1.2.3_linux_arm64.tar.gz`
|
||||
- **AND** macOS server 发布资产文件名 SHALL 为 `nex-server_1.2.3_macos_amd64.tar.gz`、`nex-server_1.2.3_macos_arm64.tar.gz` 和 `nex-server_1.2.3_macos_universal.tar.gz`
|
||||
- **AND** Windows server 发布资产文件名 SHALL 为 `nex-server_1.2.3_windows_amd64.zip`
|
||||
|
||||
#### Scenario: web 资产命名
|
||||
|
||||
- **WHEN** 当前发布版本为 `1.2.3`
|
||||
- **THEN** web 发布资产文件名 SHALL 为 `nex-web_1.2.3.tar.gz`
|
||||
- **AND** web 发布资产文件名 SHALL NOT 包含平台或架构字段
|
||||
|
||||
#### Scenario: desktop 资产命名
|
||||
|
||||
- **WHEN** 当前发布版本为 `1.2.3`
|
||||
- **THEN** Linux desktop 发布资产文件名 SHALL 包含 `1.2.3` 和 `linux`
|
||||
- **AND** Windows desktop 发布资产文件名 SHALL 包含 `1.2.3` 和 `windows`
|
||||
- **AND** macOS desktop universal 发布资产文件名 SHALL 包含 `1.2.3` 和 `macOS`
|
||||
- **THEN** Linux desktop 发布资产文件名 SHALL 使用 `nex-desktop_1.2.3_linux_<arch>.<format>` 格式
|
||||
- **AND** Windows desktop 发布资产文件名 SHALL 为 `nex-desktop_1.2.3_windows_amd64.zip`
|
||||
- **AND** macOS desktop 发布资产文件名 SHALL 为 `nex-desktop_1.2.3_macos_universal.zip` 和 `nex-desktop_1.2.3_macos_universal.dmg`
|
||||
- **AND** 发布资产文件名中的 macOS 平台字段 SHALL 使用 `macos` 而非 `darwin`
|
||||
|
||||
### Requirement: Draft Release 组装
|
||||
|
||||
系统 SHALL 将发布流水线产物上传到 GitHub Draft Release,由人工确认后再公开发布。
|
||||
系统 SHALL 将发布流水线产物上传到 GitHub Draft Release,由人工确认后再公开发布,并 SHALL 生成覆盖全部发布资产的校验和清单。
|
||||
|
||||
#### Scenario: 发布成功时创建 Draft Release
|
||||
|
||||
- **WHEN** 版本校验通过且三平台发布资产构建完成
|
||||
- **WHEN** 版本校验通过、全流程测试通过且 server、web、desktop 的全部目标发布资产构建完成
|
||||
- **THEN** 系统 SHALL 创建或更新与该 tag 对应的 GitHub Draft Release
|
||||
- **AND** 系统 SHALL 上传 server 与 desktop 的全部发布资产
|
||||
- **AND** 系统 SHALL 上传 server、web 与 desktop 的全部发布资产
|
||||
- **AND** 系统 SHALL 上传 `SHA256SUMS`
|
||||
|
||||
#### Scenario: 校验和覆盖全部资产
|
||||
|
||||
- **WHEN** Draft Release 组装步骤生成 `SHA256SUMS`
|
||||
- **THEN** `SHA256SUMS` SHALL 包含除自身以外的全部发布资产文件
|
||||
- **AND** `SHA256SUMS` 中的文件名 SHALL 与实际上传的 release asset 文件名一致
|
||||
|
||||
#### Scenario: 构建失败时阻止完成发布
|
||||
|
||||
- **WHEN** 任一平台发布资产构建失败或版本校验失败
|
||||
- **WHEN** 任一目标发布资产构建失败、打包失败、校验失败、artifact 上传为空、版本校验失败或全流程测试失败
|
||||
- **THEN** 发布流水线 SHALL 失败
|
||||
- **AND** 系统 SHALL NOT 产生可直接公开的成功发布结果
|
||||
|
||||
#### Scenario: artifact 缺失时快速失败
|
||||
|
||||
- **WHEN** 任一构建 job 尝试上传 release artifact 但匹配不到目标文件
|
||||
- **THEN** 该 job SHALL 失败
|
||||
- **AND** Draft Release 组装 SHALL NOT 继续发布不完整资产集合
|
||||
|
||||
### Requirement: 发布产物运行时资源完整性
|
||||
|
||||
发布流水线 SHALL 确保 server 和 desktop 发布产物包含运行时启动所需的数据库迁移资源,且 SHALL NOT 依赖 CI runner 的源码路径。
|
||||
|
||||
#### Scenario: desktop 发布产物包含迁移资源
|
||||
|
||||
- **WHEN** 发布流水线构建 desktop 发布资产
|
||||
- **THEN** 生成的 desktop 二进制或应用包 SHALL 包含 SQLite 和 MySQL 迁移资源
|
||||
- **THEN** macOS `.app`、`.zip` 和 `.dmg` 安装后 SHALL 不需要仓库源码目录即可执行启动迁移
|
||||
|
||||
#### Scenario: server 发布产物包含迁移资源
|
||||
|
||||
- **WHEN** 发布流水线构建 server 发布资产
|
||||
- **THEN** 生成的 server 二进制 SHALL 包含 SQLite 和 MySQL 迁移资源
|
||||
- **THEN** server 发布资产 SHALL 不需要仓库源码目录即可执行启动迁移
|
||||
|
||||
#### Scenario: 发布产物不泄漏构建机迁移路径
|
||||
|
||||
- **WHEN** 发布流水线完成 server 或 desktop 构建
|
||||
- **THEN** 构建产物 SHALL NOT 在运行时使用 `/Users/runner/work/.../backend/migrations/...` 作为迁移目录
|
||||
- **THEN** 若检测到运行时迁移路径依赖 CI runner 源码路径,发布构建 SHALL 失败
|
||||
|
||||
#### Scenario: 发布构建迁移资源验证
|
||||
|
||||
- **WHEN** 发布流水线执行 release 构建验证
|
||||
- **THEN** 验证 SHALL 覆盖迁移资源可用性
|
||||
- **THEN** 验证 SHALL 覆盖安装包内应用在无源码目录环境下可解析迁移资源
|
||||
- **THEN** 验证 MAY 通过 Go 测试或轻量资源自检完成,不要求启动图形托盘界面
|
||||
|
||||
@@ -66,3 +66,56 @@
|
||||
- **WHEN** 执行前端生产构建
|
||||
- **THEN** 构建流程 SHALL 注入 `VITE_APP_VERSION`
|
||||
- **AND** 该值 SHALL 等于 `VERSION` 中的版本号
|
||||
|
||||
### Requirement: 后端运行时版本查询
|
||||
|
||||
系统 SHALL 通过管理接口暴露后端运行时构建版本信息,供前端和用户诊断前后端版本一致性。
|
||||
|
||||
#### Scenario: 查询后端版本信息
|
||||
|
||||
- **WHEN** 客户端请求 `GET /api/version`
|
||||
- **THEN** 后端 SHALL 返回 HTTP 200
|
||||
- **THEN** 响应 JSON SHALL 包含 `version` 字段
|
||||
- **THEN** 响应 JSON SHALL 包含 `commit` 字段
|
||||
- **THEN** 响应 JSON SHALL 包含 `build_time` 字段
|
||||
|
||||
#### Scenario: 版本信息来源于构建注入
|
||||
|
||||
- **WHEN** 后端返回版本信息
|
||||
- **THEN** `version` SHALL 来源于 `buildinfo.Version()`
|
||||
- **THEN** `commit` SHALL 来源于 `buildinfo.Commit()`
|
||||
- **THEN** `build_time` SHALL 来源于 `buildinfo.BuildTime()`
|
||||
- **THEN** 后端 SHALL NOT 在运行时读取仓库 `VERSION` 文件作为接口响应来源
|
||||
|
||||
#### Scenario: 本地开发构建降级值
|
||||
|
||||
- **WHEN** 后端未通过构建参数注入版本元数据
|
||||
- **THEN** 后端版本接口 SHALL 返回 buildinfo 的默认降级值
|
||||
- **THEN** 前端 SHALL 能够展示该降级值而不崩溃
|
||||
|
||||
### Requirement: 前后端版本一致性诊断
|
||||
|
||||
系统 SHALL 支持前端使用自身构建版本和后端运行时版本进行一致性诊断。
|
||||
|
||||
#### Scenario: 前端读取构建版本
|
||||
|
||||
- **WHEN** 前端渲染版本信息
|
||||
- **THEN** 前端 SHALL 使用 `VITE_APP_VERSION` 作为前端版本号
|
||||
- **THEN** `VITE_APP_VERSION` SHALL 继续由版本同步流程保持与 `VERSION` 一致
|
||||
|
||||
#### Scenario: 诊断版本匹配
|
||||
|
||||
- **WHEN** 前端版本号和后端版本号均可判断且完全相同
|
||||
- **THEN** 前端 SHALL 将版本状态判定为一致
|
||||
|
||||
#### Scenario: 诊断版本不匹配
|
||||
|
||||
- **WHEN** 前端版本号和后端版本号均可判断且不相同
|
||||
- **THEN** 前端 SHALL 将版本状态判定为不一致
|
||||
- **THEN** 前端 SHALL 将该状态作为诊断提示展示
|
||||
|
||||
#### Scenario: 诊断版本不可判断
|
||||
|
||||
- **WHEN** 任一版本号为空、`dev` 或 `unknown`
|
||||
- **THEN** 前端 SHALL 将版本状态判定为无法判断
|
||||
- **THEN** 前端 SHALL NOT 将该状态判定为版本不一致
|
||||
|
||||
@@ -82,21 +82,29 @@
|
||||
|
||||
### Requirement: 记录请求日志
|
||||
|
||||
系统 SHALL 记录 HTTP 请求日志。
|
||||
系统 SHALL 记录 HTTP 请求日志,其中请求开始和请求结束生命周期日志 SHALL 使用 debug 级别记录。
|
||||
|
||||
#### Scenario: 请求开始日志
|
||||
|
||||
- **WHEN** 收到 HTTP 请求
|
||||
- **THEN** SHALL 以 debug 级别记录请求开始日志
|
||||
- **THEN** SHALL 记录请求方法、路径、客户端 IP
|
||||
- **THEN** SHALL 包含请求 ID
|
||||
|
||||
#### Scenario: 请求结束日志
|
||||
|
||||
- **WHEN** HTTP 请求处理完成
|
||||
- **THEN** SHALL 以 debug 级别记录请求结束日志
|
||||
- **THEN** SHALL 记录响应状态码、响应大小
|
||||
- **THEN** SHALL 记录请求耗时
|
||||
- **THEN** SHALL 包含请求 ID
|
||||
|
||||
#### Scenario: Info 级别过滤请求生命周期日志
|
||||
|
||||
- **WHEN** 日志级别配置为 info
|
||||
- **THEN** SHALL NOT 输出“请求开始”日志
|
||||
- **THEN** SHALL NOT 输出“请求结束”日志
|
||||
|
||||
### Requirement: 支持日志级别
|
||||
|
||||
系统 SHALL 支持日志级别控制。
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
- **THEN** SHALL 验证 LoadConfigFromPath 正确加载默认值
|
||||
- **THEN** SHALL 验证 YAML 配置文件正确读取
|
||||
- **THEN** SHALL 验证优先级链:CLI 参数 > 环境变量 > YAML 文件 > 默认值
|
||||
- **THEN** SHALL 验证首次启动自动创建配置文件
|
||||
- **THEN** SHALL 验证配置文件缺失时使用默认值,不自动创建配置文件
|
||||
- **THEN** SHALL 验证 SaveConfig 后重新 LoadConfig 数据一致
|
||||
|
||||
#### Scenario: 环境变量覆盖验证
|
||||
@@ -46,11 +46,12 @@
|
||||
- **THEN** SHALL 成功加载
|
||||
- **THEN** 配置值 SHALL 反映环境变量覆盖
|
||||
|
||||
#### Scenario: 自动创建配置文件验证
|
||||
#### Scenario: 配置文件缺失时使用默认值
|
||||
|
||||
- **WHEN** 调用 `LoadConfigFromPath` 并指向不存在的文件路径
|
||||
- **THEN** SHALL 成功加载(不返回 `missing configuration for 'configPath'` 错误)
|
||||
- **THEN** SHALL 返回默认配置对象
|
||||
- **THEN** SHALL NOT 自动创建配置文件
|
||||
|
||||
#### Scenario: handler 错误分支测试
|
||||
|
||||
@@ -279,3 +280,74 @@
|
||||
- **WHEN** mockgen 生成的 mock 就绪
|
||||
- **THEN** handler 测试中的手写 mock SHALL 被替换为生成的 mock
|
||||
- **THEN** 所有测试 SHALL 继续通过,行为不变
|
||||
|
||||
### Requirement: 运行时迁移资源测试覆盖
|
||||
|
||||
系统 SHALL 覆盖打包迁移资源解析和启动迁移回归场景,确保发布产物不依赖源码迁移目录。
|
||||
|
||||
#### Scenario: 运行时迁移资源解析测试
|
||||
|
||||
- **WHEN** 运行 database 包单元测试
|
||||
- **THEN** SHALL 验证 `database.Init` 在当前工作目录不是仓库根目录或 `backend/` 目录时仍能执行迁移
|
||||
- **THEN** SHALL 验证迁移资源不依赖 `runtime.Caller` 推导的源码路径
|
||||
- **THEN** SHALL 覆盖 SQLite 方言迁移资源解析
|
||||
|
||||
#### Scenario: 双方言迁移资源选择测试
|
||||
|
||||
- **WHEN** 运行迁移资源选择相关测试
|
||||
- **THEN** SHALL 验证 SQLite 方言资源可被解析
|
||||
- **THEN** SHALL 验证 MySQL 方言资源可被解析
|
||||
- **THEN** SHALL 验证未知或非法 driver 不会被静默映射到错误方言资源
|
||||
|
||||
#### Scenario: desktop 打包迁移资源测试
|
||||
|
||||
- **WHEN** 运行 desktop 专属测试
|
||||
- **THEN** SHALL 验证 desktop 模式启动数据库迁移时使用打包迁移资源
|
||||
- **THEN** SHALL 验证应用在发布产物环境中可执行迁移而不依赖源码迁移目录
|
||||
|
||||
### Requirement: Desktop 配置源隔离测试覆盖
|
||||
|
||||
系统 SHALL 为 desktop 配置加载行为建立测试覆盖,验证 desktop 只使用默认配置文件和默认值,不受 CLI 参数或 `NEX_*` 环境变量影响。
|
||||
|
||||
#### Scenario: Desktop 配置文件端口生效
|
||||
|
||||
- **WHEN** 运行 desktop 配置加载相关测试
|
||||
- **THEN** SHALL 验证 `~/.nex/config.yaml` 或等价测试配置文件中的 `server.port` 会进入 desktop 启动配置快照
|
||||
- **THEN** SHALL 验证 desktop 端口检测、HTTP 监听地址、浏览器打开地址和托盘端口显示使用同一个配置端口
|
||||
|
||||
#### Scenario: Desktop 忽略 CLI 参数
|
||||
|
||||
- **WHEN** 测试进程参数包含 `--server-port 9000`、`--database-path /tmp/test.db` 或 `--config /tmp/custom.yaml`
|
||||
- **THEN** desktop 配置加载 SHALL 忽略这些参数
|
||||
- **THEN** desktop 配置加载 SHALL 使用默认配置文件路径和配置文件值
|
||||
|
||||
#### Scenario: Desktop 忽略未知参数
|
||||
|
||||
- **WHEN** 测试进程参数包含未知命令行参数
|
||||
- **THEN** desktop 配置加载 SHALL 成功或仅因配置文件本身无效而失败
|
||||
- **THEN** desktop 配置加载 SHALL NOT 因未知参数返回参数解析错误
|
||||
|
||||
#### Scenario: Desktop 忽略环境变量
|
||||
|
||||
- **WHEN** 测试环境设置 `NEX_SERVER_PORT`、`NEX_DATABASE_PATH`、`NEX_LOG_LEVEL` 或其他 `NEX_*` 环境变量
|
||||
- **THEN** desktop 配置加载 SHALL NOT 使用这些环境变量覆盖配置文件值
|
||||
- **THEN** server 配置加载的环境变量覆盖测试 SHALL 继续通过
|
||||
|
||||
#### Scenario: Desktop 配置快照不随文件变化自动更新
|
||||
|
||||
- **WHEN** desktop 配置已加载为内存中的启动快照
|
||||
- **AND** 测试修改配置文件中的 `server.port` 或其他配置项
|
||||
- **THEN** 已加载的配置对象 SHALL 保持原值
|
||||
- **THEN** 重新启动或重新执行 desktop 配置加载时 SHALL 读取修改后的配置值
|
||||
|
||||
#### Scenario: Desktop 无效配置错误提示
|
||||
|
||||
- **WHEN** desktop 启动时配置文件存在但 YAML 无法解析或配置验证失败
|
||||
- **THEN** 测试 SHALL 验证启动流程返回或显示包含配置路径和失败原因的错误
|
||||
- **THEN** 测试 SHALL 验证 desktop 不会静默回退默认配置继续启动
|
||||
|
||||
#### Scenario: 配置文件缺失时使用默认值
|
||||
|
||||
- **WHEN** 测试配置加载时指定不存在的配置文件路径
|
||||
- **THEN** SHALL 返回默认配置值,不自动创建配置文件
|
||||
- **THEN** 测试 SHALL 验证配置文件未被创建
|
||||
|
||||
135
openspec/specs/version-bump/spec.md
Normal file
135
openspec/specs/version-bump/spec.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# 版本升迁
|
||||
|
||||
## Purpose
|
||||
|
||||
定义 `version bump` 子命令的版本号递增、下游文件同步、倒退防护及 Makefile 编排规则,确保版本升迁流程安全可自动化。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 版本号递增
|
||||
|
||||
`version bump` 子命令 SHALL 支持三种递增模式:`major`(major+1, minor=0, patch=0)、`minor`(minor+1, patch=0)、`patch`(patch+1),以及直接指定具体版本号。
|
||||
|
||||
#### Scenario: minor 递增
|
||||
|
||||
- **WHEN** 当前 VERSION 为 `0.1.0`,执行 `version bump minor`
|
||||
- **THEN** VERSION 文件 SHALL 被更新为 `0.2.0`
|
||||
|
||||
#### Scenario: major 递增
|
||||
|
||||
- **WHEN** 当前 VERSION 为 `0.1.0`,执行 `version bump major`
|
||||
- **THEN** VERSION 文件 SHALL 被更新为 `1.0.0`
|
||||
|
||||
#### Scenario: patch 递增
|
||||
|
||||
- **WHEN** 当前 VERSION 为 `0.1.0`,执行 `version bump patch`
|
||||
- **THEN** VERSION 文件 SHALL 被更新为 `0.1.1`
|
||||
|
||||
#### Scenario: 指定具体版本号
|
||||
|
||||
- **WHEN** 当前 VERSION 为 `0.1.0`,执行 `version bump 1.0.0`
|
||||
- **THEN** VERSION 文件 SHALL 被更新为 `1.0.0`
|
||||
|
||||
#### Scenario: 指定版本号等于当前 VERSION
|
||||
|
||||
- **WHEN** 当前 VERSION 为 `0.1.0`,执行 `version bump 0.1.0`
|
||||
- **THEN** 命令 SHALL 正常执行,完成 sync 和 check,输出 `0.1.0`
|
||||
|
||||
#### Scenario: 非法 bump 参数
|
||||
|
||||
- **WHEN** 执行 `version bump` 传入既非 `major|minor|patch` 也非合法 semver 的参数
|
||||
- **THEN** 命令 SHALL 以非零退出码失败并输出错误信息
|
||||
|
||||
### Requirement: bump 自动同步下游文件
|
||||
|
||||
`version bump` 子命令 SHALL 在写回 VERSION 文件后自动执行 sync 和 check,确保 `frontend/package.json` 和所有 `frontend/.env.*` 文件与新版本号一致。
|
||||
|
||||
#### Scenario: bump 自动 sync 和 check
|
||||
|
||||
- **WHEN** 执行 `version bump minor` 且当前 VERSION 为 `0.1.0`
|
||||
- **THEN** 命令 SHALL 自动将新版本号 `0.2.0` 同步到 `frontend/package.json` 的 `version` 字段和所有 `frontend/.env.*` 的 `VITE_APP_VERSION` 变量
|
||||
- **AND** 命令 SHALL 自动验证所有下游文件版本号一致性
|
||||
|
||||
#### Scenario: sync 失败时 bump 中止
|
||||
|
||||
- **WHEN** 执行 `version bump minor` 但下游文件同步失败(如文件缺失)
|
||||
- **THEN** 命令 SHALL 以非零退出码失败
|
||||
|
||||
### Requirement: 版本号倒退防护
|
||||
|
||||
`version bump` 子命令 SHALL 检查新版本号严格大于所有已有 git tag 中的最大版本号,防止版本号倒退。
|
||||
|
||||
#### Scenario: 新版本大于已有 tag
|
||||
|
||||
- **WHEN** 已有 tag `v0.1.0`,执行 `version bump minor`
|
||||
- **THEN** 命令 SHALL 成功将版本更新为 `0.2.0`
|
||||
|
||||
#### Scenario: 新版本等于已有 tag
|
||||
|
||||
- **WHEN** 已有 tag `v0.1.0`,执行 `version bump 0.1.0`
|
||||
- **THEN** 命令 SHALL 以非零退出码失败并提示版本号已存在
|
||||
|
||||
#### Scenario: 新版本小于已有 tag
|
||||
|
||||
- **WHEN** 已有 tag `v0.2.0`,执行 `version bump 0.1.5`
|
||||
- **THEN** 命令 SHALL 以非零退出码失败并提示版本号倒退
|
||||
|
||||
#### Scenario: 无已有 tag
|
||||
|
||||
- **WHEN** 不存在任何 `v*.*.*` 格式的 git tag,执行 `version bump 0.1.0`
|
||||
- **THEN** 命令 SHALL 成功
|
||||
|
||||
### Requirement: bump 输出新版本号
|
||||
|
||||
`version bump` 子命令成功时 SHALL 仅将新版本号(不含 `v` 前缀)输出到 stdout,供 Makefile 等外部工具使用。
|
||||
|
||||
#### Scenario: 输出格式
|
||||
|
||||
- **WHEN** 执行 `version bump minor`,当前版本为 `0.1.0`
|
||||
- **THEN** stdout SHALL 输出 `0.2.0`(换行结尾,无额外内容)
|
||||
|
||||
### Requirement: 版本升迁 Makefile 编排
|
||||
|
||||
`make version-bump` SHALL 编排完整的版本升迁流程:全量 lint 检查 → 全量单元测试 → 工作区干净检查 → `version bump`(含 sync/check/倒退检查)→ git add → git commit → git tag。不传 `BUMP` 参数时 SHALL 默认执行 `BUMP=patch`。lint/test 前置检查 SHALL NOT 替代工作区干净检查。
|
||||
|
||||
#### Scenario: 完整升迁流程
|
||||
|
||||
- **WHEN** 执行 `make version-bump BUMP=minor`,工作区干净,当前版本 `0.1.0`
|
||||
- **THEN** Makefile SHALL 依次执行:`make lint` → `make test` → 工作区检查 → `version bump minor` → `git add VERSION frontend/` → `git commit -m "chore: 版本升迁 v0.2.0"` → `git tag v0.2.0`
|
||||
|
||||
#### Scenario: 不传 BUMP 默认 patch
|
||||
|
||||
- **WHEN** 执行 `make version-bump`,工作区干净,当前版本 `0.1.0`
|
||||
- **THEN** Makefile SHALL 等效于执行 `make version-bump BUMP=patch`,将版本更新为 `0.1.1`
|
||||
|
||||
#### Scenario: lint 失败时终止
|
||||
|
||||
- **WHEN** 执行 `make version-bump`,但 `make lint` 报告错误
|
||||
- **THEN** Makefile SHALL 以非零退出码失败,SHALL NOT 执行 `version bump`、git commit、git tag
|
||||
- **THEN** SHALL 输出错误信息提示修复 lint 问题后重试
|
||||
|
||||
#### Scenario: test 失败时终止
|
||||
|
||||
- **WHEN** 执行 `make version-bump`,但 `make test` 报告测试失败
|
||||
- **THEN** Makefile SHALL 以非零退出码失败,SHALL NOT 执行 `version bump`、git commit、git tag
|
||||
- **THEN** SHALL 输出错误信息提示修复测试失败后重试
|
||||
|
||||
#### Scenario: 工作区不干净
|
||||
|
||||
- **WHEN** 执行 `make version-bump BUMP=minor`,但工作区有未提交的改动
|
||||
- **THEN** Makefile SHALL 以非零退出码失败并提示先提交或清理改动
|
||||
|
||||
#### Scenario: 支持指定版本号
|
||||
|
||||
- **WHEN** 执行 `make version-bump SET_VERSION=1.0.0`
|
||||
- **THEN** Makefile SHALL 将 `1.0.0` 传递给 `version bump` 子命令
|
||||
|
||||
#### Scenario: SET_VERSION 优先于默认 BUMP
|
||||
|
||||
- **WHEN** 执行 `make version-bump SET_VERSION=2.0.0`
|
||||
- **THEN** Makefile SHALL 使用 `SET_VERSION=2.0.0` 而非默认的 `BUMP=patch`
|
||||
|
||||
#### Scenario: 不自动推送
|
||||
|
||||
- **WHEN** `make version-bump` 成功完成
|
||||
- **THEN** commit 和 tag SHALL 仅存在于本地,SHALL NOT 自动 push 到远程
|
||||
@@ -8,17 +8,26 @@
|
||||
|
||||
### Requirement: 根目录公开命令分层
|
||||
|
||||
根目录 `Makefile` SHALL 仅暴露全局命令、版本命令、server 产品命令、desktop 产品命令和 release 命令,不再作为 backend 局部维护命令或内部打包步骤的公开入口。
|
||||
根目录 `Makefile` SHALL 仅暴露全局命令、版本命令、server 产品命令、desktop 产品命令和 release 命令,不再作为 backend 局部维护命令或内部打包步骤的公开入口。release 命令 SHALL 使用 `release-assets` 前缀,并 SHALL 通过清晰的目标名或变量参数表达 component、platform、arch 和 format。
|
||||
|
||||
#### Scenario: 查看根目录公开命令
|
||||
|
||||
- **WHEN** 开发者查看根目录 `Makefile` 的公开 target
|
||||
- **THEN** SHALL 仅看到 `lint`、`test`、`clean`、`version-sync`、`version-check`、`server-run`、`server-build`、`server-lint`、`server-test`、`server-clean`、`desktop-build-mac`、`desktop-build-win`、`desktop-build-linux`、`desktop-lint`、`desktop-test`、`desktop-clean`、`release-assets-linux`、`release-assets-windows`、`release-assets-macos` 这类公共入口
|
||||
- **THEN** SHALL 仅看到 `lint`、`test`、`clean`、`version-sync`、`version-check`、`server-run`、`server-build`、`server-lint`、`server-test`、`server-clean`、`desktop-build-mac`、`desktop-build-win`、`desktop-build-linux`、`desktop-lint`、`desktop-test`、`desktop-clean` 和 `release-assets` 前缀的 release 公共入口
|
||||
- **AND** release 公共入口 SHALL 能覆盖 server、web、desktop 的目标发布产物
|
||||
|
||||
#### Scenario: 根目录不暴露局部和内部命令
|
||||
|
||||
- **WHEN** 开发者查看根目录 `Makefile` 的公开 target
|
||||
- **THEN** SHALL NOT 暴露 `backend-*`、`frontend-*`、数据库迁移命令、MySQL 专项测试命令或 `desktop-prepare-*` 之类内部步骤
|
||||
- **THEN** SHALL NOT 暴露 `dev`、`build`、`all`、`desktop-dev`、`desktop-build` 这类模糊或聚合式公共命令
|
||||
|
||||
#### Scenario: release 内部步骤保持内部化
|
||||
|
||||
- **WHEN** 根目录 `Makefile` 需要复用 release 构建、打包、校验辅助步骤
|
||||
- **THEN** 内部辅助 target SHALL 使用 `_` 前缀或 Make 变量参数化方式表达
|
||||
- **AND** 内部辅助 target SHALL NOT 成为文档化的公共入口
|
||||
|
||||
### Requirement: 全局质量与清理命令
|
||||
|
||||
根目录 `Makefile` SHALL 提供 `lint`、`test`、`clean` 作为全仓默认入口。
|
||||
@@ -97,12 +106,33 @@
|
||||
|
||||
### Requirement: Release 命令沿用根目录入口
|
||||
|
||||
根目录 `Makefile` SHALL 继续提供 `release-assets-*` 作为发布资产入口,并与新的版本校验规则保持一致。
|
||||
根目录 `Makefile` SHALL 继续提供 `release-assets` 前缀 target 作为发布资产入口,并与版本校验、发布资产预检和多组件打包规则保持一致。
|
||||
|
||||
#### Scenario: 执行 release 资产命令
|
||||
- **WHEN** 执行 `make release-assets-linux`、`make release-assets-windows` 或 `make release-assets-macos`
|
||||
|
||||
- **WHEN** 执行任一 `release-assets` 前缀的公共 release target
|
||||
- **THEN** SHALL 在构建发布资产前执行版本一致性校验
|
||||
- **THEN** SHALL NOT 隐式执行 `version-sync` 或修改版本镜像文件
|
||||
- **AND** SHALL 在需要图标或桌面资源的构建前执行发布资产预检
|
||||
- **AND** SHALL NOT 隐式执行 `version-sync` 或修改版本镜像文件
|
||||
|
||||
#### Scenario: release target 职责清晰
|
||||
|
||||
- **WHEN** GitHub Actions 调用根目录 `Makefile` 生成 release 产物
|
||||
- **THEN** 对应 release target SHALL 明确生成的 component、platform、arch 或 format 范围
|
||||
- **AND** GitHub Actions SHALL NOT 以内联脚本替代 Makefile 中已有的核心构建和打包逻辑
|
||||
|
||||
#### Scenario: web release 产物生成
|
||||
|
||||
- **WHEN** 执行 web release 资产命令
|
||||
- **THEN** SHALL 使用 Bun 构建 `frontend/dist`
|
||||
- **AND** SHALL 打包生成 `nex-web_<version>.tar.gz`
|
||||
- **AND** SHALL NOT 修改前端版本镜像文件
|
||||
|
||||
#### Scenario: checksum release 产物生成
|
||||
|
||||
- **WHEN** 执行 release 汇总或 Draft Release 组装相关命令
|
||||
- **THEN** SHALL 能基于当前 release 产物目录生成 `SHA256SUMS`
|
||||
- **AND** `SHA256SUMS` SHALL 覆盖除自身以外的全部 release 资产
|
||||
|
||||
### Requirement: Backend 局部命令下沉
|
||||
|
||||
|
||||
5
packaging/linux/AppRun
Normal file
5
packaging/linux/AppRun
Normal file
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
APPDIR=$(dirname "$(readlink -f "$0")")
|
||||
exec "$APPDIR/usr/bin/nex" "$@"
|
||||
9
packaging/linux/nex.desktop
Normal file
9
packaging/linux/nex.desktop
Normal file
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Nex
|
||||
Comment=AI Gateway
|
||||
Exec=nex
|
||||
Icon=nex
|
||||
Terminal=false
|
||||
Categories=Development;Network;
|
||||
StartupNotify=false
|
||||
29
packaging/linux/nex.spec
Normal file
29
packaging/linux/nex.spec
Normal file
@@ -0,0 +1,29 @@
|
||||
Name: nex
|
||||
Version: %{nex_version}
|
||||
Release: 1%{?dist}
|
||||
Summary: AI Gateway desktop application
|
||||
License: Apache-2.0
|
||||
URL: https://github.com/nex/gateway
|
||||
Requires: gtk3
|
||||
Requires: libayatana-appindicator-gtk3
|
||||
Requires: xdg-utils
|
||||
|
||||
%description
|
||||
Nex is an AI Gateway desktop application.
|
||||
|
||||
%prep
|
||||
|
||||
%build
|
||||
|
||||
%install
|
||||
mkdir -p %{buildroot}/usr/bin
|
||||
install -m 0755 %{nex_binary} %{buildroot}/usr/bin/nex
|
||||
mkdir -p %{buildroot}/usr/share/applications
|
||||
install -m 0644 %{nex_desktop_file} %{buildroot}/usr/share/applications/nex.desktop
|
||||
mkdir -p %{buildroot}/usr/share/icons/hicolor
|
||||
cp -a %{nex_icons_dir}/. %{buildroot}/usr/share/icons/hicolor/
|
||||
|
||||
%files
|
||||
/usr/bin/nex
|
||||
/usr/share/applications/nex.desktop
|
||||
/usr/share/icons/hicolor/*/apps/nex.png
|
||||
67
scripts/git-hooks/commit-msg
Executable file
67
scripts/git-hooks/commit-msg
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
MSG_FILE=$1
|
||||
|
||||
if [ ! -f "$MSG_FILE" ]; then
|
||||
printf '%s\n' '提交信息文件不存在。' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FIRST_LINE=
|
||||
SECOND_LINE=
|
||||
HAS_BODY=
|
||||
LINE_NO=0
|
||||
|
||||
while IFS= read -r LINE || [ -n "$LINE" ]; do
|
||||
case "$LINE" in
|
||||
\#*) continue ;;
|
||||
esac
|
||||
|
||||
if [ -z "$FIRST_LINE" ]; then
|
||||
[ -n "$LINE" ] || continue
|
||||
FIRST_LINE=$LINE
|
||||
LINE_NO=1
|
||||
continue
|
||||
fi
|
||||
|
||||
LINE_NO=$((LINE_NO + 1))
|
||||
case "$LINE_NO" in
|
||||
2) SECOND_LINE=$LINE ;;
|
||||
*)
|
||||
if [ -n "$LINE" ]; then
|
||||
HAS_BODY=1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done < "$MSG_FILE"
|
||||
|
||||
case "$FIRST_LINE" in
|
||||
Merge*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! printf '%s\n' "$FIRST_LINE" | grep -Eq '^(feat|fix|refactor|docs|style|test|chore): .+$'; then
|
||||
cat >&2 <<'EOF'
|
||||
提交信息格式错误。
|
||||
|
||||
格式: <类型>: <简短描述>
|
||||
类型: feat / fix / refactor / docs / style / test / chore
|
||||
|
||||
示例:
|
||||
feat: 添加供应商批量管理功能
|
||||
fix: 修复流式响应断连问题
|
||||
chore: 版本升迁 v0.2.0
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ${#FIRST_LINE} -gt 72 ]; then
|
||||
printf '%s\n' '警告: 提交信息首行超过 72 个字符,建议保持简短。' >&2
|
||||
fi
|
||||
|
||||
if [ -n "$HAS_BODY" ] && [ -n "$SECOND_LINE" ]; then
|
||||
printf '%s\n' '提交信息首行后应为空行,再写详细描述。' >&2
|
||||
exit 1
|
||||
fi
|
||||
12
scripts/git-hooks/pre-commit
Executable file
12
scripts/git-hooks/pre-commit
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
ROOT_DIR=$(git rev-parse --show-toplevel)
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
command -v make >/dev/null 2>&1 || {
|
||||
printf '%s\n' '缺少 make 命令,请先安装 Make 或使用 Git Bash/MINGW64 环境。' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
exec make _hooks-pre-commit
|
||||
49
scripts/git-hooks/prepare-commit-msg
Executable file
49
scripts/git-hooks/prepare-commit-msg
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
MSG_FILE=$1
|
||||
MSG_SOURCE=$2
|
||||
|
||||
case "$MSG_SOURCE" in
|
||||
"") ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
if [ ! -f "$MSG_FILE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
has_content=0
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
''|\#*) ;;
|
||||
*)
|
||||
has_content=1
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done < "$MSG_FILE"
|
||||
|
||||
if [ "$has_content" -eq 1 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tmp_file=${MSG_FILE}.nex-template.$$
|
||||
{
|
||||
cat <<'EOF'
|
||||
# <类型>: <简短中文描述>
|
||||
#
|
||||
# <详细说明>
|
||||
#
|
||||
# 类型: feat / fix / refactor / docs / style / test / chore
|
||||
# 示例: feat: 添加供应商批量管理功能
|
||||
EOF
|
||||
if [ -s "$MSG_FILE" ]; then
|
||||
printf '\n'
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
printf '%s\n' "$line"
|
||||
done < "$MSG_FILE"
|
||||
fi
|
||||
} > "$tmp_file"
|
||||
|
||||
mv "$tmp_file" "$MSG_FILE"
|
||||
273
scripts/git-hooks/test-hooks.sh
Executable file
273
scripts/git-hooks/test-hooks.sh
Executable file
@@ -0,0 +1,273 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ROOT_DIR=$(git rev-parse --show-toplevel)
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
TMP_DIR=${TMPDIR:-/tmp}/nex-hooks-test.$$
|
||||
mkdir -p "$TMP_DIR"
|
||||
|
||||
cleanup() {
|
||||
rm -f \
|
||||
backend/pkg/buildinfo/hook_bad_test_fixture.go \
|
||||
frontend/src/hook_bad_fixture.ts \
|
||||
frontend/src/hook_format_fixture.ts \
|
||||
docs/hook-doc-fixture.md \
|
||||
docs/hook-conflict-fixture.md \
|
||||
docs/hook-large-fixture.txt \
|
||||
"$TMP_DIR/lfs-pointer-fixture" \
|
||||
"$TMP_DIR/lfs-bad-fixture"
|
||||
rm -rf "$TMP_DIR"
|
||||
}
|
||||
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
pass() {
|
||||
printf 'OK: %s\n' "$1"
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf 'FAIL: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
write_msg() {
|
||||
file=$1
|
||||
shift
|
||||
printf '%s\n' "$*" > "$file"
|
||||
}
|
||||
|
||||
write_conflict() {
|
||||
file=$1
|
||||
less7=$(printf '<%.0s' $(seq 7))
|
||||
eq7=$(printf '=%.0s' $(seq 7))
|
||||
gt7=$(printf '>%.0s' $(seq 7))
|
||||
printf '%s\n' "${less7} HEAD" '' "${eq7}" '' "${gt7} branch" > "$file"
|
||||
}
|
||||
|
||||
expect_success() {
|
||||
name=$1
|
||||
shift
|
||||
if "$@" > "$TMP_DIR/out" 2>&1; then
|
||||
pass "$name"
|
||||
else
|
||||
cat "$TMP_DIR/out" >&2
|
||||
fail "$name"
|
||||
fi
|
||||
}
|
||||
|
||||
expect_failure() {
|
||||
name=$1
|
||||
shift
|
||||
if "$@" > "$TMP_DIR/out" 2>&1; then
|
||||
cat "$TMP_DIR/out" >&2
|
||||
fail "$name"
|
||||
fi
|
||||
pass "$name"
|
||||
}
|
||||
|
||||
run_precommit_for() {
|
||||
index=$TMP_DIR/index
|
||||
rm -f "$index"
|
||||
GIT_INDEX_FILE=$index git read-tree HEAD
|
||||
for file in "$@"; do
|
||||
GIT_INDEX_FILE=$index git add -f "$file"
|
||||
done
|
||||
GIT_INDEX_FILE=$index make _hooks-pre-commit
|
||||
}
|
||||
|
||||
run_hooks_install_missing_source() {
|
||||
install_repo=$TMP_DIR/hooks-install-missing
|
||||
rm -rf "$install_repo"
|
||||
mkdir -p "$install_repo/scripts/git-hooks"
|
||||
cp Makefile "$install_repo/Makefile"
|
||||
cp scripts/git-hooks/pre-commit "$install_repo/scripts/git-hooks/pre-commit"
|
||||
cp scripts/git-hooks/commit-msg "$install_repo/scripts/git-hooks/commit-msg"
|
||||
git -C "$install_repo" init >/dev/null 2>&1
|
||||
(cd "$install_repo" && make hooks-install)
|
||||
}
|
||||
|
||||
MSG_FILE=$TMP_DIR/commit-msg.txt
|
||||
|
||||
# ============================================
|
||||
# commit-msg 测试
|
||||
# ============================================
|
||||
|
||||
write_msg "$MSG_FILE" 'feat: 添加 hook 测试'
|
||||
expect_success 'commit-msg accepts Chinese description' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||
|
||||
write_msg "$MSG_FILE" 'feat: add hook tests'
|
||||
expect_success 'commit-msg accepts English-only description without CJK enforcement' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||
|
||||
write_msg "$MSG_FILE" 'fix: 修复 auth 模块 bug'
|
||||
expect_success 'commit-msg accepts Chinese with English technical terms' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||
|
||||
write_msg "$MSG_FILE" 'docs: ajouter une fonctionnalité'
|
||||
expect_success 'commit-msg accepts non-CJK unicode description without CJK enforcement' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||
|
||||
write_msg "$MSG_FILE" 'update: 添加 hook 测试'
|
||||
expect_failure 'commit-msg rejects invalid type' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||
|
||||
write_msg "$MSG_FILE" 'Merge branch feature'
|
||||
expect_success 'commit-msg accepts merge commits' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||
|
||||
write_msg "$MSG_FILE" 'feat: 添加新功能
|
||||
'
|
||||
expect_success 'commit-msg accepts single line with trailing newline' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||
|
||||
printf 'feat: 添加新功能\n\n详细描述内容\n' > "$MSG_FILE"
|
||||
expect_success 'commit-msg accepts multi-line with blank line separator' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||
|
||||
printf 'feat: 添加新功能\n缺少空行\n详细描述\n' > "$MSG_FILE"
|
||||
expect_failure 'commit-msg rejects multi-line without blank line separator' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||
|
||||
printf 'feat: 添加新功能\n\n' > "$MSG_FILE"
|
||||
expect_success 'commit-msg accepts two lines with blank line 2' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||
|
||||
printf 'feat: 添加新功能\n非空行\n' > "$MSG_FILE"
|
||||
expect_success 'commit-msg accepts two lines without body (no line 3)' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||
|
||||
printf 'feat: 添加模板测试\n# <类型>: <简短中文描述>\n#\n# <详细说明>\n' > "$MSG_FILE"
|
||||
expect_success 'commit-msg ignores template comments after subject' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||
|
||||
printf '# <类型>: <简短中文描述>\n#\nfeat: 添加模板测试\n' > "$MSG_FILE"
|
||||
expect_success 'commit-msg ignores leading template comments' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||
|
||||
printf 'feat: 添加新功能\n缺少空行\n# 模板注释\n详细描述\n' > "$MSG_FILE"
|
||||
expect_failure 'commit-msg rejects non-blank separator with intervening comments' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||
|
||||
# ============================================
|
||||
# prepare-commit-msg 测试
|
||||
# ============================================
|
||||
|
||||
prepare_msg_file="$TMP_DIR/prepare-msg.txt"
|
||||
rm -f "$prepare_msg_file"
|
||||
touch "$prepare_msg_file"
|
||||
expect_success 'prepare-commit-msg writes template for empty commit' scripts/git-hooks/prepare-commit-msg "$prepare_msg_file" ""
|
||||
|
||||
if grep -q '<类型>' "$prepare_msg_file" && grep -q 'feat / fix / refactor' "$prepare_msg_file"; then
|
||||
pass 'prepare-commit-msg template contains format guidance'
|
||||
else
|
||||
fail 'prepare-commit-msg template contains format guidance'
|
||||
fi
|
||||
|
||||
printf '\n# Please enter the commit message for your changes.\n# On branch main\n' > "$prepare_msg_file"
|
||||
expect_success 'prepare-commit-msg writes template before git comments' scripts/git-hooks/prepare-commit-msg "$prepare_msg_file" ""
|
||||
if grep -q '<类型>' "$prepare_msg_file" && grep -q 'Please enter the commit message' "$prepare_msg_file"; then
|
||||
pass 'prepare-commit-msg preserves git comments after template'
|
||||
else
|
||||
fail 'prepare-commit-msg preserves git comments after template'
|
||||
fi
|
||||
|
||||
write_msg "$prepare_msg_file" 'existing content'
|
||||
expect_success 'prepare-commit-msg skips when file has content' scripts/git-hooks/prepare-commit-msg "$prepare_msg_file" ""
|
||||
if printf '%s\n' "$(cat "$prepare_msg_file")" | grep -q '^existing content$'; then
|
||||
pass 'prepare-commit-msg does not overwrite existing content'
|
||||
else
|
||||
fail 'prepare-commit-msg does not overwrite existing content'
|
||||
fi
|
||||
|
||||
rm -f "$prepare_msg_file"
|
||||
touch "$prepare_msg_file"
|
||||
expect_success 'prepare-commit-msg skips for merge' scripts/git-hooks/prepare-commit-msg "$prepare_msg_file" "merge"
|
||||
if [ ! -s "$prepare_msg_file" ]; then
|
||||
pass 'prepare-commit-msg skips template for merge'
|
||||
else
|
||||
fail 'prepare-commit-msg skips template for merge'
|
||||
fi
|
||||
|
||||
# ============================================
|
||||
# hooks-install 测试
|
||||
# ============================================
|
||||
|
||||
expect_failure 'hooks-install rejects missing source hook' run_hooks_install_missing_source
|
||||
|
||||
# ============================================
|
||||
# pre-commit 测试
|
||||
# ============================================
|
||||
|
||||
cat > backend/pkg/buildinfo/hook_bad_test_fixture.go <<'EOF'
|
||||
package buildinfo
|
||||
|
||||
import "fmt"
|
||||
|
||||
func hookBadTestFixture() {
|
||||
fmt.Println("bad")
|
||||
}
|
||||
EOF
|
||||
expect_failure 'pre-commit rejects Go lint errors (delegated to _backend-lint)' run_precommit_for backend/pkg/buildinfo/hook_bad_test_fixture.go
|
||||
rm -f backend/pkg/buildinfo/hook_bad_test_fixture.go
|
||||
|
||||
cat > frontend/src/hook_bad_fixture.ts <<'EOF'
|
||||
console.log('bad')
|
||||
EOF
|
||||
expect_failure 'pre-commit rejects frontend lint errors (delegated to _frontend-check)' run_precommit_for frontend/src/hook_bad_fixture.ts
|
||||
rm -f frontend/src/hook_bad_fixture.ts
|
||||
|
||||
cat > frontend/src/hook_format_fixture.ts <<'EOF'
|
||||
const hookFormatFixture={foo:"bar"}
|
||||
export { hookFormatFixture }
|
||||
EOF
|
||||
expect_failure 'pre-commit rejects frontend format errors (delegated to _frontend-check)' run_precommit_for frontend/src/hook_format_fixture.ts
|
||||
rm -f frontend/src/hook_format_fixture.ts
|
||||
|
||||
cat > docs/hook-doc-fixture.md <<'EOF'
|
||||
hook doc fixture
|
||||
EOF
|
||||
expect_success 'pre-commit skips non-code staged files' run_precommit_for docs/hook-doc-fixture.md
|
||||
rm -f docs/hook-doc-fixture.md
|
||||
|
||||
write_conflict docs/hook-conflict-fixture.md
|
||||
expect_failure 'pre-commit rejects conflict markers' run_precommit_for docs/hook-conflict-fixture.md
|
||||
rm -f docs/hook-conflict-fixture.md
|
||||
|
||||
index=$TMP_DIR/index
|
||||
rm -f "$index"
|
||||
GIT_INDEX_FILE=$index git read-tree HEAD
|
||||
write_conflict "$TMP_DIR/hook-conflict-fixture.sh"
|
||||
hash=$(git hash-object -w "$TMP_DIR/hook-conflict-fixture.sh")
|
||||
rm -f "$TMP_DIR/hook-conflict-fixture.sh"
|
||||
GIT_INDEX_FILE=$index git update-index --add --cacheinfo 100644 "$hash" "scripts/git-hooks/hook-conflict-fixture.sh"
|
||||
expect_failure 'pre-commit rejects conflict markers in hook scripts' env GIT_INDEX_FILE=$index make _hooks-pre-commit
|
||||
|
||||
i=0
|
||||
while [ "$i" -lt 40000 ]; do
|
||||
printf 'large hook fixture line\n'
|
||||
i=$((i + 1))
|
||||
done > docs/hook-large-fixture.txt
|
||||
if run_precommit_for docs/hook-large-fixture.txt > "$TMP_DIR/out" 2>&1 && grep -q 'Warning: large staged text file' "$TMP_DIR/out"; then
|
||||
pass 'pre-commit warns for large text files'
|
||||
else
|
||||
cat "$TMP_DIR/out" >&2
|
||||
fail 'pre-commit warns for large text files'
|
||||
fi
|
||||
rm -f docs/hook-large-fixture.txt
|
||||
|
||||
# LFS pointer 校验
|
||||
lfs_pointer='version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:abc123
|
||||
size 100
|
||||
'
|
||||
printf '%s\n' "$lfs_pointer" > "$TMP_DIR/lfs-pointer-fixture"
|
||||
hash=$(git hash-object -w "$TMP_DIR/lfs-pointer-fixture")
|
||||
index=$TMP_DIR/index
|
||||
rm -f "$index"
|
||||
GIT_INDEX_FILE=$index git read-tree HEAD
|
||||
GIT_INDEX_FILE=$index git update-index --add --cacheinfo 100644 "$hash" "assets/test-lfs-fixture.png"
|
||||
if GIT_INDEX_FILE=$index make _hooks-pre-commit > "$TMP_DIR/out" 2>&1; then
|
||||
pass 'pre-commit allows LFS pointer files'
|
||||
else
|
||||
cat "$TMP_DIR/out" >&2
|
||||
fail 'pre-commit allows LFS pointer files'
|
||||
fi
|
||||
|
||||
printf 'fake binary content\n' > "$TMP_DIR/lfs-bad-fixture"
|
||||
hash=$(git hash-object -w "$TMP_DIR/lfs-bad-fixture")
|
||||
rm -f "$index"
|
||||
GIT_INDEX_FILE=$index git read-tree HEAD
|
||||
GIT_INDEX_FILE=$index git update-index --add --cacheinfo 100644 "$hash" "assets/test-lfs-bad-fixture.png"
|
||||
if GIT_INDEX_FILE=$index make _hooks-pre-commit > "$TMP_DIR/out" 2>&1; then
|
||||
cat "$TMP_DIR/out" >&2
|
||||
fail 'pre-commit rejects non-pointer LFS files'
|
||||
fi
|
||||
pass 'pre-commit rejects non-pointer LFS files'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user