Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6908b9653b | |||
| d8e64ef0e9 | |||
| fb9f6d1d00 | |||
| 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:
|
||||
|
||||
116
.github/workflows/test.yml
vendored
Normal file
116
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
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 (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Install Linux system dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y libayatana-appindicator3-dev
|
||||
|
||||
- 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
|
||||
|
||||
BIN
assets/icon.icns
LFS
BIN
assets/icon.icns
LFS
Binary file not shown.
BIN
assets/icon.ico
LFS
BIN
assets/icon.ico
LFS
Binary file not shown.
BIN
assets/icon.png
LFS
BIN
assets/icon.png
LFS
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,6 +1,6 @@
|
||||
# AI Gateway Frontend
|
||||
# Nex Frontend
|
||||
|
||||
AI 网关管理前端,提供供应商配置和用量统计界面。
|
||||
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/ # 用量统计
|
||||
│ │ ├── 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,22 +189,29 @@ 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
|
||||
|
||||
### 用量统计
|
||||
### 总览
|
||||
|
||||
- 查看统计数据
|
||||
- 按供应商筛选
|
||||
- 按模型筛选
|
||||
- 按日期范围筛选(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,13 +12,13 @@ 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 }) => {
|
||||
const aside = page.locator('aside')
|
||||
await expect(aside.getByText('供应商管理')).toBeVisible()
|
||||
await expect(aside.getByText('用量统计')).toBeVisible()
|
||||
await expect(aside.getByText('总览')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,24 +28,33 @@ 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()
|
||||
test('应能切换到总览', async ({ page }) => {
|
||||
await page.locator('aside').getByText('总览').click()
|
||||
await expect(page.getByRole('heading', { name: '总览' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('应能切换回供应商管理', async ({ page }) => {
|
||||
await page.locator('aside').getByText('用量统计').click()
|
||||
await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible()
|
||||
await page.locator('aside').getByText('总览').click()
|
||||
await expect(page.getByRole('heading', { name: '总览' })).toBeVisible()
|
||||
|
||||
await page.locator('aside').getByText('供应商管理').click()
|
||||
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()
|
||||
await page.locator('aside').getByText('总览').click()
|
||||
await expect(page.getByRole('heading', { name: '总览' })).toBeVisible()
|
||||
|
||||
await page.reload()
|
||||
await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: '总览' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,7 +41,7 @@ test.describe('统计概览', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/stats')
|
||||
await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: '总览' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('应显示正确的总请求量', async ({ page }) => {
|
||||
@@ -99,7 +99,7 @@ test.describe('统计筛选', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/stats')
|
||||
await expect(page.getByRole('heading', { name: '用量统计' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: '总览' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('按供应商筛选', async ({ page }) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,10 +20,10 @@ export function AppLayout() {
|
||||
|
||||
const getPageTitle = () => {
|
||||
if (location.pathname === '/providers') return '供应商管理'
|
||||
if (location.pathname === '/stats') return '用量统计'
|
||||
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 />}
|
||||
@@ -69,12 +73,12 @@ export function AppLayout() {
|
||||
}
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
<MenuItem value='/stats' icon={<ChartLineIcon />}>
|
||||
总览
|
||||
</MenuItem>
|
||||
<MenuItem value='/providers' icon={<ServerIcon />}>
|
||||
供应商管理
|
||||
</MenuItem>
|
||||
<MenuItem value='/stats' icon={<ChartLineIcon />}>
|
||||
用量统计
|
||||
</MenuItem>
|
||||
<MenuItem value='/settings' icon={<SettingIcon />}>
|
||||
设置
|
||||
</MenuItem>
|
||||
|
||||
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 />
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export function AppRoutes() {
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Routes>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<Navigate to='/providers' replace />} />
|
||||
<Route index element={<Navigate to='/stats' replace />} />
|
||||
<Route path='providers' element={<ProvidersPage />} />
|
||||
<Route path='stats' element={<StatsPage />} />
|
||||
<Route path='settings' element={<SettingsPage />} />
|
||||
|
||||
@@ -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 因版本接口失败而崩溃
|
||||
|
||||
174
openspec/specs/ci-test-gate/spec.md
Normal file
174
openspec/specs/ci-test-gate/spec.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# 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(始终执行,三平台 matrix)
|
||||
|
||||
- **WHEN** 测试 workflow 被调用(无论 `full` 值)
|
||||
- **THEN** `check` job SHALL 始终执行
|
||||
- **THEN** `check` job SHALL 使用 `strategy.matrix.os` 在 `ubuntu-latest`、`macos-latest`、`windows-latest` 三个平台并行运行
|
||||
- **THEN** 每个平台 SHALL 按顺序执行:checkout(含 LFS)→ setup Go → setup Bun → `make lint` → `make test`
|
||||
- **THEN** 在 Linux 平台上,lint/test 之前 SHALL 执行 `sudo apt-get install -y libayatana-appindicator3-dev` 安装系统依赖
|
||||
- **THEN** macOS 和 Windows 平台 SHALL NOT 安装额外系统依赖
|
||||
- **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 阻止后续步骤执行
|
||||
- **THEN** 任一平台失败 SHALL 导致 `check` job 整体失败
|
||||
|
||||
#### Scenario: check job 平台特定依赖安装
|
||||
|
||||
- **WHEN** `check` job 在 Linux 平台运行
|
||||
- **THEN** SHALL 在 lint 步骤之前执行系统依赖安装步骤
|
||||
- **THEN** 该步骤 SHALL 使用 `if: runner.os == 'Linux'` 条件控制
|
||||
- **THEN** 该步骤 SHALL 安装 `libayatana-appindicator3-dev`
|
||||
|
||||
#### Scenario: check job macOS 平台
|
||||
|
||||
- **WHEN** `check` job 在 macOS 平台运行
|
||||
- **THEN** SHALL NOT 安装额外系统依赖
|
||||
- **THEN** `make lint` 和 `make test` SHALL 正常执行
|
||||
|
||||
#### Scenario: check job Windows 平台
|
||||
|
||||
- **WHEN** `check` job 在 Windows 平台运行
|
||||
- **THEN** SHALL NOT 安装额外系统依赖
|
||||
- **THEN** `make lint` 和 `make test` 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 在各平台 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: 统一修复命令
|
||||
|
||||
|
||||
@@ -2,37 +2,37 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
||||
TBD - 提供供应商、模型配置和总览的前端管理界面
|
||||
|
||||
## Requirements
|
||||
|
||||
### 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 图标)
|
||||
- **THEN** 导航菜单项 SHALL 按以下顺序包含:总览(ChartLineIcon 图标)、供应商管理(ServerIcon 图标)、设置(SettingIcon 图标)、关于(InfoCircleIcon 图标)
|
||||
|
||||
#### Scenario: 侧边栏折叠品牌显示
|
||||
|
||||
- **WHEN** 侧边栏处于折叠状态
|
||||
- **THEN** 侧边栏顶部 SHALL 显示统一应用图标
|
||||
- **THEN** 侧边栏顶部 SHALL 隐藏 `Nex` 文案
|
||||
- **THEN** 侧边栏顶部 SHALL NOT 为空白
|
||||
|
||||
#### Scenario: 导航菜单交互
|
||||
|
||||
- **WHEN** 用户点击导航中的"供应商管理"
|
||||
- **THEN** 前端 SHALL 导航到 \`/providers\` 并高亮当前菜单项
|
||||
- **WHEN** 用户点击导航中的"用量统计"
|
||||
- **THEN** 前端 SHALL 导航到 \`/stats\` 并高亮当前菜单项
|
||||
- **THEN** 前端 SHALL 导航到 `/providers` 并高亮当前菜单项
|
||||
- **WHEN** 用户点击导航中的"总览"
|
||||
- **THEN** 前端 SHALL 导航到 `/stats` 并高亮当前菜单项
|
||||
- **WHEN** 用户点击导航中的"设置"
|
||||
- **THEN** 前端 SHALL 导航到 `/settings` 并高亮当前菜单项
|
||||
- **WHEN** 用户点击导航中的"关于"
|
||||
@@ -431,10 +518,10 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
||||
- **WHEN** 应用启动
|
||||
- **THEN** 前端 SHALL 使用 React Router v7 Library 模式(BrowserRouter)
|
||||
- **THEN** `/providers` 路径 SHALL 显示供应商管理页面
|
||||
- **THEN** `/stats` 路径 SHALL 显示用量统计页面
|
||||
- **THEN** `/stats` 路径 SHALL 显示总览页面
|
||||
- **THEN** `/settings` 路径 SHALL 显示设置页面
|
||||
- **THEN** `/about` 路径 SHALL 显示关于页面
|
||||
- **THEN** `/` 路径 SHALL 重定向到 `/providers`
|
||||
- **THEN** `/` 路径 SHALL 重定向到 `/stats`
|
||||
- **THEN** 不存在的路径 SHALL 显示 404 页面
|
||||
|
||||
#### Scenario: 路由级懒加载
|
||||
@@ -448,7 +535,7 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
||||
|
||||
- **WHEN** 用户点击导航中的"供应商管理"
|
||||
- **THEN** 前端 SHALL 导航到 `/providers` 并高亮当前菜单项
|
||||
- **WHEN** 用户点击导航中的"用量统计"
|
||||
- **WHEN** 用户点击导航中的"总览"
|
||||
- **THEN** 前端 SHALL 导航到 `/stats` 并高亮当前菜单项
|
||||
|
||||
#### Scenario: URL 同步
|
||||
@@ -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` 是否存在且可执行
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user