Compare commits
14 Commits
v0.1.3
...
c04a13bf8a
| Author | SHA1 | Date | |
|---|---|---|---|
| c04a13bf8a | |||
| 5513f0c13d | |||
| 598e2acb7e | |||
| 4870d29638 | |||
| 8600a39b6c | |||
| 407d008e19 | |||
| a2751eab31 | |||
| 5655fc5560 | |||
| 49b47a1ae0 | |||
| bcf82d42bc | |||
| 394025c8ea | |||
| 34bd749741 | |||
| 290f299e22 | |||
| 859dec8ada |
42
.github/workflows/release.yml
vendored
42
.github/workflows/release.yml
vendored
@@ -136,7 +136,7 @@ jobs:
|
|||||||
make release-assets-check
|
make release-assets-check
|
||||||
|
|
||||||
- name: Build Linux release assets
|
- name: Build Linux release assets
|
||||||
run: make release-assets-linux TARGET_ARCH=${{ matrix.arch }}
|
run: make release-assets-linux
|
||||||
|
|
||||||
- name: Upload Linux release assets
|
- name: Upload Linux release assets
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -148,22 +148,19 @@ jobs:
|
|||||||
build-windows:
|
build-windows:
|
||||||
name: Build Windows ${{ matrix.arch }} Assets
|
name: Build Windows ${{ matrix.arch }} Assets
|
||||||
needs: prepare
|
needs: prepare
|
||||||
runs-on: windows-latest
|
runs-on: ${{ matrix.runner }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- arch: amd64
|
- arch: amd64
|
||||||
|
runner: windows-latest
|
||||||
msystem: MINGW64
|
msystem: MINGW64
|
||||||
|
cc: gcc
|
||||||
|
cxx: g++
|
||||||
packages: >-
|
packages: >-
|
||||||
make
|
make
|
||||||
mingw-w64-x86_64-gcc
|
mingw-w64-x86_64-gcc
|
||||||
- arch: arm64
|
|
||||||
msystem: CLANGARM64
|
|
||||||
packages: >-
|
|
||||||
make
|
|
||||||
mingw-w64-clang-aarch64-clang
|
|
||||||
mingw-w64-clang-aarch64-llvm
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
@@ -193,6 +190,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Preflight Windows release toolchain
|
- name: Preflight Windows release toolchain
|
||||||
shell: msys2 {0}
|
shell: msys2 {0}
|
||||||
|
env:
|
||||||
|
CC: ${{ matrix.cc }}
|
||||||
|
CXX: ${{ matrix.cxx }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
command -v go
|
command -v go
|
||||||
@@ -201,21 +201,12 @@ jobs:
|
|||||||
bun --version
|
bun --version
|
||||||
command -v make
|
command -v make
|
||||||
make --version
|
make --version
|
||||||
if [ "${{ matrix.arch }}" = "arm64" ]; then
|
command -v "$CC"
|
||||||
command -v clang
|
"$CC" --version
|
||||||
clang --version
|
command -v "$CXX"
|
||||||
if command -v llvm-windres >/dev/null 2>&1; then
|
"$CXX" --version
|
||||||
llvm-windres --version
|
command -v windres
|
||||||
else
|
windres --version
|
||||||
command -v windres
|
|
||||||
windres --version
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
command -v gcc
|
|
||||||
gcc --version
|
|
||||||
command -v windres
|
|
||||||
windres --version
|
|
||||||
fi
|
|
||||||
if command -v powershell.exe >/dev/null 2>&1; then
|
if command -v powershell.exe >/dev/null 2>&1; then
|
||||||
powershell.exe -NoProfile -Command '$PSVersionTable.PSVersion.ToString()'
|
powershell.exe -NoProfile -Command '$PSVersionTable.PSVersion.ToString()'
|
||||||
else
|
else
|
||||||
@@ -226,7 +217,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Windows release assets
|
- name: Build Windows release assets
|
||||||
shell: msys2 {0}
|
shell: msys2 {0}
|
||||||
run: make release-assets-windows TARGET_ARCH=${{ matrix.arch }}
|
env:
|
||||||
|
CC: ${{ matrix.cc }}
|
||||||
|
CXX: ${{ matrix.cxx }}
|
||||||
|
run: make release-assets-windows
|
||||||
|
|
||||||
- name: Upload Windows release assets
|
- name: Upload Windows release assets
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
149
Makefile
149
Makefile
@@ -1,5 +1,5 @@
|
|||||||
.PHONY: \
|
.PHONY: \
|
||||||
lint test clean \
|
lint test clean hooks-install hooks-check hooks-test \
|
||||||
version-sync version-check version-bump \
|
version-sync version-check version-bump \
|
||||||
server-run server-build server-lint server-test server-clean \
|
server-run server-build server-lint server-test server-clean \
|
||||||
desktop-build-mac desktop-build-win desktop-build-linux \
|
desktop-build-mac desktop-build-win desktop-build-linux \
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
_backend-lint _backend-test _backend-clean _backend-build \
|
_backend-lint _backend-test _backend-clean _backend-build \
|
||||||
_versionctl-lint _versionctl-test \
|
_versionctl-lint _versionctl-test \
|
||||||
_frontend-install _frontend-build _frontend-check _frontend-test _frontend-dev _frontend-clean \
|
_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 \
|
_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 \
|
_check-linux-target-arch _check-windows-target-arch _ensure-appimagetool \
|
||||||
@@ -35,16 +36,16 @@ ifeq ($(TARGET_ARCH),arm64)
|
|||||||
APPIMAGE_ARCH := aarch64
|
APPIMAGE_ARCH := aarch64
|
||||||
DEB_ARCH := arm64
|
DEB_ARCH := arm64
|
||||||
RPM_ARCH := aarch64
|
RPM_ARCH := aarch64
|
||||||
WINDOWS_WINDRES_FORMAT := pe-aarch64
|
|
||||||
WINDOWS_RESOURCE := rsrc_windows_arm64.syso
|
|
||||||
else
|
else
|
||||||
APPIMAGE_ARCH := x86_64
|
APPIMAGE_ARCH := x86_64
|
||||||
DEB_ARCH := amd64
|
DEB_ARCH := amd64
|
||||||
RPM_ARCH := x86_64
|
RPM_ARCH := x86_64
|
||||||
WINDOWS_WINDRES_FORMAT := pe-x86-64
|
|
||||||
WINDOWS_RESOURCE := rsrc_windows_amd64.syso
|
|
||||||
endif
|
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_PATH := build/tools/appimagetool-$(APPIMAGE_ARCH).AppImage
|
||||||
APPIMAGETOOL_URL ?= https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-$(APPIMAGE_ARCH).AppImage
|
APPIMAGETOOL_URL ?= https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-$(APPIMAGE_ARCH).AppImage
|
||||||
APPIMAGETOOL ?= $(APPIMAGETOOL_PATH)
|
APPIMAGETOOL ?= $(APPIMAGETOOL_PATH)
|
||||||
@@ -62,6 +63,101 @@ test: _backend-test _frontend-test _desktop-test _versionctl-test
|
|||||||
clean: _backend-clean _frontend-clean _desktop-clean
|
clean: _backend-clean _frontend-clean _desktop-clean
|
||||||
@printf 'Clean complete\n'
|
@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'
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 版本管理
|
# 版本管理
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -73,13 +169,21 @@ version-check:
|
|||||||
go run ./versionctl check
|
go run ./versionctl check
|
||||||
|
|
||||||
version-bump: BUMP ?= patch
|
version-bump: BUMP ?= patch
|
||||||
version-bump:
|
version-bump: lint test _check-clean-worktree
|
||||||
$(eval _BUMP_ARG := $(if $(SET_VERSION),$(SET_VERSION),$(BUMP)))
|
@set -e; \
|
||||||
$(eval _NEW_VERSION := $(shell go run ./versionctl bump $(_BUMP_ARG)))
|
bump_arg="$(if $(SET_VERSION),$(SET_VERSION),$(BUMP))"; \
|
||||||
git add VERSION frontend/
|
new_version=$$(go run ./versionctl bump "$$bump_arg"); \
|
||||||
git commit -m "chore: 版本升迁 v$(_NEW_VERSION)"
|
git add VERSION frontend/; \
|
||||||
git tag "v$(_NEW_VERSION)"
|
git commit -m "chore: 版本升迁 v$$new_version"; \
|
||||||
@printf '版本升迁完成: v%s\n' "$(_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 模式
|
# Server 模式
|
||||||
@@ -116,6 +220,7 @@ desktop-build-mac: version-check _desktop-prepare-frontend _desktop-prepare-embe
|
|||||||
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-mac-amd64 ./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 -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'
|
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'
|
@printf 'Packaging macOS app bundle...\n'
|
||||||
rm -rf build/Nex.app
|
rm -rf build/Nex.app
|
||||||
mkdir -p build/Nex.app/Contents/MacOS build/Nex.app/Contents/Resources
|
mkdir -p build/Nex.app/Contents/MacOS build/Nex.app/Contents/Resources
|
||||||
@@ -160,7 +265,7 @@ _desktop-test:
|
|||||||
cd backend && go test ./cmd/desktop/... -v
|
cd backend && go test ./cmd/desktop/... -v
|
||||||
|
|
||||||
_desktop-clean:
|
_desktop-clean:
|
||||||
rm -rf build/ embedfs/assets embedfs/frontend-dist backend/cmd/desktop/rsrc_windows_amd64.syso backend/cmd/desktop/rsrc_windows_arm64.syso
|
rm -rf build/ embedfs/assets embedfs/frontend-dist backend/cmd/desktop/rsrc_windows_amd64.syso
|
||||||
|
|
||||||
_desktop-prepare-frontend: _frontend-install
|
_desktop-prepare-frontend: _frontend-install
|
||||||
@printf 'Preparing frontend for desktop...\n'
|
@printf 'Preparing frontend for desktop...\n'
|
||||||
@@ -176,13 +281,16 @@ _desktop-prepare-embedfs:
|
|||||||
|
|
||||||
_desktop-prepare-windows-resource: _check-windows-target-arch
|
_desktop-prepare-windows-resource: _check-windows-target-arch
|
||||||
@printf 'Preparing Windows $(TARGET_ARCH) executable icon...\n'
|
@printf 'Preparing Windows $(TARGET_ARCH) executable icon...\n'
|
||||||
@if [ "$(TARGET_ARCH)" = "arm64" ] && [ "$(WINDRES)" = "windres" ] && command -v llvm-windres >/dev/null 2>&1; then \
|
@WINDRES_CMD="$(WINDRES)"; \
|
||||||
|
WINDRES_FMT="$(WINDOWS_WINDRES_FORMAT_BFD)"; \
|
||||||
|
if command -v llvm-windres >/dev/null 2>&1; then \
|
||||||
WINDRES_CMD=llvm-windres; \
|
WINDRES_CMD=llvm-windres; \
|
||||||
else \
|
WINDRES_FMT="$(WINDOWS_WINDRES_FORMAT_LLVM)"; \
|
||||||
WINDRES_CMD="$(WINDRES)"; \
|
elif "$$WINDRES_CMD" --version 2>&1 | grep -qi LLVM; then \
|
||||||
|
WINDRES_FMT="$(WINDOWS_WINDRES_FORMAT_LLVM)"; \
|
||||||
fi; \
|
fi; \
|
||||||
command -v "$$WINDRES_CMD" >/dev/null 2>&1 || { printf 'Missing windres tool: %s\n' "$$WINDRES_CMD"; exit 1; }; \
|
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 $(WINDOWS_WINDRES_FORMAT) -i icon_windows.rc -o $(WINDOWS_RESOURCE)
|
cd backend/cmd/desktop && "$$WINDRES_CMD" -O coff -F "$$WINDRES_FMT" -i icon_windows.rc -o $(WINDOWS_RESOURCE)
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 发布资产
|
# 发布资产
|
||||||
@@ -241,6 +349,7 @@ release-assets-server-macos: version-check
|
|||||||
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-macos-arm64
|
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-macos-arm64
|
||||||
asset=$$(go run ./versionctl asset-name server macos universal tar.gz); \
|
asset=$$(go run ./versionctl asset-name server macos universal tar.gz); \
|
||||||
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-macos-universal
|
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-linux: version-check release-assets-check desktop-build-linux _package-linux-tar _package-linux-appimage _package-linux-deb _package-linux-rpm
|
||||||
|
|
||||||
@@ -251,6 +360,7 @@ release-assets-desktop-windows: version-check release-assets-check desktop-build
|
|||||||
"$$POWERSHELL" -NoProfile -Command "Compress-Archive -LiteralPath '$(WINDOWS_DESKTOP_BINARY)' -DestinationPath '$(RELEASE_DIR)/$$asset' -Force"
|
"$$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
|
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:
|
release-assets-checksums:
|
||||||
@cd "$(RELEASE_DIR)" && \
|
@cd "$(RELEASE_DIR)" && \
|
||||||
@@ -275,7 +385,7 @@ _check-linux-target-arch:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
_check-windows-target-arch:
|
_check-windows-target-arch:
|
||||||
@if [ "$(TARGET_ARCH)" != "amd64" ] && [ "$(TARGET_ARCH)" != "arm64" ]; then \
|
@if [ "$(TARGET_ARCH)" != "amd64" ]; then \
|
||||||
printf 'Unsupported Windows TARGET_ARCH: %s\n' "$(TARGET_ARCH)"; \
|
printf 'Unsupported Windows TARGET_ARCH: %s\n' "$(TARGET_ARCH)"; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
fi
|
fi
|
||||||
@@ -366,7 +476,8 @@ _package-macos-dmg:
|
|||||||
ln -s /Applications "$$dmgdir/Applications"; \
|
ln -s /Applications "$$dmgdir/Applications"; \
|
||||||
asset=$$(go run ./versionctl asset-name desktop macos universal dmg); \
|
asset=$$(go run ./versionctl asset-name desktop macos universal dmg); \
|
||||||
hdiutil create -volname Nex -srcfolder "$$dmgdir" -ov -format UDZO "$(RELEASE_DIR)/$$asset"; \
|
hdiutil create -volname Nex -srcfolder "$$dmgdir" -ov -format UDZO "$(RELEASE_DIR)/$$asset"; \
|
||||||
hdiutil verify "$(RELEASE_DIR)/$$asset" >/dev/null
|
hdiutil verify "$(RELEASE_DIR)/$$asset" >/dev/null && \
|
||||||
|
rm -rf "$$dmgdir"
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 共享 helper targets
|
# 共享 helper targets
|
||||||
|
|||||||
47
README.md
47
README.md
@@ -66,7 +66,7 @@ nex/
|
|||||||
- **ORM**: GORM
|
- **ORM**: GORM
|
||||||
- **数据库**: SQLite / MySQL
|
- **数据库**: SQLite / MySQL
|
||||||
- **日志**: zap + lumberjack(结构化日志 + 日志轮转 + 模块标识)
|
- **日志**: zap + lumberjack(结构化日志 + 日志轮转 + 模块标识)
|
||||||
- **配置**: Viper + pflag(多层配置:CLI > 环境变量 > 配置文件 > 默认值)
|
- **配置**: Viper + pflag(Server 多层配置,Desktop 配置文件快照)
|
||||||
- **验证**: go-playground/validator/v10
|
- **验证**: go-playground/validator/v10
|
||||||
- **迁移**: goose
|
- **迁移**: goose
|
||||||
|
|
||||||
@@ -109,9 +109,6 @@ make desktop-build-mac
|
|||||||
# Windows
|
# Windows
|
||||||
make desktop-build-win
|
make desktop-build-win
|
||||||
|
|
||||||
# Windows arm64
|
|
||||||
make desktop-build-win TARGET_ARCH=arm64
|
|
||||||
|
|
||||||
# Linux
|
# Linux
|
||||||
make desktop-build-linux
|
make desktop-build-linux
|
||||||
|
|
||||||
@@ -120,7 +117,7 @@ make desktop-build-linux TARGET_ARCH=arm64
|
|||||||
```
|
```
|
||||||
|
|
||||||
**使用桌面应用**:
|
**使用桌面应用**:
|
||||||
- 双击启动应用(macOS: Nex.app,Windows: nex-win-amd64.exe / nex-win-arm64.exe,Linux: nex-linux-amd64 / nex-linux-arm64)
|
- 双击启动应用(macOS: Nex.app,Windows: nex-win-amd64.exe,Linux: nex-linux-amd64 / nex-linux-arm64)
|
||||||
- 系统托盘图标出现,浏览器自动打开管理界面
|
- 系统托盘图标出现,浏览器自动打开管理界面
|
||||||
- 点击托盘图标显示菜单,可打开管理界面或退出
|
- 点击托盘图标显示菜单,可打开管理界面或退出
|
||||||
- 关闭浏览器后服务继续运行,可通过托盘重新打开
|
- 关闭浏览器后服务继续运行,可通过托盘重新打开
|
||||||
@@ -150,7 +147,6 @@ make server-run
|
|||||||
- 前端开发服务器:`http://localhost:5173`
|
- 前端开发服务器:`http://localhost:5173`
|
||||||
|
|
||||||
前端请求会继续通过 Vite proxy 转发到后端。后端首次启动会自动:
|
前端请求会继续通过 Vite proxy 转发到后端。后端首次启动会自动:
|
||||||
- 创建配置文件 `~/.nex/config.yaml`
|
|
||||||
- 初始化数据库 `~/.nex/config.db`
|
- 初始化数据库 `~/.nex/config.db`
|
||||||
- 运行数据库迁移
|
- 运行数据库迁移
|
||||||
- 创建日志目录 `~/.nex/log/`
|
- 创建日志目录 `~/.nex/log/`
|
||||||
@@ -175,7 +171,6 @@ make server-build
|
|||||||
| macOS arm64 | `nex-server_<version>_macos_arm64.tar.gz` |
|
| macOS arm64 | `nex-server_<version>_macos_arm64.tar.gz` |
|
||||||
| macOS universal | `nex-server_<version>_macos_universal.tar.gz` |
|
| macOS universal | `nex-server_<version>_macos_universal.tar.gz` |
|
||||||
| Windows amd64 | `nex-server_<version>_windows_amd64.zip` |
|
| Windows amd64 | `nex-server_<version>_windows_amd64.zip` |
|
||||||
| Windows arm64 | `nex-server_<version>_windows_arm64.zip` |
|
|
||||||
|
|
||||||
**web 产物**:
|
**web 产物**:
|
||||||
|
|
||||||
@@ -191,10 +186,11 @@ make server-build
|
|||||||
| Linux arm64 | `nex-desktop_<version>_linux_arm64.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` |
|
| macOS universal | `nex-desktop_<version>_macos_universal.zip`、`nex-desktop_<version>_macos_universal.dmg` |
|
||||||
| Windows amd64 | `nex-desktop_<version>_windows_amd64.zip` |
|
| Windows amd64 | `nex-desktop_<version>_windows_amd64.zip` |
|
||||||
| Windows arm64 | `nex-desktop_<version>_windows_arm64.zip` |
|
|
||||||
|
|
||||||
Linux deb 包声明 `libgtk-3-0`、`libayatana-appindicator3-1`、`xdg-utils` 运行依赖;rpm 包声明 `gtk3`、`libayatana-appindicator-gtk3`、`xdg-utils` 运行依赖。Rocky Linux 9 等发行版可能需要启用 EPEL 才能解析 Ayatana AppIndicator 依赖。
|
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 接口
|
## API 接口
|
||||||
|
|
||||||
### 代理接口(对外部应用)
|
### 代理接口(对外部应用)
|
||||||
@@ -248,11 +244,14 @@ Linux deb 包声明 `libgtk-3-0`、`libayatana-appindicator3-1`、`xdg-utils`
|
|||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
配置支持多种方式,优先级为:**CLI 参数 > 环境变量 > 配置文件 > 默认值**
|
配置方式取决于启动模式:
|
||||||
|
|
||||||
|
- **Server 模式**(`cmd/server`):支持 CLI 参数 > 环境变量 > 配置文件 > 默认值
|
||||||
|
- **Desktop 模式**(`cmd/desktop`):仅支持配置文件 `~/.nex/config.yaml` > 默认值,修改配置文件后需重启 desktop 生效
|
||||||
|
|
||||||
### 配置文件
|
### 配置文件
|
||||||
|
|
||||||
配置文件位于 `~/.nex/config.yaml`,首次启动自动生成:
|
配置文件位于 `~/.nex/config.yaml`。配置文件不存在时使用默认值,不会自动生成;需要自定义时手动创建该文件:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
server:
|
server:
|
||||||
@@ -282,9 +281,9 @@ log:
|
|||||||
compress: true
|
compress: true
|
||||||
```
|
```
|
||||||
|
|
||||||
### 环境变量
|
### 环境变量(仅 Server 模式)
|
||||||
|
|
||||||
所有配置项支持环境变量,使用 `NEX_` 前缀:
|
Server 模式下,所有配置项支持环境变量,使用 `NEX_` 前缀:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export NEX_SERVER_PORT=9000
|
export NEX_SERVER_PORT=9000
|
||||||
@@ -302,7 +301,11 @@ export NEX_DATABASE_DBNAME=nex
|
|||||||
|
|
||||||
命名规则:配置路径转大写 + 下划线(如 `server.port` → `NEX_SERVER_PORT`)。
|
命名规则:配置路径转大写 + 下划线(如 `server.port` → `NEX_SERVER_PORT`)。
|
||||||
|
|
||||||
### CLI 参数
|
**Desktop 模式不支持环境变量覆盖。**Desktop 仅从 `~/.nex/config.yaml` 和默认值读取配置。
|
||||||
|
|
||||||
|
### CLI 参数(仅 Server 模式)
|
||||||
|
|
||||||
|
Server 模式下,支持命令行参数:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./server --server-port 9000 --log-level debug --database-path /tmp/test.db
|
./server --server-port 9000 --log-level debug --database-path /tmp/test.db
|
||||||
@@ -310,6 +313,8 @@ export NEX_DATABASE_DBNAME=nex
|
|||||||
|
|
||||||
命名规则:配置路径转 kebab-case(如 `server.port` → `--server-port`)。
|
命名规则:配置路径转 kebab-case(如 `server.port` → `--server-port`)。
|
||||||
|
|
||||||
|
**Desktop 不支持命令行参数覆盖配置。**Desktop 忽略所有 CLI 参数,仅从 `~/.nex/config.yaml` 读取。
|
||||||
|
|
||||||
### 数据文件
|
### 数据文件
|
||||||
|
|
||||||
- `~/.nex/config.yaml` - 配置文件
|
- `~/.nex/config.yaml` - 配置文件
|
||||||
@@ -333,7 +338,13 @@ backend 分类测试、MySQL 专项测试和前端 E2E 测试请分别查看 `ba
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 首次克隆后安装 Git hooks
|
# 首次克隆后安装 Git hooks
|
||||||
lefthook install
|
make hooks-install
|
||||||
|
|
||||||
|
# 检查 Git hooks 安装状态
|
||||||
|
make hooks-check
|
||||||
|
|
||||||
|
# 运行 Git hooks 回归测试
|
||||||
|
make hooks-test
|
||||||
|
|
||||||
# 全局命令
|
# 全局命令
|
||||||
make lint # 前后端共享检查
|
make lint # 前后端共享检查
|
||||||
@@ -356,6 +367,12 @@ make desktop-test # desktop 专属测试
|
|||||||
make desktop-clean # 清理 desktop 产物
|
make desktop-clean # 清理 desktop 产物
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Git hooks 使用仓库内 `scripts/git-hooks/` 的原生脚本,不依赖额外 hook 框架。当前 hooks 包含:
|
||||||
|
|
||||||
|
- pre-commit:检查 staged files 的冲突标记、大文件告警和 LFS 指针,并按文件类型委托后端、versionctl、前端检查
|
||||||
|
- prepare-commit-msg:在编辑器打开时提供提交信息模板,辅助填写 `类型: 简短描述` 和多行说明
|
||||||
|
- commit-msg:校验提交信息格式为 `类型: 简短描述`,多行说明需在首行后保留空行;提交描述按项目规范使用中文,hook 不做字符集检测
|
||||||
|
|
||||||
## 版本与发布
|
## 版本与发布
|
||||||
|
|
||||||
### 统一版本源
|
### 统一版本源
|
||||||
@@ -366,7 +383,7 @@ make desktop-clean # 清理 desktop 产物
|
|||||||
### 本地版本演进
|
### 本地版本演进
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 递增版本(自动 sync + check + commit + tag)
|
# 递增版本(自动 lint + test + 工作区检查 + sync/check + commit + tag)
|
||||||
make version-bump BUMP=minor
|
make version-bump BUMP=minor
|
||||||
|
|
||||||
# 或指定具体版本号
|
# 或指定具体版本号
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ GORM 日志自动桥接到 zap,SQL 查询映射到 Debug 级别。
|
|||||||
- **ORM**: GORM
|
- **ORM**: GORM
|
||||||
- **数据库**: SQLite / MySQL
|
- **数据库**: SQLite / MySQL
|
||||||
- **日志**: zap + lumberjack
|
- **日志**: zap + lumberjack
|
||||||
- **配置**: Viper + pflag(多层配置:CLI > 环境变量 > 配置文件 > 默认值)
|
- **配置**: Viper + pflag(Server 多层配置,Desktop 配置文件快照)
|
||||||
- **验证**: go-playground/validator/v10
|
- **验证**: go-playground/validator/v10
|
||||||
- **迁移**: goose
|
- **迁移**: goose
|
||||||
|
|
||||||
@@ -164,7 +164,11 @@ backend/
|
|||||||
│ └── validator/ # 验证器
|
│ └── validator/ # 验证器
|
||||||
│ └── validator.go
|
│ └── validator.go
|
||||||
├── migrations/ # 数据库迁移
|
├── migrations/ # 数据库迁移
|
||||||
│ └── 20260421000001_initial_schema.sql
|
│ ├── embed.go # go:embed 迁移资源入口
|
||||||
|
│ ├── sqlite/
|
||||||
|
│ │ └── 20260421000001_initial_schema.sql
|
||||||
|
│ └── mysql/
|
||||||
|
│ └── 20260421000001_initial_schema.sql
|
||||||
├── tests/ # 集成测试
|
├── tests/ # 集成测试
|
||||||
│ ├── helpers.go # 测试辅助函数
|
│ ├── helpers.go # 测试辅助函数
|
||||||
│ ├── config/ # 测试配置
|
│ ├── config/ # 测试配置
|
||||||
@@ -330,15 +334,18 @@ go mod download
|
|||||||
go run cmd/server/main.go
|
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
|
```yaml
|
||||||
server:
|
server:
|
||||||
@@ -368,9 +375,9 @@ log:
|
|||||||
compress: true
|
compress: true
|
||||||
```
|
```
|
||||||
|
|
||||||
### 环境变量
|
### 环境变量(仅 Server 入口)
|
||||||
|
|
||||||
所有配置项都支持环境变量,使用 `NEX_` 前缀:
|
Server 入口下,所有配置项都支持环境变量,使用 `NEX_` 前缀:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export NEX_SERVER_PORT=9000
|
export NEX_SERVER_PORT=9000
|
||||||
@@ -388,7 +395,7 @@ export NEX_DATABASE_DBNAME=nex
|
|||||||
|
|
||||||
命名规则:配置路径转大写 + 下划线 + `NEX_` 前缀(如 `server.port` → `NEX_SERVER_PORT`)。
|
命名规则:配置路径转大写 + 下划线 + `NEX_` 前缀(如 `server.port` → `NEX_SERVER_PORT`)。
|
||||||
|
|
||||||
### 命令行参数
|
### 命令行参数(仅 Server 入口)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./server --server-port 9000 --log-level debug --database-path /tmp/test.db
|
./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
|
```bash
|
||||||
# 使用 Makefile
|
# 使用 Makefile
|
||||||
make migrate-up DB_DSN=~/.nex/config.db
|
make migrate-up DB_DSN=~/.nex/config.db
|
||||||
|
|||||||
@@ -43,10 +43,23 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
port := 9826
|
|
||||||
|
|
||||||
minimalLogger := pkgLogger.NewMinimal()
|
minimalLogger := pkgLogger.NewMinimal()
|
||||||
|
|
||||||
|
cfg, err := config.LoadDesktopConfig()
|
||||||
|
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"))
|
singleLock := NewSingletonLock(filepath.Join(os.TempDir(), "nex-gateway.lock"))
|
||||||
if err := singleLock.Lock(); err != nil {
|
if err := singleLock.Lock(); err != nil {
|
||||||
minimalLogger.Error("已有 Nex 实例运行")
|
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{
|
zapLogger, err = pkgLogger.Upgrade(minimalLogger, pkgLogger.Config{
|
||||||
Level: cfg.Log.Level,
|
Level: cfg.Log.Level,
|
||||||
Path: cfg.Log.Path,
|
Path: cfg.Log.Path,
|
||||||
@@ -144,7 +146,7 @@ func main() {
|
|||||||
setupStaticFiles(r)
|
setupStaticFiles(r)
|
||||||
|
|
||||||
server = &http.Server{
|
server = &http.Server{
|
||||||
Addr: fmt.Sprintf(":%d", port),
|
Addr: desktopListenAddr(port),
|
||||||
Handler: r,
|
Handler: r,
|
||||||
ReadTimeout: cfg.Server.ReadTimeout,
|
ReadTimeout: cfg.Server.ReadTimeout,
|
||||||
WriteTimeout: cfg.Server.WriteTimeout,
|
WriteTimeout: cfg.Server.WriteTimeout,
|
||||||
@@ -165,7 +167,7 @@ func main() {
|
|||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(500 * time.Millisecond)
|
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))
|
zapLogger.Warn("无法打开浏览器", zap.Error(err))
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -309,7 +311,7 @@ func setupSystray(port int) {
|
|||||||
systray.AddSeparator()
|
systray.AddSeparator()
|
||||||
mStatus := systray.AddMenuItem("状态: 运行中", "")
|
mStatus := systray.AddMenuItem("状态: 运行中", "")
|
||||||
mStatus.Disable()
|
mStatus.Disable()
|
||||||
mPort := systray.AddMenuItem(fmt.Sprintf("端口: %d", port), "")
|
mPort := systray.AddMenuItem(desktopPortMenuTitle(port), "")
|
||||||
mPort.Disable()
|
mPort.Disable()
|
||||||
systray.AddSeparator()
|
systray.AddSeparator()
|
||||||
mQuit := systray.AddMenuItem("退出", "停止服务并退出")
|
mQuit := systray.AddMenuItem("退出", "停止服务并退出")
|
||||||
@@ -318,7 +320,7 @@ func setupSystray(port int) {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-mOpen.ClickedCh:
|
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))
|
zapLogger.Warn("打开浏览器失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
case <-mQuit.ClickedCh:
|
case <-mQuit.ClickedCh:
|
||||||
@@ -349,6 +351,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 {
|
func checkPortAvailable(port int) error {
|
||||||
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||||
if err != nil {
|
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"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -67,3 +68,62 @@ func TestCheckPortAvailableAfterClose(t *testing.T) {
|
|||||||
|
|
||||||
t.Log("端口关闭后可用测试通过")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
minimalLogger := pkgLogger.NewMinimal()
|
minimalLogger := pkgLogger.NewMinimal()
|
||||||
|
|
||||||
cfg, err := config.LoadConfig()
|
cfg, err := config.LoadServerConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
minimalLogger.Fatal("加载配置失败", zap.Error(err))
|
minimalLogger.Fatal("加载配置失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -225,68 +224,71 @@ func setupConfigFile(v *viper.Viper, configPath string) error {
|
|||||||
v.SetConfigFile(configPath)
|
v.SetConfigFile(configPath)
|
||||||
v.SetConfigType("yaml")
|
v.SetConfigType("yaml")
|
||||||
|
|
||||||
// 尝试读取配置文件,如果不存在则忽略
|
|
||||||
if err := v.ReadInConfig(); err != nil {
|
if err := v.ReadInConfig(); err != nil {
|
||||||
if !os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return appErrors.Wrap(appErrors.ErrInternal, err)
|
|
||||||
}
|
|
||||||
// 配置文件不存在,创建默认配置文件
|
|
||||||
writeErr := v.SafeWriteConfigAs(configPath)
|
|
||||||
if writeErr == nil {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
return appErrors.Wrap(appErrors.ErrInternal, err)
|
||||||
var alreadyExistsErr viper.ConfigFileAlreadyExistsError
|
|
||||||
if errors.As(writeErr, &alreadyExistsErr) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return appErrors.Wrap(appErrors.ErrInternal, writeErr)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadConfig loads config from YAML file, creates default if not exists
|
// loadOptions 控制配置加载器行为
|
||||||
func LoadConfig() (*Config, error) {
|
type loadOptions struct {
|
||||||
configPath, err := GetConfigPath()
|
configPathOverride string
|
||||||
if err != nil {
|
useCLI bool
|
||||||
return nil, appErrors.Wrap(appErrors.ErrInternal, err)
|
useEnv bool
|
||||||
}
|
useConfigFlag bool
|
||||||
return LoadConfigFromPath(configPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadConfigFromPath 从指定路径加载配置
|
// resolveConfigPath 根据 loadOptions 解析 CLI 参数并返回最终配置文件路径
|
||||||
func LoadConfigFromPath(configPath string) (*Config, error) {
|
func resolveConfigPath(v *viper.Viper, opts loadOptions) (string, error) {
|
||||||
// 1. 创建 Viper 实例
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConfig 共享配置加载逻辑,通过 loadOptions 控制是否启用 CLI、环境变量和 --config 覆盖
|
||||||
|
func loadConfig(opts loadOptions) (*Config, error) {
|
||||||
v := viper.New()
|
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)
|
setupDefaults(v)
|
||||||
|
|
||||||
// 6. 绑定环境变量
|
configPath, err := resolveConfigPath(v, opts)
|
||||||
setupEnv(v)
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.useEnv {
|
||||||
|
setupEnv(v)
|
||||||
|
}
|
||||||
|
|
||||||
// 7. 读取配置文件
|
|
||||||
if err := setupConfigFile(v, configPath); err != nil {
|
if err := setupConfigFile(v, configPath); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. 反序列化到结构体
|
|
||||||
cfg := &Config{}
|
cfg := &Config{}
|
||||||
if err := v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
|
if err := v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
|
||||||
mapstructure.StringToTimeDurationHookFunc(),
|
mapstructure.StringToTimeDurationHookFunc(),
|
||||||
@@ -295,7 +297,6 @@ func LoadConfigFromPath(configPath string) (*Config, error) {
|
|||||||
return nil, appErrors.Wrap(appErrors.ErrInternal, err)
|
return nil, appErrors.Wrap(appErrors.ErrInternal, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. 验证配置
|
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -303,6 +304,61 @@ func LoadConfigFromPath(configPath string) (*Config, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadServerConfig 为 server 入口加载配置,支持 CLI 参数、环境变量和 --config
|
||||||
|
func LoadServerConfig() (*Config, error) {
|
||||||
|
configPath, err := GetConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, appErrors.Wrap(appErrors.ErrInternal, err)
|
||||||
|
}
|
||||||
|
return loadConfig(loadOptions{
|
||||||
|
configPathOverride: configPath,
|
||||||
|
useCLI: true,
|
||||||
|
useEnv: true,
|
||||||
|
useConfigFlag: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadDesktopConfig 为 desktop 入口加载配置,固定使用默认配置文件,不支持 CLI、环境变量和 --config
|
||||||
|
func LoadDesktopConfig() (*Config, error) {
|
||||||
|
configPath, err := GetConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, appErrors.Wrap(appErrors.ErrInternal, err)
|
||||||
|
}
|
||||||
|
return loadConfig(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
|
// SaveConfig saves config to YAML file
|
||||||
func SaveConfig(cfg *Config) error {
|
func SaveConfig(cfg *Config) error {
|
||||||
configPath, err := GetConfigPath()
|
configPath, err := GetConfigPath()
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
|
|
||||||
"github.com/pressly/goose/v3"
|
"github.com/pressly/goose/v3"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"nex/backend/internal/config"
|
"nex/backend/internal/config"
|
||||||
|
"nex/backend/migrations"
|
||||||
pkglogger "nex/backend/pkg/logger"
|
pkglogger "nex/backend/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -77,29 +78,24 @@ func runMigrations(db *gorm.DB, driver string, zapLogger *zap.Logger) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
gooseDialect := "sqlite3"
|
dialect, fsys, err := migrations.ForDriver(driver)
|
||||||
migrationsSubDir := "sqlite"
|
if err != nil {
|
||||||
if driver == "mysql" {
|
return err
|
||||||
gooseDialect = "mysql"
|
|
||||||
migrationsSubDir = "mysql"
|
|
||||||
}
|
|
||||||
|
|
||||||
migrationsDir := getMigrationsDir(driver)
|
|
||||||
if _, err := os.Stat(migrationsDir); os.IsNotExist(err) {
|
|
||||||
return fmt.Errorf("迁移目录不存在: %s", migrationsDir)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if zapLogger != nil {
|
if zapLogger != nil {
|
||||||
zapLogger.Info("执行数据库迁移",
|
zapLogger.Info("执行数据库迁移",
|
||||||
zap.String("dialect", gooseDialect),
|
zap.String("dialect", string(dialect)),
|
||||||
zap.String("dir", migrationsSubDir))
|
zap.String("driver", driver))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := goose.SetDialect(gooseDialect); err != nil {
|
provider, err := goose.NewProvider(dialect, sqlDB, fsys)
|
||||||
return err
|
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
|
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 {
|
func BuildDSN(cfg *config.DatabaseConfig) string {
|
||||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local",
|
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)
|
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"nex/backend/internal/config"
|
"nex/backend/internal/config"
|
||||||
|
"nex/backend/migrations"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -76,3 +79,87 @@ func TestBuildDSN_EmptyPassword(t *testing.T) {
|
|||||||
dsn := BuildDSN(cfg)
|
dsn := BuildDSN(cfg)
|
||||||
assert.Equal(t, "root:@tcp(localhost:3306)/nex?charset=utf8mb4&parseTime=true&loc=Local", dsn)
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,7 +120,7 @@ log:
|
|||||||
assert.Equal(t, "warn", cfg.Log.Level, "YAML value should be used when no CLI/ENV override")
|
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()
|
tmpDir := t.TempDir()
|
||||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||||
|
|
||||||
@@ -132,6 +132,9 @@ func TestLoadConfig_AutoCreate(t *testing.T) {
|
|||||||
require.NotNil(t, cfg)
|
require.NotNil(t, cfg)
|
||||||
|
|
||||||
assert.Equal(t, 9826, cfg.Server.Port, "should load with default values")
|
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) {
|
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.MaxAge, loaded.Log.MaxAge)
|
||||||
assert.Equal(t, cfg.Log.Compress, loaded.Log.Compress)
|
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_API_BASE=
|
||||||
VITE_APP_VERSION=0.1.3
|
VITE_APP_VERSION=0.1.7
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
VITE_API_BASE=
|
VITE_API_BASE=
|
||||||
VITE_APP_VERSION=0.1.3
|
VITE_APP_VERSION=0.1.7
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
VITE_API_BASE=/api
|
VITE_API_BASE=/api
|
||||||
VITE_APP_VERSION=0.1.3
|
VITE_APP_VERSION=0.1.7
|
||||||
|
|||||||
@@ -149,7 +149,8 @@ bun run build
|
|||||||
```bash
|
```bash
|
||||||
bun run lint # ESLint 检查
|
bun run lint # ESLint 检查
|
||||||
bun run format:check # Prettier 格式检查
|
bun run format:check # Prettier 格式检查
|
||||||
bun run check # 同时检查 lint 和格式
|
bun run typecheck # TypeScript 类型检查
|
||||||
|
bun run check # 同时检查类型、lint 和格式
|
||||||
```
|
```
|
||||||
|
|
||||||
### 代码格式化
|
### 代码格式化
|
||||||
@@ -188,13 +189,14 @@ bun run test:e2e
|
|||||||
- API Key 脱敏显示
|
- API Key 脱敏显示
|
||||||
- 启用/禁用状态标签
|
- 启用/禁用状态标签
|
||||||
- **协议字段**:支持 OpenAI 和 Anthropic 协议选择
|
- **协议字段**:支持 OpenAI 和 Anthropic 协议选择
|
||||||
|
- **一键复制**:Base URL 和 API Key 支持一键复制到剪贴板
|
||||||
|
|
||||||
### 模型管理
|
### 模型管理
|
||||||
|
|
||||||
- 展开供应商行查看关联模型
|
- 展开供应商行查看关联模型
|
||||||
- 添加/编辑/删除模型
|
- 添加/编辑/删除模型
|
||||||
- 按供应商筛选模型
|
- 按供应商筛选模型
|
||||||
- **统一模型 ID**:显示格式为 `provider_id/model_name`,用于跨协议模型识别
|
- **统一模型 ID**:显示格式为 `provider_id/model_name`,用于跨协议模型识别,支持一键复制
|
||||||
- **UUID 自动生成**:创建模型时后端自动生成 UUID,无需手动输入 ID
|
- **UUID 自动生成**:创建模型时后端自动生成 UUID,无需手动输入 ID
|
||||||
|
|
||||||
### 用量统计
|
### 用量统计
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.3",
|
"version": "0.1.7",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && bun run check && vite build",
|
"build": "bun run check && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"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",
|
"fix": "bun run lint:fix && bun run format",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
|||||||
@@ -4,6 +4,21 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|||||||
import { ModelTable } from '@/pages/Providers/ModelTable'
|
import { ModelTable } from '@/pages/Providers/ModelTable'
|
||||||
import type { Model } from '@/types'
|
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[] = [
|
const mockModels: Model[] = [
|
||||||
{
|
{
|
||||||
id: 'model-1',
|
id: 'model-1',
|
||||||
@@ -44,6 +59,7 @@ const defaultProps = {
|
|||||||
describe('ModelTable', () => {
|
describe('ModelTable', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockMutate.mockClear()
|
mockMutate.mockClear()
|
||||||
|
mockMessagePluginSuccess.mockClear()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders model list with unified ID and model name', () => {
|
it('renders model list with unified ID and model name', () => {
|
||||||
@@ -120,4 +136,19 @@ describe('ModelTable', () => {
|
|||||||
|
|
||||||
expect(screen.queryByRole('button', { name: /编 ?辑/ })).not.toBeInTheDocument()
|
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 { render, screen } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
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 { ProviderTable } from '@/pages/Providers/ProviderTable'
|
||||||
import type { Provider } from '@/types'
|
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 = [
|
const mockModelsData = [
|
||||||
{ id: 'model-1', providerId: 'openai', modelName: 'gpt-4o', enabled: true, unifiedId: 'openai/gpt-4o' },
|
{ id: 'model-1', providerId: 'openai', modelName: 'gpt-4o', enabled: true, unifiedId: 'openai/gpt-4o' },
|
||||||
{
|
{
|
||||||
@@ -54,6 +69,9 @@ const defaultProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('ProviderTable', () => {
|
describe('ProviderTable', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockMessagePluginSuccess.mockClear()
|
||||||
|
})
|
||||||
it('renders provider list with name, baseUrl, apiKey, and status tags', () => {
|
it('renders provider list with name, baseUrl, apiKey, and status tags', () => {
|
||||||
render(<ProviderTable {...defaultProps} />)
|
render(<ProviderTable {...defaultProps} />)
|
||||||
|
|
||||||
@@ -203,4 +221,66 @@ describe('ProviderTable', () => {
|
|||||||
const protocolCell = container.querySelector('[data-colkey="protocol"]')
|
const protocolCell = container.querySelector('[data-colkey="protocol"]')
|
||||||
expect(protocolCell).toBeInTheDocument()
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 { useModels, useDeleteModel } from '@/hooks/useModels'
|
||||||
import type { Model } from '@/types'
|
import type { Model } from '@/types'
|
||||||
import type { PrimaryTableCol } from 'tdesign-react/es/table/type'
|
import type { PrimaryTableCol } from 'tdesign-react/es/table/type'
|
||||||
@@ -18,8 +18,32 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
|
|||||||
title: '统一模型 ID',
|
title: '统一模型 ID',
|
||||||
colKey: 'unifiedId',
|
colKey: 'unifiedId',
|
||||||
width: 250,
|
width: 250,
|
||||||
ellipsis: true,
|
cell: ({ row }) => {
|
||||||
cell: ({ row }) => row.unifiedId || `${row.providerId}/${row.modelName}`,
|
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: '模型名称',
|
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 type { Provider, Model } from '@/types'
|
||||||
import { ModelTable } from './ModelTable'
|
import { ModelTable } from './ModelTable'
|
||||||
import type { PrimaryTableCol } from 'tdesign-react/es/table/type'
|
import type { PrimaryTableCol } from 'tdesign-react/es/table/type'
|
||||||
@@ -32,7 +32,30 @@ export function ProviderTable({
|
|||||||
{
|
{
|
||||||
title: 'Base URL',
|
title: 'Base URL',
|
||||||
colKey: 'baseUrl',
|
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: '协议',
|
title: '协议',
|
||||||
@@ -47,7 +70,30 @@ export function ProviderTable({
|
|||||||
{
|
{
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
colKey: 'apiKey',
|
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: '状态',
|
title: '状态',
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
pre-commit:
|
|
||||||
commands:
|
|
||||||
backend-lint:
|
|
||||||
glob: "backend/**/*.go"
|
|
||||||
run: cd backend && go tool golangci-lint run --new-from-rev HEAD ./...
|
|
||||||
@@ -8,20 +8,27 @@
|
|||||||
|
|
||||||
### Requirement: 使用 YAML 配置文件
|
### Requirement: 使用 YAML 配置文件
|
||||||
|
|
||||||
系统 SHALL 使用 YAML 格式的配置文件。
|
系统 SHALL 使用 YAML 格式的配置文件,并按入口区分配置文件路径选择能力。
|
||||||
|
|
||||||
#### Scenario: 配置文件路径
|
#### Scenario: Server 默认配置文件路径
|
||||||
|
|
||||||
- **WHEN** 应用启动且未指定 `--config` 参数
|
- **WHEN** server 应用启动且未指定 `--config` 参数
|
||||||
- **THEN** SHALL 从 `~/.nex/config.yaml` 加载配置
|
- **THEN** SHALL 从 `~/.nex/config.yaml` 加载配置
|
||||||
- **THEN** SHALL 解析 YAML 格式
|
- **THEN** SHALL 解析 YAML 格式
|
||||||
|
|
||||||
#### Scenario: 自定义配置文件路径
|
#### Scenario: Server 自定义配置文件路径
|
||||||
|
|
||||||
- **WHEN** 应用启动且指定 `--config /path/to/custom.yaml`
|
- **WHEN** server 应用启动且指定 `--config /path/to/custom.yaml`
|
||||||
- **THEN** SHALL 从指定路径加载配置文件
|
- **THEN** SHALL 从指定路径加载配置文件
|
||||||
- **THEN** SHALL NOT 使用默认路径 `~/.nex/config.yaml`
|
- **THEN** SHALL NOT 使用默认路径 `~/.nex/config.yaml`
|
||||||
|
|
||||||
|
#### Scenario: Desktop 固定配置文件路径
|
||||||
|
|
||||||
|
- **WHEN** desktop 应用启动
|
||||||
|
- **THEN** SHALL 从 `~/.nex/config.yaml` 加载配置
|
||||||
|
- **THEN** SHALL 解析 YAML 格式
|
||||||
|
- **THEN** SHALL NOT 支持通过 `--config` 指定其他配置文件路径
|
||||||
|
|
||||||
#### Scenario: 配置文件结构
|
#### Scenario: 配置文件结构
|
||||||
|
|
||||||
- **WHEN** 加载配置文件
|
- **WHEN** 加载配置文件
|
||||||
@@ -30,14 +37,14 @@
|
|||||||
|
|
||||||
### Requirement: 自动生成默认配置
|
### Requirement: 自动生成默认配置
|
||||||
|
|
||||||
系统 SHALL 在首次使用时自动生成默认配置。
|
系统 SHALL 在配置文件不存在时使用默认配置值,不自动创建配置文件。
|
||||||
|
|
||||||
#### Scenario: 配置文件不存在
|
#### Scenario: 配置文件不存在
|
||||||
|
|
||||||
- **WHEN** 应用启动且配置文件不存在
|
- **WHEN** 应用启动且配置文件不存在
|
||||||
- **THEN** SHALL 自动创建配置文件
|
- **THEN** SHALL 使用默认配置值
|
||||||
- **THEN** SHALL 写入默认配置值
|
- **THEN** SHALL NOT 自动创建配置文件
|
||||||
- **THEN** SHALL 记录日志提示已创建
|
- **THEN** SHALL NOT 写入默认配置值到磁盘
|
||||||
|
|
||||||
#### Scenario: 配置文件已存在
|
#### Scenario: 配置文件已存在
|
||||||
|
|
||||||
@@ -163,22 +170,36 @@
|
|||||||
|
|
||||||
### Requirement: 配置加载流程
|
### Requirement: 配置加载流程
|
||||||
|
|
||||||
系统 SHALL 实现标准化的配置加载流程。
|
系统 SHALL 为 server 和 desktop 实现标准化且入口隔离的配置加载流程。
|
||||||
|
|
||||||
#### Scenario: 加载步骤
|
#### Scenario: Server 加载步骤
|
||||||
|
|
||||||
- **WHEN** 应用启动
|
- **WHEN** server 应用启动
|
||||||
- **THEN** SHALL 按以下顺序加载配置:
|
- **THEN** SHALL 按以下顺序加载配置:
|
||||||
1. 解析 CLI 参数(获取 --config 路径)
|
1. 解析 CLI 参数(获取 --config 路径)
|
||||||
2. 初始化配置管理器
|
2. 初始化配置管理器
|
||||||
3. 设置默认值
|
3. 设置默认值
|
||||||
4. 绑定 CLI 参数
|
4. 绑定 CLI 参数
|
||||||
5. 绑定环境变量
|
5. 绑定环境变量
|
||||||
6. 读取配置文件(不存在时自动创建)
|
6. 读取配置文件(不存在时使用默认值)
|
||||||
7. 反序列化到结构体
|
7. 反序列化到结构体
|
||||||
8. 验证配置
|
8. 验证配置
|
||||||
9. 打印配置摘要
|
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: 加载失败处理
|
#### Scenario: 加载失败处理
|
||||||
|
|
||||||
- **WHEN** 配置加载过程中发生错误
|
- **WHEN** 配置加载过程中发生错误
|
||||||
@@ -188,25 +209,25 @@
|
|||||||
|
|
||||||
### Requirement: 配置优先级管理
|
### Requirement: 配置优先级管理
|
||||||
|
|
||||||
系统 SHALL 实现明确的配置优先级机制。
|
系统 SHALL 为不同入口实现明确的配置优先级机制。
|
||||||
|
|
||||||
#### Scenario: 优先级顺序
|
#### Scenario: Server 优先级顺序
|
||||||
|
|
||||||
- **WHEN** 同一配置项在多个配置源中设置
|
- **WHEN** 同一配置项在多个 server 配置源中设置
|
||||||
- **THEN** SHALL 按以下优先级顺序(从高到低):
|
- **THEN** SHALL 按以下优先级顺序(从高到低):
|
||||||
1. CLI 参数
|
1. CLI 参数
|
||||||
2. 环境变量
|
2. 环境变量
|
||||||
3. 配置文件
|
3. 配置文件
|
||||||
4. 默认值
|
4. 默认值
|
||||||
|
|
||||||
#### Scenario: CLI 参数最高优先级
|
#### Scenario: Server CLI 参数最高优先级
|
||||||
|
|
||||||
- **WHEN** 配置文件设置 `server.port: 9826`
|
- **WHEN** 配置文件设置 `server.port: 9826`
|
||||||
- **AND** 环境变量设置 `NEX_SERVER_PORT=9000`
|
- **AND** 环境变量设置 `NEX_SERVER_PORT=9000`
|
||||||
- **AND** CLI 参数设置 `--server-port 8080`
|
- **AND** server CLI 参数设置 `--server-port 8080`
|
||||||
- **THEN** SHALL 使用 CLI 参数值 8080
|
- **THEN** SHALL 使用 CLI 参数值 8080
|
||||||
|
|
||||||
#### Scenario: 环境变量次高优先级
|
#### Scenario: Server 环境变量次高优先级
|
||||||
|
|
||||||
- **WHEN** 配置文件设置 `server.port: 9826`
|
- **WHEN** 配置文件设置 `server.port: 9826`
|
||||||
- **AND** 环境变量设置 `NEX_SERVER_PORT=9000`
|
- **AND** 环境变量设置 `NEX_SERVER_PORT=9000`
|
||||||
@@ -227,21 +248,35 @@
|
|||||||
- **AND** 未设置 CLI 参数
|
- **AND** 未设置 CLI 参数
|
||||||
- **THEN** SHALL 使用默认值
|
- **THEN** SHALL 使用默认值
|
||||||
|
|
||||||
#### Scenario: 部分配置覆盖
|
#### Scenario: Server 部分配置覆盖
|
||||||
|
|
||||||
- **WHEN** 配置文件设置完整配置
|
- **WHEN** 配置文件设置完整配置
|
||||||
- **AND** CLI 参数仅覆盖部分配置项
|
- **AND** server CLI 参数仅覆盖部分配置项
|
||||||
- **THEN** SHALL 合并所有配置源
|
- **THEN** SHALL 合并所有配置源
|
||||||
- **THEN** SHALL 使用高优先级源覆盖指定项
|
- **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 仅覆盖 server.port 配置项
|
||||||
- **THEN** SHALL NOT 影响其他配置项
|
- **THEN** SHALL NOT 影响其他配置项
|
||||||
- **THEN** SHALL 其他配置项使用配置文件或默认值
|
- **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: 启动后配置锁定
|
#### Scenario: 启动后配置锁定
|
||||||
|
|
||||||
- **WHEN** 应用启动完成
|
- **WHEN** 应用启动完成
|
||||||
@@ -314,67 +349,79 @@
|
|||||||
|
|
||||||
### Requirement: CLI 参数配置支持
|
### Requirement: CLI 参数配置支持
|
||||||
|
|
||||||
系统 SHALL 支持通过命令行参数设置所有配置项。
|
server 入口 SHALL 支持通过命令行参数设置所有配置项;desktop 入口 SHALL NOT 将命令行参数作为配置源。
|
||||||
|
|
||||||
#### Scenario: 基本参数解析
|
#### Scenario: Server 基本参数解析
|
||||||
|
|
||||||
- **WHEN** 应用启动时传入命令行参数
|
- **WHEN** server 应用启动时传入命令行参数
|
||||||
- **THEN** SHALL 解析所有 CLI 参数
|
- **THEN** SHALL 解析所有 CLI 参数
|
||||||
- **THEN** SHALL 将参数值应用到对应配置项
|
- **THEN** SHALL 将参数值应用到对应配置项
|
||||||
|
|
||||||
#### Scenario: 参数命名规范
|
#### Scenario: 参数命名规范
|
||||||
|
|
||||||
- **WHEN** 使用命令行参数
|
- **WHEN** server 使用命令行参数
|
||||||
- **THEN** SHALL 使用 kebab-case 命名(如 `--server-port`)
|
- **THEN** SHALL 使用 kebab-case 命名(如 `--server-port`)
|
||||||
- **THEN** SHALL 保持完整的层次结构(如 `--database-max-idle-conns`)
|
- **THEN** SHALL 保持完整的层次结构(如 `--database-max-idle-conns`)
|
||||||
- **THEN** SHALL 与配置文件路径对应(`database.max_idle_conns` → `--database-max-idle-conns`)
|
- **THEN** SHALL 与配置文件路径对应(`database.max_idle_conns` → `--database-max-idle-conns`)
|
||||||
|
|
||||||
#### Scenario: 参数类型支持
|
#### Scenario: 参数类型支持
|
||||||
|
|
||||||
- **WHEN** 解析不同类型的参数
|
- **WHEN** server 解析不同类型的参数
|
||||||
- **THEN** SHALL 支持 int 类型(如 `--server-port 9000`)
|
- **THEN** SHALL 支持 int 类型(如 `--server-port 9000`)
|
||||||
- **THEN** SHALL 支持 string 类型(如 `--database-path /data/nex.db`)
|
- **THEN** SHALL 支持 string 类型(如 `--database-path /data/nex.db`)
|
||||||
- **THEN** SHALL 支持 duration 类型(如 `--server-read-timeout 60s`)
|
- **THEN** SHALL 支持 duration 类型(如 `--server-read-timeout 60s`)
|
||||||
- **THEN** SHALL 支持 bool 类型(如 `--log-compress`)
|
- **THEN** SHALL 支持 bool 类型(如 `--log-compress`)
|
||||||
|
|
||||||
#### Scenario: 完整配置覆盖
|
#### Scenario: Server 完整配置覆盖
|
||||||
|
|
||||||
- **WHEN** 使用服务器相关参数
|
- **WHEN** server 使用服务器相关参数
|
||||||
- **THEN** SHALL 支持 `--server-port`、`--server-read-timeout`、`--server-write-timeout`
|
- **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`
|
- **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`
|
- **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 按功能分组展示参数(服务器、数据库、日志)
|
- **THEN** SHALL 按功能分组展示参数(服务器、数据库、日志)
|
||||||
- **THEN** SHALL 显示每个参数的默认值和说明
|
- **THEN** SHALL 显示每个参数的默认值和说明
|
||||||
|
|
||||||
#### Scenario: 参数错误处理
|
#### Scenario: Server 参数错误处理
|
||||||
|
|
||||||
- **WHEN** 传入无效的参数值(如 `--server-port abc`)
|
- **WHEN** server 传入无效的参数值(如 `--server-port abc`)
|
||||||
- **THEN** SHALL 返回明确的错误信息,指示参数名称和期望类型
|
- **THEN** SHALL 返回明确的错误信息,指示参数名称和期望类型
|
||||||
- **THEN** SHALL NOT 启动应用
|
- **THEN** SHALL NOT 启动应用
|
||||||
- **WHEN** 传入未定义的参数(如 `--unknown-param value`)
|
- **WHEN** server 传入未定义的参数(如 `--unknown-param value`)
|
||||||
- **THEN** SHALL 返回错误信息,指示未知参数名称
|
- **THEN** SHALL 返回错误信息,指示未知参数名称
|
||||||
- **THEN** SHALL NOT 启动应用
|
- **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: 环境变量配置支持
|
### 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 自动读取所有 `NEX_` 前缀的环境变量
|
||||||
- **THEN** SHALL 将环境变量值应用到对应配置项
|
- **THEN** SHALL 将环境变量值应用到对应配置项
|
||||||
|
|
||||||
#### Scenario: 环境变量命名规范
|
#### Scenario: 环境变量命名规范
|
||||||
|
|
||||||
- **WHEN** 使用环境变量配置
|
- **WHEN** server 使用环境变量配置
|
||||||
- **THEN** SHALL 使用 `NEX_` 前缀
|
- **THEN** SHALL 使用 `NEX_` 前缀
|
||||||
- **THEN** SHALL 使用大写字母和下划线分隔(如 `NEX_SERVER_PORT`)
|
- **THEN** SHALL 使用大写字母和下划线分隔(如 `NEX_SERVER_PORT`)
|
||||||
- **THEN** SHALL 保持完整层次结构(如 `NEX_DATABASE_MAX_IDLE_CONNS`)
|
- **THEN** SHALL 保持完整层次结构(如 `NEX_DATABASE_MAX_IDLE_CONNS`)
|
||||||
@@ -382,35 +429,41 @@
|
|||||||
|
|
||||||
#### Scenario: 环境变量类型转换
|
#### Scenario: 环境变量类型转换
|
||||||
|
|
||||||
- **WHEN** 解析不同类型的环境变量
|
- **WHEN** server 解析不同类型的环境变量
|
||||||
- **THEN** SHALL 支持 int 类型(如 `NEX_SERVER_PORT=9000`)
|
- **THEN** SHALL 支持 int 类型(如 `NEX_SERVER_PORT=9000`)
|
||||||
- **THEN** SHALL 支持 string 类型(如 `NEX_DATABASE_PATH=/data/nex.db`)
|
- **THEN** SHALL 支持 string 类型(如 `NEX_DATABASE_PATH=/data/nex.db`)
|
||||||
- **THEN** SHALL 支持 duration 类型(如 `NEX_SERVER_READ_TIMEOUT=60s`)
|
- **THEN** SHALL 支持 duration 类型(如 `NEX_SERVER_READ_TIMEOUT=60s`)
|
||||||
- **THEN** SHALL 支持 bool 类型(如 `NEX_LOG_COMPRESS=true`)
|
- **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`
|
- **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`
|
- **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`
|
- **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 通过环境变量区分环境配置
|
||||||
- **THEN** SHALL NOT 修改代码或配置文件
|
- **THEN** SHALL NOT 修改代码或配置文件
|
||||||
- **WHEN** 配置包含敏感信息(如密钥、密码)
|
- **WHEN** server 配置包含敏感信息(如密钥、密码)
|
||||||
- **THEN** SHALL 通过环境变量传递
|
- **THEN** SHALL 通过环境变量传递
|
||||||
- **THEN** SHALL NOT 存储在配置文件中
|
- **THEN** SHALL NOT 存储在配置文件中
|
||||||
|
|
||||||
#### Scenario: 环境变量错误处理
|
#### Scenario: Server 环境变量错误处理
|
||||||
|
|
||||||
- **WHEN** 环境变量值格式无效(如 `NEX_SERVER_PORT=abc`)
|
- **WHEN** server 环境变量值格式无效(如 `NEX_SERVER_PORT=abc`)
|
||||||
- **THEN** SHALL 返回明确的错误信息,指示环境变量名称和期望类型
|
- **THEN** SHALL 返回明确的错误信息,指示环境变量名称和期望类型
|
||||||
- **THEN** SHALL NOT 启动应用
|
- **THEN** SHALL NOT 启动应用
|
||||||
- **WHEN** 必需配置项既无配置文件也无环境变量
|
- **WHEN** server 必需配置项既无配置文件也无环境变量
|
||||||
- **THEN** SHALL 使用默认值
|
- **THEN** SHALL 使用默认值
|
||||||
- **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` 和默认值加载配置
|
||||||
|
|||||||
@@ -104,14 +104,14 @@
|
|||||||
|
|
||||||
### Requirement: 应用启动时迁移
|
### Requirement: 应用启动时迁移
|
||||||
|
|
||||||
应用 SHALL 在启动时执行迁移。
|
应用 SHALL 在启动时执行迁移,并 SHALL 使用随应用构建产物可用的打包迁移资源。
|
||||||
|
|
||||||
#### Scenario: 自动迁移
|
#### Scenario: 自动迁移
|
||||||
|
|
||||||
- **WHEN** 应用启动
|
- **WHEN** 应用启动
|
||||||
- **THEN** SHALL 根据 `database.driver` 配置选择对应的迁移目录和 goose dialect
|
- **THEN** SHALL 根据 `database.driver` 配置选择对应的迁移资源和 goose dialect
|
||||||
- **THEN** SHALL 在 `driver=sqlite` 时使用 `migrations/sqlite/` 目录,goose dialect 为 `sqlite3`
|
- **THEN** SHALL 在 `driver=sqlite` 时使用 SQLite 方言迁移资源,goose dialect 为 `sqlite3`
|
||||||
- **THEN** SHALL 在 `driver=mysql` 时使用 `migrations/mysql/` 目录,goose dialect 为 `mysql`
|
- **THEN** SHALL 在 `driver=mysql` 时使用 MySQL 方言迁移资源,goose dialect 为 `mysql`
|
||||||
- **THEN** SHALL 自动执行待执行的迁移
|
- **THEN** SHALL 自动执行待执行的迁移
|
||||||
- **THEN** SHALL 在迁移失败时拒绝启动
|
- **THEN** SHALL 在迁移失败时拒绝启动
|
||||||
- **THEN** SHALL 记录迁移日志
|
- **THEN** SHALL 记录迁移日志
|
||||||
@@ -122,6 +122,15 @@
|
|||||||
- **THEN** SHALL 检查数据库迁移版本
|
- **THEN** SHALL 检查数据库迁移版本
|
||||||
- **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: 连接池配置
|
### Requirement: 连接池配置
|
||||||
|
|
||||||
系统 SHALL 配置数据库连接池。
|
系统 SHALL 配置数据库连接池。
|
||||||
@@ -157,7 +166,7 @@
|
|||||||
|
|
||||||
### Requirement: 迁移文件管理
|
### Requirement: 迁移文件管理
|
||||||
|
|
||||||
迁移文件 SHALL 版本化管理。
|
迁移文件 SHALL 版本化管理,并 SHALL 在构建发布产物时作为运行时迁移资源打包。
|
||||||
|
|
||||||
#### Scenario: 迁移文件命名
|
#### Scenario: 迁移文件命名
|
||||||
|
|
||||||
@@ -171,3 +180,10 @@
|
|||||||
- **WHEN** 创建迁移文件
|
- **WHEN** 创建迁移文件
|
||||||
- **THEN** SHALL 按 SQL 方言存储在对应子目录(`migrations/sqlite/` 或 `migrations/mysql/`)
|
- **THEN** SHALL 按 SQL 方言存储在对应子目录(`migrations/sqlite/` 或 `migrations/mysql/`)
|
||||||
- **THEN** SHALL 提交到版本控制系统
|
- **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: 桌面应用启动
|
### Requirement: 桌面应用启动
|
||||||
|
|
||||||
系统 SHALL 支持作为桌面应用启动,将后端服务与前端静态资源打包为单一可执行文件。
|
系统 SHALL 支持作为桌面应用启动,将后端服务与前端静态资源打包为单一可执行文件,并使用启动配置中的 `server.port` 作为本次运行端口。
|
||||||
|
|
||||||
#### Scenario: 双击启动
|
#### Scenario: 双击启动
|
||||||
|
|
||||||
- **WHEN** 用户双击桌面应用可执行文件
|
- **WHEN** 用户双击桌面应用可执行文件
|
||||||
- **THEN** 系统使用 `gofrs/flock` 尝试获取排他文件锁
|
- **THEN** 系统从 `~/.nex/config.yaml` 和默认值加载启动配置快照
|
||||||
|
- **AND** 系统使用 `gofrs/flock` 尝试获取排他文件锁
|
||||||
- **AND** 锁文件路径为系统临时目录下的 `nex-gateway.lock`
|
- **AND** 锁文件路径为系统临时目录下的 `nex-gateway.lock`
|
||||||
- **AND** 系统启动后端服务
|
- **AND** 系统使用启动配置中的 `server.port` 启动后端服务
|
||||||
|
- **AND** 未配置 `server.port` 时默认端口为 9826
|
||||||
- **AND** 系统托盘图标出现
|
- **AND** 系统托盘图标出现
|
||||||
- **AND** 浏览器自动打开 `http://localhost:9826` 显示管理界面
|
- **AND** 浏览器自动打开 `http://localhost:<server.port>` 显示管理界面
|
||||||
|
|
||||||
#### Scenario: 单实例检查
|
#### Scenario: 单实例检查
|
||||||
|
|
||||||
@@ -34,7 +36,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
|||||||
|
|
||||||
### Requirement: 系统托盘
|
### Requirement: 系统托盘
|
||||||
|
|
||||||
系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择。
|
系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择,端口显示 SHALL 使用启动配置中的 `server.port`。
|
||||||
|
|
||||||
#### Scenario: 托盘图标显示
|
#### Scenario: 托盘图标显示
|
||||||
|
|
||||||
@@ -50,19 +52,19 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
|||||||
- **THEN** 显示托盘菜单
|
- **THEN** 显示托盘菜单
|
||||||
- **AND** 菜单包含"打开管理界面"选项
|
- **AND** 菜单包含"打开管理界面"选项
|
||||||
- **AND** 菜单包含"状态: 运行中"选项(禁用状态)
|
- **AND** 菜单包含"状态: 运行中"选项(禁用状态)
|
||||||
- **AND** 菜单包含"端口: 9826"选项(禁用状态)
|
- **AND** 菜单包含"端口: <server.port>"选项(禁用状态)
|
||||||
- **AND** 菜单包含"退出"选项
|
- **AND** 菜单包含"退出"选项
|
||||||
|
|
||||||
#### Scenario: 打开管理界面
|
#### Scenario: 打开管理界面
|
||||||
|
|
||||||
- **WHEN** 用户点击托盘菜单"打开管理界面"
|
- **WHEN** 用户点击托盘菜单"打开管理界面"
|
||||||
- **THEN** 系统在浏览器中打开 `http://localhost:9826`
|
- **THEN** 系统在浏览器中打开 `http://localhost:<server.port>`
|
||||||
|
|
||||||
#### Scenario: 浏览器打开失败
|
#### Scenario: 浏览器打开失败
|
||||||
|
|
||||||
- **WHEN** 系统无法打开浏览器(浏览器未安装等)
|
- **WHEN** 系统无法打开浏览器(浏览器未安装等)
|
||||||
- **THEN** 托盘菜单仍可正常使用
|
- **THEN** 托盘菜单仍可正常使用
|
||||||
- **AND** 用户可手动访问 `http://localhost:9826`
|
- **AND** 用户可手动访问 `http://localhost:<server.port>`
|
||||||
|
|
||||||
#### Scenario: 退出应用
|
#### Scenario: 退出应用
|
||||||
|
|
||||||
@@ -124,19 +126,81 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
|||||||
|
|
||||||
### Requirement: 端口冲突检测
|
### Requirement: 端口冲突检测
|
||||||
|
|
||||||
系统 SHALL 在启动前检测端口是否可用。
|
系统 SHALL 在启动前检测启动配置中的 `server.port` 是否可用。
|
||||||
|
|
||||||
#### Scenario: 端口可用
|
#### Scenario: 配置端口可用
|
||||||
|
|
||||||
- **WHEN** 端口 9826 未被占用
|
- **WHEN** 启动配置中的 `server.port` 未被占用
|
||||||
- **THEN** 服务正常启动
|
- **THEN** 服务正常启动
|
||||||
|
|
||||||
#### Scenario: 端口被占用
|
#### Scenario: 配置端口被占用
|
||||||
|
|
||||||
- **WHEN** 端口 9826 已被其他程序占用
|
- **WHEN** 启动配置中的 `server.port` 已被其他程序占用
|
||||||
- **THEN** 显示错误提示"端口 9826 已被占用"
|
- **THEN** 显示错误提示"端口 <server.port> 已被占用"
|
||||||
- **AND** 应用退出
|
- **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: 跨平台构建
|
### Requirement: 跨平台构建
|
||||||
|
|
||||||
系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台和架构分离或参数化,中间构建产物文件名 SHALL 保持可区分目标平台和架构,最终桌面发布资产文件名 SHALL 包含统一版本号、组件、平台、架构和格式信息。
|
系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台和架构分离或参数化,中间构建产物文件名 SHALL 保持可区分目标平台和架构,最终桌面发布资产文件名 SHALL 包含统一版本号、组件、平台、架构和格式信息。
|
||||||
@@ -152,9 +216,9 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
|||||||
#### Scenario: Windows 构建
|
#### Scenario: Windows 构建
|
||||||
|
|
||||||
- **WHEN** 执行 Windows desktop 构建命令且当前版本为 `1.2.3`
|
- **WHEN** 执行 Windows desktop 构建命令且当前版本为 `1.2.3`
|
||||||
- **THEN** 系统 SHALL 生成 Windows amd64 和 arm64 desktop 可执行文件
|
- **THEN** 系统 SHALL 生成 Windows amd64 desktop 可执行文件
|
||||||
- **AND** Windows desktop 构建 SHALL 使用 `-H=windowsgui` linker flag 隐藏控制台窗口
|
- **AND** Windows desktop 构建 SHALL 使用 `-H=windowsgui` linker flag 隐藏控制台窗口
|
||||||
- **AND** 最终 Windows desktop 发布资产文件名 SHALL 包含 `1.2.3`、`windows` 和对应架构标识
|
- **AND** 最终 Windows desktop 发布资产文件名 SHALL 包含 `1.2.3`、`windows` 和 `amd64`
|
||||||
|
|
||||||
#### Scenario: Linux 构建
|
#### Scenario: Linux 构建
|
||||||
|
|
||||||
@@ -316,3 +380,28 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
|||||||
#### Scenario: 多行文本处理
|
#### Scenario: 多行文本处理
|
||||||
- **WHEN** 对话框消息包含换行符 `\n`
|
- **WHEN** 对话框消息包含换行符 `\n`
|
||||||
- **THEN** AppleScript 正确显示多行文本
|
- **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 检查
|
### Requirement: 构建集成 lint 检查
|
||||||
|
|
||||||
前端 SHALL 在 `build` 命令中集成 ESLint 检查和 Prettier 格式检查。
|
前端 SHALL 在 `build` 命令中集成 TypeScript 类型检查、ESLint 检查和 Prettier 格式检查。
|
||||||
|
|
||||||
#### Scenario: 构建时执行 lint 和格式检查
|
#### Scenario: 构建时执行类型检查、lint 和格式检查
|
||||||
|
|
||||||
- **WHEN** 执行 `bun run build`
|
- **WHEN** 执行 `bun run build`
|
||||||
- **THEN** 构建 SHALL 依次执行 `tsc -b`、`bun run check`、`vite build`
|
- **THEN** 构建 SHALL 依次执行 `bun run check`、`vite build`
|
||||||
- **THEN** `bun run check` SHALL 执行 `bun run lint && bun run format:check`
|
- **THEN** `bun run check` SHALL 依次执行 `bun run typecheck`、`bun run lint`、`bun run format:check`
|
||||||
|
- **THEN** 若 `tsc -b` 报告类型错误,构建 SHALL 中断
|
||||||
- **THEN** 若 `eslint .` 报告任何错误,构建 SHALL 中断
|
- **THEN** 若 `eslint .` 报告任何错误,构建 SHALL 中断
|
||||||
- **THEN** 若 `prettier --check .` 报告任何格式问题,构建 SHALL 中断
|
- **THEN** 若 `prettier --check .` 报告任何格式问题,构建 SHALL 中断
|
||||||
|
|
||||||
@@ -77,8 +78,13 @@ TBD - 定义前端 ESLint 规则配置、构建集成 lint 检查、以及自定
|
|||||||
#### Scenario: 统一检查命令
|
#### Scenario: 统一检查命令
|
||||||
|
|
||||||
- **WHEN** 执行 `bun run check`
|
- **WHEN** 执行 `bun run check`
|
||||||
- **THEN** SHALL 运行 `bun run lint && bun run format:check`
|
- **THEN** SHALL 依次运行 `bun run typecheck`、`bun run lint`、`bun run format:check`
|
||||||
- **THEN** lint 错误和格式问题 SHALL 都被检查
|
- **THEN** 类型错误、lint 错误和格式问题 SHALL 都被检查
|
||||||
|
|
||||||
|
#### Scenario: 单独执行类型检查
|
||||||
|
|
||||||
|
- **WHEN** 执行 `bun run typecheck`
|
||||||
|
- **THEN** SHALL 运行 `tsc -b`
|
||||||
|
|
||||||
#### Scenario: 统一修复命令
|
#### Scenario: 统一修复命令
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,26 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
|||||||
- **WHEN** 供应商列表为空
|
- **WHEN** 供应商列表为空
|
||||||
- **THEN** 表格 SHALL 显示自定义空状态文案 "暂无供应商,点击上方按钮添加"
|
- **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: 添加新供应商
|
#### Scenario: 添加新供应商
|
||||||
|
|
||||||
- **WHEN** 用户点击"添加供应商"按钮
|
- **WHEN** 用户点击"添加供应商"按钮
|
||||||
@@ -184,6 +204,16 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
|||||||
- **WHEN** 模型列表为空
|
- **WHEN** 模型列表为空
|
||||||
- **THEN** 表格 SHALL 显示自定义空状态文案 "暂无模型,点击上方按钮添加"
|
- **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: 为供应商添加模型
|
#### Scenario: 为供应商添加模型
|
||||||
|
|
||||||
- **WHEN** 用户在展开行中点击"添加模型"
|
- **WHEN** 用户在展开行中点击"添加模型"
|
||||||
|
|||||||
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 触发时一致
|
||||||
@@ -54,13 +54,13 @@
|
|||||||
|
|
||||||
### Requirement: 数据库初始化公共包
|
### Requirement: 数据库初始化公共包
|
||||||
|
|
||||||
系统 SHALL 提供 `internal/database` 公共包,封装数据库初始化、迁移执行和连接关闭逻辑,供 `cmd/server` 和 `cmd/desktop` 共同调用。
|
系统 SHALL 提供 `internal/database` 公共包,封装数据库初始化、迁移执行和连接关闭逻辑,供 `cmd/server` 和 `cmd/desktop` 共同调用,并 SHALL 使用随应用构建产物打包的迁移资源执行运行时迁移。
|
||||||
|
|
||||||
#### Scenario: 公共包 Init 函数
|
#### Scenario: 公共包 Init 函数
|
||||||
|
|
||||||
- **WHEN** 调用 `database.Init(cfg, logger)`
|
- **WHEN** 调用 `database.Init(cfg, logger)`
|
||||||
- **THEN** SHALL 根据 `cfg.Driver` 选择对应的 GORM 驱动打开连接
|
- **THEN** SHALL 根据 `cfg.Driver` 选择对应的 GORM 驱动打开连接
|
||||||
- **THEN** SHALL 执行对应方言的 goose 迁移
|
- **THEN** SHALL 使用随应用构建产物打包的迁移资源执行对应方言的 goose 迁移
|
||||||
- **THEN** SHALL 配置连接池参数
|
- **THEN** SHALL 配置连接池参数
|
||||||
- **THEN** SHALL 在 `driver=sqlite` 时执行 `PRAGMA journal_mode=WAL`
|
- **THEN** SHALL 在 `driver=sqlite` 时执行 `PRAGMA journal_mode=WAL`
|
||||||
- **THEN** SHALL 在 `driver=mysql` 时跳过 SQLite 专有 PRAGMA
|
- **THEN** SHALL 在 `driver=mysql` 时跳过 SQLite 专有 PRAGMA
|
||||||
@@ -71,11 +71,20 @@
|
|||||||
- **WHEN** 调用 `database.Close(db)`
|
- **WHEN** 调用 `database.Close(db)`
|
||||||
- **THEN** SHALL 获取底层 `sql.DB` 并关闭连接
|
- **THEN** SHALL 获取底层 `sql.DB` 并关闭连接
|
||||||
|
|
||||||
#### Scenario: 迁移目录选择
|
#### Scenario: 迁移方言资源选择
|
||||||
|
|
||||||
- **WHEN** 执行迁移
|
- **WHEN** 执行运行时迁移
|
||||||
- **THEN** SHALL 在 `driver=sqlite` 时使用 `migrations/sqlite/` 目录,goose dialect 为 `sqlite3`
|
- **THEN** SHALL 在 `driver=sqlite` 时选择 SQLite 方言迁移资源,goose dialect 为 `sqlite3`
|
||||||
- **THEN** SHALL 在 `driver=mysql` 时使用 `migrations/mysql/` 目录,goose dialect 为 `mysql`
|
- **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 方言迁移文件
|
### Requirement: MySQL 方言迁移文件
|
||||||
|
|
||||||
|
|||||||
57
openspec/specs/prepare-commit-msg-hook/spec.md
Normal file
57
openspec/specs/prepare-commit-msg-hook/spec.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# prepare-commit-msg-hook
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
定义 prepare-commit-msg Git hook,在 `git commit` 编辑器打开时为开发者提供提交信息模板。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: prepare-commit-msg hook 提供提交信息模板
|
||||||
|
|
||||||
|
prepare-commit-msg hook SHALL 在 `git commit` 打开编辑器时,将规范格式的提交信息模板预填充到提交信息文件中,辅助开发者编写符合项目规范的多行提交信息。
|
||||||
|
|
||||||
|
#### Scenario: 模板预填充到提交信息文件
|
||||||
|
|
||||||
|
- **WHEN** `git commit` 被执行且编辑器打开提交信息文件
|
||||||
|
- **THEN** prepare-commit-msg hook SHALL 在提交信息文件中写入模板内容
|
||||||
|
- **THEN** 模板 SHALL 包含注释行(以 `#` 开头)引导开发者填写规范格式
|
||||||
|
|
||||||
|
#### Scenario: 模板包含格式引导
|
||||||
|
|
||||||
|
- **WHEN** 模板被写入提交信息文件
|
||||||
|
- **THEN** 模板 SHALL 包含首行格式提示:`# <类型>: <简短中文描述>`
|
||||||
|
- **THEN** 模板 SHALL 包含空行占位符
|
||||||
|
- **THEN** 模板 SHALL 包含详细描述区:`# <详细说明>`
|
||||||
|
- **THEN** 模板 SHALL 列出可用类型:`feat / fix / refactor / docs / style / test / chore`
|
||||||
|
- **THEN** 模板 SHALL 包含示例:`feat: 添加供应商批量管理功能`
|
||||||
|
|
||||||
|
#### Scenario: 注释行不被提交
|
||||||
|
|
||||||
|
- **WHEN** 用户在编辑器中基于模板填写提交信息并保存
|
||||||
|
- **THEN** 以 `#` 开头的模板注释行 SHALL 被 Git 作为注释过滤,不会成为提交信息的一部分
|
||||||
|
|
||||||
|
#### Scenario: 已有提交信息时跳过
|
||||||
|
|
||||||
|
- **WHEN** 提交信息文件已包含非注释内容(如 `-m` 参数指定、`git commit --amend`、merge commit、cherry-pick)
|
||||||
|
- **THEN** prepare-commit-msg hook SHALL NOT 覆盖已有内容,直接退出
|
||||||
|
|
||||||
|
#### Scenario: Git 默认注释不阻止模板写入
|
||||||
|
|
||||||
|
- **WHEN** 提交信息文件只包含空行或 Git 默认生成的 `#` 注释行
|
||||||
|
- **THEN** prepare-commit-msg hook SHALL 将其视为没有已有提交信息
|
||||||
|
- **THEN** hook SHALL 在文件顶部写入模板,并保留 Git 原有注释内容
|
||||||
|
|
||||||
|
### Requirement: 通过 hooks-install 安装
|
||||||
|
|
||||||
|
prepare-commit-msg hook SHALL 随 `make hooks-install` 一起安装到 `.git/hooks/`。
|
||||||
|
|
||||||
|
#### Scenario: 安装 prepare-commit-msg
|
||||||
|
|
||||||
|
- **WHEN** 执行 `make hooks-install`
|
||||||
|
- **THEN** `scripts/git-hooks/prepare-commit-msg` SHALL 被复制到 `.git/hooks/prepare-commit-msg`
|
||||||
|
- **THEN** 该文件 SHALL 被设置为可执行(`chmod +x`)
|
||||||
|
|
||||||
|
#### Scenario: hooks-check 验证安装状态
|
||||||
|
|
||||||
|
- **WHEN** 执行 `make hooks-check`
|
||||||
|
- **THEN** 命令 SHALL 检查 `.git/hooks/prepare-commit-msg` 是否存在且可执行
|
||||||
@@ -189,7 +189,8 @@
|
|||||||
|
|
||||||
- `format = "prettier --write ."` — 格式化所有文件
|
- `format = "prettier --write ."` — 格式化所有文件
|
||||||
- `format:check = "prettier --check ."` — 检查文件格式
|
- `format:check = "prettier --check ."` — 检查文件格式
|
||||||
- `check = "bun run lint && bun run format:check"` — 检查 lint 和格式
|
- `typecheck = "tsc -b"` — TypeScript 类型检查
|
||||||
|
- `check = "bun run typecheck && bun run lint && bun run format:check"` — 检查类型、lint 和格式
|
||||||
- `fix = "bun run lint:fix && bun run format"` — 修复 lint 问题并格式化
|
- `fix = "bun run lint:fix && bun run format"` — 修复 lint 问题并格式化
|
||||||
|
|
||||||
#### Scenario: 运行格式化命令
|
#### Scenario: 运行格式化命令
|
||||||
@@ -207,8 +208,14 @@
|
|||||||
#### Scenario: 运行统一检查命令
|
#### Scenario: 运行统一检查命令
|
||||||
|
|
||||||
- **WHEN** 执行 `bun run check`
|
- **WHEN** 执行 `bun run check`
|
||||||
- **THEN** SHALL 运行 `bun run lint && bun run format:check`
|
- **THEN** SHALL 依次运行 `bun run typecheck`、`bun run lint`、`bun run format:check`
|
||||||
- **THEN** lint 错误和格式问题 SHALL 都被检查
|
- **THEN** TypeScript 类型错误、lint 错误和格式问题 SHALL 都被检查
|
||||||
|
|
||||||
|
#### Scenario: 运行类型检查命令
|
||||||
|
|
||||||
|
- **WHEN** 执行 `bun run typecheck`
|
||||||
|
- **THEN** SHALL 运行 `tsc -b`
|
||||||
|
- **THEN** TypeScript 类型错误 SHALL 报告错误
|
||||||
|
|
||||||
#### Scenario: 运行统一修复命令
|
#### Scenario: 运行统一修复命令
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,6 @@
|
|||||||
- **AND** 系统 SHALL 生成 `nex-server_<version>_macos_arm64.tar.gz`
|
- **AND** 系统 SHALL 生成 `nex-server_<version>_macos_arm64.tar.gz`
|
||||||
- **AND** 系统 SHALL 生成 `nex-server_<version>_macos_universal.tar.gz`
|
- **AND** 系统 SHALL 生成 `nex-server_<version>_macos_universal.tar.gz`
|
||||||
- **AND** 系统 SHALL 生成 `nex-server_<version>_windows_amd64.zip`
|
- **AND** 系统 SHALL 生成 `nex-server_<version>_windows_amd64.zip`
|
||||||
- **AND** 系统 SHALL 生成 `nex-server_<version>_windows_arm64.zip`
|
|
||||||
|
|
||||||
#### Scenario: web 发布构建
|
#### Scenario: web 发布构建
|
||||||
|
|
||||||
@@ -57,22 +56,33 @@
|
|||||||
- **WHEN** 发布流水线执行 Linux desktop 发布构建
|
- **WHEN** 发布流水线执行 Linux desktop 发布构建
|
||||||
- **THEN** 系统 SHALL 在可访问 Go、Bun、CGO、GTK3、Ayatana AppIndicator 和 Linux 打包工具链的环境中构建
|
- **THEN** 系统 SHALL 在可访问 Go、Bun、CGO、GTK3、Ayatana AppIndicator 和 Linux 打包工具链的环境中构建
|
||||||
- **AND** 系统 SHALL 为 `amd64` 和 `arm64` 分别生成 tar.gz、AppImage、deb 和 rpm desktop 发布资产
|
- **AND** 系统 SHALL 为 `amd64` 和 `arm64` 分别生成 tar.gz、AppImage、deb 和 rpm desktop 发布资产
|
||||||
- **AND** Linux arm64 desktop 发布构建 SHALL 使用原生 arm64 runner 或等价的 arm64 Linux 构建环境
|
- **AND** Linux amd64 desktop 发布构建 SHALL 在 `ubuntu-latest` runner 上执行
|
||||||
|
- **AND** Linux arm64 desktop 发布构建 SHALL 在 `ubuntu-24.04-arm` runner 上执行
|
||||||
|
- **AND** 系统 SHALL NOT 在构建步骤中显式传递 TARGET_ARCH 参数
|
||||||
|
|
||||||
#### Scenario: Windows desktop 发布构建
|
#### Scenario: Windows desktop 发布构建
|
||||||
|
|
||||||
- **WHEN** 发布流水线执行 Windows desktop 发布构建
|
- **WHEN** 发布流水线执行 Windows desktop 发布构建
|
||||||
- **THEN** 系统 SHALL 在包含对应架构 MSYS2/MinGW 或等价 CGO 工具链的环境中构建
|
- **THEN** 系统 SHALL 在包含对应架构 MSYS2/MinGW 或等价 CGO 工具链的环境中构建
|
||||||
|
- **AND** Windows amd64 desktop 发布构建 SHALL 在 `windows-latest` runner 上的 MSYS2 MINGW64 环境中执行
|
||||||
- **AND** 系统 SHALL 生成 `nex-desktop_<version>_windows_amd64.zip`
|
- **AND** 系统 SHALL 生成 `nex-desktop_<version>_windows_amd64.zip`
|
||||||
- **AND** 系统 SHALL 生成 `nex-desktop_<version>_windows_arm64.zip`
|
- **AND** 系统 SHALL NOT 在构建步骤中显式传递 TARGET_ARCH 参数
|
||||||
|
|
||||||
#### Scenario: macOS desktop 发布构建
|
#### Scenario: macOS desktop 发布构建
|
||||||
|
|
||||||
- **WHEN** 发布流水线执行 macOS desktop 发布构建
|
- **WHEN** 发布流水线执行 macOS desktop 发布构建
|
||||||
- **THEN** 系统 SHALL 在可访问 Go、Bun、Xcode 命令行工具、`lipo`、`hdiutil` 和 zip 打包工具的 macOS 环境中构建
|
- **THEN** 系统 SHALL 在可访问 Go、Bun、Xcode 命令行工具、`lipo`、`hdiutil` 和 zip 打包工具的 macOS 环境中构建
|
||||||
|
- **AND** 系统 SHALL 在 ARM64 macOS runner 上编译 amd64 和 arm64 双架构二进制并使用 `lipo` 合并为 universal binary
|
||||||
- **AND** 系统 SHALL 生成 `nex-desktop_<version>_macos_universal.zip`
|
- **AND** 系统 SHALL 生成 `nex-desktop_<version>_macos_universal.zip`
|
||||||
- **AND** 系统 SHALL 生成 `nex-desktop_<version>_macos_universal.dmg`
|
- **AND** 系统 SHALL 生成 `nex-desktop_<version>_macos_universal.dmg`
|
||||||
|
|
||||||
|
#### Scenario: 原生架构构建
|
||||||
|
|
||||||
|
- **WHEN** 发布流水线执行 Linux 或 Windows 的 server/desktop 构建步骤
|
||||||
|
- **THEN** 系统 SHALL NOT 显式传递 TARGET_ARCH 参数
|
||||||
|
- **AND** Makefile SHALL 通过 `go env GOARCH` 自动检测目标架构
|
||||||
|
- **AND** 原生 runner 的实际架构 SHALL 与 `go env GOARCH` 返回值一致
|
||||||
|
|
||||||
### Requirement: 三平台发布构建预检
|
### Requirement: 三平台发布构建预检
|
||||||
|
|
||||||
系统 SHALL 在正式执行各平台 release 构建前验证对应 job 的关键工具链可用性,并在环境不完整时快速失败且输出明确诊断。
|
系统 SHALL 在正式执行各平台 release 构建前验证对应 job 的关键工具链可用性,并在环境不完整时快速失败且输出明确诊断。
|
||||||
@@ -166,7 +176,7 @@
|
|||||||
- **WHEN** 当前发布版本为 `1.2.3`
|
- **WHEN** 当前发布版本为 `1.2.3`
|
||||||
- **THEN** Linux server 发布资产文件名 SHALL 为 `nex-server_1.2.3_linux_amd64.tar.gz` 和 `nex-server_1.2.3_linux_arm64.tar.gz`
|
- **THEN** Linux server 发布资产文件名 SHALL 为 `nex-server_1.2.3_linux_amd64.tar.gz` 和 `nex-server_1.2.3_linux_arm64.tar.gz`
|
||||||
- **AND** macOS server 发布资产文件名 SHALL 为 `nex-server_1.2.3_macos_amd64.tar.gz`、`nex-server_1.2.3_macos_arm64.tar.gz` 和 `nex-server_1.2.3_macos_universal.tar.gz`
|
- **AND** macOS server 发布资产文件名 SHALL 为 `nex-server_1.2.3_macos_amd64.tar.gz`、`nex-server_1.2.3_macos_arm64.tar.gz` 和 `nex-server_1.2.3_macos_universal.tar.gz`
|
||||||
- **AND** Windows server 发布资产文件名 SHALL 为 `nex-server_1.2.3_windows_amd64.zip` 和 `nex-server_1.2.3_windows_arm64.zip`
|
- **AND** Windows server 发布资产文件名 SHALL 为 `nex-server_1.2.3_windows_amd64.zip`
|
||||||
|
|
||||||
#### Scenario: web 资产命名
|
#### Scenario: web 资产命名
|
||||||
|
|
||||||
@@ -178,7 +188,7 @@
|
|||||||
|
|
||||||
- **WHEN** 当前发布版本为 `1.2.3`
|
- **WHEN** 当前发布版本为 `1.2.3`
|
||||||
- **THEN** Linux desktop 发布资产文件名 SHALL 使用 `nex-desktop_1.2.3_linux_<arch>.<format>` 格式
|
- **THEN** Linux desktop 发布资产文件名 SHALL 使用 `nex-desktop_1.2.3_linux_<arch>.<format>` 格式
|
||||||
- **AND** Windows desktop 发布资产文件名 SHALL 为 `nex-desktop_1.2.3_windows_amd64.zip` 和 `nex-desktop_1.2.3_windows_arm64.zip`
|
- **AND** Windows desktop 发布资产文件名 SHALL 为 `nex-desktop_1.2.3_windows_amd64.zip`
|
||||||
- **AND** macOS desktop 发布资产文件名 SHALL 为 `nex-desktop_1.2.3_macos_universal.zip` 和 `nex-desktop_1.2.3_macos_universal.dmg`
|
- **AND** macOS desktop 发布资产文件名 SHALL 为 `nex-desktop_1.2.3_macos_universal.zip` 和 `nex-desktop_1.2.3_macos_universal.dmg`
|
||||||
- **AND** 发布资产文件名中的 macOS 平台字段 SHALL 使用 `macos` 而非 `darwin`
|
- **AND** 发布资产文件名中的 macOS 平台字段 SHALL 使用 `macos` 而非 `darwin`
|
||||||
|
|
||||||
@@ -210,3 +220,32 @@
|
|||||||
- **WHEN** 任一构建 job 尝试上传 release artifact 但匹配不到目标文件
|
- **WHEN** 任一构建 job 尝试上传 release artifact 但匹配不到目标文件
|
||||||
- **THEN** 该 job SHALL 失败
|
- **THEN** 该 job SHALL 失败
|
||||||
- **AND** Draft Release 组装 SHALL NOT 继续发布不完整资产集合
|
- **AND** Draft Release 组装 SHALL NOT 继续发布不完整资产集合
|
||||||
|
|
||||||
|
### Requirement: 发布产物运行时资源完整性
|
||||||
|
|
||||||
|
发布流水线 SHALL 确保 server 和 desktop 发布产物包含运行时启动所需的数据库迁移资源,且 SHALL NOT 依赖 CI runner 的源码路径。
|
||||||
|
|
||||||
|
#### Scenario: desktop 发布产物包含迁移资源
|
||||||
|
|
||||||
|
- **WHEN** 发布流水线构建 desktop 发布资产
|
||||||
|
- **THEN** 生成的 desktop 二进制或应用包 SHALL 包含 SQLite 和 MySQL 迁移资源
|
||||||
|
- **THEN** macOS `.app`、`.zip` 和 `.dmg` 安装后 SHALL 不需要仓库源码目录即可执行启动迁移
|
||||||
|
|
||||||
|
#### Scenario: server 发布产物包含迁移资源
|
||||||
|
|
||||||
|
- **WHEN** 发布流水线构建 server 发布资产
|
||||||
|
- **THEN** 生成的 server 二进制 SHALL 包含 SQLite 和 MySQL 迁移资源
|
||||||
|
- **THEN** server 发布资产 SHALL 不需要仓库源码目录即可执行启动迁移
|
||||||
|
|
||||||
|
#### Scenario: 发布产物不泄漏构建机迁移路径
|
||||||
|
|
||||||
|
- **WHEN** 发布流水线完成 server 或 desktop 构建
|
||||||
|
- **THEN** 构建产物 SHALL NOT 在运行时使用 `/Users/runner/work/.../backend/migrations/...` 作为迁移目录
|
||||||
|
- **THEN** 若检测到运行时迁移路径依赖 CI runner 源码路径,发布构建 SHALL 失败
|
||||||
|
|
||||||
|
#### Scenario: 发布构建迁移资源验证
|
||||||
|
|
||||||
|
- **WHEN** 发布流水线执行 release 构建验证
|
||||||
|
- **THEN** 验证 SHALL 覆盖迁移资源可用性
|
||||||
|
- **THEN** 验证 SHALL 覆盖安装包内应用在无源码目录环境下可解析迁移资源
|
||||||
|
- **THEN** 验证 MAY 通过 Go 测试或轻量资源自检完成,不要求启动图形托盘界面
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
- **THEN** SHALL 验证 LoadConfigFromPath 正确加载默认值
|
- **THEN** SHALL 验证 LoadConfigFromPath 正确加载默认值
|
||||||
- **THEN** SHALL 验证 YAML 配置文件正确读取
|
- **THEN** SHALL 验证 YAML 配置文件正确读取
|
||||||
- **THEN** SHALL 验证优先级链:CLI 参数 > 环境变量 > YAML 文件 > 默认值
|
- **THEN** SHALL 验证优先级链:CLI 参数 > 环境变量 > YAML 文件 > 默认值
|
||||||
- **THEN** SHALL 验证首次启动自动创建配置文件
|
- **THEN** SHALL 验证配置文件缺失时使用默认值,不自动创建配置文件
|
||||||
- **THEN** SHALL 验证 SaveConfig 后重新 LoadConfig 数据一致
|
- **THEN** SHALL 验证 SaveConfig 后重新 LoadConfig 数据一致
|
||||||
|
|
||||||
#### Scenario: 环境变量覆盖验证
|
#### Scenario: 环境变量覆盖验证
|
||||||
@@ -46,11 +46,12 @@
|
|||||||
- **THEN** SHALL 成功加载
|
- **THEN** SHALL 成功加载
|
||||||
- **THEN** 配置值 SHALL 反映环境变量覆盖
|
- **THEN** 配置值 SHALL 反映环境变量覆盖
|
||||||
|
|
||||||
#### Scenario: 自动创建配置文件验证
|
#### Scenario: 配置文件缺失时使用默认值
|
||||||
|
|
||||||
- **WHEN** 调用 `LoadConfigFromPath` 并指向不存在的文件路径
|
- **WHEN** 调用 `LoadConfigFromPath` 并指向不存在的文件路径
|
||||||
- **THEN** SHALL 成功加载(不返回 `missing configuration for 'configPath'` 错误)
|
- **THEN** SHALL 成功加载(不返回 `missing configuration for 'configPath'` 错误)
|
||||||
- **THEN** SHALL 返回默认配置对象
|
- **THEN** SHALL 返回默认配置对象
|
||||||
|
- **THEN** SHALL NOT 自动创建配置文件
|
||||||
|
|
||||||
#### Scenario: handler 错误分支测试
|
#### Scenario: handler 错误分支测试
|
||||||
|
|
||||||
@@ -279,3 +280,74 @@
|
|||||||
- **WHEN** mockgen 生成的 mock 就绪
|
- **WHEN** mockgen 生成的 mock 就绪
|
||||||
- **THEN** handler 测试中的手写 mock SHALL 被替换为生成的 mock
|
- **THEN** handler 测试中的手写 mock SHALL 被替换为生成的 mock
|
||||||
- **THEN** 所有测试 SHALL 继续通过,行为不变
|
- **THEN** 所有测试 SHALL 继续通过,行为不变
|
||||||
|
|
||||||
|
### Requirement: 运行时迁移资源测试覆盖
|
||||||
|
|
||||||
|
系统 SHALL 覆盖打包迁移资源解析和启动迁移回归场景,确保发布产物不依赖源码迁移目录。
|
||||||
|
|
||||||
|
#### Scenario: 运行时迁移资源解析测试
|
||||||
|
|
||||||
|
- **WHEN** 运行 database 包单元测试
|
||||||
|
- **THEN** SHALL 验证 `database.Init` 在当前工作目录不是仓库根目录或 `backend/` 目录时仍能执行迁移
|
||||||
|
- **THEN** SHALL 验证迁移资源不依赖 `runtime.Caller` 推导的源码路径
|
||||||
|
- **THEN** SHALL 覆盖 SQLite 方言迁移资源解析
|
||||||
|
|
||||||
|
#### Scenario: 双方言迁移资源选择测试
|
||||||
|
|
||||||
|
- **WHEN** 运行迁移资源选择相关测试
|
||||||
|
- **THEN** SHALL 验证 SQLite 方言资源可被解析
|
||||||
|
- **THEN** SHALL 验证 MySQL 方言资源可被解析
|
||||||
|
- **THEN** SHALL 验证未知或非法 driver 不会被静默映射到错误方言资源
|
||||||
|
|
||||||
|
#### Scenario: desktop 打包迁移资源测试
|
||||||
|
|
||||||
|
- **WHEN** 运行 desktop 专属测试
|
||||||
|
- **THEN** SHALL 验证 desktop 模式启动数据库迁移时使用打包迁移资源
|
||||||
|
- **THEN** SHALL 验证应用在发布产物环境中可执行迁移而不依赖源码迁移目录
|
||||||
|
|
||||||
|
### Requirement: Desktop 配置源隔离测试覆盖
|
||||||
|
|
||||||
|
系统 SHALL 为 desktop 配置加载行为建立测试覆盖,验证 desktop 只使用默认配置文件和默认值,不受 CLI 参数或 `NEX_*` 环境变量影响。
|
||||||
|
|
||||||
|
#### Scenario: Desktop 配置文件端口生效
|
||||||
|
|
||||||
|
- **WHEN** 运行 desktop 配置加载相关测试
|
||||||
|
- **THEN** SHALL 验证 `~/.nex/config.yaml` 或等价测试配置文件中的 `server.port` 会进入 desktop 启动配置快照
|
||||||
|
- **THEN** SHALL 验证 desktop 端口检测、HTTP 监听地址、浏览器打开地址和托盘端口显示使用同一个配置端口
|
||||||
|
|
||||||
|
#### Scenario: Desktop 忽略 CLI 参数
|
||||||
|
|
||||||
|
- **WHEN** 测试进程参数包含 `--server-port 9000`、`--database-path /tmp/test.db` 或 `--config /tmp/custom.yaml`
|
||||||
|
- **THEN** desktop 配置加载 SHALL 忽略这些参数
|
||||||
|
- **THEN** desktop 配置加载 SHALL 使用默认配置文件路径和配置文件值
|
||||||
|
|
||||||
|
#### Scenario: Desktop 忽略未知参数
|
||||||
|
|
||||||
|
- **WHEN** 测试进程参数包含未知命令行参数
|
||||||
|
- **THEN** desktop 配置加载 SHALL 成功或仅因配置文件本身无效而失败
|
||||||
|
- **THEN** desktop 配置加载 SHALL NOT 因未知参数返回参数解析错误
|
||||||
|
|
||||||
|
#### Scenario: Desktop 忽略环境变量
|
||||||
|
|
||||||
|
- **WHEN** 测试环境设置 `NEX_SERVER_PORT`、`NEX_DATABASE_PATH`、`NEX_LOG_LEVEL` 或其他 `NEX_*` 环境变量
|
||||||
|
- **THEN** desktop 配置加载 SHALL NOT 使用这些环境变量覆盖配置文件值
|
||||||
|
- **THEN** server 配置加载的环境变量覆盖测试 SHALL 继续通过
|
||||||
|
|
||||||
|
#### Scenario: Desktop 配置快照不随文件变化自动更新
|
||||||
|
|
||||||
|
- **WHEN** desktop 配置已加载为内存中的启动快照
|
||||||
|
- **AND** 测试修改配置文件中的 `server.port` 或其他配置项
|
||||||
|
- **THEN** 已加载的配置对象 SHALL 保持原值
|
||||||
|
- **THEN** 重新启动或重新执行 desktop 配置加载时 SHALL 读取修改后的配置值
|
||||||
|
|
||||||
|
#### Scenario: Desktop 无效配置错误提示
|
||||||
|
|
||||||
|
- **WHEN** desktop 启动时配置文件存在但 YAML 无法解析或配置验证失败
|
||||||
|
- **THEN** 测试 SHALL 验证启动流程返回或显示包含配置路径和失败原因的错误
|
||||||
|
- **THEN** 测试 SHALL 验证 desktop 不会静默回退默认配置继续启动
|
||||||
|
|
||||||
|
#### Scenario: 配置文件缺失时使用默认值
|
||||||
|
|
||||||
|
- **WHEN** 测试配置加载时指定不存在的配置文件路径
|
||||||
|
- **THEN** SHALL 返回默认配置值,不自动创建配置文件
|
||||||
|
- **THEN** 测试 SHALL 验证配置文件未被创建
|
||||||
|
|||||||
@@ -90,22 +90,34 @@
|
|||||||
|
|
||||||
### Requirement: 版本升迁 Makefile 编排
|
### Requirement: 版本升迁 Makefile 编排
|
||||||
|
|
||||||
`make version-bump` SHALL 编排完整的版本升迁流程:工作区干净检查 → `version bump`(含 sync/check/倒退检查)→ git add → git commit → git tag。不传 `BUMP` 参数时 SHALL 默认执行 `BUMP=patch`。
|
`make version-bump` SHALL 编排完整的版本升迁流程:全量 lint 检查 → 全量单元测试 → 工作区干净检查 → `version bump`(含 sync/check/倒退检查)→ git add → git commit → git tag。不传 `BUMP` 参数时 SHALL 默认执行 `BUMP=patch`。lint/test 前置检查 SHALL NOT 替代工作区干净检查。
|
||||||
|
|
||||||
#### Scenario: 完整升迁流程
|
#### Scenario: 完整升迁流程
|
||||||
|
|
||||||
- **WHEN** 执行 `make version-bump BUMP=minor`,工作区干净,当前版本 `0.1.0`
|
- **WHEN** 执行 `make version-bump BUMP=minor`,工作区干净,当前版本 `0.1.0`
|
||||||
- **THEN** Makefile SHALL 依次执行:工作区检查 → `version bump minor` → `git add VERSION frontend/` → `git commit -m "chore: 版本升迁 v0.2.0"` → `git tag v0.2.0`
|
- **THEN** Makefile SHALL 依次执行:`make lint` → `make test` → 工作区检查 → `version bump minor` → `git add VERSION frontend/` → `git commit -m "chore: 版本升迁 v0.2.0"` → `git tag v0.2.0`
|
||||||
|
|
||||||
#### Scenario: 不传 BUMP 默认 patch
|
#### Scenario: 不传 BUMP 默认 patch
|
||||||
|
|
||||||
- **WHEN** 执行 `make version-bump`,工作区干净,当前版本 `0.1.0`
|
- **WHEN** 执行 `make version-bump`,工作区干净,当前版本 `0.1.0`
|
||||||
- **THEN** Makefile SHALL 等效于执行 `make version-bump BUMP=patch`,将版本更新为 `0.1.1`
|
- **THEN** Makefile SHALL 等效于执行 `make version-bump BUMP=patch`,将版本更新为 `0.1.1`
|
||||||
|
|
||||||
|
#### Scenario: lint 失败时终止
|
||||||
|
|
||||||
|
- **WHEN** 执行 `make version-bump`,但 `make lint` 报告错误
|
||||||
|
- **THEN** Makefile SHALL 以非零退出码失败,SHALL NOT 执行 `version bump`、git commit、git tag
|
||||||
|
- **THEN** SHALL 输出错误信息提示修复 lint 问题后重试
|
||||||
|
|
||||||
|
#### Scenario: test 失败时终止
|
||||||
|
|
||||||
|
- **WHEN** 执行 `make version-bump`,但 `make test` 报告测试失败
|
||||||
|
- **THEN** Makefile SHALL 以非零退出码失败,SHALL NOT 执行 `version bump`、git commit、git tag
|
||||||
|
- **THEN** SHALL 输出错误信息提示修复测试失败后重试
|
||||||
|
|
||||||
#### Scenario: 工作区不干净
|
#### Scenario: 工作区不干净
|
||||||
|
|
||||||
- **WHEN** 执行 `make version-bump BUMP=minor`,但工作区有未提交的改动
|
- **WHEN** 执行 `make version-bump BUMP=minor`,但工作区有未提交的改动
|
||||||
- **THEN** Makefile SHALL 以非零退出码失败并提示先提交或暂存改动
|
- **THEN** Makefile SHALL 以非零退出码失败并提示先提交或清理改动
|
||||||
|
|
||||||
#### Scenario: 支持指定版本号
|
#### Scenario: 支持指定版本号
|
||||||
|
|
||||||
|
|||||||
67
scripts/git-hooks/commit-msg
Executable file
67
scripts/git-hooks/commit-msg
Executable file
@@ -0,0 +1,67 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
MSG_FILE=$1
|
||||||
|
|
||||||
|
if [ ! -f "$MSG_FILE" ]; then
|
||||||
|
printf '%s\n' '提交信息文件不存在。' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
FIRST_LINE=
|
||||||
|
SECOND_LINE=
|
||||||
|
HAS_BODY=
|
||||||
|
LINE_NO=0
|
||||||
|
|
||||||
|
while IFS= read -r LINE || [ -n "$LINE" ]; do
|
||||||
|
case "$LINE" in
|
||||||
|
\#*) continue ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -z "$FIRST_LINE" ]; then
|
||||||
|
[ -n "$LINE" ] || continue
|
||||||
|
FIRST_LINE=$LINE
|
||||||
|
LINE_NO=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
LINE_NO=$((LINE_NO + 1))
|
||||||
|
case "$LINE_NO" in
|
||||||
|
2) SECOND_LINE=$LINE ;;
|
||||||
|
*)
|
||||||
|
if [ -n "$LINE" ]; then
|
||||||
|
HAS_BODY=1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done < "$MSG_FILE"
|
||||||
|
|
||||||
|
case "$FIRST_LINE" in
|
||||||
|
Merge*)
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if ! printf '%s\n' "$FIRST_LINE" | grep -Eq '^(feat|fix|refactor|docs|style|test|chore): .+$'; then
|
||||||
|
cat >&2 <<'EOF'
|
||||||
|
提交信息格式错误。
|
||||||
|
|
||||||
|
格式: <类型>: <简短描述>
|
||||||
|
类型: feat / fix / refactor / docs / style / test / chore
|
||||||
|
|
||||||
|
示例:
|
||||||
|
feat: 添加供应商批量管理功能
|
||||||
|
fix: 修复流式响应断连问题
|
||||||
|
chore: 版本升迁 v0.2.0
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ${#FIRST_LINE} -gt 72 ]; then
|
||||||
|
printf '%s\n' '警告: 提交信息首行超过 72 个字符,建议保持简短。' >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$HAS_BODY" ] && [ -n "$SECOND_LINE" ]; then
|
||||||
|
printf '%s\n' '提交信息首行后应为空行,再写详细描述。' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
12
scripts/git-hooks/pre-commit
Executable file
12
scripts/git-hooks/pre-commit
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
ROOT_DIR=$(git rev-parse --show-toplevel)
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
command -v make >/dev/null 2>&1 || {
|
||||||
|
printf '%s\n' '缺少 make 命令,请先安装 Make 或使用 Git Bash/MINGW64 环境。' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
exec make _hooks-pre-commit
|
||||||
49
scripts/git-hooks/prepare-commit-msg
Executable file
49
scripts/git-hooks/prepare-commit-msg
Executable file
@@ -0,0 +1,49 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
MSG_FILE=$1
|
||||||
|
MSG_SOURCE=$2
|
||||||
|
|
||||||
|
case "$MSG_SOURCE" in
|
||||||
|
"") ;;
|
||||||
|
*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ ! -f "$MSG_FILE" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
has_content=0
|
||||||
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
|
case "$line" in
|
||||||
|
''|\#*) ;;
|
||||||
|
*)
|
||||||
|
has_content=1
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done < "$MSG_FILE"
|
||||||
|
|
||||||
|
if [ "$has_content" -eq 1 ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
tmp_file=${MSG_FILE}.nex-template.$$
|
||||||
|
{
|
||||||
|
cat <<'EOF'
|
||||||
|
# <类型>: <简短中文描述>
|
||||||
|
#
|
||||||
|
# <详细说明>
|
||||||
|
#
|
||||||
|
# 类型: feat / fix / refactor / docs / style / test / chore
|
||||||
|
# 示例: feat: 添加供应商批量管理功能
|
||||||
|
EOF
|
||||||
|
if [ -s "$MSG_FILE" ]; then
|
||||||
|
printf '\n'
|
||||||
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
|
printf '%s\n' "$line"
|
||||||
|
done < "$MSG_FILE"
|
||||||
|
fi
|
||||||
|
} > "$tmp_file"
|
||||||
|
|
||||||
|
mv "$tmp_file" "$MSG_FILE"
|
||||||
273
scripts/git-hooks/test-hooks.sh
Executable file
273
scripts/git-hooks/test-hooks.sh
Executable file
@@ -0,0 +1,273 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT_DIR=$(git rev-parse --show-toplevel)
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
TMP_DIR=${TMPDIR:-/tmp}/nex-hooks-test.$$
|
||||||
|
mkdir -p "$TMP_DIR"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -f \
|
||||||
|
backend/pkg/buildinfo/hook_bad_test_fixture.go \
|
||||||
|
frontend/src/hook_bad_fixture.ts \
|
||||||
|
frontend/src/hook_format_fixture.ts \
|
||||||
|
docs/hook-doc-fixture.md \
|
||||||
|
docs/hook-conflict-fixture.md \
|
||||||
|
docs/hook-large-fixture.txt \
|
||||||
|
"$TMP_DIR/lfs-pointer-fixture" \
|
||||||
|
"$TMP_DIR/lfs-bad-fixture"
|
||||||
|
rm -rf "$TMP_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT HUP INT TERM
|
||||||
|
|
||||||
|
pass() {
|
||||||
|
printf 'OK: %s\n' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'FAIL: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
write_msg() {
|
||||||
|
file=$1
|
||||||
|
shift
|
||||||
|
printf '%s\n' "$*" > "$file"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_conflict() {
|
||||||
|
file=$1
|
||||||
|
less7=$(printf '<%.0s' $(seq 7))
|
||||||
|
eq7=$(printf '=%.0s' $(seq 7))
|
||||||
|
gt7=$(printf '>%.0s' $(seq 7))
|
||||||
|
printf '%s\n' "${less7} HEAD" '' "${eq7}" '' "${gt7} branch" > "$file"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_success() {
|
||||||
|
name=$1
|
||||||
|
shift
|
||||||
|
if "$@" > "$TMP_DIR/out" 2>&1; then
|
||||||
|
pass "$name"
|
||||||
|
else
|
||||||
|
cat "$TMP_DIR/out" >&2
|
||||||
|
fail "$name"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_failure() {
|
||||||
|
name=$1
|
||||||
|
shift
|
||||||
|
if "$@" > "$TMP_DIR/out" 2>&1; then
|
||||||
|
cat "$TMP_DIR/out" >&2
|
||||||
|
fail "$name"
|
||||||
|
fi
|
||||||
|
pass "$name"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_precommit_for() {
|
||||||
|
index=$TMP_DIR/index
|
||||||
|
rm -f "$index"
|
||||||
|
GIT_INDEX_FILE=$index git read-tree HEAD
|
||||||
|
for file in "$@"; do
|
||||||
|
GIT_INDEX_FILE=$index git add -f "$file"
|
||||||
|
done
|
||||||
|
GIT_INDEX_FILE=$index make _hooks-pre-commit
|
||||||
|
}
|
||||||
|
|
||||||
|
run_hooks_install_missing_source() {
|
||||||
|
install_repo=$TMP_DIR/hooks-install-missing
|
||||||
|
rm -rf "$install_repo"
|
||||||
|
mkdir -p "$install_repo/scripts/git-hooks"
|
||||||
|
cp Makefile "$install_repo/Makefile"
|
||||||
|
cp scripts/git-hooks/pre-commit "$install_repo/scripts/git-hooks/pre-commit"
|
||||||
|
cp scripts/git-hooks/commit-msg "$install_repo/scripts/git-hooks/commit-msg"
|
||||||
|
git -C "$install_repo" init >/dev/null 2>&1
|
||||||
|
(cd "$install_repo" && make hooks-install)
|
||||||
|
}
|
||||||
|
|
||||||
|
MSG_FILE=$TMP_DIR/commit-msg.txt
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# commit-msg 测试
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
write_msg "$MSG_FILE" 'feat: 添加 hook 测试'
|
||||||
|
expect_success 'commit-msg accepts Chinese description' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||||
|
|
||||||
|
write_msg "$MSG_FILE" 'feat: add hook tests'
|
||||||
|
expect_success 'commit-msg accepts English-only description without CJK enforcement' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||||
|
|
||||||
|
write_msg "$MSG_FILE" 'fix: 修复 auth 模块 bug'
|
||||||
|
expect_success 'commit-msg accepts Chinese with English technical terms' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||||
|
|
||||||
|
write_msg "$MSG_FILE" 'docs: ajouter une fonctionnalité'
|
||||||
|
expect_success 'commit-msg accepts non-CJK unicode description without CJK enforcement' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||||
|
|
||||||
|
write_msg "$MSG_FILE" 'update: 添加 hook 测试'
|
||||||
|
expect_failure 'commit-msg rejects invalid type' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||||
|
|
||||||
|
write_msg "$MSG_FILE" 'Merge branch feature'
|
||||||
|
expect_success 'commit-msg accepts merge commits' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||||
|
|
||||||
|
write_msg "$MSG_FILE" 'feat: 添加新功能
|
||||||
|
'
|
||||||
|
expect_success 'commit-msg accepts single line with trailing newline' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||||
|
|
||||||
|
printf 'feat: 添加新功能\n\n详细描述内容\n' > "$MSG_FILE"
|
||||||
|
expect_success 'commit-msg accepts multi-line with blank line separator' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||||
|
|
||||||
|
printf 'feat: 添加新功能\n缺少空行\n详细描述\n' > "$MSG_FILE"
|
||||||
|
expect_failure 'commit-msg rejects multi-line without blank line separator' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||||
|
|
||||||
|
printf 'feat: 添加新功能\n\n' > "$MSG_FILE"
|
||||||
|
expect_success 'commit-msg accepts two lines with blank line 2' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||||
|
|
||||||
|
printf 'feat: 添加新功能\n非空行\n' > "$MSG_FILE"
|
||||||
|
expect_success 'commit-msg accepts two lines without body (no line 3)' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||||
|
|
||||||
|
printf 'feat: 添加模板测试\n# <类型>: <简短中文描述>\n#\n# <详细说明>\n' > "$MSG_FILE"
|
||||||
|
expect_success 'commit-msg ignores template comments after subject' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||||
|
|
||||||
|
printf '# <类型>: <简短中文描述>\n#\nfeat: 添加模板测试\n' > "$MSG_FILE"
|
||||||
|
expect_success 'commit-msg ignores leading template comments' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||||
|
|
||||||
|
printf 'feat: 添加新功能\n缺少空行\n# 模板注释\n详细描述\n' > "$MSG_FILE"
|
||||||
|
expect_failure 'commit-msg rejects non-blank separator with intervening comments' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# prepare-commit-msg 测试
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
prepare_msg_file="$TMP_DIR/prepare-msg.txt"
|
||||||
|
rm -f "$prepare_msg_file"
|
||||||
|
touch "$prepare_msg_file"
|
||||||
|
expect_success 'prepare-commit-msg writes template for empty commit' scripts/git-hooks/prepare-commit-msg "$prepare_msg_file" ""
|
||||||
|
|
||||||
|
if grep -q '<类型>' "$prepare_msg_file" && grep -q 'feat / fix / refactor' "$prepare_msg_file"; then
|
||||||
|
pass 'prepare-commit-msg template contains format guidance'
|
||||||
|
else
|
||||||
|
fail 'prepare-commit-msg template contains format guidance'
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\n# Please enter the commit message for your changes.\n# On branch main\n' > "$prepare_msg_file"
|
||||||
|
expect_success 'prepare-commit-msg writes template before git comments' scripts/git-hooks/prepare-commit-msg "$prepare_msg_file" ""
|
||||||
|
if grep -q '<类型>' "$prepare_msg_file" && grep -q 'Please enter the commit message' "$prepare_msg_file"; then
|
||||||
|
pass 'prepare-commit-msg preserves git comments after template'
|
||||||
|
else
|
||||||
|
fail 'prepare-commit-msg preserves git comments after template'
|
||||||
|
fi
|
||||||
|
|
||||||
|
write_msg "$prepare_msg_file" 'existing content'
|
||||||
|
expect_success 'prepare-commit-msg skips when file has content' scripts/git-hooks/prepare-commit-msg "$prepare_msg_file" ""
|
||||||
|
if printf '%s\n' "$(cat "$prepare_msg_file")" | grep -q '^existing content$'; then
|
||||||
|
pass 'prepare-commit-msg does not overwrite existing content'
|
||||||
|
else
|
||||||
|
fail 'prepare-commit-msg does not overwrite existing content'
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$prepare_msg_file"
|
||||||
|
touch "$prepare_msg_file"
|
||||||
|
expect_success 'prepare-commit-msg skips for merge' scripts/git-hooks/prepare-commit-msg "$prepare_msg_file" "merge"
|
||||||
|
if [ ! -s "$prepare_msg_file" ]; then
|
||||||
|
pass 'prepare-commit-msg skips template for merge'
|
||||||
|
else
|
||||||
|
fail 'prepare-commit-msg skips template for merge'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# hooks-install 测试
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
expect_failure 'hooks-install rejects missing source hook' run_hooks_install_missing_source
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# pre-commit 测试
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
cat > backend/pkg/buildinfo/hook_bad_test_fixture.go <<'EOF'
|
||||||
|
package buildinfo
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func hookBadTestFixture() {
|
||||||
|
fmt.Println("bad")
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
expect_failure 'pre-commit rejects Go lint errors (delegated to _backend-lint)' run_precommit_for backend/pkg/buildinfo/hook_bad_test_fixture.go
|
||||||
|
rm -f backend/pkg/buildinfo/hook_bad_test_fixture.go
|
||||||
|
|
||||||
|
cat > frontend/src/hook_bad_fixture.ts <<'EOF'
|
||||||
|
console.log('bad')
|
||||||
|
EOF
|
||||||
|
expect_failure 'pre-commit rejects frontend lint errors (delegated to _frontend-check)' run_precommit_for frontend/src/hook_bad_fixture.ts
|
||||||
|
rm -f frontend/src/hook_bad_fixture.ts
|
||||||
|
|
||||||
|
cat > frontend/src/hook_format_fixture.ts <<'EOF'
|
||||||
|
const hookFormatFixture={foo:"bar"}
|
||||||
|
export { hookFormatFixture }
|
||||||
|
EOF
|
||||||
|
expect_failure 'pre-commit rejects frontend format errors (delegated to _frontend-check)' run_precommit_for frontend/src/hook_format_fixture.ts
|
||||||
|
rm -f frontend/src/hook_format_fixture.ts
|
||||||
|
|
||||||
|
cat > docs/hook-doc-fixture.md <<'EOF'
|
||||||
|
hook doc fixture
|
||||||
|
EOF
|
||||||
|
expect_success 'pre-commit skips non-code staged files' run_precommit_for docs/hook-doc-fixture.md
|
||||||
|
rm -f docs/hook-doc-fixture.md
|
||||||
|
|
||||||
|
write_conflict docs/hook-conflict-fixture.md
|
||||||
|
expect_failure 'pre-commit rejects conflict markers' run_precommit_for docs/hook-conflict-fixture.md
|
||||||
|
rm -f docs/hook-conflict-fixture.md
|
||||||
|
|
||||||
|
index=$TMP_DIR/index
|
||||||
|
rm -f "$index"
|
||||||
|
GIT_INDEX_FILE=$index git read-tree HEAD
|
||||||
|
write_conflict "$TMP_DIR/hook-conflict-fixture.sh"
|
||||||
|
hash=$(git hash-object -w "$TMP_DIR/hook-conflict-fixture.sh")
|
||||||
|
rm -f "$TMP_DIR/hook-conflict-fixture.sh"
|
||||||
|
GIT_INDEX_FILE=$index git update-index --add --cacheinfo 100644 "$hash" "scripts/git-hooks/hook-conflict-fixture.sh"
|
||||||
|
expect_failure 'pre-commit rejects conflict markers in hook scripts' env GIT_INDEX_FILE=$index make _hooks-pre-commit
|
||||||
|
|
||||||
|
i=0
|
||||||
|
while [ "$i" -lt 40000 ]; do
|
||||||
|
printf 'large hook fixture line\n'
|
||||||
|
i=$((i + 1))
|
||||||
|
done > docs/hook-large-fixture.txt
|
||||||
|
if run_precommit_for docs/hook-large-fixture.txt > "$TMP_DIR/out" 2>&1 && grep -q 'Warning: large staged text file' "$TMP_DIR/out"; then
|
||||||
|
pass 'pre-commit warns for large text files'
|
||||||
|
else
|
||||||
|
cat "$TMP_DIR/out" >&2
|
||||||
|
fail 'pre-commit warns for large text files'
|
||||||
|
fi
|
||||||
|
rm -f docs/hook-large-fixture.txt
|
||||||
|
|
||||||
|
# LFS pointer 校验
|
||||||
|
lfs_pointer='version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:abc123
|
||||||
|
size 100
|
||||||
|
'
|
||||||
|
printf '%s\n' "$lfs_pointer" > "$TMP_DIR/lfs-pointer-fixture"
|
||||||
|
hash=$(git hash-object -w "$TMP_DIR/lfs-pointer-fixture")
|
||||||
|
index=$TMP_DIR/index
|
||||||
|
rm -f "$index"
|
||||||
|
GIT_INDEX_FILE=$index git read-tree HEAD
|
||||||
|
GIT_INDEX_FILE=$index git update-index --add --cacheinfo 100644 "$hash" "assets/test-lfs-fixture.png"
|
||||||
|
if GIT_INDEX_FILE=$index make _hooks-pre-commit > "$TMP_DIR/out" 2>&1; then
|
||||||
|
pass 'pre-commit allows LFS pointer files'
|
||||||
|
else
|
||||||
|
cat "$TMP_DIR/out" >&2
|
||||||
|
fail 'pre-commit allows LFS pointer files'
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'fake binary content\n' > "$TMP_DIR/lfs-bad-fixture"
|
||||||
|
hash=$(git hash-object -w "$TMP_DIR/lfs-bad-fixture")
|
||||||
|
rm -f "$index"
|
||||||
|
GIT_INDEX_FILE=$index git read-tree HEAD
|
||||||
|
GIT_INDEX_FILE=$index git update-index --add --cacheinfo 100644 "$hash" "assets/test-lfs-bad-fixture.png"
|
||||||
|
if GIT_INDEX_FILE=$index make _hooks-pre-commit > "$TMP_DIR/out" 2>&1; then
|
||||||
|
cat "$TMP_DIR/out" >&2
|
||||||
|
fail 'pre-commit rejects non-pointer LFS files'
|
||||||
|
fi
|
||||||
|
pass 'pre-commit rejects non-pointer LFS files'
|
||||||
@@ -288,7 +288,6 @@ func serverAssetName(version, platform, arch, format string) (string, error) {
|
|||||||
{platform: "macos", arch: "arm64", format: "tar.gz"},
|
{platform: "macos", arch: "arm64", format: "tar.gz"},
|
||||||
{platform: "macos", arch: "universal", format: "tar.gz"},
|
{platform: "macos", arch: "universal", format: "tar.gz"},
|
||||||
{platform: "windows", arch: "amd64", format: "zip"},
|
{platform: "windows", arch: "amd64", format: "zip"},
|
||||||
{platform: "windows", arch: "arm64", format: "zip"},
|
|
||||||
}) {
|
}) {
|
||||||
return "", fmt.Errorf("不支持的 server 资产目标 %s/%s/%s", platform, arch, format)
|
return "", fmt.Errorf("不支持的 server 资产目标 %s/%s/%s", platform, arch, format)
|
||||||
}
|
}
|
||||||
@@ -321,7 +320,6 @@ func desktopAssetName(version, platform, arch, format string) (string, error) {
|
|||||||
{platform: "macos", arch: "universal", format: "zip"},
|
{platform: "macos", arch: "universal", format: "zip"},
|
||||||
{platform: "macos", arch: "universal", format: "dmg"},
|
{platform: "macos", arch: "universal", format: "dmg"},
|
||||||
{platform: "windows", arch: "amd64", format: "zip"},
|
{platform: "windows", arch: "amd64", format: "zip"},
|
||||||
{platform: "windows", arch: "arm64", format: "zip"},
|
|
||||||
}) {
|
}) {
|
||||||
return "", fmt.Errorf("不支持的 desktop 资产目标 %s/%s/%s", platform, arch, format)
|
return "", fmt.Errorf("不支持的 desktop 资产目标 %s/%s/%s", platform, arch, format)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ func TestAssetNames(t *testing.T) {
|
|||||||
{"server macos arm64", "server", "macos", "arm64", "tar.gz", "nex-server_1.2.3_macos_arm64.tar.gz"},
|
{"server macos arm64", "server", "macos", "arm64", "tar.gz", "nex-server_1.2.3_macos_arm64.tar.gz"},
|
||||||
{"server macos universal", "server", "macos", "universal", "tar.gz", "nex-server_1.2.3_macos_universal.tar.gz"},
|
{"server macos universal", "server", "macos", "universal", "tar.gz", "nex-server_1.2.3_macos_universal.tar.gz"},
|
||||||
{"server windows amd64", "server", "windows", "amd64", "zip", "nex-server_1.2.3_windows_amd64.zip"},
|
{"server windows amd64", "server", "windows", "amd64", "zip", "nex-server_1.2.3_windows_amd64.zip"},
|
||||||
{"server windows arm64", "server", "windows", "arm64", "zip", "nex-server_1.2.3_windows_arm64.zip"},
|
|
||||||
{"web", "web", "", "", "tar.gz", "nex-web_1.2.3.tar.gz"},
|
{"web", "web", "", "", "tar.gz", "nex-web_1.2.3.tar.gz"},
|
||||||
{"desktop linux amd64 tar", "desktop", "linux", "amd64", "tar.gz", "nex-desktop_1.2.3_linux_amd64.tar.gz"},
|
{"desktop linux amd64 tar", "desktop", "linux", "amd64", "tar.gz", "nex-desktop_1.2.3_linux_amd64.tar.gz"},
|
||||||
{"desktop linux amd64 appimage", "desktop", "linux", "amd64", "AppImage", "nex-desktop_1.2.3_linux_amd64.AppImage"},
|
{"desktop linux amd64 appimage", "desktop", "linux", "amd64", "AppImage", "nex-desktop_1.2.3_linux_amd64.AppImage"},
|
||||||
@@ -110,7 +109,6 @@ func TestAssetNames(t *testing.T) {
|
|||||||
{"desktop macos zip", "desktop", "macos", "universal", "zip", "nex-desktop_1.2.3_macos_universal.zip"},
|
{"desktop macos zip", "desktop", "macos", "universal", "zip", "nex-desktop_1.2.3_macos_universal.zip"},
|
||||||
{"desktop macos dmg", "desktop", "macos", "universal", "dmg", "nex-desktop_1.2.3_macos_universal.dmg"},
|
{"desktop macos dmg", "desktop", "macos", "universal", "dmg", "nex-desktop_1.2.3_macos_universal.dmg"},
|
||||||
{"desktop windows amd64", "desktop", "windows", "amd64", "zip", "nex-desktop_1.2.3_windows_amd64.zip"},
|
{"desktop windows amd64", "desktop", "windows", "amd64", "zip", "nex-desktop_1.2.3_windows_amd64.zip"},
|
||||||
{"desktop windows arm64", "desktop", "windows", "arm64", "zip", "nex-desktop_1.2.3_windows_arm64.zip"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
|
|||||||
Reference in New Issue
Block a user