1
0

12 Commits

Author SHA1 Message Date
1d7e839b49 Merge branch 'dev-frontend-encrypt' into master 2026-04-23 18:42:42 +08:00
fa7babf13b chore: 归档 fix-windows-desktop-packaging 变更 2026-04-23 18:40:23 +08:00
280099b89c refactor: 后端日志系统重构
- 新增模块化日志器(pkg/logger/module.go)
- 新增 GORM 日志适配器
- 统一日志入口,移除所有 zap.L() 全局 logger 调用
- 字段标准化
- 启动阶段使用结构化日志
- 更新所有相关测试
2026-04-23 18:37:51 +08:00
0a92a25451 feat: 前端生产构建添加代码混淆
- 集成 vite-plugin-javascript-obfuscator 插件
- 配置中等偏高强度混淆策略(变量名、字符串、对象键、数字)
- 仅生产构建时启用,不影响开发体验
- 仅混淆业务代码,排除第三方库
- 不生成 Source Map
- 新增 frontend-obfuscation 规范
2026-04-23 18:23:07 +08:00
8c075194e5 fix: 修复合并后代码质量问题
- 修正 Makefile 迁移目录路径(sqlite3 → sqlite)
- 统一 database.go 日志风格(log.Printf → zapLogger)
- 修复 config.go validator 标签大小写
- 修复 database_test.go 测试使用 nil logger
- 移除未使用的 log 导入
2026-04-23 16:58:01 +08:00
53e477d383 Merge branch 'dev-mysql-support' into master
- 新增 MySQL 数据库驱动支持,支持跨设备数据同步
- 新增 MySQL 专项测试能力(并发、约束、迁移)
- 重构迁移目录结构:migrations/sqlite 和 migrations/mysql
- 修复 statsRepo 并发竞态条件,使用 upsert 保证原子性
- Makefile 合并:保留完整命令体系 + 新增 MySQL 测试命令
2026-04-23 16:31:29 +08:00
1522c87c74 fix: 修复 statsRepo 并发竞态条件,使用 upsert 保证原子性
- 使用 GORM clause.OnConflict 替代事务包装
- Record 和 BatchUpdate 方法改用 upsert 模式
- 修复 UsageStats 的 GORM struct tag,确保 AutoMigrate 创建正确的 UNIQUE 约束
- 更新 usage-statistics spec 以反映 upsert 操作

MySQL 并发测试验证:10 并发调用 → request_count = 10
2026-04-23 15:54:56 +08:00
e0d05c9869 refactor: Makefile 命名规范化,新增顶层便捷命令
统一命名规范为 <namespace>-<action>[-<variant>] 格式:
- 重命名 desktop-mac/win/linux → desktop-build-mac/win/linux
- 重命名 backend-migrate-* → backend-db-*
- 重命名 frontend-build-desktop → desktop-prepare-frontend
- 重命名 embedfs-prepare → desktop-prepare-embedfs
- 重命名 package-macos → desktop-package-mac

新增顶层便捷命令:
- dev: 并行启动开发环境
- build: 构建所有产物
- test: 运行所有测试
- lint: 检查所有代码
- clean: 清理所有构建产物
2026-04-23 12:30:02 +08:00
5b401e29cb feat: 新增 MySQL 专项测试能力
- 新增 backend/tests/mysql/ 目录,包含 Docker Compose 配置和测试文件
- 新增 Makefile 命令: test-mysql, test-mysql-up, test-mysql-down, test-mysql-quick
- 使用 build tag 控制测试启用,默认不运行
- 测试覆盖: 迁移正确性、外键约束、UNIQUE 约束、并发写入
- 发现 statsRepo.Record 存在并发 bug(检查-然后-操作竞态条件)
2026-04-23 12:25:55 +08:00
65ac9f740a refactor: 桌面应用对话框代码拆分为平台专用文件
- 新增 dialog_windows.go、dialog_darwin.go、dialog_linux.go
- 使用 Go 构建标签实现条件编译
- 修复跨平台编译错误(syscall.NewLazyDLL 在 macOS/Linux 未定义)
- 实现 Linux 多工具降级策略(zenity → kdialog → notify-send → xmessage → stderr)
- 实现 macOS AppleScript 字符转义
- 更新 messagebox_test.go 构建标签
- 更新 desktop-app spec 新增 Linux 降级策略和 macOS 字符转义规范
2026-04-23 11:47:48 +08:00
58ebcaa299 refactor(scripts): 开发分支初始化脚本从 bash 重构为 Python,增强跨平台支持和用户体验 2026-04-23 10:43:59 +08:00
5b765c8b5e feat: 新增 MySQL 数据库驱动支持,支持跨设备数据同步 2026-04-23 00:43:23 +08:00
71 changed files with 3570 additions and 759 deletions

139
Makefile
View File

@@ -1,22 +1,45 @@
.PHONY: all clean \
backend-build backend-run backend-test backend-test-unit backend-test-integration backend-test-coverage \
backend-lint backend-deps backend-generate \
backend-migrate-up backend-migrate-down backend-migrate-status backend-migrate-create \
frontend-build frontend-dev frontend-test frontend-test-watch frontend-test-coverage frontend-test-e2e frontend-lint \
desktop-mac desktop-win desktop-linux package-macos
.PHONY: all dev build test lint clean \
backend-build backend-run backend-dev backend-test backend-test-unit backend-test-integration backend-test-coverage \
backend-lint backend-clean backend-deps backend-generate \
backend-db-up backend-db-down backend-db-status backend-db-create \
test-mysql-up test-mysql-down test-mysql test-mysql-quick \
frontend-build frontend-dev frontend-test frontend-test-watch frontend-test-coverage frontend-test-e2e frontend-lint frontend-clean \
desktop-build desktop-build-mac desktop-build-win desktop-build-linux \
desktop-dev desktop-package-mac desktop-package-win desktop-package-linux desktop-clean \
desktop-prepare-frontend desktop-prepare-embedfs
# ============================================
# 顶层便捷命令
# ============================================
dev:
@echo "🚀 Starting development environment..."
@$(MAKE) -j2 backend-dev frontend-dev
build: backend-build frontend-build
@echo "✅ Build complete"
test: backend-test frontend-test
@echo "✅ All tests passed"
lint: backend-lint frontend-lint
@echo "✅ Lint complete"
all: build test lint
# ============================================
# 后端
# ============================================
all: backend-build
backend-build:
cd backend && go build -o bin/server ./cmd/server
backend-run:
cd backend && go run ./cmd/server
backend-dev:
cd backend && go run ./cmd/server
backend-test:
cd backend && go test ./... -v
@@ -34,24 +57,63 @@ backend-test-coverage:
backend-lint:
cd backend && go tool golangci-lint run ./...
backend-clean:
rm -rf backend/bin/ backend/coverage.out backend/coverage.html
backend-deps:
cd backend && go mod tidy
backend-generate:
cd backend && go generate ./...
backend-migrate-up:
cd backend && goose -dir migrations sqlite3 $(DB_PATH) up
backend-db-up:
@echo "Running database migration up..."
cd backend && goose -dir migrations/sqlite sqlite3 "$(DB_PATH)" up
backend-migrate-down:
cd backend && goose -dir migrations sqlite3 $(DB_PATH) down
backend-db-down:
@echo "Running database migration down..."
cd backend && goose -dir migrations/sqlite sqlite3 "$(DB_PATH)" down
backend-migrate-status:
cd backend && goose -dir migrations sqlite3 $(DB_PATH) status
backend-db-status:
@echo "Checking database migration status..."
cd backend && goose -dir migrations/sqlite sqlite3 "$(DB_PATH)" status
backend-migrate-create:
backend-db-create:
@read -p "Migration name: " name; \
cd backend && goose -dir migrations create $$name sql
cd backend && goose -dir migrations/sqlite create $$name sql; \
cd backend && goose -dir migrations/mysql create $$name sql
# ============================================
# MySQL 专项测试
# ============================================
test-mysql-up:
@echo "Starting MySQL test container..."
cd backend/tests/mysql && docker-compose up -d
@echo "Waiting for MySQL to be ready..."
@for i in $$(seq 1 30); do \
if docker exec nex-mysql-test mysqladmin ping -h localhost -u root -ptestpass --silent 2>/dev/null; then \
echo "MySQL is ready!"; \
exit 0; \
fi; \
echo "Waiting... ($$i/30)"; \
sleep 1; \
done; \
echo "MySQL failed to start"; \
exit 1
test-mysql-down:
@echo "Stopping MySQL test container..."
cd backend/tests/mysql && docker-compose down -v
test-mysql: test-mysql-up
@echo "Running MySQL tests..."
cd backend && go test -tags=mysql ./tests/mysql/... -v -count=1
$(MAKE) test-mysql-down
test-mysql-quick:
@echo "Running MySQL tests (without container management)..."
cd backend && go test -tags=mysql ./tests/mysql/... -v -count=1
# ============================================
# 前端
@@ -78,35 +140,60 @@ frontend-test-e2e:
frontend-lint:
cd frontend && bun run lint
frontend-clean:
rm -rf frontend/dist frontend/.next frontend/node_modules
# ============================================
# 桌面应用
# ============================================
frontend-build-desktop:
cd frontend && cp .env.desktop .env.production.local && bun install && bun run build && rm -f .env.production.local
desktop-build: desktop-build-mac desktop-build-win desktop-build-linux
@echo "✅ Desktop builds complete for all platforms"
embedfs-prepare:
desktop-prepare-frontend:
@echo "📦 Preparing frontend for desktop..."
cd frontend && cp .env.desktop .env.production.local
cd frontend && bun install && bun run build
rm -f frontend/.env.production.local
desktop-prepare-embedfs:
@echo "📦 Preparing embedded filesystem..."
rm -rf embedfs/assets embedfs/frontend-dist
cp -r assets embedfs/assets
cp -r frontend/dist embedfs/frontend-dist
desktop-mac: frontend-build-desktop embedfs-prepare
desktop-build-mac: desktop-prepare-frontend desktop-prepare-embedfs
@echo "🍎 Building macOS..."
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -o ../build/nex-mac-arm64 ./cmd/desktop
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -o ../build/nex-mac-amd64 ./cmd/desktop
desktop-win: frontend-build-desktop embedfs-prepare
desktop-build-win: desktop-prepare-frontend desktop-prepare-embedfs
@echo "🪟 Building Windows..."
cd backend && CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -ldflags "-H=windowsgui" -o ../build/nex-win-amd64.exe ./cmd/desktop
desktop-linux: frontend-build-desktop embedfs-prepare
desktop-build-linux: desktop-prepare-frontend desktop-prepare-embedfs
@echo "🐧 Building Linux..."
cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o ../build/nex-linux-amd64 ./cmd/desktop
package-macos:
desktop-dev: desktop-prepare-frontend desktop-prepare-embedfs
@echo "🖥️ Starting desktop app in dev mode..."
cd backend && go run ./cmd/desktop
desktop-package-mac:
./scripts/build/package-macos.sh
desktop-package-win:
@echo "⚠️ Windows packaging not implemented yet"
desktop-package-linux:
@echo "⚠️ Linux packaging not implemented yet"
desktop-clean:
rm -rf build/ embedfs/assets embedfs/frontend-dist
# ============================================
# 清理
# ============================================
clean:
rm -rf backend/bin/ backend/coverage.out backend/coverage.html
rm -rf build/
clean: backend-clean frontend-clean desktop-clean
@echo "✅ Clean complete"

View File

@@ -66,12 +66,26 @@ nex/
- **语言**: Go 1.26+
- **HTTP 框架**: Gin
- **ORM**: GORM
- **数据库**: SQLite
- **日志**: zap + lumberjack结构化日志 + 日志轮转)
- **数据库**: SQLite / MySQL
- **日志**: zap + lumberjack结构化日志 + 日志轮转 + 模块标识
- **配置**: Viper + pflag多层配置CLI > 环境变量 > 配置文件 > 默认值)
- **验证**: go-playground/validator/v10
- **迁移**: goose
#### 日志模块标识规范
每个模块通过依赖注入获取带模块标识的 logger日志输出格式为 `[module.name]`
```
Console: INFO [handler.proxy] 处理请求 method=POST path=/v1/chat
JSON: {"level":"info","logger":"handler.proxy","msg":"处理请求","method":"POST"}
```
模块命名规范:
- 单一职责包:`database``config`
- 多实体包:`handler.proxy``service.provider`
- 子包:`handler.middleware`
### 前端
- **运行时**: Bun
- **构建工具**: Vite
@@ -92,14 +106,17 @@ nex/
```bash
# macOS (arm64 + amd64)
make desktop-mac
make package-macos # 打包为 .app
make desktop-build-mac
make desktop-package-mac # 打包为 .app
# Windows
make desktop-win
make desktop-build-win
# Linux
make desktop-linux
make desktop-build-linux
# 构建所有平台
make desktop-build
```
**使用桌面应用**
@@ -201,7 +218,14 @@ server:
write_timeout: 30s
database:
path: ~/.nex/config.db
driver: sqlite # sqlite 或 mysql
path: ~/.nex/config.db # SQLite 数据库文件路径
# --- MySQL 配置driver=mysql 时生效)---
# host: localhost
# port: 3306
# user: nex
# password: ""
# dbname: nex
max_idle_conns: 10
max_open_conns: 100
conn_max_lifetime: 1h
@@ -223,6 +247,14 @@ log:
export NEX_SERVER_PORT=9000
export NEX_DATABASE_PATH=/data/nex.db
export NEX_LOG_LEVEL=debug
# MySQL 模式
export NEX_DATABASE_DRIVER=mysql
export NEX_DATABASE_HOST=db.example.com
export NEX_DATABASE_PORT=3306
export NEX_DATABASE_USER=nex
export NEX_DATABASE_PASSWORD=secret
export NEX_DATABASE_DBNAME=nex
```
命名规则:配置路径转大写 + 下划线(如 `server.port``NEX_SERVER_PORT`)。
@@ -238,29 +270,54 @@ export NEX_LOG_LEVEL=debug
### 数据文件
- `~/.nex/config.yaml` - 配置文件
- `~/.nex/config.db` - SQLite 数据库
- `~/.nex/config.db` - SQLite 数据库MySQL 模式下不使用本地数据库文件)
- `~/.nex/log/` - 日志目录
## 测试
```bash
# 顶层便捷命令
make test # 运行所有测试
# 后端测试
make backend-test # 后端测试
make backend-test-coverage # 后端覆盖率
make backend-test-unit # 后端单元测试
make backend-test-integration # 后端集成测试
# 前端测试
make frontend-test # 前端测试
make frontend-test-e2e # 前端 E2E 测试
make frontend-test-coverage # 前端覆盖率
```
## 开发
```bash
# 顶层便捷命令
make dev # 启动开发环境(并行启动后端和前端)
make build # 构建所有产物
make lint # 检查所有代码
make clean # 清理所有构建产物
# 后端开发
make backend-build # 构建后端
make backend-run # 运行后端
make backend-dev # 后端开发模式
make backend-lint # 后端代码检查
make backend-migrate-up # 数据库迁移
make backend-clean # 清理后端构建产物
# 数据库操作
make backend-db-up # 数据库迁移
make backend-db-down # 数据库回滚
make backend-db-status # 数据库迁移状态
make backend-db-create # 创建新迁移
# 前端开发
make frontend-build # 构建前端
make frontend-dev # 前端开发模式
make frontend-lint # 前端代码检查
make frontend-clean # 清理前端构建产物
```
## 开发规范

View File

@@ -14,17 +14,63 @@ AI 网关后端服务,提供统一的大模型 API 代理接口。
- 支持扩展层接口Models、Embeddings、Rerank
- 多供应商配置和路由
- 用量统计
- 结构化日志zap + lumberjack
- 结构化日志zap + lumberjack + 模块标识
- YAML 配置管理
- 请求验证
- 中间件支持(请求 ID、日志、恢复、CORS
## 日志规范
### 模块标识
每个模块通过依赖注入获取带模块标识的 logger
```go
func NewProxyHandler(..., logger *zap.Logger) *ProxyHandler {
return &ProxyHandler{
logger: pkglogger.WithModule(logger, "handler.proxy"),
}
}
```
输出格式:
- Console: `INFO [handler.proxy] 处理请求 method=POST path=/v1/chat`
- JSON: `{"level":"info","logger":"handler.proxy","msg":"处理请求"}`
### 模块命名规范
| 模块 | 命名 |
|------|------|
| ProxyHandler | `handler.proxy` |
| ProviderHandler | `handler.provider` |
| Provider Client | `provider.client` |
| ConversionEngine | `conversion.engine` |
| RoutingCache | `service.routing_cache` |
| StatsBuffer | `service.stats_buffer` |
| Database | `database` |
### 标准字段
使用 `pkg/logger/field.go` 中定义的字段构造函数:
```go
logger.Info("请求开始",
pkglogger.Method("POST"),
pkglogger.Path("/v1/chat"),
pkglogger.RequestID("xxx"),
)
```
### GORM 日志
GORM 日志自动桥接到 zapSQL 查询映射到 Debug 级别。
## 技术栈
- **语言**: Go 1.26+
- **HTTP 框架**: Gin
- **ORM**: GORM
- **数据库**: SQLite
- **数据库**: SQLite / MySQL
- **日志**: zap + lumberjack
- **配置**: Viper + pflag多层配置CLI > 环境变量 > 配置文件 > 默认值)
- **验证**: go-playground/validator/v10
@@ -105,9 +151,13 @@ backend/
│ │ ├── errors.go
│ │ └── wrap.go
│ ├── logger/ # 日志系统
│ │ ├── logger.go
│ │ ├── rotate.go
│ │ ── context.go
│ │ ├── logger.go # 核心初始化
│ │ ├── field.go # 标准字段定义
│ │ ── module.go # 模块日志器
│ │ ├── context.go # Context 辅助函数
│ │ ├── gorm.go # GORM 适配器
│ │ ├── minimal.go # 最小化 logger
│ │ └── rotate.go # 日志轮转
│ ├── modelid/ # 统一模型 ID 工具包
│ │ ├── model_id.go
│ │ └── model_id_test.go
@@ -294,7 +344,14 @@ server:
write_timeout: 30s
database:
path: ~/.nex/config.db
driver: sqlite # sqlite 或 mysql
path: ~/.nex/config.db # SQLite 数据库文件路径
# --- MySQL 配置driver=mysql 时生效)---
# host: localhost
# port: 3306
# user: nex
# password: ""
# dbname: nex
max_idle_conns: 10
max_open_conns: 100
conn_max_lifetime: 1h
@@ -316,6 +373,14 @@ log:
export NEX_SERVER_PORT=9000
export NEX_DATABASE_PATH=/data/nex.db
export NEX_LOG_LEVEL=debug
# MySQL 模式
export NEX_DATABASE_DRIVER=mysql
export NEX_DATABASE_HOST=db.example.com
export NEX_DATABASE_PORT=3306
export NEX_DATABASE_USER=nex
export NEX_DATABASE_PASSWORD=secret
export NEX_DATABASE_DBNAME=nex
```
命名规则:配置路径转大写 + 下划线 + `NEX_` 前缀(如 `server.port``NEX_SERVER_PORT`)。
@@ -332,7 +397,7 @@ export NEX_LOG_LEVEL=debug
```
服务器: --server-port, --server-read-timeout, --server-write-timeout
数据库: --database-path, --database-max-idle-conns, --database-max-open-conns, --database-conn-max-lifetime
数据库: --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
日志: --log-level, --log-path, --log-max-size, --log-max-backups, --log-max-age, --log-compress
通用: --config (指定配置文件路径)
```
@@ -352,15 +417,20 @@ export NEX_LOG_LEVEL=debug
# Docker 部署
docker run -d -e NEX_SERVER_PORT=9000 -e NEX_LOG_LEVEL=info nex-server
# MySQL 模式
./server --database-driver mysql --database-host db.example.com --database-user nex --database-password secret --database-dbname nex
# 自定义配置文件
./server --config /path/to/custom.yaml
```
数据文件:
- `~/.nex/config.yaml` - 配置文件
- `~/.nex/config.db` - SQLite 数据库
- `~/.nex/config.db` - SQLite 数据库MySQL 模式下不使用本地数据库文件)
- `~/.nex/log/` - 日志目录
**MySQL 连接说明**MySQL 连接使用 DSN 格式: `user:password@tcp(host:port)/dbname?charset=utf8mb4&parseTime=true&loc=Local`,最低支持 MySQL 8.0+。
## 测试
```bash

View File

@@ -0,0 +1,28 @@
//go:build darwin
package main
import (
"fmt"
"os/exec"
"strings"
)
func showError(title, message string) {
script := fmt.Sprintf(`display dialog "%s" buttons {"OK"} default button "OK" with title "%s"`,
escapeAppleScript(message), escapeAppleScript(title))
exec.Command("osascript", "-e", script).Run()
}
func showAbout() {
message := "Nex Gateway\n\nAI Gateway - 统一的大模型 API 网关\n\nhttps://github.com/nex/gateway"
script := fmt.Sprintf(`display dialog "%s" buttons {"OK"} default button "OK" with title "关于 Nex Gateway"`,
escapeAppleScript(message))
exec.Command("osascript", "-e", script).Run()
}
func escapeAppleScript(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "\"", "\\\"")
return s
}

View File

@@ -0,0 +1,88 @@
//go:build linux
package main
import (
"fmt"
"os"
"os/exec"
"sync"
)
type dialogToolType int
const (
toolNone dialogToolType = iota
toolZenity
toolKdialog
toolNotifySend
toolXmessage
)
var (
dialogTool dialogToolType
dialogToolOnce sync.Once
)
func init() {
dialogToolOnce.Do(detectDialogTool)
}
func detectDialogTool() {
tools := []struct {
name string
typ dialogToolType
}{
{"zenity", toolZenity},
{"kdialog", toolKdialog},
{"notify-send", toolNotifySend},
{"xmessage", toolXmessage},
}
for _, tool := range tools {
if _, err := exec.LookPath(tool.name); err == nil {
dialogTool = tool.typ
return
}
}
dialogTool = toolNone
}
func showError(title, message string) {
switch dialogTool {
case toolZenity:
exec.Command("zenity", "--error",
fmt.Sprintf("--title=%s", title),
fmt.Sprintf("--text=%s", message)).Run()
case toolKdialog:
exec.Command("kdialog", "--error", message, "--title", title).Run()
case toolNotifySend:
exec.Command("notify-send", "-u", "critical", title, message).Run()
case toolXmessage:
exec.Command("xmessage", "-center",
fmt.Sprintf("%s: %s", title, message)).Run()
default:
fmt.Fprintf(os.Stderr, "错误: %s: %s\n", title, message)
}
}
func showAbout() {
message := "Nex Gateway\n\nAI Gateway - 统一的大模型 API 网关\n\nhttps://github.com/nex/gateway"
switch dialogTool {
case toolZenity:
exec.Command("zenity", "--info",
"--title=关于 Nex Gateway",
fmt.Sprintf("--text=%s", message)).Run()
case toolKdialog:
exec.Command("kdialog", "--msgbox", message, "--title", "关于 Nex Gateway").Run()
case toolNotifySend:
exec.Command("notify-send", "关于 Nex Gateway", message).Run()
case toolXmessage:
exec.Command("xmessage", "-center",
fmt.Sprintf("关于 Nex Gateway: %s", message)).Run()
default:
fmt.Fprintf(os.Stderr, "关于 Nex Gateway: %s\n", message)
}
}

View File

@@ -0,0 +1,38 @@
//go:build windows
package main
import (
"syscall"
"unsafe"
)
const (
MB_ICONERROR = 0x10
MB_ICONINFORMATION = 0x40
)
var (
user32 = syscall.NewLazyDLL("user32.dll")
procMessageBoxW = user32.NewProc("MessageBoxW")
)
func showError(title, message string) {
messageBox(title, message, MB_ICONERROR)
}
func showAbout() {
message := "Nex Gateway\n\nAI Gateway - 统一的大模型 API 网关\n\nhttps://github.com/nex/gateway"
messageBox("关于 Nex Gateway", message, MB_ICONINFORMATION)
}
func messageBox(title, message string, flags uint) {
titlePtr, _ := syscall.UTF16PtrFromString(title)
messagePtr, _ := syscall.UTF16PtrFromString(message)
procMessageBoxW.Call(
0,
uintptr(unsafe.Pointer(messagePtr)),
uintptr(unsafe.Pointer(titlePtr)),
uintptr(flags),
)
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"io/fs"
"log"
"net"
"net/http"
"os"
@@ -12,23 +11,18 @@ import (
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
"unsafe"
"github.com/gin-gonic/gin"
"github.com/getlantern/systray"
"github.com/gin-gonic/gin"
"github.com/gofrs/flock"
"github.com/pressly/goose/v3"
"go.uber.org/zap"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"nex/backend/internal/config"
"nex/backend/internal/conversion"
"nex/backend/internal/conversion/anthropic"
"nex/backend/internal/conversion/openai"
"nex/backend/internal/database"
"nex/backend/internal/handler"
"nex/backend/internal/handler/middleware"
"nex/backend/internal/provider"
@@ -49,25 +43,28 @@ var (
func main() {
port := 9826
minimalLogger := pkgLogger.NewMinimal()
singleLock := NewSingletonLock(filepath.Join(os.TempDir(), "nex-gateway.lock"))
if err := singleLock.Lock(); err != nil {
minimalLogger.Error("已有 Nex 实例运行")
showError("Nex Gateway", "已有 Nex 实例运行")
os.Exit(1)
}
defer singleLock.Unlock()
if err := checkPortAvailable(port); err != nil {
minimalLogger.Error("端口不可用", zap.Error(err))
showError("Nex Gateway", err.Error())
os.Exit(1)
}
cfg, err := config.LoadConfig()
if err != nil {
showError("Nex Gateway", fmt.Sprintf("加载配置失败: %v", err))
os.Exit(1)
minimalLogger.Fatal("加载配置失败", zap.Error(err))
}
zapLogger, err = pkgLogger.New(pkgLogger.Config{
zapLogger, err = pkgLogger.Upgrade(minimalLogger, pkgLogger.Config{
Level: cfg.Log.Level,
Path: cfg.Log.Path,
MaxSize: cfg.Log.MaxSize,
@@ -76,17 +73,17 @@ func main() {
Compress: cfg.Log.Compress,
})
if err != nil {
showError("Nex Gateway", fmt.Sprintf("初始化日志失败: %v", err))
os.Exit(1)
minimalLogger.Fatal("初始化日志失败", zap.Error(err))
}
defer zapLogger.Sync()
db, err := initDatabase(cfg)
cfg.PrintSummary(zapLogger)
db, err := database.Init(&cfg.Database, zapLogger)
if err != nil {
showError("Nex Gateway", fmt.Sprintf("初始化数据库失败: %v", err))
os.Exit(1)
zapLogger.Fatal("初始化数据库失败", zap.Error(err))
}
defer closeDB(db)
defer database.Close(db)
providerRepo := repository.NewProviderRepository(db)
modelRepo := repository.NewModelRepository(db)
@@ -110,16 +107,16 @@ func main() {
registry := conversion.NewMemoryRegistry()
if err := registry.Register(openai.NewAdapter()); err != nil {
zapLogger.Fatal("注册 OpenAI 适配器失败", zap.String("error", err.Error()))
zapLogger.Fatal("注册 OpenAI 适配器失败", zap.Error(err))
}
if err := registry.Register(anthropic.NewAdapter()); err != nil {
zapLogger.Fatal("注册 Anthropic 适配器失败", zap.String("error", err.Error()))
zapLogger.Fatal("注册 Anthropic 适配器失败", zap.Error(err))
}
engine := conversion.NewConversionEngine(registry, zapLogger)
providerClient := provider.NewClient()
providerClient := provider.NewClient(zapLogger)
proxyHandler := handler.NewProxyHandler(engine, providerClient, routingService, providerService, statsService)
proxyHandler := handler.NewProxyHandler(engine, providerClient, routingService, providerService, statsService, zapLogger)
providerHandler := handler.NewProviderHandler(providerService)
modelHandler := handler.NewModelHandler(modelService)
statsHandler := handler.NewStatsHandler(statsService)
@@ -161,76 +158,6 @@ func main() {
setupSystray(port)
}
func initDatabase(cfg *config.Config) (*gorm.DB, error) {
dbDir := filepath.Dir(cfg.Database.Path)
if err := os.MkdirAll(dbDir, 0755); err != nil {
return nil, fmt.Errorf("创建数据库目录失败: %w", err)
}
db, err := gorm.Open(sqlite.Open(cfg.Database.Path), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
return nil, err
}
if err := runMigrations(db); err != nil {
return nil, fmt.Errorf("数据库迁移失败: %w", err)
}
if err := db.Exec("PRAGMA journal_mode=WAL").Error; err != nil {
log.Printf("警告: 启用 WAL 模式失败: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns)
sqlDB.SetConnMaxLifetime(cfg.Database.ConnMaxLifetime)
return db, nil
}
func runMigrations(db *gorm.DB) error {
sqlDB, err := db.DB()
if err != nil {
return err
}
migrationsDir := getMigrationsDir()
if _, err := os.Stat(migrationsDir); os.IsNotExist(err) {
return fmt.Errorf("迁移目录不存在: %s", migrationsDir)
}
goose.SetDialect("sqlite3")
if err := goose.Up(sqlDB, migrationsDir); err != nil {
return err
}
return nil
}
func getMigrationsDir() string {
_, filename, _, ok := runtime.Caller(0)
if ok {
dir := filepath.Join(filepath.Dir(filename), "..", "..", "migrations")
if abs, err := filepath.Abs(dir); err == nil {
return abs
}
}
return "./migrations"
}
func closeDB(db *gorm.DB) {
sqlDB, err := db.DB()
if err != nil {
return
}
sqlDB.Close()
}
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler) {
r.Any("/v1/*path", proxyHandler.HandleProxy)
@@ -447,49 +374,3 @@ func openBrowser(url string) error {
return cmd.Start()
}
func showError(title, message string) {
switch runtime.GOOS {
case "darwin":
script := fmt.Sprintf(`display dialog "%s" buttons {"OK"} default button "OK" with title "%s"`, message, title)
exec.Command("osascript", "-e", script).Run()
case "windows":
messageBox(title, message, MB_ICONERROR)
case "linux":
exec.Command("zenity", "--error", fmt.Sprintf("--title=%s", title), fmt.Sprintf("--text=%s", message)).Run()
}
}
func showAbout() {
message := "Nex Gateway\n\nAI Gateway - 统一的大模型 API 网关\n\nhttps://github.com/nex/gateway"
switch runtime.GOOS {
case "darwin":
script := fmt.Sprintf(`display dialog "%s" buttons {"OK"} default button "OK" with title "关于 Nex Gateway"`, message)
exec.Command("osascript", "-e", script).Run()
case "windows":
messageBox("关于 Nex Gateway", message, MB_ICONINFORMATION)
case "linux":
exec.Command("zenity", "--info", "--title=关于 Nex Gateway", fmt.Sprintf("--text=%s", message)).Run()
}
}
const (
MB_ICONERROR = 0x10
MB_ICONINFORMATION = 0x40
)
var (
user32 = syscall.NewLazyDLL("user32.dll")
procMessageBoxW = user32.NewProc("MessageBoxW")
)
func messageBox(title, message string, flags uint) {
titlePtr, _ := syscall.UTF16PtrFromString(title)
messagePtr, _ := syscall.UTF16PtrFromString(message)
procMessageBoxW.Call(
0,
uintptr(unsafe.Pointer(messagePtr)),
uintptr(unsafe.Pointer(titlePtr)),
uintptr(flags),
)
}

View File

@@ -1,30 +1,19 @@
//go:build windows
package main
import (
"runtime"
"testing"
)
func TestMessageBoxW_WindowsOnly(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("MessageBoxW 仅在 Windows 上测试")
}
messageBox("测试标题", "测试消息", MB_ICONINFORMATION)
}
func TestShowError_WindowsBranch(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows 原生对话框测试仅在 Windows 上运行")
}
showError("测试错误", "这是一条测试错误消息")
}
func TestShowAbout_WindowsBranch(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows 原生对话框测试仅在 Windows 上运行")
}
showAbout()
}

View File

@@ -3,26 +3,20 @@ package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"runtime"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/pressly/goose/v3"
"go.uber.org/zap"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"nex/backend/internal/config"
"nex/backend/internal/conversion"
"nex/backend/internal/conversion/anthropic"
"nex/backend/internal/conversion/openai"
"nex/backend/internal/database"
"nex/backend/internal/handler"
"nex/backend/internal/handler/middleware"
"nex/backend/internal/provider"
@@ -32,17 +26,14 @@ import (
)
func main() {
// 1. 加载配置(已包含 CLI 参数解析、环境变量绑定、配置文件读取和验证)
minimalLogger := pkgLogger.NewMinimal()
cfg, err := config.LoadConfig()
if err != nil {
log.Fatalf("加载配置失败: %v", err)
minimalLogger.Fatal("加载配置失败", zap.Error(err))
}
// 2. 打印配置摘要
cfg.PrintSummary()
// 3. 初始化日志
zapLogger, err := pkgLogger.New(pkgLogger.Config{
zapLogger, err := pkgLogger.Upgrade(minimalLogger, pkgLogger.Config{
Level: cfg.Log.Level,
Path: cfg.Log.Path,
MaxSize: cfg.Log.MaxSize,
@@ -51,60 +42,53 @@ func main() {
Compress: cfg.Log.Compress,
})
if err != nil {
log.Fatalf("初始化日志失败: %v", err)
minimalLogger.Fatal("初始化日志失败", zap.Error(err))
}
defer zapLogger.Sync()
// 3. 初始化数据库
db, err := initDatabase(cfg)
if err != nil {
zapLogger.Fatal("初始化数据库失败", zap.String("error", err.Error()))
}
defer closeDB(db)
cfg.PrintSummary(zapLogger)
db, err := database.Init(&cfg.Database, zapLogger)
if err != nil {
zapLogger.Fatal("初始化数据库失败", zap.Error(err))
}
defer database.Close(db)
// 4. 初始化 repository 层
providerRepo := repository.NewProviderRepository(db)
modelRepo := repository.NewModelRepository(db)
statsRepo := repository.NewStatsRepository(db)
// 5. 初始化缓存
routingCache := service.NewRoutingCache(modelRepo, providerRepo, zapLogger)
if err := routingCache.Preload(); err != nil {
zapLogger.Warn("缓存预热失败,将使用懒加载", zap.Error(err))
}
// 6. 初始化统计缓冲
statsBuffer := service.NewStatsBuffer(statsRepo, zapLogger,
service.WithFlushInterval(5*time.Second),
service.WithFlushThreshold(100))
statsBuffer.Start()
// 7. 初始化 service 层
providerService := service.NewProviderService(providerRepo, modelRepo, routingCache)
modelService := service.NewModelService(modelRepo, providerRepo, routingCache)
routingService := service.NewRoutingService(routingCache)
statsService := service.NewStatsService(statsRepo, statsBuffer)
// 8. 创建 ConversionEngine
registry := conversion.NewMemoryRegistry()
if err := registry.Register(openai.NewAdapter()); err != nil {
zapLogger.Fatal("注册 OpenAI 适配器失败", zap.String("error", err.Error()))
zapLogger.Fatal("注册 OpenAI 适配器失败", zap.Error(err))
}
if err := registry.Register(anthropic.NewAdapter()); err != nil {
zapLogger.Fatal("注册 Anthropic 适配器失败", zap.String("error", err.Error()))
zapLogger.Fatal("注册 Anthropic 适配器失败", zap.Error(err))
}
engine := conversion.NewConversionEngine(registry, zapLogger)
// 9. 初始化 provider client
providerClient := provider.NewClient()
providerClient := provider.NewClient(zapLogger)
// 10. 初始化 handler 层
proxyHandler := handler.NewProxyHandler(engine, providerClient, routingService, providerService, statsService)
proxyHandler := handler.NewProxyHandler(engine, providerClient, routingService, providerService, statsService, zapLogger)
providerHandler := handler.NewProviderHandler(providerService)
modelHandler := handler.NewModelHandler(modelService)
statsHandler := handler.NewStatsHandler(statsService)
// 11. 创建 Gin 引擎
gin.SetMode(gin.ReleaseMode)
r := gin.New()
@@ -115,9 +99,8 @@ func main() {
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler)
// 12. 启动服务器
srv := &http.Server{
Addr: formatAddr(cfg.Server.Port),
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
Handler: r,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
@@ -126,7 +109,7 @@ func main() {
go func() {
zapLogger.Info("AI Gateway 启动", zap.String("addr", srv.Addr))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
zapLogger.Fatal("服务器启动失败", zap.String("error", err.Error()))
zapLogger.Fatal("服务器启动失败", zap.Error(err))
}
}()
@@ -140,7 +123,7 @@ func main() {
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
zapLogger.Fatal("服务器强制关闭", zap.String("error", err.Error()))
zapLogger.Fatal("服务器强制关闭", zap.Error(err))
}
statsBuffer.Stop()
@@ -148,83 +131,9 @@ func main() {
zapLogger.Info("服务器已关闭")
}
func initDatabase(cfg *config.Config) (*gorm.DB, error) {
db, err := gorm.Open(sqlite.Open(cfg.Database.Path), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
return nil, err
}
if err := runMigrations(db); err != nil {
return nil, fmt.Errorf("数据库迁移失败: %w", err)
}
if err := db.Exec("PRAGMA journal_mode=WAL").Error; err != nil {
log.Printf("警告: 启用 WAL 模式失败: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns)
sqlDB.SetConnMaxLifetime(cfg.Database.ConnMaxLifetime)
log.Printf("数据库连接池配置: MaxIdle=%d, MaxOpen=%d, MaxLifetime=%v",
cfg.Database.MaxIdleConns, cfg.Database.MaxOpenConns, cfg.Database.ConnMaxLifetime)
return db, nil
}
func runMigrations(db *gorm.DB) error {
sqlDB, err := db.DB()
if err != nil {
return err
}
migrationsDir := getMigrationsDir()
if _, err := os.Stat(migrationsDir); os.IsNotExist(err) {
return fmt.Errorf("迁移目录不存在: %s", migrationsDir)
}
goose.SetDialect("sqlite3")
if err := goose.Up(sqlDB, migrationsDir); err != nil {
return err
}
return nil
}
func getMigrationsDir() string {
_, filename, _, ok := runtime.Caller(0)
if ok {
dir := filepath.Join(filepath.Dir(filename), "..", "..", "migrations")
if abs, err := filepath.Abs(dir); err == nil {
return abs
}
}
return "./migrations"
}
func closeDB(db *gorm.DB) {
sqlDB, err := db.DB()
if err != nil {
return
}
sqlDB.Close()
}
func formatAddr(port int) string {
return fmt.Sprintf(":%d", port)
}
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler) {
// 统一代理入口: /{protocol}/{path}
r.Any("/:protocol/*path", proxyHandler.HandleProxy)
// 供应商管理 API
providers := r.Group("/api/providers")
{
providers.GET("", providerHandler.ListProviders)
@@ -234,7 +143,6 @@ func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHand
providers.DELETE("/:id", providerHandler.DeleteProvider)
}
// 模型管理 API
models := r.Group("/api/models")
{
models.GET("", modelHandler.ListModels)
@@ -244,14 +152,12 @@ func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHand
models.DELETE("/:id", modelHandler.DeleteModel)
}
// 统计查询 API
stats := r.Group("/api/stats")
{
stats.GET("", statsHandler.GetStats)
stats.GET("/aggregate", statsHandler.AggregateStats)
}
// 健康检查
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})

View File

@@ -24,6 +24,7 @@ require (
go.uber.org/zap v1.27.1
gopkg.in/lumberjack.v2 v2.0.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
nex/embedfs v0.0.0-00010101000000-000000000000
@@ -32,6 +33,7 @@ require (
require (
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/4meepo/tagalign v1.4.2 // indirect
github.com/Abirdcfly/dupword v0.1.3 // indirect
github.com/Antonboom/errname v1.0.0 // indirect
@@ -90,6 +92,7 @@ require (
github.com/go-critic/go-critic v0.12.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
github.com/go-toolsmith/astcopy v1.1.0 // indirect

View File

@@ -35,6 +35,8 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E=
github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI=
github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE=
@@ -206,6 +208,8 @@ github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
@@ -1052,6 +1056,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=

View File

@@ -11,6 +11,7 @@ import (
"github.com/mitchellh/mapstructure"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
appErrors "nex/backend/pkg/errors"
@@ -32,7 +33,13 @@ type ServerConfig struct {
// DatabaseConfig 数据库配置
type DatabaseConfig struct {
Path string `yaml:"path" mapstructure:"path" validate:"required"`
Driver string `yaml:"driver" mapstructure:"driver" validate:"required,oneof=sqlite mysql"`
Path string `yaml:"path" mapstructure:"path" validate:"required_if=Driver sqlite"`
Host string `yaml:"host" mapstructure:"host" validate:"required_if=Driver mysql"`
Port int `yaml:"port" mapstructure:"port" validate:"required_if=Driver mysql,min=1,max=65535"`
User string `yaml:"user" mapstructure:"user" validate:"required_if=Driver mysql"`
Password string `yaml:"password" mapstructure:"password"`
DBName string `yaml:"dbname" mapstructure:"dbname" validate:"required_if=Driver mysql"`
MaxIdleConns int `yaml:"max_idle_conns" mapstructure:"max_idle_conns" validate:"required,min=1"`
MaxOpenConns int `yaml:"max_open_conns" mapstructure:"max_open_conns" validate:"required,min=1"`
ConnMaxLifetime time.Duration `yaml:"conn_max_lifetime" mapstructure:"conn_max_lifetime" validate:"required"`
@@ -61,7 +68,13 @@ func DefaultConfig() *Config {
WriteTimeout: 30 * time.Second,
},
Database: DatabaseConfig{
Driver: "sqlite",
Path: filepath.Join(nexDir, "config.db"),
Host: "",
Port: 3306,
User: "",
Password: "",
DBName: "nex",
MaxIdleConns: 10,
MaxOpenConns: 100,
ConnMaxLifetime: 1 * time.Hour,
@@ -117,7 +130,13 @@ func setupDefaults(v *viper.Viper) {
v.SetDefault("server.read_timeout", "30s")
v.SetDefault("server.write_timeout", "30s")
v.SetDefault("database.driver", "sqlite")
v.SetDefault("database.path", filepath.Join(nexDir, "config.db"))
v.SetDefault("database.host", "")
v.SetDefault("database.port", 3306)
v.SetDefault("database.user", "")
v.SetDefault("database.password", "")
v.SetDefault("database.dbname", "nex")
v.SetDefault("database.max_idle_conns", 10)
v.SetDefault("database.max_open_conns", 100)
v.SetDefault("database.conn_max_lifetime", "1h")
@@ -138,7 +157,13 @@ func setupFlags(v *viper.Viper, flagSet *pflag.FlagSet) {
flagSet.Duration("server-read-timeout", 0, "读超时")
flagSet.Duration("server-write-timeout", 0, "写超时")
flagSet.String("database-driver", "", "数据库驱动sqlite/mysql")
flagSet.String("database-path", "", "数据库文件路径")
flagSet.String("database-host", "", "MySQL 主机地址")
flagSet.Int("database-port", 0, "MySQL 端口")
flagSet.String("database-user", "", "MySQL 用户名")
flagSet.String("database-password", "", "MySQL 密码")
flagSet.String("database-dbname", "", "MySQL 数据库名")
flagSet.Int("database-max-idle-conns", 0, "最大空闲连接数")
flagSet.Int("database-max-open-conns", 0, "最大打开连接数")
flagSet.Duration("database-conn-max-lifetime", 0, "连接最大生命周期")
@@ -156,7 +181,13 @@ func setupFlags(v *viper.Viper, flagSet *pflag.FlagSet) {
v.BindPFlag("server.read_timeout", flagSet.Lookup("server-read-timeout"))
v.BindPFlag("server.write_timeout", flagSet.Lookup("server-write-timeout"))
v.BindPFlag("database.driver", flagSet.Lookup("database-driver"))
v.BindPFlag("database.path", flagSet.Lookup("database-path"))
v.BindPFlag("database.host", flagSet.Lookup("database-host"))
v.BindPFlag("database.port", flagSet.Lookup("database-port"))
v.BindPFlag("database.user", flagSet.Lookup("database-user"))
v.BindPFlag("database.password", flagSet.Lookup("database-password"))
v.BindPFlag("database.dbname", flagSet.Lookup("database-dbname"))
v.BindPFlag("database.max_idle_conns", flagSet.Lookup("database-max-idle-conns"))
v.BindPFlag("database.max_open_conns", flagSet.Lookup("database-max-open-conns"))
v.BindPFlag("database.conn_max_lifetime", flagSet.Lookup("database-conn-max-lifetime"))
@@ -268,7 +299,7 @@ func SaveConfig(cfg *Config) error {
return appErrors.Wrap(appErrors.ErrInternal, err)
}
return os.WriteFile(configPath, data, 0644)
return os.WriteFile(configPath, data, 0600)
}
// Validate validates the config
@@ -281,16 +312,24 @@ func (c *Config) Validate() error {
}
// PrintSummary 打印配置摘要
func (c *Config) PrintSummary() {
fmt.Println("\nAI Gateway 启动配置")
fmt.Println("==================")
fmt.Printf("服务器端口: %d\n", c.Server.Port)
fmt.Printf("数据库路径: %s\n", c.Database.Path)
fmt.Printf("日志级别: %s\n", c.Log.Level)
fmt.Println("\n配置来源:")
configPath, _ := GetConfigPath()
fmt.Printf(" 配置文件: %s\n", configPath)
fmt.Println(" 环境变量: 待统计")
fmt.Println(" CLI 参数: 待统计")
fmt.Println()
func (c *Config) PrintSummary(logger *zap.Logger) {
logger.Info("AI Gateway 启动配置",
zap.Int("server_port", c.Server.Port),
zap.String("database_driver", c.Database.Driver),
zap.String("log_level", c.Log.Level),
)
if c.Database.Driver == "mysql" {
logger.Info("数据库配置",
zap.String("driver", "mysql"),
zap.String("host", c.Database.Host),
zap.Int("port", c.Database.Port),
zap.String("database", c.Database.DBName),
)
} else {
logger.Info("数据库配置",
zap.String("driver", "sqlite"),
zap.String("path", c.Database.Path),
)
}
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
@@ -19,6 +20,12 @@ func TestDefaultConfig(t *testing.T) {
assert.Equal(t, 30*time.Second, cfg.Server.ReadTimeout)
assert.Equal(t, 30*time.Second, cfg.Server.WriteTimeout)
assert.Equal(t, "sqlite", cfg.Database.Driver)
assert.Equal(t, "", cfg.Database.Host)
assert.Equal(t, 3306, cfg.Database.Port)
assert.Equal(t, "", cfg.Database.User)
assert.Equal(t, "", cfg.Database.Password)
assert.Equal(t, "nex", cfg.Database.DBName)
assert.Equal(t, 10, cfg.Database.MaxIdleConns)
assert.Equal(t, 100, cfg.Database.MaxOpenConns)
assert.Equal(t, 1*time.Hour, cfg.Database.ConnMaxLifetime)
@@ -86,11 +93,76 @@ func TestConfig_Validate(t *testing.T) {
wantErr: false,
},
{
name: "数据库路径为空无效",
name: "SQLite模式路径为空无效",
modify: func(c *Config) { c.Database.Path = "" },
wantErr: true,
errMsg: "配置验证失败",
},
{
name: "driver值不合法",
modify: func(c *Config) { c.Database.Driver = "postgres" },
wantErr: true,
errMsg: "配置验证失败",
},
{
name: "MySQL配置有效",
modify: func(c *Config) {
c.Database.Driver = "mysql"
c.Database.Host = "localhost"
c.Database.Port = 3306
c.Database.User = "root"
c.Database.DBName = "nex"
c.Database.Path = ""
},
wantErr: false,
},
{
name: "MySQL模式host为空无效",
modify: func(c *Config) {
c.Database.Driver = "mysql"
c.Database.Host = ""
c.Database.User = "root"
c.Database.DBName = "nex"
c.Database.Path = ""
},
wantErr: true,
errMsg: "配置验证失败",
},
{
name: "MySQL模式user为空无效",
modify: func(c *Config) {
c.Database.Driver = "mysql"
c.Database.Host = "localhost"
c.Database.User = ""
c.Database.DBName = "nex"
c.Database.Path = ""
},
wantErr: true,
errMsg: "配置验证失败",
},
{
name: "MySQL模式dbname为空无效",
modify: func(c *Config) {
c.Database.Driver = "mysql"
c.Database.Host = "localhost"
c.Database.User = "root"
c.Database.DBName = ""
c.Database.Path = ""
},
wantErr: true,
errMsg: "配置验证失败",
},
{
name: "MySQL模式忽略path字段",
modify: func(c *Config) {
c.Database.Driver = "mysql"
c.Database.Host = "localhost"
c.Database.User = "root"
c.Database.DBName = "nex"
c.Database.Path = ""
},
wantErr: false,
},
}
for _, tt := range tests {
@@ -100,7 +172,9 @@ func TestConfig_Validate(t *testing.T) {
err := cfg.Validate()
if tt.wantErr {
assert.Error(t, err)
if err != nil {
assert.Contains(t, err.Error(), tt.errMsg)
}
} else {
assert.NoError(t, err)
}
@@ -140,7 +214,10 @@ func TestSaveAndLoadConfig(t *testing.T) {
WriteTimeout: 20 * time.Second,
},
Database: DatabaseConfig{
Driver: "sqlite",
Path: filepath.Join(dir, "test.db"),
Port: 3306,
DBName: "nex",
MaxIdleConns: 5,
MaxOpenConns: 50,
ConnMaxLifetime: 30 * time.Minute,
@@ -210,6 +287,9 @@ func TestConfigPriority(t *testing.T) {
assert.Equal(t, 9826, cfg.Server.Port)
assert.Equal(t, 30*time.Second, cfg.Server.ReadTimeout)
assert.Equal(t, 30*time.Second, cfg.Server.WriteTimeout)
assert.Equal(t, "sqlite", cfg.Database.Driver)
assert.Equal(t, 3306, cfg.Database.Port)
assert.Equal(t, "nex", cfg.Database.DBName)
assert.Equal(t, 10, cfg.Database.MaxIdleConns)
assert.Equal(t, 100, cfg.Database.MaxOpenConns)
assert.Equal(t, 1*time.Hour, cfg.Database.ConnMaxLifetime)
@@ -222,13 +302,21 @@ func TestConfigPriority(t *testing.T) {
}
func TestPrintSummary(t *testing.T) {
// 测试配置摘要输出
t.Run("打印配置摘要", func(t *testing.T) {
t.Run("SQLite模式摘要", func(t *testing.T) {
cfg := DefaultConfig()
// PrintSummary 只是打印,不会返回错误
// 这里主要验证不会 panic
assert.NotPanics(t, func() {
cfg.PrintSummary()
cfg.PrintSummary(zap.NewNop())
})
})
t.Run("MySQL模式摘要", func(t *testing.T) {
cfg := DefaultConfig()
cfg.Database.Driver = "mysql"
cfg.Database.Host = "db.example.com"
cfg.Database.Port = 3306
cfg.Database.User = "nex"
cfg.Database.DBName = "nex"
assert.NotPanics(t, func() {
cfg.PrintSummary(zap.NewNop())
})
})
}

View File

@@ -29,8 +29,8 @@ type Model struct {
// UsageStats 用量统计
type UsageStats struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
ProviderID string `gorm:"not null;index" json:"provider_id"`
ModelName string `gorm:"not null;index" json:"model_name"`
ProviderID string `gorm:"not null;index;uniqueIndex:idx_provider_model_date" json:"provider_id"`
ModelName string `gorm:"not null;index;uniqueIndex:idx_provider_model_date" json:"model_name"`
RequestCount int `gorm:"default:0" json:"request_count"`
Date time.Time `gorm:"type:date;not null;uniqueIndex:idx_provider_model_date" json:"date"`
}

View File

@@ -7,6 +7,8 @@ import (
"github.com/google/uuid"
"go.uber.org/zap"
pkglogger "nex/backend/pkg/logger"
)
// HTTPRequestSpec HTTP 请求规格
@@ -33,13 +35,10 @@ type ConversionEngine struct {
// NewConversionEngine 创建转换引擎
func NewConversionEngine(registry AdapterRegistry, logger *zap.Logger) *ConversionEngine {
if logger == nil {
logger = zap.L()
}
return &ConversionEngine{
registry: registry,
middlewareChain: NewMiddlewareChain(),
logger: logger,
logger: pkglogger.WithModule(logger, "conversion.engine"),
}
}
@@ -90,7 +89,7 @@ func (e *ConversionEngine) ConvertHttpRequest(spec HTTPRequestSpec, clientProtoc
rewrittenBody, err = providerAdapter.RewriteRequestModelName(spec.Body, provider.ModelName, interfaceType)
if err != nil {
e.logger.Warn("Smart Passthrough 改写请求失败,使用原始请求体",
zap.String("error", err.Error()),
zap.Error(err),
zap.String("interface", string(interfaceType)))
rewrittenBody = spec.Body
}
@@ -143,7 +142,7 @@ func (e *ConversionEngine) ConvertHttpResponse(spec HTTPResponseSpec, clientProt
rewrittenBody, err := adapter.RewriteResponseModelName(spec.Body, modelOverride, interfaceType)
if err != nil {
e.logger.Warn("Smart Passthrough 改写响应失败,使用原始响应体",
zap.String("error", err.Error()),
zap.Error(err),
zap.String("interface", string(interfaceType)))
return &spec, nil
}
@@ -312,7 +311,7 @@ func (e *ConversionEngine) convertModelsResponseBody(clientAdapter, providerAdap
}
encoded, err := clientAdapter.EncodeModelsResponse(models)
if err != nil {
e.logger.Warn("编码 Models 响应失败,返回原始响应", zap.String("error", err.Error()))
e.logger.Warn("编码 Models 响应失败,返回原始响应", zap.Error(err))
return body, nil
}
return encoded, nil

View File

@@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestConversionError_WithProviderProtocol(t *testing.T) {
@@ -39,7 +40,7 @@ func TestConversionError_FullBuilder(t *testing.T) {
func TestEngine_Use(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
called := false
engine.Use(&testMiddleware{fn: func(req *canonical.CanonicalRequest, cp, pp string, ctx *ConversionContext) (*canonical.CanonicalRequest, error) {
called = true
@@ -66,7 +67,7 @@ func TestEngine_Use(t *testing.T) {
func TestConvertHttpRequest_DecodeError(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
clientAdapter := newMockAdapter("client", false)
clientAdapter.decodeReqFn = func(raw []byte) (*canonical.CanonicalRequest, error) {
return nil, errors.New("decode failed")
@@ -82,7 +83,7 @@ func TestConvertHttpRequest_DecodeError(t *testing.T) {
func TestConvertHttpRequest_EncodeError(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
_ = engine.RegisterAdapter(newMockAdapter("client", false))
providerAdapter := newMockAdapter("provider", false)
providerAdapter.encodeReqFn = func(req *canonical.CanonicalRequest, p *TargetProvider) ([]byte, error) {
@@ -98,7 +99,7 @@ func TestConvertHttpRequest_EncodeError(t *testing.T) {
func TestConvertHttpResponse_CrossProtocol(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
clientAdapter := newMockAdapter("client", false)
clientAdapter.encodeRespFn = func(resp *canonical.CanonicalResponse) ([]byte, error) {
@@ -121,7 +122,7 @@ func TestConvertHttpResponse_CrossProtocol(t *testing.T) {
func TestConvertHttpResponse_DecodeError(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
providerAdapter := newMockAdapter("provider", false)
providerAdapter.decodeRespFn = func(raw []byte) (*canonical.CanonicalResponse, error) {
return nil, errors.New("decode error")
@@ -135,7 +136,7 @@ func TestConvertHttpResponse_DecodeError(t *testing.T) {
func TestConvertHttpRequest_EmbeddingInterface(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
clientAdapter := newMockAdapter("client", false)
clientAdapter.ifaceType = InterfaceTypeEmbeddings
@@ -158,7 +159,7 @@ func TestConvertHttpRequest_EmbeddingInterface(t *testing.T) {
func TestConvertHttpRequest_RerankInterface(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
clientAdapter := newMockAdapter("client", false)
clientAdapter.ifaceType = InterfaceTypeRerank
@@ -178,7 +179,7 @@ func TestConvertHttpRequest_RerankInterface(t *testing.T) {
func TestConvertHttpResponse_EmbeddingInterface(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
clientAdapter := newMockAdapter("client", false)
clientAdapter.supportsIface = map[InterfaceType]bool{InterfaceTypeEmbeddings: true}
@@ -196,7 +197,7 @@ func TestConvertHttpResponse_EmbeddingInterface(t *testing.T) {
func TestConvertHttpResponse_RerankInterface(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
clientAdapter := newMockAdapter("client", false)
clientAdapter.supportsIface = map[InterfaceType]bool{InterfaceTypeRerank: true}
@@ -214,7 +215,7 @@ func TestConvertHttpResponse_RerankInterface(t *testing.T) {
func TestConvertHttpRequest_ModelsInterface_Passthrough(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
clientAdapter := newMockAdapter("client", false)
clientAdapter.ifaceType = InterfaceTypeModels
providerAdapter := newMockAdapter("provider", false)
@@ -232,7 +233,7 @@ func TestConvertHttpRequest_ModelsInterface_Passthrough(t *testing.T) {
func TestConvertHttpResponse_ModelsInterface(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
clientAdapter := newMockAdapter("client", false)
clientAdapter.supportsIface = map[InterfaceType]bool{InterfaceTypeModels: true}
providerAdapter := newMockAdapter("provider", false)
@@ -249,7 +250,7 @@ func TestConvertHttpResponse_ModelsInterface(t *testing.T) {
func TestConvertHttpResponse_ModelInfoInterface(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
clientAdapter := newMockAdapter("client", false)
clientAdapter.supportsIface = map[InterfaceType]bool{InterfaceTypeModelInfo: true}
providerAdapter := newMockAdapter("provider", false)
@@ -324,7 +325,7 @@ var _ = json.Marshal
func TestConvertEmbeddingBody_DecodeError(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
clientAdapter := newMockAdapter("client", false)
clientAdapter.decodeEmbeddingReqFn = func(raw []byte) (*canonical.CanonicalEmbeddingRequest, error) {
@@ -344,7 +345,7 @@ func TestConvertEmbeddingBody_DecodeError(t *testing.T) {
func TestConvertRerankBody_DecodeError(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
clientAdapter := newMockAdapter("client", false)
clientAdapter.decodeRerankReqFn = func(raw []byte) (*canonical.CanonicalRerankRequest, error) {
@@ -364,7 +365,7 @@ func TestConvertRerankBody_DecodeError(t *testing.T) {
func TestConvertBody_UnknownInterfaceType(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
clientAdapter := newMockAdapter("client", false)
providerAdapter := newMockAdapter("provider", false)

View File

@@ -203,7 +203,7 @@ func (e *noopStreamEncoder) Flush() [][]byte
func TestNewConversionEngine(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
assert.NotNil(t, engine)
assert.Equal(t, registry, engine.GetRegistry())
}
@@ -211,7 +211,7 @@ func TestNewConversionEngine(t *testing.T) {
func TestNewConversionEngine_LoggerInjection(t *testing.T) {
t.Run("nil_logger_uses_global", func(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
assert.NotNil(t, engine.logger)
})
@@ -219,13 +219,14 @@ func TestNewConversionEngine_LoggerInjection(t *testing.T) {
registry := NewMemoryRegistry()
customLogger := zap.NewNop()
engine := NewConversionEngine(registry, customLogger)
assert.Equal(t, customLogger, engine.logger)
assert.NotNil(t, engine.logger)
assert.Contains(t, engine.logger.Name(), "conversion.engine")
})
}
func TestRegisterAdapter(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
adapter := newMockAdapter("test-proto", true)
err := engine.RegisterAdapter(adapter)
@@ -237,7 +238,7 @@ func TestRegisterAdapter(t *testing.T) {
func TestIsPassthrough_SameProtocol(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
adapter := newMockAdapter("openai", true)
_ = engine.RegisterAdapter(adapter)
@@ -246,7 +247,7 @@ func TestIsPassthrough_SameProtocol(t *testing.T) {
func TestIsPassthrough_DifferentProtocol(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
_ = engine.RegisterAdapter(newMockAdapter("openai", true))
_ = engine.RegisterAdapter(newMockAdapter("anthropic", true))
@@ -255,7 +256,7 @@ func TestIsPassthrough_DifferentProtocol(t *testing.T) {
func TestIsPassthrough_NoPassthrough(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
_ = engine.RegisterAdapter(newMockAdapter("custom", false))
assert.False(t, engine.IsPassthrough("custom", "custom"))
@@ -263,7 +264,7 @@ func TestIsPassthrough_NoPassthrough(t *testing.T) {
func TestDetectInterfaceType(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
adapter := newMockAdapter("test", true)
adapter.ifaceType = InterfaceTypeChat
_ = engine.RegisterAdapter(adapter)
@@ -275,7 +276,7 @@ func TestDetectInterfaceType(t *testing.T) {
func TestDetectInterfaceType_NonExistentProtocol(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
_, err := engine.DetectInterfaceType("/v1/chat", "nonexistent")
assert.Error(t, err)
@@ -283,7 +284,7 @@ func TestDetectInterfaceType_NonExistentProtocol(t *testing.T) {
func TestConvertHttpRequest_Passthrough(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
_ = engine.RegisterAdapter(newMockAdapter("openai", true))
provider := NewTargetProvider("https://api.openai.com/v1", "sk-test", "gpt-4")
@@ -301,7 +302,7 @@ func TestConvertHttpRequest_Passthrough(t *testing.T) {
func TestConvertHttpRequest_CrossProtocol(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
clientAdapter := newMockAdapter("client-proto", false)
clientAdapter.decodeReqFn = func(raw []byte) (*canonical.CanonicalRequest, error) {
@@ -333,7 +334,7 @@ func TestConvertHttpRequest_CrossProtocol(t *testing.T) {
func TestConvertHttpResponse_Passthrough(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
_ = engine.RegisterAdapter(newMockAdapter("openai", true))
spec := HTTPResponseSpec{
@@ -349,7 +350,7 @@ func TestConvertHttpResponse_Passthrough(t *testing.T) {
func TestCreateStreamConverter_Passthrough(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
_ = engine.RegisterAdapter(newMockAdapter("openai", true))
converter, err := engine.CreateStreamConverter("openai", "openai", "", InterfaceTypeChat)
@@ -360,7 +361,7 @@ func TestCreateStreamConverter_Passthrough(t *testing.T) {
func TestCreateStreamConverter_Canonical(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
_ = engine.RegisterAdapter(newMockAdapter("client", false))
_ = engine.RegisterAdapter(newMockAdapter("provider", false))
@@ -372,7 +373,7 @@ func TestCreateStreamConverter_Canonical(t *testing.T) {
func TestEncodeError(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
_ = engine.RegisterAdapter(newMockAdapter("openai", true))
convErr := NewConversionError(ErrorCodeInvalidInput, "测试错误")
@@ -384,7 +385,7 @@ func TestEncodeError(t *testing.T) {
func TestEncodeError_NonExistentProtocol(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
convErr := NewConversionError(ErrorCodeInvalidInput, "测试错误")
body, statusCode, err := engine.EncodeError(convErr, "nonexistent")
@@ -417,7 +418,7 @@ func TestRegistry_GetNonExistent(t *testing.T) {
func TestConvertHttpResponse_ModelOverride_CrossProtocol(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
clientAdapter := newMockAdapter("client", false)
clientAdapter.encodeRespFn = func(resp *canonical.CanonicalResponse) ([]byte, error) {
@@ -446,7 +447,7 @@ func TestConvertHttpResponse_ModelOverride_CrossProtocol(t *testing.T) {
func TestConvertHttpResponse_ModelOverride_SameProtocol(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
// 使用真实 OpenAI adapter 验证 Smart Passthrough 改写
openaiAdapter := newMockAdapter("openai", true)
@@ -476,7 +477,7 @@ func TestConvertHttpResponse_ModelOverride_SameProtocol(t *testing.T) {
func TestCreateStreamConverter_ModelOverride_SmartPassthrough(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
openaiAdapter := newMockAdapter("openai", true)
openaiAdapter.rewriteRespFn = func(body []byte, newModel string, ifaceType InterfaceType) ([]byte, error) {
@@ -506,7 +507,7 @@ func TestCreateStreamConverter_ModelOverride_SmartPassthrough(t *testing.T) {
func TestCreateStreamConverter_ModelOverride_CrossProtocol(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
// provider adapter 解码出含 model 的流式事件
providerAdapter := newMockAdapter("provider", false)
@@ -560,7 +561,7 @@ func TestCreateStreamConverter_ModelOverride_CrossProtocol(t *testing.T) {
func TestCreateStreamConverter_ModelOverride_CrossProtocol_Empty(t *testing.T) {
registry := NewMemoryRegistry()
engine := NewConversionEngine(registry, nil)
engine := NewConversionEngine(registry, zap.NewNop())
providerAdapter := newMockAdapter("provider", false)
providerAdapter.streamDecoderFn = func() StreamDecoder {

View File

@@ -0,0 +1,149 @@
package database
import (
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/pressly/goose/v3"
"go.uber.org/zap"
"gorm.io/driver/mysql"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"nex/backend/internal/config"
pkglogger "nex/backend/pkg/logger"
)
func Init(cfg *config.DatabaseConfig, zapLogger *zap.Logger) (*gorm.DB, error) {
moduleLogger := pkglogger.WithModule(zapLogger, "database")
db, err := initDB(cfg, moduleLogger)
if err != nil {
return nil, fmt.Errorf("初始化数据库失败: %w", err)
}
if err := runMigrations(db, cfg.Driver, moduleLogger); err != nil {
return nil, fmt.Errorf("数据库迁移失败: %w", err)
}
configurePool(db, cfg, moduleLogger)
return db, nil
}
func Close(db *gorm.DB) {
sqlDB, err := db.DB()
if err != nil {
return
}
sqlDB.Close()
}
func initDB(cfg *config.DatabaseConfig, zapLogger *zap.Logger) (*gorm.DB, error) {
gormLogger := pkglogger.NewGormLogger(zapLogger)
gormConfig := &gorm.Config{
Logger: gormLogger,
}
switch cfg.Driver {
case "mysql":
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local",
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName)
if zapLogger != nil {
zapLogger.Info("连接 MySQL 数据库",
zap.String("host", cfg.Host),
zap.Int("port", cfg.Port),
zap.String("database", cfg.DBName))
}
return gorm.Open(mysql.Open(dsn), gormConfig)
default:
dbDir := filepath.Dir(cfg.Path)
if err := os.MkdirAll(dbDir, 0755); err != nil {
return nil, fmt.Errorf("创建数据库目录失败: %w", err)
}
if zapLogger != nil {
zapLogger.Info("连接 SQLite 数据库", zap.String("path", cfg.Path))
}
return gorm.Open(sqlite.Open(cfg.Path), gormConfig)
}
}
func runMigrations(db *gorm.DB, driver string, zapLogger *zap.Logger) error {
sqlDB, err := db.DB()
if err != nil {
return err
}
gooseDialect := "sqlite3"
migrationsSubDir := "sqlite"
if driver == "mysql" {
gooseDialect = "mysql"
migrationsSubDir = "mysql"
}
migrationsDir := getMigrationsDir(driver)
if _, err := os.Stat(migrationsDir); os.IsNotExist(err) {
return fmt.Errorf("迁移目录不存在: %s", migrationsDir)
}
if zapLogger != nil {
zapLogger.Info("执行数据库迁移",
zap.String("dialect", gooseDialect),
zap.String("dir", migrationsSubDir))
}
goose.SetDialect(gooseDialect)
if err := goose.Up(sqlDB, migrationsDir); err != nil {
return err
}
return nil
}
func configurePool(db *gorm.DB, cfg *config.DatabaseConfig, zapLogger *zap.Logger) {
if cfg.Driver == "sqlite" {
if err := db.Exec("PRAGMA journal_mode=WAL").Error; err != nil {
if zapLogger != nil {
zapLogger.Warn("启用 WAL 模式失败", zap.Error(err))
}
}
}
sqlDB, err := db.DB()
if err != nil {
return
}
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
sqlDB.SetConnMaxLifetime(cfg.ConnMaxLifetime)
if zapLogger != nil {
zapLogger.Info("数据库连接池配置",
zap.Int("max_idle_conns", cfg.MaxIdleConns),
zap.Int("max_open_conns", cfg.MaxOpenConns),
zap.Duration("conn_max_lifetime", cfg.ConnMaxLifetime))
}
}
func getMigrationsDir(driver string) string {
_, filename, _, ok := runtime.Caller(0)
if ok {
subDir := "sqlite"
if driver == "mysql" {
subDir = "mysql"
}
dir := filepath.Join(filepath.Dir(filename), "..", "..", "migrations", subDir)
if abs, err := filepath.Abs(dir); err == nil {
return abs
}
}
return "./migrations"
}
func BuildDSN(cfg *config.DatabaseConfig) string {
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local",
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName)
}

View File

@@ -0,0 +1,78 @@
package database
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"nex/backend/internal/config"
)
func TestInit_SQLite(t *testing.T) {
dir := t.TempDir()
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 TestClose(t *testing.T) {
dir := t.TempDir()
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)
Close(db)
}
func TestBuildDSN(t *testing.T) {
cfg := &config.DatabaseConfig{
Driver: "mysql",
Host: "db.example.com",
Port: 3306,
User: "nexuser",
Password: "secretpass",
DBName: "nexdb",
}
dsn := BuildDSN(cfg)
assert.Equal(t, "nexuser:secretpass@tcp(db.example.com:3306)/nexdb?charset=utf8mb4&parseTime=true&loc=Local", dsn)
}
func TestBuildDSN_EmptyPassword(t *testing.T) {
cfg := &config.DatabaseConfig{
Driver: "mysql",
Host: "localhost",
Port: 3306,
User: "root",
DBName: "nex",
}
dsn := BuildDSN(cfg)
assert.Equal(t, "root:@tcp(localhost:3306)/nex?charset=utf8mb4&parseTime=true&loc=Local", dsn)
}

View File

@@ -5,9 +5,10 @@ import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
pkglogger "nex/backend/pkg/logger"
)
// Logging 日志中间件
func Logging(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
@@ -15,12 +16,17 @@ func Logging(logger *zap.Logger) gin.HandlerFunc {
query := c.Request.URL.RawQuery
requestID, _ := c.Get(RequestIDKey)
var requestIDStr string
if id, ok := requestID.(string); ok {
requestIDStr = id
}
logger.Info("请求开始",
zap.String("method", c.Request.Method),
zap.String("path", path),
zap.String("query", query),
zap.String("client_ip", c.ClientIP()),
zap.Any("request_id", requestID),
pkglogger.Method(c.Request.Method),
pkglogger.Path(path),
pkglogger.Query(query),
pkglogger.ClientIP(c.ClientIP()),
pkglogger.RequestID(requestIDStr),
)
c.Next()
@@ -29,12 +35,12 @@ func Logging(logger *zap.Logger) gin.HandlerFunc {
statusCode := c.Writer.Status()
logger.Info("请求结束",
zap.Int("status", statusCode),
zap.String("method", c.Request.Method),
zap.String("path", path),
zap.Duration("latency", latency),
zap.Int("body_size", c.Writer.Size()),
zap.Any("request_id", requestID),
pkglogger.StatusCode(statusCode),
pkglogger.Method(c.Request.Method),
pkglogger.Path(path),
pkglogger.Latency(latency),
pkglogger.BodySize(c.Writer.Size()),
pkglogger.RequestID(requestIDStr),
)
}
}

View File

@@ -16,6 +16,7 @@ import (
"nex/backend/internal/provider"
"nex/backend/internal/service"
"nex/backend/pkg/modelid"
pkglogger "nex/backend/pkg/logger"
)
// ProxyHandler 统一代理处理器
@@ -29,14 +30,14 @@ type ProxyHandler struct {
}
// NewProxyHandler 创建统一代理处理器
func NewProxyHandler(engine *conversion.ConversionEngine, client provider.ProviderClient, routingService service.RoutingService, providerService service.ProviderService, statsService service.StatsService) *ProxyHandler {
func NewProxyHandler(engine *conversion.ConversionEngine, client provider.ProviderClient, routingService service.RoutingService, providerService service.ProviderService, statsService service.StatsService, logger *zap.Logger) *ProxyHandler {
return &ProxyHandler{
engine: engine,
client: client,
routingService: routingService,
providerService: providerService,
statsService: statsService,
logger: zap.L(),
logger: pkglogger.WithModule(logger, "handler.proxy"),
}
}
@@ -331,7 +332,7 @@ func (h *ProxyHandler) handleModelInfo(c *gin.Context, unifiedID string, adapter
// 使用 adapter 编码返回
body, err := adapter.EncodeModelInfoResponse(modelInfo)
if err != nil {
h.logger.Error("编码 ModelInfo 响应失败", zap.String("error", err.Error()))
h.logger.Error("编码 ModelInfo 响应失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "编码响应失败"})
return
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"go.uber.org/zap"
"nex/backend/internal/conversion"
"nex/backend/internal/conversion/anthropic"
@@ -31,7 +32,7 @@ func init() {
func setupProxyEngine(t *testing.T) *conversion.ConversionEngine {
t.Helper()
registry := conversion.NewMemoryRegistry()
engine := conversion.NewConversionEngine(registry, nil)
engine := conversion.NewConversionEngine(registry, zap.NewNop())
require.NoError(t, registry.Register(openai.NewAdapter()))
require.NoError(t, registry.Register(anthropic.NewAdapter()))
return engine
@@ -44,6 +45,7 @@ func newTestProxyHandler(engine *conversion.ConversionEngine, client *mocks.Mock
routingSvc,
providerSvc,
statsSvc,
zap.NewNop(),
)
}
@@ -499,7 +501,7 @@ func TestProxyHandler_HandleStream_CreateStreamConverterError(t *testing.T) {
defer ctrl.Finish()
registry := conversion.NewMemoryRegistry()
engine := conversion.NewConversionEngine(registry, nil)
engine := conversion.NewConversionEngine(registry, zap.NewNop())
err := registry.Register(openai.NewAdapter())
require.NoError(t, err)
@@ -527,7 +529,7 @@ func TestProxyHandler_HandleStream_ConvertRequestError(t *testing.T) {
defer ctrl.Finish()
registry := conversion.NewMemoryRegistry()
engine := conversion.NewConversionEngine(registry, nil)
engine := conversion.NewConversionEngine(registry, zap.NewNop())
require.NoError(t, registry.Register(openai.NewAdapter()))
routingSvc := mocks.NewMockRoutingService(ctrl)
@@ -554,7 +556,7 @@ func TestProxyHandler_HandleNonStream_ConvertResponseError(t *testing.T) {
defer ctrl.Finish()
registry := conversion.NewMemoryRegistry()
engine := conversion.NewConversionEngine(registry, nil)
engine := conversion.NewConversionEngine(registry, zap.NewNop())
require.NoError(t, registry.Register(openai.NewAdapter()))
require.NoError(t, registry.Register(anthropic.NewAdapter()))
@@ -623,7 +625,7 @@ func TestProxyHandler_ForwardPassthrough_CrossProtocol(t *testing.T) {
defer ctrl.Finish()
registry := conversion.NewMemoryRegistry()
engine := conversion.NewConversionEngine(registry, nil)
engine := conversion.NewConversionEngine(registry, zap.NewNop())
require.NoError(t, registry.Register(openai.NewAdapter()))
anthropicAdapter := anthropic.NewAdapter()

View File

@@ -15,6 +15,7 @@ import (
"nex/backend/internal/conversion"
pkgErrors "nex/backend/pkg/errors"
pkglogger "nex/backend/pkg/logger"
)
// StreamConfig 流式处理配置
@@ -57,12 +58,12 @@ type ProviderClient interface {
}
// NewClient 创建供应商客户端
func NewClient() *Client {
func NewClient(logger *zap.Logger) *Client {
return &Client{
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
logger: zap.L(),
logger: pkglogger.WithModule(logger, "provider.client"),
streamCfg: DefaultStreamConfig(),
}
}
@@ -186,7 +187,7 @@ func (c *Client) readStream(ctx context.Context, cancel context.CancelFunc, body
c.logger.Error("流网络错误", zap.String("error", err.Error()))
eventChan <- StreamEvent{Error: fmt.Errorf("网络错误: %w", err)}
} else {
c.logger.Error("流读取错误", zap.String("error", err.Error()))
c.logger.Error("流读取错误", zap.Error(err))
eventChan <- StreamEvent{Error: fmt.Errorf("读取错误: %w", err)}
}
return

View File

@@ -13,12 +13,13 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"nex/backend/internal/conversion"
)
func TestNewClient(t *testing.T) {
client := NewClient()
client := NewClient(zap.NewNop())
require.NotNil(t, client)
assert.NotNil(t, client.httpClient)
assert.Equal(t, 4096, client.streamCfg.InitialBufferSize)
@@ -44,7 +45,7 @@ func TestClient_Send_Success(t *testing.T) {
}))
defer server.Close()
client := NewClient()
client := NewClient(zap.NewNop())
spec := conversion.HTTPRequestSpec{
URL: server.URL + "/v1/chat/completions",
Method: "POST",
@@ -68,7 +69,7 @@ func TestClient_Send_ErrorResponse(t *testing.T) {
}))
defer server.Close()
client := NewClient()
client := NewClient(zap.NewNop())
spec := conversion.HTTPRequestSpec{
URL: server.URL + "/v1/chat/completions",
Method: "POST",
@@ -82,7 +83,7 @@ func TestClient_Send_ErrorResponse(t *testing.T) {
}
func TestClient_Send_ConnectionError(t *testing.T) {
client := NewClient()
client := NewClient(zap.NewNop())
spec := conversion.HTTPRequestSpec{
URL: "http://localhost:1/v1/chat/completions",
Method: "POST",
@@ -99,7 +100,7 @@ func TestClient_SendStream_CreatesChannel(t *testing.T) {
}))
defer server.Close()
client := NewClient()
client := NewClient(zap.NewNop())
spec := conversion.HTTPRequestSpec{
URL: server.URL + "/v1/chat/completions",
Method: "POST",
@@ -121,7 +122,7 @@ func TestClient_SendStream_ErrorResponse(t *testing.T) {
}))
defer server.Close()
client := NewClient()
client := NewClient(zap.NewNop())
spec := conversion.HTTPRequestSpec{
URL: server.URL + "/v1/chat/completions",
Method: "POST",
@@ -150,7 +151,7 @@ func TestClient_SendStream_SSEEvents(t *testing.T) {
}))
defer server.Close()
client := NewClient()
client := NewClient(zap.NewNop())
spec := conversion.HTTPRequestSpec{
URL: server.URL + "/v1/chat/completions",
Method: "POST",
@@ -188,7 +189,7 @@ func TestClient_SendStream_ContextCancellation(t *testing.T) {
defer server.Close()
ctx, cancel := context.WithCancel(context.Background())
client := NewClient()
client := NewClient(zap.NewNop())
spec := conversion.HTTPRequestSpec{
URL: server.URL + "/v1/chat/completions",
Method: "POST",
@@ -218,7 +219,7 @@ func TestClient_Send_EmptyBody(t *testing.T) {
}))
defer server.Close()
client := NewClient()
client := NewClient(zap.NewNop())
spec := conversion.HTTPRequestSpec{
URL: server.URL + "/v1/models",
Method: "GET",
@@ -246,7 +247,7 @@ func TestClient_SendStream_SlowSSE(t *testing.T) {
}))
defer server.Close()
client := NewClient()
client := NewClient(zap.NewNop())
spec := conversion.HTTPRequestSpec{
URL: server.URL + "/v1/chat/completions",
Method: "POST",
@@ -287,7 +288,7 @@ func TestClient_SendStream_SplitSSEEvents(t *testing.T) {
}))
defer server.Close()
client := NewClient()
client := NewClient(zap.NewNop())
spec := conversion.HTTPRequestSpec{
URL: server.URL + "/v1/chat/completions",
Method: "POST",
@@ -375,7 +376,7 @@ func TestClient_SendStream_MidStreamNetworkError(t *testing.T) {
}))
defer server.Close()
client := NewClient()
client := NewClient(zap.NewNop())
spec := conversion.HTTPRequestSpec{
URL: server.URL + "/v1/chat/completions",
Method: "POST",

View File

@@ -1,10 +1,10 @@
package repository
import (
"errors"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"nex/backend/internal/config"
"nex/backend/internal/domain"
@@ -22,47 +22,43 @@ func (r *statsRepository) Record(providerID, modelName string) error {
today := time.Now().Format("2006-01-02")
todayTime, _ := time.Parse("2006-01-02", today)
return r.db.Transaction(func(tx *gorm.DB) error {
var stats config.UsageStats
err := tx.Where("provider_id = ? AND model_name = ? AND date = ?",
providerID, modelName, todayTime).First(&stats).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
stats = config.UsageStats{
stats := config.UsageStats{
ProviderID: providerID,
ModelName: modelName,
RequestCount: 1,
Date: todayTime,
}
return tx.Create(&stats).Error
} else if err != nil {
return err
}
return tx.Model(&stats).Update("request_count", gorm.Expr("request_count + 1")).Error
})
return r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{
{Name: "provider_id"},
{Name: "model_name"},
{Name: "date"},
},
DoUpdates: clause.Assignments(map[string]interface{}{
"request_count": gorm.Expr("request_count + 1"),
}),
}).Create(&stats).Error
}
func (r *statsRepository) BatchUpdate(providerID, modelName string, date time.Time, delta int) error {
return r.db.Transaction(func(tx *gorm.DB) error {
var stats config.UsageStats
err := tx.Where("provider_id = ? AND model_name = ? AND date = ?",
providerID, modelName, date).First(&stats).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return tx.Create(&config.UsageStats{
stats := config.UsageStats{
ProviderID: providerID,
ModelName: modelName,
RequestCount: delta,
Date: date,
}).Error
} else if err != nil {
return err
}
return tx.Model(&stats).
Update("request_count", gorm.Expr("request_count + ?", delta)).Error
})
return r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{
{Name: "provider_id"},
{Name: "model_name"},
{Name: "date"},
},
DoUpdates: clause.Assignments(map[string]interface{}{
"request_count": gorm.Expr("request_count + ?", delta),
}),
}).Create(&stats).Error
}
func (r *statsRepository) Query(providerID, modelName string, startDate, endDate *time.Time) ([]domain.UsageStats, error) {

View File

@@ -8,6 +8,7 @@ import (
"nex/backend/internal/domain"
"nex/backend/internal/repository"
pkglogger "nex/backend/pkg/logger"
)
type RoutingCache struct {
@@ -27,7 +28,7 @@ func NewRoutingCache(
return &RoutingCache{
modelRepo: modelRepo,
providerRepo: providerRepo,
logger: logger,
logger: pkglogger.WithModule(logger, "service.routing_cache"),
}
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"nex/backend/internal/domain"
"nex/backend/internal/repository"
@@ -119,7 +120,7 @@ func TestModelService_Delete_NotFound(t *testing.T) {
func TestStatsService_Aggregate_Default(t *testing.T) {
statsRepo := repository.NewStatsRepository(nil)
buffer := NewStatsBuffer(statsRepo, nil)
buffer := NewStatsBuffer(statsRepo, zap.NewNop())
svc := NewStatsService(statsRepo, buffer)
stats := []domain.UsageStats{

View File

@@ -318,7 +318,7 @@ func TestStatsService_Aggregate_ByModel(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
db := setupServiceTestDB(t)
statsRepo := repository.NewStatsRepository(db)
buffer := NewStatsBuffer(statsRepo, nil); svc := NewStatsService(statsRepo, buffer)
buffer := NewStatsBuffer(statsRepo, zap.NewNop()); svc := NewStatsService(statsRepo, buffer)
result := svc.Aggregate(tt.stats, "model")
@@ -379,7 +379,7 @@ func TestStatsService_Aggregate_ByDate(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
db := setupServiceTestDB(t)
statsRepo := repository.NewStatsRepository(db)
buffer := NewStatsBuffer(statsRepo, nil); svc := NewStatsService(statsRepo, buffer)
buffer := NewStatsBuffer(statsRepo, zap.NewNop()); svc := NewStatsService(statsRepo, buffer)
result := svc.Aggregate(tt.stats, "date")

View File

@@ -9,6 +9,7 @@ import (
"go.uber.org/zap"
"nex/backend/internal/repository"
pkglogger "nex/backend/pkg/logger"
)
type StatsBuffer struct {
@@ -46,7 +47,7 @@ func NewStatsBuffer(
) *StatsBuffer {
b := &StatsBuffer{
statsRepo: statsRepo,
logger: logger,
logger: pkglogger.WithModule(logger, "service.stats_buffer"),
flushInterval: 5 * time.Second,
flushThreshold: 100,
stopCh: make(chan struct{}),

View File

@@ -0,0 +1,44 @@
-- +goose Up
-- MySQL 方言初始迁移providers、models、usage_stats 完整表结构
CREATE TABLE IF NOT EXISTS providers (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
api_key VARCHAR(255) NOT NULL,
base_url VARCHAR(255) NOT NULL,
protocol VARCHAR(50) DEFAULT 'openai',
enabled BOOLEAN DEFAULT TRUE,
created_at DATETIME(3),
updated_at DATETIME(3)
);
CREATE TABLE IF NOT EXISTS models (
id VARCHAR(36) PRIMARY KEY,
provider_id VARCHAR(36) NOT NULL,
model_name VARCHAR(255) NOT NULL,
enabled BOOLEAN DEFAULT TRUE,
created_at DATETIME(3),
FOREIGN KEY (provider_id) REFERENCES providers(id) ON DELETE CASCADE,
UNIQUE(provider_id, model_name)
);
CREATE TABLE IF NOT EXISTS usage_stats (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
provider_id VARCHAR(36) NOT NULL,
model_name VARCHAR(255) NOT NULL,
request_count INT DEFAULT 0,
date DATE NOT NULL,
UNIQUE(provider_id, model_name, date)
);
CREATE INDEX idx_models_provider_id ON models(provider_id);
CREATE INDEX idx_models_model_name ON models(model_name);
CREATE INDEX idx_usage_stats_provider_model_date ON usage_stats(provider_id, model_name, date);
-- +goose Down
DROP INDEX IF EXISTS idx_usage_stats_provider_model_date ON usage_stats;
DROP INDEX IF EXISTS idx_models_model_name ON models;
DROP INDEX IF EXISTS idx_models_provider_id ON models;
DROP TABLE IF EXISTS usage_stats;
DROP TABLE IF EXISTS models;
DROP TABLE IF EXISTS providers;

View File

@@ -1,13 +1,20 @@
package logger
import "go.uber.org/zap"
import (
"context"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type ctxKey struct{}
const requestIDKey = "request_id"
// WithRequestID 向 logger 添加 request_id 字段
func WithRequestID(logger *zap.Logger, requestID string) *zap.Logger {
return logger.With(zap.String("request_id", requestID))
return logger.With(zap.String(requestIDKey, requestID))
}
// WithContext 向 logger 添加多个自定义字段
func WithContext(logger *zap.Logger, fields map[string]interface{}) *zap.Logger {
zapFields := make([]zap.Field, 0, len(fields))
for k, v := range fields {
@@ -15,3 +22,37 @@ func WithContext(logger *zap.Logger, fields map[string]interface{}) *zap.Logger
}
return logger.With(zapFields...)
}
func RequestIDFromGinContext(c *gin.Context) zap.Field {
requestID, exists := c.Get("request_id")
if !exists {
return zap.Skip()
}
if id, ok := requestID.(string); ok {
return RequestID(id)
}
return zap.Skip()
}
func RequestIDFromContext(ctx context.Context) zap.Field {
requestID := ctx.Value(ctxKey{})
if requestID == nil {
return zap.Skip()
}
if id, ok := requestID.(string); ok {
return RequestID(id)
}
return zap.Skip()
}
func ContextWithRequestID(ctx context.Context, requestID string) context.Context {
return context.WithValue(ctx, ctxKey{}, requestID)
}
func LoggerFromContext(ctx context.Context, baseLogger *zap.Logger) *zap.Logger {
field := RequestIDFromContext(ctx)
if field == zap.Skip() {
return baseLogger
}
return baseLogger.With(field)
}

View File

@@ -0,0 +1,77 @@
package logger
import "go.uber.org/zap"
const (
FieldRequestID = "request_id"
FieldProviderID = "provider_id"
FieldModelName = "model_name"
FieldMethod = "method"
FieldPath = "path"
FieldStatusCode = "status"
FieldLatency = "latency"
FieldClientIP = "client_ip"
FieldQuery = "query"
FieldBodySize = "body_size"
FieldSQL = "sql"
FieldRows = "rows_affected"
)
func RequestID(id string) zap.Field {
return zap.String(FieldRequestID, id)
}
func ProviderID(id string) zap.Field {
return zap.String(FieldProviderID, id)
}
func ModelName(name string) zap.Field {
return zap.String(FieldModelName, name)
}
func Method(method string) zap.Field {
return zap.String(FieldMethod, method)
}
func Path(path string) zap.Field {
return zap.String(FieldPath, path)
}
func StatusCode(code int) zap.Field {
return zap.Int(FieldStatusCode, code)
}
func Latency(latency interface{}) zap.Field {
switch v := latency.(type) {
case int64:
return zap.Int64(FieldLatency, v)
case int:
return zap.Int(FieldLatency, v)
default:
return zap.Any(FieldLatency, latency)
}
}
func ClientIP(ip string) zap.Field {
return zap.String(FieldClientIP, ip)
}
func Query(query string) zap.Field {
return zap.String(FieldQuery, query)
}
func BodySize(size int) zap.Field {
return zap.Int(FieldBodySize, size)
}
func SQL(sql string) zap.Field {
return zap.String(FieldSQL, sql)
}
func Rows(rows int64) zap.Field {
return zap.Int64(FieldRows, rows)
}
func Err(err error) zap.Field {
return zap.Error(err)
}

View File

@@ -0,0 +1,99 @@
package logger
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestFieldConstants(t *testing.T) {
assert.Equal(t, "request_id", FieldRequestID)
assert.Equal(t, "provider_id", FieldProviderID)
assert.Equal(t, "model_name", FieldModelName)
assert.Equal(t, "method", FieldMethod)
assert.Equal(t, "path", FieldPath)
assert.Equal(t, "status", FieldStatusCode)
assert.Equal(t, "latency", FieldLatency)
assert.Equal(t, "client_ip", FieldClientIP)
assert.Equal(t, "query", FieldQuery)
assert.Equal(t, "body_size", FieldBodySize)
assert.Equal(t, "sql", FieldSQL)
assert.Equal(t, "rows_affected", FieldRows)
}
func TestFieldConstructors(t *testing.T) {
t.Run("RequestID", func(t *testing.T) {
field := RequestID("test-id")
assert.Equal(t, FieldRequestID, field.Key)
assert.Equal(t, "test-id", field.String)
})
t.Run("ProviderID", func(t *testing.T) {
field := ProviderID("provider-123")
assert.Equal(t, FieldProviderID, field.Key)
assert.Equal(t, "provider-123", field.String)
})
t.Run("ModelName", func(t *testing.T) {
field := ModelName("gpt-4")
assert.Equal(t, FieldModelName, field.Key)
assert.Equal(t, "gpt-4", field.String)
})
t.Run("Method", func(t *testing.T) {
field := Method("POST")
assert.Equal(t, FieldMethod, field.Key)
assert.Equal(t, "POST", field.String)
})
t.Run("Path", func(t *testing.T) {
field := Path("/v1/chat")
assert.Equal(t, FieldPath, field.Key)
assert.Equal(t, "/v1/chat", field.String)
})
t.Run("StatusCode", func(t *testing.T) {
field := StatusCode(200)
assert.Equal(t, FieldStatusCode, field.Key)
})
t.Run("Latency", func(t *testing.T) {
field := Latency(int64(100))
assert.Equal(t, FieldLatency, field.Key)
})
t.Run("ClientIP", func(t *testing.T) {
field := ClientIP("127.0.0.1")
assert.Equal(t, FieldClientIP, field.Key)
assert.Equal(t, "127.0.0.1", field.String)
})
t.Run("Query", func(t *testing.T) {
field := Query("key=value")
assert.Equal(t, FieldQuery, field.Key)
assert.Equal(t, "key=value", field.String)
})
t.Run("BodySize", func(t *testing.T) {
field := BodySize(1024)
assert.Equal(t, FieldBodySize, field.Key)
})
t.Run("SQL", func(t *testing.T) {
field := SQL("SELECT * FROM users")
assert.Equal(t, FieldSQL, field.Key)
assert.Equal(t, "SELECT * FROM users", field.String)
})
t.Run("Rows", func(t *testing.T) {
field := Rows(42)
assert.Equal(t, FieldRows, field.Key)
assert.Equal(t, int64(42), field.Integer)
})
t.Run("Err", func(t *testing.T) {
err := assert.AnError
field := Err(err)
assert.Equal(t, "error", field.Key)
})
}

130
backend/pkg/logger/gorm.go Normal file
View File

@@ -0,0 +1,130 @@
package logger
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
type GormLogger struct {
logger *zap.Logger
level zapcore.Level
}
func NewGormLogger(logger *zap.Logger) *GormLogger {
return &GormLogger{
logger: logger.Named("database"),
level: zapcore.DebugLevel,
}
}
func (l *GormLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface {
newLogger := &GormLogger{
logger: l.logger,
level: l.gormLevelToZap(level),
}
return newLogger
}
func (l *GormLogger) Info(ctx context.Context, msg string, data ...interface{}) {
if l.level > zapcore.DebugLevel {
return
}
l.log(ctx, zapcore.DebugLevel, msg, data...)
}
func (l *GormLogger) Warn(ctx context.Context, msg string, data ...interface{}) {
if l.level > zapcore.WarnLevel {
return
}
l.log(ctx, zapcore.WarnLevel, msg, data...)
}
func (l *GormLogger) Error(ctx context.Context, msg string, data ...interface{}) {
if l.level > zapcore.ErrorLevel {
return
}
l.log(ctx, zapcore.ErrorLevel, msg, data...)
}
func (l *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
if l.level > zapcore.DebugLevel {
return
}
elapsed := time.Since(begin)
sql, rows := fc()
fields := []zap.Field{
zap.String("sql", l.formatSQL(sql)),
zap.Int64("rows", rows),
zap.Duration("latency", elapsed),
}
if requestIDField := RequestIDFromContext(ctx); requestIDField != zap.Skip() {
fields = append([]zap.Field{requestIDField}, fields...)
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
fields = append(fields, zap.Error(err))
l.logger.Error("SQL执行错误", fields...)
return
}
l.logger.Debug("SQL查询", fields...)
}
func (l *GormLogger) log(ctx context.Context, level zapcore.Level, msg string, data ...interface{}) {
fields := make([]zap.Field, 0, len(data)/2+1)
if requestIDField := RequestIDFromContext(ctx); requestIDField != zap.Skip() {
fields = append(fields, requestIDField)
}
for i := 0; i < len(data); i += 2 {
if i+1 < len(data) {
key, ok := data[i].(string)
if !ok {
continue
}
fields = append(fields, zap.Any(key, data[i+1]))
}
}
switch level {
case zapcore.DebugLevel:
l.logger.Debug(fmt.Sprintf(msg, data...), fields...)
case zapcore.WarnLevel:
l.logger.Warn(fmt.Sprintf(msg, data...), fields...)
case zapcore.ErrorLevel:
l.logger.Error(fmt.Sprintf(msg, data...), fields...)
}
}
func (l *GormLogger) gormLevelToZap(level gormlogger.LogLevel) zapcore.Level {
switch level {
case gormlogger.Silent:
return zapcore.PanicLevel
case gormlogger.Error:
return zapcore.ErrorLevel
case gormlogger.Warn:
return zapcore.WarnLevel
case gormlogger.Info:
return zapcore.DebugLevel
default:
return zapcore.DebugLevel
}
}
func (l *GormLogger) formatSQL(sql string) string {
re := regexp.MustCompile(`\s+`)
return strings.TrimSpace(re.ReplaceAllString(sql, " "))
}

View File

@@ -0,0 +1,131 @@
package logger
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
func TestNewGormLogger(t *testing.T) {
logger := zap.NewNop()
gormLogger := NewGormLogger(logger)
assert.NotNil(t, gormLogger)
assert.NotNil(t, gormLogger.logger)
assert.Equal(t, zap.DebugLevel, gormLogger.level)
}
func TestGormLogger_LogMode(t *testing.T) {
logger := zap.NewNop()
gormLogger := NewGormLogger(logger)
tests := []struct {
name string
level gormlogger.LogLevel
expectedLevel zapcore.Level
}{
{"Silent", gormlogger.Silent, zapcore.PanicLevel},
{"Error", gormlogger.Error, zapcore.ErrorLevel},
{"Warn", gormlogger.Warn, zapcore.WarnLevel},
{"Info", gormlogger.Info, zapcore.DebugLevel},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
newLogger := gormLogger.LogMode(tt.level)
assert.NotNil(t, newLogger)
})
}
}
func TestGormLogger_Trace(t *testing.T) {
logger := zap.NewNop()
gormLogger := NewGormLogger(logger)
ctx := context.Background()
begin := time.Now()
fc := func() (string, int64) {
return "SELECT * FROM users", 10
}
gormLogger.Trace(ctx, begin, fc, nil)
}
func TestGormLogger_TraceWithError(t *testing.T) {
logger := zap.NewNop()
gormLogger := NewGormLogger(logger)
ctx := context.Background()
begin := time.Now()
fc := func() (string, int64) {
return "SELECT * FROM users", 0
}
gormLogger.Trace(ctx, begin, fc, gorm.ErrRecordNotFound)
}
func TestGormLogger_Info(t *testing.T) {
logger := zap.NewNop()
gormLogger := NewGormLogger(logger)
ctx := context.Background()
gormLogger.Info(ctx, "test info message")
}
func TestGormLogger_Warn(t *testing.T) {
logger := zap.NewNop()
gormLogger := NewGormLogger(logger)
ctx := context.Background()
gormLogger.Warn(ctx, "test warn message")
}
func TestGormLogger_Error(t *testing.T) {
logger := zap.NewNop()
gormLogger := NewGormLogger(logger)
ctx := context.Background()
gormLogger.Error(ctx, "test error message")
}
func TestGormLogger_FormatSQL(t *testing.T) {
logger := zap.NewNop()
gormLogger := NewGormLogger(logger)
tests := []struct {
name string
input string
expected string
}{
{
name: "simple query",
input: "SELECT * FROM users",
expected: "SELECT * FROM users",
},
{
name: "query with extra spaces",
input: "SELECT * FROM users",
expected: "SELECT * FROM users",
},
{
name: "query with newlines",
input: "SELECT\n*\nFROM\nusers",
expected: "SELECT * FROM users",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := gormLogger.formatSQL(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}

View File

@@ -50,6 +50,7 @@ func New(cfg Config) (*zap.Logger, error) {
EncodeTime: zapcore.ISO8601TimeEncoder,
EncodeDuration: zapcore.StringDurationEncoder,
EncodeCaller: zapcore.ShortCallerEncoder,
EncodeName: encodeLoggerName,
})
stdoutCore := zapcore.NewCore(
@@ -115,3 +116,10 @@ func logFileName() string {
func logFilePath(dir string) string {
return filepath.Join(dir, logFileName())
}
// encodeLoggerName 自定义 logger 名称编码器,输出 [name] 格式
func encodeLoggerName(name string, enc zapcore.PrimitiveArrayEncoder) {
if name != "" {
enc.AppendString("[" + name + "]")
}
}

View File

@@ -0,0 +1,35 @@
package logger
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func NewMinimal() *zap.Logger {
encoder := zapcore.NewConsoleEncoder(zapcore.EncoderConfig{
TimeKey: "ts",
LevelKey: "level",
NameKey: "logger",
CallerKey: "caller",
FunctionKey: zapcore.OmitKey,
MessageKey: "msg",
StacktraceKey: "stacktrace",
LineEnding: zapcore.DefaultLineEnding,
EncodeLevel: zapcore.CapitalColorLevelEncoder,
EncodeTime: zapcore.ISO8601TimeEncoder,
EncodeDuration: zapcore.StringDurationEncoder,
EncodeCaller: zapcore.ShortCallerEncoder,
})
core := zapcore.NewCore(
encoder,
zapcore.AddSync(stdoutWriter{}),
zapcore.DebugLevel,
)
return zap.New(core, zap.AddCaller(), zap.AddStacktrace(zap.ErrorLevel))
}
func Upgrade(minimalLogger *zap.Logger, cfg Config) (*zap.Logger, error) {
return New(cfg)
}

View File

@@ -0,0 +1,7 @@
package logger
import "go.uber.org/zap"
func WithModule(logger *zap.Logger, moduleName string) *zap.Logger {
return logger.Named(moduleName)
}

View File

@@ -0,0 +1,25 @@
package logger
import (
"testing"
"github.com/stretchr/testify/assert"
"go.uber.org/zap"
)
func TestWithModule(t *testing.T) {
baseLogger := zap.NewNop()
moduleLogger := WithModule(baseLogger, "handler.proxy")
assert.NotNil(t, moduleLogger)
}
func TestWithModuleMultiple(t *testing.T) {
baseLogger := zap.NewNop()
logger1 := WithModule(baseLogger, "handler.proxy")
logger2 := WithModule(baseLogger, "provider.client")
assert.NotNil(t, logger1)
assert.NotNil(t, logger2)
}

View File

@@ -158,7 +158,10 @@ func TestSaveAndLoadConfig(t *testing.T) {
WriteTimeout: 45 * time.Second,
},
Database: config.DatabaseConfig{
Driver: "sqlite",
Path: filepath.Join(tmpDir, "test.db"),
Port: 3306,
DBName: "nex",
MaxIdleConns: 15,
MaxOpenConns: 150,
ConnMaxLifetime: 2 * time.Hour,

View File

@@ -68,10 +68,10 @@ func setupConversionTest(t *testing.T) (*gin.Engine, *gorm.DB, *httptest.Server)
registry := conversion.NewMemoryRegistry()
require.NoError(t, registry.Register(openaiConv.NewAdapter()))
require.NoError(t, registry.Register(anthropic.NewAdapter()))
engine := conversion.NewConversionEngine(registry, nil)
engine := conversion.NewConversionEngine(registry, logger)
providerClient := provider.NewClient()
proxyHandler := handler.NewProxyHandler(engine, providerClient, routingService, providerService, statsService)
providerClient := provider.NewClient(logger)
proxyHandler := handler.NewProxyHandler(engine, providerClient, routingService, providerService, statsService, logger)
providerHandler := handler.NewProviderHandler(providerService)
modelHandler := handler.NewModelHandler(modelService)
statsHandler := handler.NewStatsHandler(statsService)

View File

@@ -61,10 +61,10 @@ func setupE2ETest(t *testing.T) (*gin.Engine, *httptest.Server) {
registry := conversion.NewMemoryRegistry()
require.NoError(t, registry.Register(openaiConv.NewAdapter()))
require.NoError(t, registry.Register(anthropic.NewAdapter()))
engine := conversion.NewConversionEngine(registry, nil)
engine := conversion.NewConversionEngine(registry, logger)
providerClient := provider.NewClient()
proxyHandler := handler.NewProxyHandler(engine, providerClient, routingService, providerService, statsService)
providerClient := provider.NewClient(logger)
proxyHandler := handler.NewProxyHandler(engine, providerClient, routingService, providerService, statsService, logger)
providerHandler := handler.NewProviderHandler(providerService)
modelHandler := handler.NewModelHandler(modelService)

View File

@@ -0,0 +1,158 @@
//go:build mysql
package mysql
import (
"sync"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"nex/backend/internal/config"
"nex/backend/internal/repository"
)
func TestConcurrent_UsageStatsRecord(t *testing.T) {
db := SetupMySQLTestDB(t)
statsRepo := repository.NewStatsRepository(db)
providerID := "concurrent-test-provider"
modelName := "gpt-4"
concurrency := 10
var wg sync.WaitGroup
wg.Add(concurrency)
errChan := make(chan error, concurrency)
for i := 0; i < concurrency; i++ {
go func() {
defer wg.Done()
err := statsRepo.Record(providerID, modelName)
if err != nil {
errChan <- err
}
}()
}
wg.Wait()
close(errChan)
var errorCount int
uniqueErrors := make(map[string]int)
for err := range errChan {
errorCount++
uniqueErrors[err.Error()]++
}
t.Logf("并发 %d 次,错误 %d 次", concurrency, errorCount)
for errMsg, count := range uniqueErrors {
t.Logf(" 错误: %s (出现 %d 次)", errMsg, count)
}
var stats config.UsageStats
err := db.Where("provider_id = ? AND model_name = ?", providerID, modelName).
First(&stats).Error
require.NoError(t, err, "应能查到 usage_stats 记录")
successCount := concurrency - errorCount
t.Logf("成功次数: %d, 最终 request_count: %d", successCount, stats.RequestCount)
assert.Equal(t, concurrency, stats.RequestCount, "request_count 应等于并发数,无数据丢失或重复")
}
func TestConcurrent_ProviderCreate(t *testing.T) {
db := SetupMySQLTestDB(t)
providerID := "concurrent-provider-id"
concurrency := 10
var wg sync.WaitGroup
wg.Add(concurrency)
successCount := 0
var mu sync.Mutex
for i := 0; i < concurrency; i++ {
go func() {
defer wg.Done()
provider := config.Provider{
ID: providerID,
Name: "Concurrent Provider",
APIKey: "test-key",
BaseURL: "https://test.com",
Enabled: true,
}
err := db.Create(&provider).Error
if err == nil {
mu.Lock()
successCount++
mu.Unlock()
}
}()
}
wg.Wait()
assert.Equal(t, 1, successCount, "仅 1 个创建应成功")
var count int64
db.Model(&config.Provider{}).Where("id = ?", providerID).Count(&count)
assert.Equal(t, int64(1), count, "最终应有 1 条记录")
}
func TestConcurrent_ModelCreate(t *testing.T) {
db := SetupMySQLTestDB(t)
provider := config.Provider{
ID: "concurrent-model-provider",
Name: "Test Provider",
APIKey: "test-key",
BaseURL: "https://test.com",
Enabled: true,
}
err := db.Create(&provider).Error
require.NoError(t, err, "创建 provider 应成功")
modelName := "gpt-4-concurrent"
concurrency := 10
var wg sync.WaitGroup
wg.Add(concurrency)
successCount := 0
var mu sync.Mutex
for i := 0; i < concurrency; i++ {
go func(idx int) {
defer wg.Done()
model := config.Model{
ID: uuid.New().String(),
ProviderID: provider.ID,
ModelName: modelName,
Enabled: true,
}
err := db.Create(&model).Error
if err == nil {
mu.Lock()
successCount++
mu.Unlock()
}
}(i)
}
wg.Wait()
assert.Equal(t, 1, successCount, "仅 1 个创建应成功")
var count int64
db.Model(&config.Model{}).Where("provider_id = ? AND model_name = ?", provider.ID, modelName).Count(&count)
assert.Equal(t, int64(1), count, "最终应有 1 条记录")
}

View File

@@ -0,0 +1,130 @@
//go:build mysql
package mysql
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"nex/backend/internal/config"
)
func TestConstraint_ForeignKeyEnforced(t *testing.T) {
db := SetupMySQLTestDB(t)
model := config.Model{
ID: "test-model-id",
ProviderID: "non-existent-provider",
ModelName: "gpt-4",
Enabled: true,
}
err := db.Create(&model).Error
assert.Error(t, err, "创建 model 时 provider_id 不存在应失败")
assert.Contains(t, err.Error(), "foreign key constraint", "错误应为外键约束错误")
}
func TestConstraint_CascadeDelete(t *testing.T) {
db := SetupMySQLTestDB(t)
provider := config.Provider{
ID: "test-provider-cascade",
Name: "Test Provider",
APIKey: "test-key",
BaseURL: "https://test.com",
Enabled: true,
}
err := db.Create(&provider).Error
require.NoError(t, err, "创建 provider 应成功")
model := config.Model{
ID: "test-model-cascade",
ProviderID: provider.ID,
ModelName: "gpt-4",
Enabled: true,
}
err = db.Create(&model).Error
require.NoError(t, err, "创建 model 应成功")
err = db.Delete(&provider).Error
require.NoError(t, err, "删除 provider 应成功")
var count int64
err = db.Model(&config.Model{}).Where("provider_id = ?", provider.ID).Count(&count).Error
require.NoError(t, err)
assert.Equal(t, int64(0), count, "删除 provider 后其 models 应被级联删除")
}
func TestConstraint_UniqueProviderModel(t *testing.T) {
db := SetupMySQLTestDB(t)
provider := config.Provider{
ID: "test-provider-unique",
Name: "Test Provider",
APIKey: "test-key",
BaseURL: "https://test.com",
Enabled: true,
}
err := db.Create(&provider).Error
require.NoError(t, err, "创建 provider 应成功")
model1 := config.Model{
ID: "test-model-unique-1",
ProviderID: provider.ID,
ModelName: "gpt-4",
Enabled: true,
}
err = db.Create(&model1).Error
require.NoError(t, err, "创建第一个 model 应成功")
model2 := config.Model{
ID: "test-model-unique-2",
ProviderID: provider.ID,
ModelName: "gpt-4",
Enabled: true,
}
err = db.Create(&model2).Error
assert.Error(t, err, "创建相同 (provider_id, model_name) 的 model 应失败")
assert.True(t, errors.Is(err, gorm.ErrDuplicatedKey) ||
(err != nil && (err.Error() == "Error 1062" || containsDuplicateError(err.Error()))),
"错误应为唯一约束错误")
}
func TestConstraint_UniqueUsageStats(t *testing.T) {
db := SetupMySQLTestDB(t)
today := time.Now().Format("2006-01-02")
todayTime, _ := time.Parse("2006-01-02", today)
providerID := "test-provider-unique-stats"
stats1 := config.UsageStats{
ProviderID: providerID,
ModelName: "gpt-4",
RequestCount: 10,
Date: todayTime,
}
err := db.Create(&stats1).Error
require.NoError(t, err, "创建第一个 usage_stats 应成功")
stats2 := config.UsageStats{
ProviderID: providerID,
ModelName: "gpt-4",
RequestCount: 20,
Date: todayTime,
}
err = db.Create(&stats2).Error
assert.Error(t, err, "创建相同 (provider_id, model_name, date) 的 usage_stats 应失败")
assert.True(t, errors.Is(err, gorm.ErrDuplicatedKey) ||
(err != nil && (err.Error() == "Error 1062" || containsDuplicateError(err.Error()))),
"错误应为唯一约束错误")
}
func containsDuplicateError(errStr string) bool {
return len(errStr) > 0 && (errStr[0:8] == "Error 10" || errStr[0:5] == "Dupli")
}

View File

@@ -0,0 +1,21 @@
version: '3.8'
services:
mysql:
image: mysql:8.0
container_name: nex-mysql-test
environment:
MYSQL_ROOT_PASSWORD: testpass
MYSQL_DATABASE: nex_test
MYSQL_USER: nex_test
MYSQL_PASSWORD: testpass
ports:
- "13306:3306"
tmpfs:
- /var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"]
interval: 1s
timeout: 5s
retries: 10
start_period: 10s

View File

@@ -0,0 +1,126 @@
//go:build mysql
package mysql
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMigration_TablesExist(t *testing.T) {
db := SetupMySQLTestDB(t)
var tables []string
err := db.Raw("SHOW TABLES").Scan(&tables).Error
require.NoError(t, err)
expectedTables := []string{"providers", "models", "usage_stats"}
for _, expected := range expectedTables {
assert.Contains(t, tables, expected, "表 %s 应存在", expected)
}
}
func TestMigration_TableColumns(t *testing.T) {
db := SetupMySQLTestDB(t)
t.Run("providers 表字段", func(t *testing.T) {
var columns []struct {
Field string
Type string
Null string
}
err := db.Raw("SHOW COLUMNS FROM providers").Scan(&columns).Error
require.NoError(t, err)
columnMap := make(map[string]string)
for _, col := range columns {
columnMap[col.Field] = col.Type
}
assert.Contains(t, columnMap["id"], "varchar", "id 应为 VARCHAR 类型")
assert.Contains(t, columnMap["name"], "varchar", "name 应为 VARCHAR 类型")
assert.Contains(t, columnMap["api_key"], "varchar", "api_key 应为 VARCHAR 类型")
assert.Contains(t, columnMap["base_url"], "varchar", "base_url 应为 VARCHAR 类型")
assert.Contains(t, columnMap["protocol"], "varchar", "protocol 应为 VARCHAR 类型")
assert.Contains(t, columnMap["enabled"], "tinyint", "enabled 应为 TINYINT (BOOLEAN) 类型")
assert.Contains(t, columnMap["created_at"], "datetime", "created_at 应为 DATETIME 类型")
assert.Contains(t, columnMap["updated_at"], "datetime", "updated_at 应为 DATETIME 类型")
})
t.Run("models 表字段", func(t *testing.T) {
var columns []struct {
Field string
Type string
}
err := db.Raw("SHOW COLUMNS FROM models").Scan(&columns).Error
require.NoError(t, err)
columnMap := make(map[string]string)
for _, col := range columns {
columnMap[col.Field] = col.Type
}
assert.Contains(t, columnMap["id"], "varchar", "id 应为 VARCHAR 类型")
assert.Contains(t, columnMap["provider_id"], "varchar", "provider_id 应为 VARCHAR 类型")
assert.Contains(t, columnMap["model_name"], "varchar", "model_name 应为 VARCHAR 类型")
assert.Contains(t, columnMap["enabled"], "tinyint", "enabled 应为 TINYINT (BOOLEAN) 类型")
assert.Contains(t, columnMap["created_at"], "datetime", "created_at 应为 DATETIME 类型")
})
t.Run("usage_stats 表字段", func(t *testing.T) {
var columns []struct {
Field string
Type string
}
err := db.Raw("SHOW COLUMNS FROM usage_stats").Scan(&columns).Error
require.NoError(t, err)
columnMap := make(map[string]string)
for _, col := range columns {
columnMap[col.Field] = col.Type
}
assert.Contains(t, columnMap["id"], "int", "id 应为 INT 类型")
assert.Contains(t, columnMap["provider_id"], "varchar", "provider_id 应为 VARCHAR 类型")
assert.Contains(t, columnMap["model_name"], "varchar", "model_name 应为 VARCHAR 类型")
assert.Contains(t, columnMap["request_count"], "int", "request_count 应为 INT 类型")
assert.Contains(t, columnMap["date"], "date", "date 应为 DATE 类型")
})
}
func TestMigration_IndexesExist(t *testing.T) {
db := SetupMySQLTestDB(t)
t.Run("models 表索引", func(t *testing.T) {
var indexes []struct {
KeyName string
}
err := db.Raw("SHOW INDEX FROM models").Scan(&indexes).Error
require.NoError(t, err)
indexMap := make(map[string]bool)
for _, idx := range indexes {
indexMap[idx.KeyName] = true
}
assert.True(t, indexMap["idx_models_provider_id"], "idx_models_provider_id 索引应存在")
assert.True(t, indexMap["idx_models_model_name"], "idx_models_model_name 索引应存在")
})
t.Run("usage_stats 表索引", func(t *testing.T) {
var indexes []struct {
KeyName string
}
err := db.Raw("SHOW INDEX FROM usage_stats").Scan(&indexes).Error
require.NoError(t, err)
indexMap := make(map[string]bool)
for _, idx := range indexes {
indexMap[idx.KeyName] = true
}
assert.True(t, indexMap["idx_usage_stats_provider_model_date"], "idx_usage_stats_provider_model_date 索引应存在")
})
}

View File

@@ -0,0 +1,160 @@
//go:build mysql
package mysql
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/pressly/goose/v3"
"github.com/stretchr/testify/require"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type MySQLTestConfig struct {
Host string
Port int
User string
Password string
Database string
}
func getMySQLTestConfig() *MySQLTestConfig {
return &MySQLTestConfig{
Host: getEnvOrDefault("NEX_TEST_MYSQL_HOST", "localhost"),
Port: getEnvOrDefaultInt("NEX_TEST_MYSQL_PORT", 13306),
User: getEnvOrDefault("NEX_TEST_MYSQL_USER", "nex_test"),
Password: getEnvOrDefault("NEX_TEST_MYSQL_PASSWORD", "testpass"),
Database: getEnvOrDefault("NEX_TEST_MYSQL_DATABASE", "nex_test"),
}
}
func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func getEnvOrDefaultInt(key string, defaultValue int) int {
if value := os.Getenv(key); value != "" {
var intValue int
if _, err := fmt.Sscanf(value, "%d", &intValue); err == nil {
return intValue
}
}
return defaultValue
}
func SkipIfMySQLUnavailable(t *testing.T) {
t.Helper()
cfg := getMySQLTestConfig()
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local",
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Database)
db, err := sql.Open("mysql", dsn)
if err != nil {
t.Skipf("MySQL 不可用: %v", err)
}
defer db.Close()
if err := db.Ping(); err != nil {
t.Skipf("MySQL 不可用: %v", err)
}
}
func SetupMySQLTestDB(t *testing.T) *gorm.DB {
t.Helper()
SkipIfMySQLUnavailable(t)
cfg := getMySQLTestConfig()
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local",
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Database)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
require.NoError(t, err, "连接 MySQL 失败")
if err := runMigrations(db); err != nil {
require.NoError(t, err, "运行迁移失败")
}
if err := cleanupTables(db); err != nil {
require.NoError(t, err, "清理表数据失败")
}
sqlDB, err := db.DB()
require.NoError(t, err)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetMaxOpenConns(100)
sqlDB.SetConnMaxLifetime(time.Hour)
t.Cleanup(func() {
time.Sleep(50 * time.Millisecond)
sqlDB, err := db.DB()
if err == nil {
sqlDB.Close()
}
})
return db
}
func cleanupTables(db *gorm.DB) error {
if err := db.Exec("SET FOREIGN_KEY_CHECKS = 0").Error; err != nil {
return err
}
if err := db.Exec("TRUNCATE TABLE usage_stats").Error; err != nil {
return err
}
if err := db.Exec("TRUNCATE TABLE models").Error; err != nil {
return err
}
if err := db.Exec("TRUNCATE TABLE providers").Error; err != nil {
return err
}
if err := db.Exec("SET FOREIGN_KEY_CHECKS = 1").Error; err != nil {
return err
}
return nil
}
func runMigrations(db *gorm.DB) error {
sqlDB, err := db.DB()
if err != nil {
return err
}
migrationsDir := getMigrationsDir()
if _, err := os.Stat(migrationsDir); os.IsNotExist(err) {
return fmt.Errorf("迁移目录不存在: %s", migrationsDir)
}
goose.SetDialect("mysql")
if err := goose.Up(sqlDB, migrationsDir); err != nil {
return err
}
return nil
}
func getMigrationsDir() string {
_, filename, _, ok := runtime.Caller(0)
if ok {
dir := filepath.Join(filepath.Dir(filename), "..", "..", "migrations", "mysql")
if abs, err := filepath.Abs(dir); err == nil {
return abs
}
}
return "./migrations/mysql"
}

View File

@@ -33,6 +33,7 @@
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"happy-dom": "^20.9.0",
"javascript-obfuscator": "^5.4.1",
"jsdom": "^26.1.0",
"msw": "^2.8.2",
"sass": "^1.99.0",
@@ -40,6 +41,7 @@
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4",
"vite-plugin-javascript-obfuscator": "^3.1.0",
"vitest": "^3.2.1",
},
},
@@ -191,10 +193,20 @@
"@inquirer/type": ["@inquirer/type@3.0.10", "https://registry.npmmirror.com/@inquirer/type/-/type-3.0.10.tgz", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="],
"@inversifyjs/common": ["@inversifyjs/common@1.3.3", "https://registry.npmmirror.com/@inversifyjs/common/-/common-1.3.3.tgz", {}, "sha512-ZH0wrgaJwIo3s9gMCDM2wZoxqrJ6gB97jWXncROfYdqZJv8f3EkqT57faZqN5OTeHWgtziQ6F6g3L8rCvGceCw=="],
"@inversifyjs/core": ["@inversifyjs/core@1.3.4", "https://registry.npmmirror.com/@inversifyjs/core/-/core-1.3.4.tgz", { "dependencies": { "@inversifyjs/common": "1.3.3", "@inversifyjs/reflect-metadata-utils": "0.2.3" } }, "sha512-gCCmA4BdbHEFwvVZ2elWgHuXZWk6AOu/1frxsS+2fWhjEk2c/IhtypLo5ytSUie1BCiT6i9qnEo4bruBomQsAA=="],
"@inversifyjs/reflect-metadata-utils": ["@inversifyjs/reflect-metadata-utils@0.2.3", "https://registry.npmmirror.com/@inversifyjs/reflect-metadata-utils/-/reflect-metadata-utils-0.2.3.tgz", { "peerDependencies": { "reflect-metadata": "0.2.2" } }, "sha512-d3D0o9TeSlvaGM2I24wcNw/Aj3rc4OYvHXOKDC09YEph5fMMiKd6fq1VTQd9tOkDNWvVbw+cnt45Wy9P/t5Lvw=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@istanbuljs/schema": ["@istanbuljs/schema@0.1.6", "https://registry.npmmirror.com/@istanbuljs/schema/-/schema-0.1.6.tgz", {}, "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw=="],
"@javascript-obfuscator/escodegen": ["@javascript-obfuscator/escodegen@2.4.1", "https://registry.npmmirror.com/@javascript-obfuscator/escodegen/-/escodegen-2.4.1.tgz", { "dependencies": { "@javascript-obfuscator/estraverse": "^5.3.0", "esprima": "^4.0.1", "esutils": "^2.0.2", "optionator": "^0.8.1" }, "optionalDependencies": { "source-map": "~0.6.1" } }, "sha512-YrEJJDr4cb+pIQKWzHFoDlDkQzatcrNB6OhAD6iTSwiKwzZUMVdobwbOuLpF4EiLxUj0qP28Xl1saTHYzIPCLg=="],
"@javascript-obfuscator/estraverse": ["@javascript-obfuscator/estraverse@5.4.0", "https://registry.npmmirror.com/@javascript-obfuscator/estraverse/-/estraverse-5.4.0.tgz", {}, "sha512-CZFX7UZVN9VopGbjTx4UXaXsi9ewoM1buL0kY7j1ftYdSs7p2spv9opxFjHlQ/QGTgh4UqufYqJJ0WKLml7b6w=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -389,6 +401,8 @@
"@types/json5": ["@types/json5@0.0.29", "https://registry.npmmirror.com/@types/json5/-/json5-0.0.29.tgz", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
"@types/minimatch": ["@types/minimatch@3.0.5", "https://registry.npmmirror.com/@types/minimatch/-/minimatch-3.0.5.tgz", {}, "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ=="],
"@types/node": ["@types/node@24.12.2", "https://registry.npmmirror.com/@types/node/-/node-24.12.2.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="],
"@types/react": ["@types/react@19.2.14", "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
@@ -429,6 +443,8 @@
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="],
"@vercel/blob": ["@vercel/blob@2.3.3", "https://registry.npmmirror.com/@vercel/blob/-/blob-2.3.3.tgz", { "dependencies": { "async-retry": "^1.3.3", "is-buffer": "^2.0.5", "is-node-process": "^1.2.0", "throttleit": "^2.1.0", "undici": "^6.23.0" } }, "sha512-MtD7VLo6hU07eHR7bmk5SIMD290q574UaNYTe46qeyRT+hWrCy26CoAqfd7PnIefVXvRehRZBzukxuTO9iGTVg=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
"@vitest/coverage-v8": ["@vitest/coverage-v8@3.2.4", "https://registry.npmmirror.com/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", "ast-v8-to-istanbul": "^0.3.3", "debug": "^4.4.1", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", "magic-string": "^0.30.17", "magicast": "^0.3.5", "std-env": "^3.9.0", "test-exclude": "^7.0.1", "tinyrainbow": "^2.0.0" }, "peerDependencies": { "@vitest/browser": "3.2.4", "vitest": "3.2.4" }, "optionalPeers": ["@vitest/browser"] }, "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ=="],
@@ -447,7 +463,9 @@
"@vitest/utils": ["@vitest/utils@3.2.4", "https://registry.npmmirror.com/@vitest/utils/-/utils-3.2.4.tgz", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn": ["acorn@8.15.0", "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-import-attributes": ["acorn-import-attributes@1.9.5", "https://registry.npmmirror.com/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -455,18 +473,26 @@
"ajv": ["ajv@6.14.0", "https://registry.npmmirror.com/ajv/-/ajv-6.14.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"ajv-formats": ["ajv-formats@3.0.1", "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"ansi-regex": ["ansi-regex@5.0.1", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"anymatch": ["anymatch@3.1.3", "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"argparse": ["argparse@2.0.1", "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-query": ["aria-query@5.3.2", "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.2.tgz", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "https://registry.npmmirror.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
"array-differ": ["array-differ@3.0.0", "https://registry.npmmirror.com/array-differ/-/array-differ-3.0.0.tgz", {}, "sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg=="],
"array-includes": ["array-includes@3.1.9", "https://registry.npmmirror.com/array-includes/-/array-includes-3.1.9.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="],
"array-union": ["array-union@2.1.0", "https://registry.npmmirror.com/array-union/-/array-union-2.1.0.tgz", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="],
"array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "https://registry.npmmirror.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-shim-unscopables": "^1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="],
"array.prototype.flat": ["array.prototype.flat@1.3.3", "https://registry.npmmirror.com/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="],
@@ -475,12 +501,20 @@
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "https://registry.npmmirror.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
"arrify": ["arrify@2.0.1", "https://registry.npmmirror.com/arrify/-/arrify-2.0.1.tgz", {}, "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug=="],
"assert": ["assert@2.1.0", "https://registry.npmmirror.com/assert/-/assert-2.1.0.tgz", { "dependencies": { "call-bind": "^1.0.2", "is-nan": "^1.3.2", "object-is": "^1.1.5", "object.assign": "^4.1.4", "util": "^0.12.5" } }, "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw=="],
"assertion-error": ["assertion-error@2.0.1", "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"ast-v8-to-istanbul": ["ast-v8-to-istanbul@0.3.12", "https://registry.npmmirror.com/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g=="],
"async-function": ["async-function@1.0.0", "https://registry.npmmirror.com/async-function/-/async-function-1.0.0.tgz", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
"async-retry": ["async-retry@1.3.3", "https://registry.npmmirror.com/async-retry/-/async-retry-1.3.3.tgz", { "dependencies": { "retry": "0.13.1" } }, "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw=="],
"atomically": ["atomically@2.1.1", "https://registry.npmmirror.com/atomically/-/atomically-2.1.1.tgz", { "dependencies": { "stubborn-fs": "^2.0.0", "when-exit": "^2.1.4" } }, "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
@@ -491,6 +525,8 @@
"browserslist": ["browserslist@4.28.2", "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"buffer-from": ["buffer-from@1.1.2", "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"cac": ["cac@6.7.14", "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"call-bind": ["call-bind@1.0.9", "https://registry.npmmirror.com/call-bind/-/call-bind-1.0.9.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
@@ -507,10 +543,18 @@
"chalk": ["chalk@4.1.2", "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chance": ["chance@1.1.13", "https://registry.npmmirror.com/chance/-/chance-1.1.13.tgz", {}, "sha512-V6lQCljcLznE7tUYUM9EOAnnKXbctE6j/rdQkYOHIWbfGQbrzTsAXNW9CdU5XCo4ArXQCj/rb6HgxPlmGJcaUg=="],
"char-regex": ["char-regex@1.0.2", "https://registry.npmmirror.com/char-regex/-/char-regex-1.0.2.tgz", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="],
"charenc": ["charenc@0.0.2", "https://registry.npmmirror.com/charenc/-/charenc-0.0.2.tgz", {}, "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA=="],
"check-error": ["check-error@2.1.3", "https://registry.npmmirror.com/check-error/-/check-error-2.1.3.tgz", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
"chokidar": ["chokidar@4.0.3", "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"class-validator": ["class-validator@0.14.3", "https://registry.npmmirror.com/class-validator/-/class-validator-0.14.3.tgz", { "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", "validator": "^13.15.20" } }, "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA=="],
"classnames": ["classnames@2.5.1", "https://registry.npmmirror.com/classnames/-/classnames-2.5.1.tgz", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
"cli-width": ["cli-width@4.1.0", "https://registry.npmmirror.com/cli-width/-/cli-width-4.1.0.tgz", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
@@ -523,14 +567,20 @@
"color-name": ["color-name@1.1.4", "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"commander": ["commander@12.1.0", "https://registry.npmmirror.com/commander/-/commander-12.1.0.tgz", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
"concat-map": ["concat-map@0.0.1", "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"conf": ["conf@15.0.2", "https://registry.npmmirror.com/conf/-/conf-15.0.2.tgz", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^10.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.5.0" } }, "sha512-JBSrutapCafTrddF9dH3lc7+T2tBycGF4uPkI4Js+g4vLLEhG6RZcFi3aJd5zntdf5tQxAejJt8dihkoQ/eSJw=="],
"convert-source-map": ["convert-source-map@2.0.0", "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@1.1.1", "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"crypt": ["crypt@0.0.2", "https://registry.npmmirror.com/crypt/-/crypt-0.0.2.tgz", {}, "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow=="],
"css.escape": ["css.escape@1.5.1", "https://registry.npmmirror.com/css.escape/-/css.escape-1.5.1.tgz", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
"cssstyle": ["cssstyle@4.6.0", "https://registry.npmmirror.com/cssstyle/-/cssstyle-4.6.0.tgz", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="],
@@ -569,6 +619,8 @@
"dayjs": ["dayjs@1.11.10", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.10.tgz", {}, "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="],
"debounce-fn": ["debounce-fn@6.0.0", "https://registry.npmmirror.com/debounce-fn/-/debounce-fn-6.0.0.tgz", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ=="],
"debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js": ["decimal.js@10.6.0", "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
@@ -593,6 +645,8 @@
"dom-helpers": ["dom-helpers@5.2.1", "https://registry.npmmirror.com/dom-helpers/-/dom-helpers-5.2.1.tgz", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"dot-prop": ["dot-prop@10.1.0", "https://registry.npmmirror.com/dot-prop/-/dot-prop-10.1.0.tgz", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="],
"dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
@@ -603,6 +657,8 @@
"entities": ["entities@7.0.1", "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"env-paths": ["env-paths@4.0.0", "https://registry.npmmirror.com/env-paths/-/env-paths-4.0.0.tgz", { "dependencies": { "is-safe-filename": "^0.1.0" } }, "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw=="],
"es-abstract": ["es-abstract@1.24.2", "https://registry.npmmirror.com/es-abstract/-/es-abstract-1.24.2.tgz", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="],
"es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
@@ -645,6 +701,8 @@
"espree": ["espree@10.4.0", "https://registry.npmmirror.com/espree/-/espree-10.4.0.tgz", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"esprima": ["esprima@4.0.1", "https://registry.npmmirror.com/esprima/-/esprima-4.0.1.tgz", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"esquery": ["esquery@1.7.0", "https://registry.npmmirror.com/esquery/-/esquery-1.7.0.tgz", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrecurse": ["esrecurse@4.3.0", "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
@@ -665,6 +723,8 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-uri": ["fast-uri@3.1.0", "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.0.tgz", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
@@ -757,10 +817,16 @@
"indent-string": ["indent-string@4.0.0", "https://registry.npmmirror.com/indent-string/-/indent-string-4.0.0.tgz", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
"inherits": ["inherits@2.0.4", "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"internal-slot": ["internal-slot@1.1.0", "https://registry.npmmirror.com/internal-slot/-/internal-slot-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
"internmap": ["internmap@2.0.3", "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"inversify": ["inversify@6.1.4", "https://registry.npmmirror.com/inversify/-/inversify-6.1.4.tgz", { "dependencies": { "@inversifyjs/common": "1.3.3", "@inversifyjs/core": "1.3.4" } }, "sha512-PbxrZH/gTa1fpPEEGAjJQzK8tKMIp5gRg6EFNJlCtzUcycuNdmhv3uk5P8Itm/RIjgHJO16oQRLo9IHzQN51bA=="],
"is-arguments": ["is-arguments@1.2.0", "https://registry.npmmirror.com/is-arguments/-/is-arguments-1.2.0.tgz", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="],
"is-array-buffer": ["is-array-buffer@3.0.5", "https://registry.npmmirror.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
"is-async-function": ["is-async-function@2.1.1", "https://registry.npmmirror.com/is-async-function/-/is-async-function-2.1.1.tgz", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
@@ -769,6 +835,8 @@
"is-boolean-object": ["is-boolean-object@1.2.2", "https://registry.npmmirror.com/is-boolean-object/-/is-boolean-object-1.2.2.tgz", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="],
"is-buffer": ["is-buffer@2.0.5", "https://registry.npmmirror.com/is-buffer/-/is-buffer-2.0.5.tgz", {}, "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ=="],
"is-callable": ["is-callable@1.2.7", "https://registry.npmmirror.com/is-callable/-/is-callable-1.2.7.tgz", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
"is-core-module": ["is-core-module@2.16.1", "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
@@ -789,6 +857,8 @@
"is-map": ["is-map@2.0.3", "https://registry.npmmirror.com/is-map/-/is-map-2.0.3.tgz", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="],
"is-nan": ["is-nan@1.3.2", "https://registry.npmmirror.com/is-nan/-/is-nan-1.3.2.tgz", { "dependencies": { "call-bind": "^1.0.0", "define-properties": "^1.1.3" } }, "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w=="],
"is-negative-zero": ["is-negative-zero@2.0.3", "https://registry.npmmirror.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="],
"is-node-process": ["is-node-process@1.2.0", "https://registry.npmmirror.com/is-node-process/-/is-node-process-1.2.0.tgz", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="],
@@ -799,6 +869,8 @@
"is-regex": ["is-regex@1.2.1", "https://registry.npmmirror.com/is-regex/-/is-regex-1.2.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
"is-safe-filename": ["is-safe-filename@0.1.1", "https://registry.npmmirror.com/is-safe-filename/-/is-safe-filename-0.1.1.tgz", {}, "sha512-4SrR7AdnY11LHfDKTZY1u6Ga3RuxZdl3YKWWShO5iyuG5h8QS4GD2tOb04peBJ5I7pXbR+CGBNEhTcwK+FzN3g=="],
"is-set": ["is-set@2.0.3", "https://registry.npmmirror.com/is-set/-/is-set-2.0.3.tgz", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="],
"is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "https://registry.npmmirror.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="],
@@ -829,6 +901,10 @@
"jackspeak": ["jackspeak@3.4.3", "https://registry.npmmirror.com/jackspeak/-/jackspeak-3.4.3.tgz", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"javascript-obfuscator": ["javascript-obfuscator@5.4.1", "https://registry.npmmirror.com/javascript-obfuscator/-/javascript-obfuscator-5.4.1.tgz", { "dependencies": { "@javascript-obfuscator/escodegen": "2.4.1", "@javascript-obfuscator/estraverse": "5.4.0", "@vercel/blob": ">=0.23.0", "acorn": "8.15.0", "acorn-import-attributes": "^1.9.5", "assert": "2.1.0", "chalk": "4.1.2", "chance": "1.1.13", "class-validator": "0.14.3", "commander": "12.1.0", "env-paths": "4.0.0", "eslint-scope": "8.4.0", "eslint-visitor-keys": "4.2.1", "fast-deep-equal": "3.1.3", "inversify": "6.1.4", "js-string-escape": "1.0.1", "md5": "2.3.0", "multimatch": "5.0.0", "process": "0.11.10", "reflect-metadata": "0.2.2", "string-template": "1.0.0", "stringz": "2.1.0", "tslib": "2.8.1" }, "bin": { "javascript-obfuscator": "bin/javascript-obfuscator" } }, "sha512-HoG2vdmo0xM37YQtcjYXNBTlRvypW5G6gtTMFrCBzLkVMLWQy97+XISDNL1DUMfKQQ6Njp8SddfPJix1h2FhVg=="],
"js-string-escape": ["js-string-escape@1.0.1", "https://registry.npmmirror.com/js-string-escape/-/js-string-escape-1.0.1.tgz", {}, "sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg=="],
"js-tokens": ["js-tokens@10.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-10.0.0.tgz", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
"js-yaml": ["js-yaml@4.1.1", "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
@@ -841,6 +917,8 @@
"json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "https://registry.npmmirror.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json5": ["json5@1.0.2", "https://registry.npmmirror.com/json5/-/json5-1.0.2.tgz", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
@@ -849,6 +927,8 @@
"levn": ["levn@0.4.1", "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"libphonenumber-js": ["libphonenumber-js@1.12.42", "https://registry.npmmirror.com/libphonenumber-js/-/libphonenumber-js-1.12.42.tgz", {}, "sha512-oKQFPTibqQwZZkChCDVMFVJXMZdyJNqDWZWYNn8BgyAaK/6yFJEowxCY0RVFirRyWP63hMRuKlkSEd9qlvbWXg=="],
"lightningcss": ["lightningcss@1.32.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
@@ -895,6 +975,10 @@
"math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"md5": ["md5@2.3.0", "https://registry.npmmirror.com/md5/-/md5-2.3.0.tgz", { "dependencies": { "charenc": "0.0.2", "crypt": "0.0.2", "is-buffer": "~1.1.6" } }, "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g=="],
"mimic-function": ["mimic-function@5.0.1", "https://registry.npmmirror.com/mimic-function/-/mimic-function-5.0.1.tgz", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"min-indent": ["min-indent@1.0.1", "https://registry.npmmirror.com/min-indent/-/min-indent-1.0.1.tgz", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
"minimatch": ["minimatch@3.1.5", "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
@@ -905,10 +989,14 @@
"mitt": ["mitt@3.0.1", "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
"mkdirp": ["mkdirp@3.0.1", "https://registry.npmmirror.com/mkdirp/-/mkdirp-3.0.1.tgz", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="],
"ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"msw": ["msw@2.13.3", "https://registry.npmmirror.com/msw/-/msw-2.13.3.tgz", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.7", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-/F49bxavkNGfreMlrKmTxZs6YorjfMbbDLd89Q3pWi+cXGtQQNXXaHt4MkXN7li91xnQJ24HWXqW9QDm5id33w=="],
"multimatch": ["multimatch@5.0.0", "https://registry.npmmirror.com/multimatch/-/multimatch-5.0.0.tgz", { "dependencies": { "@types/minimatch": "^3.0.3", "array-differ": "^3.0.0", "array-union": "^2.1.0", "arrify": "^2.0.1", "minimatch": "^3.0.4" } }, "sha512-ypMKuglUrZUD99Tk2bUQ+xNQj43lPEfAeX2o9cTteAmShXy2VHDJpuwu1o0xqoKCt9jLVAvwyFKdLTPXKAfJyA=="],
"mute-stream": ["mute-stream@2.0.0", "https://registry.npmmirror.com/mute-stream/-/mute-stream-2.0.0.tgz", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="],
"nanoid": ["nanoid@3.3.11", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
@@ -921,12 +1009,16 @@
"node-releases": ["node-releases@2.0.37", "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.37.tgz", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
"normalize-path": ["normalize-path@3.0.0", "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"nwsapi": ["nwsapi@2.2.23", "https://registry.npmmirror.com/nwsapi/-/nwsapi-2.2.23.tgz", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="],
"object-assign": ["object-assign@4.1.1", "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"object-is": ["object-is@1.1.6", "https://registry.npmmirror.com/object-is/-/object-is-1.1.6.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1" } }, "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q=="],
"object-keys": ["object-keys@1.1.1", "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
"object.assign": ["object.assign@4.1.7", "https://registry.npmmirror.com/object.assign/-/object.assign-4.1.7.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="],
@@ -987,6 +1079,8 @@
"pretty-format": ["pretty-format@27.5.1", "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"process": ["process@0.11.10", "https://registry.npmmirror.com/process/-/process-0.11.10.tgz", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
"prop-types": ["prop-types@15.8.1", "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"punycode": ["punycode@2.3.1", "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
@@ -1017,6 +1111,8 @@
"redux-thunk": ["redux-thunk@3.1.0", "https://registry.npmmirror.com/redux-thunk/-/redux-thunk-3.1.0.tgz", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
"reflect-metadata": ["reflect-metadata@0.2.2", "https://registry.npmmirror.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "https://registry.npmmirror.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
"regenerator-runtime": ["regenerator-runtime@0.14.1", "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="],
@@ -1025,12 +1121,16 @@
"require-directory": ["require-directory@2.1.1", "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"reselect": ["reselect@5.1.1", "https://registry.npmmirror.com/reselect/-/reselect-5.1.1.tgz", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"resolve": ["resolve@2.0.0-next.6", "https://registry.npmmirror.com/resolve/-/resolve-2.0.0-next.6.tgz", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA=="],
"resolve-from": ["resolve-from@4.0.0", "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"retry": ["retry@0.13.1", "https://registry.npmmirror.com/retry/-/retry-0.13.1.tgz", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
"rettime": ["rettime@0.11.7", "https://registry.npmmirror.com/rettime/-/rettime-0.11.7.tgz", {}, "sha512-DoAm1WjR1eH7z8sHPtvvUMIZh4/CSKkGCz6CxPqOrEAnOGtOuHSnSE9OC+razqxKuf4ub7pAYyl/vZV0vGs5tg=="],
"rolldown": ["rolldown@1.0.0-rc.15", "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.15.tgz", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="],
@@ -1081,8 +1181,12 @@
"sortablejs": ["sortablejs@1.15.7", "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.7.tgz", {}, "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A=="],
"source-map": ["source-map@0.6.1", "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"source-map-support": ["source-map-support@0.5.21", "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"sql.js": ["sql.js@1.14.1", "https://registry.npmmirror.com/sql.js/-/sql.js-1.14.1.tgz", {}, "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A=="],
"stackback": ["stackback@0.0.2", "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
@@ -1095,6 +1199,8 @@
"strict-event-emitter": ["strict-event-emitter@0.5.1", "https://registry.npmmirror.com/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="],
"string-template": ["string-template@1.0.0", "https://registry.npmmirror.com/string-template/-/string-template-1.0.0.tgz", {}, "sha512-SLqR3GBUXuoPP5MmYtD7ompvXiG87QjT6lzOszyXjTM86Uu7At7vNnt2xgyTLq5o9T4IxTYFyGxcULqpsmsfdg=="],
"string-width": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string-width-cjs": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -1105,6 +1211,8 @@
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "https://registry.npmmirror.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
"stringz": ["stringz@2.1.0", "https://registry.npmmirror.com/stringz/-/stringz-2.1.0.tgz", { "dependencies": { "char-regex": "^1.0.2" } }, "sha512-KlywLT+MZ+v0IRepfMxRtnSvDCMc3nR1qqCs3m/qIbSOWkNZYT8XHQA31rS3TnKp0c5xjZu3M4GY/2aRKSi/6A=="],
"strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-ansi-cjs": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -1117,6 +1225,10 @@
"strip-literal": ["strip-literal@3.1.0", "https://registry.npmmirror.com/strip-literal/-/strip-literal-3.1.0.tgz", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
"stubborn-fs": ["stubborn-fs@2.0.0", "https://registry.npmmirror.com/stubborn-fs/-/stubborn-fs-2.0.0.tgz", { "dependencies": { "stubborn-utils": "^1.0.1" } }, "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA=="],
"stubborn-utils": ["stubborn-utils@1.0.2", "https://registry.npmmirror.com/stubborn-utils/-/stubborn-utils-1.0.2.tgz", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="],
"supports-color": ["supports-color@7.2.0", "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
@@ -1131,6 +1243,8 @@
"test-exclude": ["test-exclude@7.0.2", "https://registry.npmmirror.com/test-exclude/-/test-exclude-7.0.2.tgz", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", "minimatch": "^10.2.2" } }, "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw=="],
"throttleit": ["throttleit@2.1.0", "https://registry.npmmirror.com/throttleit/-/throttleit-2.1.0.tgz", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinybench": ["tinybench@2.9.0", "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
@@ -1157,7 +1271,7 @@
"tsconfig-paths": ["tsconfig-paths@3.15.0", "https://registry.npmmirror.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="],
"tslib": ["tslib@2.3.1", "https://registry.npmmirror.com/tslib/-/tslib-2.3.1.tgz", {}, "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="],
"tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-check": ["type-check@0.4.0", "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
@@ -1175,8 +1289,12 @@
"typescript-eslint": ["typescript-eslint@8.58.2", "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.58.2.tgz", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.58.2", "@typescript-eslint/parser": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/utils": "8.58.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ=="],
"uint8array-extras": ["uint8array-extras@1.5.0", "https://registry.npmmirror.com/uint8array-extras/-/uint8array-extras-1.5.0.tgz", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"unbox-primitive": ["unbox-primitive@1.1.0", "https://registry.npmmirror.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
"undici": ["undici@6.25.0", "https://registry.npmmirror.com/undici/-/undici-6.25.0.tgz", {}, "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg=="],
"undici-types": ["undici-types@7.16.0", "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"until-async": ["until-async@3.0.2", "https://registry.npmmirror.com/until-async/-/until-async-3.0.2.tgz", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="],
@@ -1187,6 +1305,8 @@
"use-sync-external-store": ["use-sync-external-store@1.6.0", "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"util": ["util@0.12.5", "https://registry.npmmirror.com/util/-/util-0.12.5.tgz", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
"validator": ["validator@13.15.35", "https://registry.npmmirror.com/validator/-/validator-13.15.35.tgz", {}, "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw=="],
"victory-vendor": ["victory-vendor@37.3.6", "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
@@ -1195,6 +1315,8 @@
"vite-node": ["vite-node@3.2.4", "https://registry.npmmirror.com/vite-node/-/vite-node-3.2.4.tgz", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
"vite-plugin-javascript-obfuscator": ["vite-plugin-javascript-obfuscator@3.1.0", "https://registry.npmmirror.com/vite-plugin-javascript-obfuscator/-/vite-plugin-javascript-obfuscator-3.1.0.tgz", { "dependencies": { "anymatch": "~3.1.3", "javascript-obfuscator": "^4.1.0" } }, "sha512-sf4JFlG1iUPl7bLXHGOy+bKWOQUFyXzJFWa+n2S2xMMvyfM+V9R40HhpZoIF1eAjifArM1SF7fbSFIaTuUIbPA=="],
"vitest": ["vitest@3.2.4", "https://registry.npmmirror.com/vitest/-/vitest-3.2.4.tgz", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
@@ -1207,6 +1329,8 @@
"whatwg-url": ["whatwg-url@14.2.0", "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-14.2.0.tgz", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
"when-exit": ["when-exit@2.1.5", "https://registry.npmmirror.com/when-exit/-/when-exit-2.1.5.tgz", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="],
"which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "https://registry.npmmirror.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
@@ -1255,12 +1379,6 @@
"@babel/core/json5": ["json5@2.2.3", "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"@emnapi/core/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@emnapi/runtime/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@emnapi/wasi-threads/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
@@ -1271,14 +1389,14 @@
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"@javascript-obfuscator/escodegen/optionator": ["optionator@0.8.3", "https://registry.npmmirror.com/optionator/-/optionator-0.8.3.tgz", { "dependencies": { "deep-is": "~0.1.3", "fast-levenshtein": "~2.0.6", "levn": "~0.3.0", "prelude-ls": "~1.1.2", "type-check": "~0.3.2", "word-wrap": "~1.2.3" } }, "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA=="],
"@reduxjs/toolkit/immer": ["immer@11.1.4", "https://registry.npmmirror.com/immer/-/immer-11.1.4.tgz", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.0.tgz", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"@tybys/wasm-util/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
@@ -1287,8 +1405,18 @@
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"ajv-formats/ajv": ["ajv@8.18.0", "https://registry.npmmirror.com/ajv/-/ajv-8.18.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"anymatch/picomatch": ["picomatch@2.3.2", "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"conf/ajv": ["ajv@8.18.0", "https://registry.npmmirror.com/ajv/-/ajv-8.18.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"conf/env-paths": ["env-paths@3.0.0", "https://registry.npmmirror.com/env-paths/-/env-paths-3.0.0.tgz", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"conf/semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"data-urls/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"eslint-import-resolver-node/debug": ["debug@3.2.7", "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
@@ -1297,6 +1425,8 @@
"eslint-plugin-import/debug": ["debug@3.2.7", "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"espree/acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"glob/minimatch": ["minimatch@9.0.9", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
@@ -1307,6 +1437,8 @@
"make-dir/semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"md5/is-buffer": ["is-buffer@1.1.6", "https://registry.npmmirror.com/is-buffer/-/is-buffer-1.1.6.tgz", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="],
"msw/tough-cookie": ["tough-cookie@6.0.1", "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-6.0.1.tgz", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
"parse5/entities": ["entities@6.0.1", "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
@@ -1327,10 +1459,14 @@
"tdesign-react/@babel/runtime": ["@babel/runtime@7.26.10", "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.26.10.tgz", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw=="],
"tdesign-react/tslib": ["tslib@2.3.1", "https://registry.npmmirror.com/tslib/-/tslib-2.3.1.tgz", {}, "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="],
"test-exclude/minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"vite-node/vite": ["vite@7.3.2", "https://registry.npmmirror.com/vite/-/vite-7.3.2.tgz", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="],
"vite-plugin-javascript-obfuscator/javascript-obfuscator": ["javascript-obfuscator@4.2.2", "https://registry.npmmirror.com/javascript-obfuscator/-/javascript-obfuscator-4.2.2.tgz", { "dependencies": { "@javascript-obfuscator/escodegen": "2.3.1", "@javascript-obfuscator/estraverse": "5.4.0", "acorn": "8.15.0", "assert": "2.1.0", "chalk": "4.1.2", "chance": "1.1.13", "class-validator": "0.14.3", "commander": "12.1.0", "conf": "15.0.2", "eslint-scope": "8.4.0", "eslint-visitor-keys": "4.2.1", "fast-deep-equal": "3.1.3", "inversify": "6.1.4", "js-string-escape": "1.0.1", "md5": "2.3.0", "mkdirp": "3.0.1", "multimatch": "5.0.0", "process": "0.11.10", "reflect-metadata": "0.2.2", "source-map-support": "0.5.21", "string-template": "1.0.0", "stringz": "2.1.0", "tslib": "2.8.1" }, "bin": { "javascript-obfuscator": "bin/javascript-obfuscator" } }, "sha512-+7oXAUnFCA6vS0omIGHcWpSr67dUBIF7FKGYSXyzxShSLqM6LBgdugWKFl0XrYtGWyJMGfQR5F4LL85iCefkRA=="],
"vitest/vite": ["vite@7.3.2", "https://registry.npmmirror.com/vite/-/vite-7.3.2.tgz", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
@@ -1339,18 +1475,38 @@
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"@javascript-obfuscator/escodegen/optionator/levn": ["levn@0.3.0", "https://registry.npmmirror.com/levn/-/levn-0.3.0.tgz", { "dependencies": { "prelude-ls": "~1.1.2", "type-check": "~0.3.2" } }, "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA=="],
"@javascript-obfuscator/escodegen/optionator/prelude-ls": ["prelude-ls@1.1.2", "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.1.2.tgz", {}, "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w=="],
"@javascript-obfuscator/escodegen/optionator/type-check": ["type-check@0.3.2", "https://registry.npmmirror.com/type-check/-/type-check-0.3.2.tgz", { "dependencies": { "prelude-ls": "~1.1.2" } }, "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"conf/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.0.tgz", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="],
"msw/tough-cookie/tldts": ["tldts@7.0.28", "https://registry.npmmirror.com/tldts/-/tldts-7.0.28.tgz", { "dependencies": { "tldts-core": "^7.0.28" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw=="],
"test-exclude/minimatch/brace-expansion": ["brace-expansion@5.0.5", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"vite-plugin-javascript-obfuscator/javascript-obfuscator/@javascript-obfuscator/escodegen": ["@javascript-obfuscator/escodegen@2.3.1", "https://registry.npmmirror.com/@javascript-obfuscator/escodegen/-/escodegen-2.3.1.tgz", { "dependencies": { "@javascript-obfuscator/estraverse": "^5.3.0", "esprima": "^4.0.1", "esutils": "^2.0.2", "optionator": "^0.8.1" }, "optionalDependencies": { "source-map": "~0.6.1" } }, "sha512-Z0HEAVwwafOume+6LFXirAVZeuEMKWuPzpFbQhCEU9++BMz0IwEa9bmedJ+rMn/IlXRBID9j3gQ0XYAa6jM10g=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"msw/tough-cookie/tldts/tldts-core": ["tldts-core@7.0.28", "https://registry.npmmirror.com/tldts-core/-/tldts-core-7.0.28.tgz", {}, "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ=="],
"test-exclude/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"vite-plugin-javascript-obfuscator/javascript-obfuscator/@javascript-obfuscator/escodegen/optionator": ["optionator@0.8.3", "https://registry.npmmirror.com/optionator/-/optionator-0.8.3.tgz", { "dependencies": { "deep-is": "~0.1.3", "fast-levenshtein": "~2.0.6", "levn": "~0.3.0", "prelude-ls": "~1.1.2", "type-check": "~0.3.2", "word-wrap": "~1.2.3" } }, "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA=="],
"vite-plugin-javascript-obfuscator/javascript-obfuscator/@javascript-obfuscator/escodegen/optionator/levn": ["levn@0.3.0", "https://registry.npmmirror.com/levn/-/levn-0.3.0.tgz", { "dependencies": { "prelude-ls": "~1.1.2", "type-check": "~0.3.2" } }, "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA=="],
"vite-plugin-javascript-obfuscator/javascript-obfuscator/@javascript-obfuscator/escodegen/optionator/prelude-ls": ["prelude-ls@1.1.2", "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.1.2.tgz", {}, "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w=="],
"vite-plugin-javascript-obfuscator/javascript-obfuscator/@javascript-obfuscator/escodegen/optionator/type-check": ["type-check@0.3.2", "https://registry.npmmirror.com/type-check/-/type-check-0.3.2.tgz", { "dependencies": { "prelude-ls": "~1.1.2" } }, "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg=="],
}
}

View File

@@ -42,6 +42,7 @@
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"happy-dom": "^20.9.0",
"javascript-obfuscator": "^5.4.1",
"jsdom": "^26.1.0",
"msw": "^2.8.2",
"sass": "^1.99.0",
@@ -49,6 +50,7 @@
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4",
"vite-plugin-javascript-obfuscator": "^3.1.0",
"vitest": "^3.2.1"
}
}

View File

@@ -1,5 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import obfuscatorPlugin from 'vite-plugin-javascript-obfuscator'
import path from 'node:path'
const vendorChunks: Record<string, string[]> = {
@@ -10,7 +11,42 @@ const vendorChunks: Record<string, string[]> = {
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [
react(),
obfuscatorPlugin({
apply: 'build',
include: ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.js', 'src/**/*.jsx'],
exclude: [/node_modules/],
options: {
compact: true,
simplify: true,
identifierNamesGenerator: 'mangled-shuffled',
stringArray: true,
stringArrayCallsTransform: true,
stringArrayCallsTransformThreshold: 0.5,
stringArrayEncoding: ['base64'],
stringArrayIndexShift: true,
stringArrayRotate: true,
stringArrayShuffle: true,
stringArrayWrappersCount: 2,
stringArrayWrappersChainedCalls: true,
stringArrayThreshold: 0.75,
splitStrings: true,
splitStringsChunkLength: 10,
numbersToExpressions: true,
transformObjectKeys: true,
unicodeEscapeSequence: true,
debugProtection: true,
debugProtectionInterval: 2000,
disableConsoleOutput: true,
ignoreImports: true,
controlFlowFlattening: false,
deadCodeInjection: false,
selfDefending: false,
log: false,
},
}),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
@@ -26,6 +62,7 @@ export default defineConfig({
},
build: {
chunkSizeWarningLimit: 700,
sourcemap: false,
rollupOptions: {
output: {
manualChunks(id) {

View File

@@ -4,6 +4,7 @@ cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQ
cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
github.com/ClickHouse/clickhouse-go/v2 v2.43.0/go.mod h1:o6jf7JM/zveWC/PP277BLxjHy5KjnGX/jfljhM4s34g=
@@ -25,6 +26,7 @@ github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6v
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
@@ -35,6 +37,7 @@ github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=

View File

@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-04-22

View File

@@ -1,81 +0,0 @@
## Context
Nex 桌面应用是一个将后端服务Go/Gin与前端静态资源embed.FS打包为单一可执行文件的跨平台应用。当前 Windows 构建存在三类问题:
1. **通用 `desktop` target 无平台感知**:输出 `build/nex``.exe` 后缀,且缺少 `-H=windowsgui` linker flag 导致控制台窗口闪现
2. **系统托盘图标加载失败**`getlantern/systray` 在 Windows 上期望 ICO 格式,代码传入了 64x64 的 PNG
3. **`showError`/`showAbout` 使用 `msg *`**Windows Home 版本可能不可用,配合 `-H=windowsgui` 后行为不可预测,且不支持自定义标题栏
项目已有 `assets/icon.ico`256x256但代码未使用。
## Goals / Non-Goals
**Goals:**
- Windows 构建产物可直接双击运行(.exe 后缀、无控制台窗口)
- 系统托盘图标在所有平台上正确加载
- Windows 上使用原生 `MessageBoxW` 对话框替代 `msg *`
- Makefile target 命名简洁统一
**Non-Goals:**
- 不引入新的第三方依赖
- 不改变 macOS/Linux 上的现有行为
- 不涉及应用签名或代码公证(属于发布流程)
- 不重构整体打包架构
## Decisions
### 1. 删除通用 `desktop` target重命名平台 target
**决策**:删除 `desktop` target`desktop-darwin`/`desktop-windows`/`desktop-linux` 重命名为 `desktop-mac`/`desktop-win`/`desktop-linux`
**理由**:通用 target 在跨平台构建时必然需要条件判断,增加复杂度。按平台分离更明确,且项目已有先例。短命名 `win`/`mac`/`linux` 更简洁。
**产物命名统一**`nex-{os}-{arch}[.exe]`
- `nex-mac-arm64``nex-mac-amd64`
- `nex-win-amd64.exe`
- `nex-linux-amd64`
### 2. 托盘图标运行时按平台选择格式
**决策**:在 `setupSystray` 中根据 `runtime.GOOS` 选择图标文件:
- Windows加载 `assets/icon.ico`256x256 ICO
- 其他:加载 `assets/icon.png`PNG
**备选方案**
- ~~Build tags + 编译时选择~~:增加文件数,维护成本高
- ~~所有平台统一用 ICO~~Linux/macOS 的 systray 实现对 ICO 支持不一致
**理由**:运行时判断最简单,两个文件都已通过 `embedfs.Assets``assets/*`)嵌入,零额外成本。
### 3. Windows 原生对话框使用 `user32.MessageBoxW`
**决策**:通过 `syscall` 调用 `user32.dll``MessageBoxW`,替换 `msg *`
**实现方式**
```go
var (
user32 = syscall.NewLazyDLL("user32.dll")
procMessageBoxW = user32.NewProc("MessageBoxW")
)
func messageBox(title, message string) {
procMessageBoxW.Call(0, uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(message))),
uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(title))), 0x10)
}
```
**备选方案**
- ~~继续用 `msg *`~~:不解决 Home 版不可用、标题栏不支持的问题
- ~~`rundll32` 调用~~:同样不可靠
- ~~引入 `lxn/walk` 等 GUI 库~~:引入重依赖,过度
**理由**`MessageBoxW` 是 Windows 原生 API所有版本都有`-H=windowsgui` 完美兼容,支持标题栏和图标类型,零依赖。使用 `syscall`(非 `unsafe` 外部依赖)即可。
### 4. `showError`/`showAbout` 统一用平台 switch
**决策**:保持现有的 `switch runtime.GOOS` 结构,仅替换 Windows 分支实现。macOSosascript和 Linuxzenity不变。
## Risks / Trade-offs
- **[syscall 跨架构]** `MessageBoxW``syscall.NewLazyDLL` 仅在 Windows 上有效 → 使用 `runtime.GOOS` 守卫,非 Windows 不会执行该路径,编译时通过 build 文件或运行时判断确保不触发
- **[ICO 嵌入体积]** `icon.ico` 270KB已在 `embedfs` 中,不增加新体积 → 无风险
- **[Makefile 兼容性]** 删除 `desktop` target 后CI/本地脚本如果引用它需更新 → 需检查是否有外部引用

View File

@@ -1,28 +0,0 @@
## Why
Windows 桌面应用存在三个影响用户体验的问题:构建产物无 `.exe` 后缀无法双击运行、运行时弹出控制台窗口、系统托盘图标加载失败。此外 `showError`/`showAbout` 在 Windows 上使用 `msg *` 命令不可靠。这些问题导致应用在 Windows 上不够专业,需要统一修复。
## What Changes
- 删除通用 `desktop` Makefile target仅保留按平台分离的 target
- Makefile target 重命名为简短形式:`desktop-win``desktop-mac``desktop-linux`
- 构建产物文件名统一为 `nex-{os}-{arch}[.exe]` 格式
- 系统托盘图标在 Windows 上使用 `.ico` 格式(运行时 `runtime.GOOS` 判断)
- Windows `showError`/`showAbout` 改用 `user32.dll``MessageBoxW` 原生对话框
- 同步更新已有 `desktop-app` spec 中的构建产物命名和图标格式要求
## Capabilities
### New Capabilities
### Modified Capabilities
- `desktop-app`: 构建产物命名规范变更(`nex-{os}-{arch}`Windows 托盘图标需使用 `.ico` 格式Windows 原生对话框替代 `msg *` 命令
## Impact
- `Makefile`:删除 `desktop` target重命名其余三个 target 和产物文件名
- `backend/cmd/desktop/main.go`:修改 `setupSystray` 图标加载逻辑、`showError`/`showAbout` Windows 实现
- `openspec/specs/desktop-app/spec.md`:更新构建产物命名和 Windows 图标格式要求

View File

@@ -1,87 +0,0 @@
## MODIFIED Requirements
### Requirement: 系统托盘
系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择。
#### Scenario: 托盘图标显示
- **WHEN** 桌面应用启动成功
- **THEN** 系统根据平台加载正确的图标格式
- **AND** 在 Windows 上加载 ICO 格式图标(`assets/icon.ico`
- **AND** 在 macOS 和 Linux 上加载 PNG 格式图标(`assets/icon.png`
- **AND** 托盘图标 tooltip 显示"AI Gateway"
#### Scenario: 托盘菜单显示
- **WHEN** 用户点击托盘图标(左键或右键)
- **THEN** 显示托盘菜单
- **AND** 菜单包含"打开管理界面"选项
- **AND** 菜单包含"状态: 运行中"选项(禁用状态)
- **AND** 菜单包含"端口: 9826"选项(禁用状态)
- **AND** 菜单包含"关于"选项
- **AND** 菜单包含"退出"选项
#### Scenario: 打开管理界面
- **WHEN** 用户点击托盘菜单"打开管理界面"
- **THEN** 系统在浏览器中打开 `http://localhost:9826`
#### Scenario: 浏览器打开失败
- **WHEN** 系统无法打开浏览器(浏览器未安装等)
- **THEN** 托盘菜单仍可正常使用
- **AND** 用户可手动访问 `http://localhost:9826`
#### Scenario: 退出应用
- **WHEN** 用户点击托盘菜单"退出"
- **THEN** 系统优雅关闭后端服务
- **AND** 托盘图标消失
- **AND** 应用进程退出
### Requirement: 跨平台构建
系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台分离,产物文件名 SHALL 使用 `nex-{os}-{arch}[.exe]` 格式。
#### Scenario: macOS 构建
- **WHEN** 执行 `desktop-mac` 构建命令
- **THEN** 生成 `nex-mac-arm64``nex-mac-amd64` 可执行文件
- **AND** 可打包为 `.app` bundle
#### Scenario: Windows 构建
- **WHEN** 执行 `desktop-win` 构建命令
- **THEN** 生成 `nex-win-amd64.exe` 可执行文件
- **AND** 使用 `-H=windowsgui` linker flag 隐藏控制台窗口
#### Scenario: Linux 构建
- **WHEN** 执行 `desktop-linux` 构建命令
- **THEN** 生成 `nex-linux-amd64` 可执行文件
### Requirement: 关于对话框
系统 SHALL 提供关于对话框显示应用信息。在 Windows 上 SHALL 使用 `user32.dll``MessageBoxW` API 实现。
#### Scenario: 显示关于
- **WHEN** 用户点击托盘菜单"关于"
- **THEN** 显示对话框包含应用名称、项目链接
- **AND** 在 Windows 上使用 `MessageBoxW` 原生对话框实现
## ADDED Requirements
### Requirement: Windows 原生对话框
系统 SHALL 在 Windows 上使用 `user32.dll``MessageBoxW` API 显示错误和关于对话框,替代 `msg *` 命令。
#### Scenario: 错误提示对话框
- **WHEN** 应用在 Windows 上遇到启动错误(端口占用、配置加载失败等)
- **THEN** 使用 `MessageBoxW` 显示模态对话框
- **AND** 对话框标题栏显示应用名称
- **AND** 对话框包含错误描述文本
- **AND** 对话框显示错误图标MB_ICONERROR
#### Scenario: 关于对话框
- **WHEN** 用户在 Windows 上点击托盘菜单"关于"
- **THEN** 使用 `MessageBoxW` 显示模态对话框
- **AND** 对话框标题栏显示"关于 Nex Gateway"
- **AND** 对话框包含应用信息文本
- **AND** 对话框显示信息图标MB_ICONINFORMATION
#### Scenario: 非 Windows 平台不受影响
- **WHEN** 应用运行在 macOS 或 Linux 上
- **THEN** 错误和关于对话框仍使用平台原有实现osascript / zenity

View File

@@ -1,28 +0,0 @@
## 1. Makefile 重构
- [x] 1.1 删除通用 `desktop` target 及其相关 `.PHONY` 声明
- [x] 1.2 将 `desktop-darwin` 重命名为 `desktop-mac`,产物文件名改为 `nex-mac-arm64``nex-mac-amd64`
- [x] 1.3 将 `desktop-windows` 重命名为 `desktop-win`,产物文件名改为 `nex-win-amd64.exe`
- [x] 1.4 将 `desktop-linux` 产物文件名改为 `nex-linux-amd64`
- [x] 1.5 更新 `.PHONY` 声明和 `all` target如引用了旧名称
## 2. Windows 原生对话框
- [x] 2.1 在 `backend/cmd/desktop/main.go` 中添加 Windows 平台的 `user32.MessageBoxW` 调用封装(`syscall.NewLazyDLL` + `syscall.StringToUTF16Ptr`),在 `showError`/`showAbout` 的 Windows `runtime.GOOS` 分支内直接调用
- [x] 2.2 替换 `showError` 函数的 Windows 分支,使用 `MessageBoxW` 替代 `msg *`
- [x] 2.3 替换 `showAbout` 函数的 Windows 分支,使用 `MessageBoxW` 替代 `msg *`
## 3. 系统托盘图标修复
- [x] 3.1 修改 `setupSystray` 函数中的图标加载逻辑,根据 `runtime.GOOS` 在 Windows 上加载 `assets/icon.ico`,其他平台加载 `assets/icon.png`
## 4. 文档和脚本更新
- [x] 4.1 更新 `README.md` 中的构建命令引用(`desktop-darwin``desktop-mac``desktop-windows``desktop-win``desktop-linux` 保持不变或改为 `desktop-linux`
- [x] 4.2 更新 `scripts/build/package-macos.sh` 中对 `desktop-darwin` 的引用
## 5. 测试验证
- [x] 5.1 为 `showError`/`showAbout` 的 Windows `MessageBoxW` 封装编写单元测试(验证参数传递和调用正确性)
- [x] 5.2 为图标加载的平台选择逻辑编写单元测试(验证 Windows 选 `.ico`,其他选 `.png`
- [x] 5.3 运行 `make desktop-win` 在 Windows 上验证:产物有 `.exe` 后缀、双击无控制台窗口、托盘图标正常显示、错误对话框使用原生样式

View File

@@ -77,6 +77,7 @@
- **THEN** SHALL 支持 `required` 规则
- **THEN** SHALL 支持 `min``max` 规则
- **THEN** SHALL 支持 `oneof` 规则
- **THEN** SHALL 支持 `required_if` 条件验证规则
#### Scenario: 验证执行
@@ -85,6 +86,17 @@
- **THEN** SHALL 返回验证错误
- **THEN** SHALL NOT 启动应用(如果验证失败)
#### Scenario: 数据库驱动条件验证
- **WHEN** `database.driver``sqlite`
- **THEN** SHALL 验证 `database.path` 必填
- **THEN** SHALL NOT 要求 MySQL 字段host/port/user/password/dbname
- **WHEN** `database.driver``mysql`
- **THEN** SHALL 验证 `database.host` 必填
- **THEN** SHALL 验证 `database.user` 必填
- **THEN** SHALL 验证 `database.dbname` 必填
- **THEN** SHALL NOT 要求 `database.path`
### Requirement: 配置结构定义
系统 SHALL 定义清晰的配置结构。
@@ -98,7 +110,14 @@
#### Scenario: Database 配置
- **WHEN** 加载 database 配置
- **THEN** SHALL 包含 path、max_idle_conns、max_open_conns、conn_max_lifetime 字段
- **THEN** SHALL 包含 driver 字段(值为 `sqlite``mysql`,默认 `sqlite`
- **THEN** SHALL 包含 path 字段SQLite 模式下的数据库文件路径)
- **THEN** SHALL 包含 host 字段MySQL 主机地址)
- **THEN** SHALL 包含 port 字段MySQL 端口,默认 3306
- **THEN** SHALL 包含 user 字段MySQL 用户名)
- **THEN** SHALL 包含 password 字段MySQL 密码,选填)
- **THEN** SHALL 包含 dbname 字段MySQL 数据库名)
- **THEN** SHALL 包含 max_idle_conns、max_open_conns、conn_max_lifetime 字段
- **THEN** SHALL 使用合理的默认值
#### Scenario: Log 配置
@@ -121,7 +140,13 @@
#### Scenario: Database 默认值
- **WHEN** 使用默认配置
- **THEN** database.driver SHALL 为 `sqlite`
- **THEN** database.path SHALL 为 `~/.nex/config.db`
- **THEN** database.host SHALL 为空字符串
- **THEN** database.port SHALL 为 3306
- **THEN** database.user SHALL 为空字符串
- **THEN** database.password SHALL 为空字符串
- **THEN** database.dbname SHALL 为 `nex`
- **THEN** database.max_idle_conns SHALL 为 10
- **THEN** database.max_open_conns SHALL 为 100
- **THEN** database.conn_max_lifetime SHALL 为 1h
@@ -248,18 +273,38 @@
- **THEN** SHALL 在日志中记录覆盖信息
- **THEN** SHALL 显示被覆盖的配置项名称
### Requirement: 配置文件安全
系统 SHALL 使用安全的文件权限保存配置文件。
#### Scenario: 配置文件权限
- **WHEN** 保存配置文件(`SaveConfig`
- **THEN** SHALL 使用 `0600` 权限写入文件(仅 owner 可读写)
- **THEN** SHALL 防止其他用户读取配置中的 MySQL 密码等敏感信息
### Requirement: 配置摘要输出
系统 SHALL 在启动时输出配置摘要。
#### Scenario: 摘要内容
#### Scenario: SQLite 模式摘要
- **WHEN** 配置加载完成
- **WHEN** `database.driver``sqlite`
- **THEN** SHALL 打印关键配置项(端口、数据库路径、日志级别等)
- **THEN** SHALL 打印配置文件路径
- **THEN** SHALL 打印环境变量数量
- **THEN** SHALL 打印 CLI 参数数量
#### Scenario: MySQL 模式摘要
- **WHEN** `database.driver``mysql`
- **THEN** SHALL 打印关键配置项(端口、数据库类型、数据库地址、日志级别等)
- **THEN** SHALL 打印数据库地址格式为 `{host}:{port}/{dbname}`
- **THEN** SHALL 不打印密码
- **THEN** SHALL 打印配置文件路径
- **THEN** SHALL 打印环境变量数量
- **THEN** SHALL 打印 CLI 参数数量
#### Scenario: 摘要格式
- **WHEN** 打印配置摘要
@@ -297,7 +342,7 @@
- **WHEN** 使用服务器相关参数
- **THEN** SHALL 支持 `--server-port``--server-read-timeout``--server-write-timeout`
- **WHEN** 使用数据库相关参数
- **THEN** SHALL 支持 `--database-path``--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** 使用日志相关参数
- **THEN** SHALL 支持 `--log-level``--log-path``--log-max-size``--log-max-backups``--log-max-age``--log-compress`
@@ -348,7 +393,7 @@
- **WHEN** 设置服务器相关环境变量
- **THEN** SHALL 支持 `NEX_SERVER_PORT``NEX_SERVER_READ_TIMEOUT``NEX_SERVER_WRITE_TIMEOUT`
- **WHEN** 设置数据库相关环境变量
- **THEN** SHALL 支持 `NEX_DATABASE_PATH``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** 设置日志相关环境变量
- **THEN** SHALL 支持 `NEX_LOG_LEVEL``NEX_LOG_PATH``NEX_LOG_MAX_SIZE``NEX_LOG_MAX_BACKUPS``NEX_LOG_MAX_AGE``NEX_LOG_COMPRESS`

View File

@@ -46,6 +46,14 @@
- **THEN** SHALL 删除所有表和索引
- **THEN** SHALL 按正确顺序删除(避免外键约束错误)
#### Scenario: 按数据库方言拆分迁移目录
- **WHEN** 组织迁移文件
- **THEN** SHALL 将 SQLite 方言迁移文件存储在 `migrations/sqlite/` 目录
- **THEN** SHALL 将 MySQL 方言迁移文件存储在 `migrations/mysql/` 目录
- **THEN** SHALL 两个目录维护独立的版本号序列
- **THEN** SHALL 两个目录的迁移文件内容在逻辑上一致(相同的表结构和约束),但使用各自方言的 DDL
### Requirement: models 表 schema 变更
系统 SHALL 在初始迁移脚本中直接创建新的 models 表结构(服务未上线,无需考虑数据迁移,迁移脚本已合并为单个初始迁移文件)。
@@ -63,28 +71,37 @@
#### Scenario: 迁移 up 命令
- **WHEN** 执行 `make migrate-up`
- **WHEN** 执行 `make backend-migrate-up`
- **THEN** SHALL 执行所有待执行的迁移
- **THEN** SHALL 使用 `DB_DRIVER` 变量选择方言目录(默认 `sqlite3`
- **THEN** SHALL 使用 `DB_DSN` 变量作为数据库连接串
- **THEN** SHALL 显示迁移进度
#### Scenario: 迁移 down 命令
- **WHEN** 执行 `make migrate-down`
- **WHEN** 执行 `make backend-migrate-down`
- **THEN** SHALL 回滚最后一个迁移
- **THEN** SHALL 使用 `DB_DRIVER``DB_DSN` 变量
- **THEN** SHALL 显示回滚进度
#### Scenario: 迁移状态命令
- **WHEN** 执行 `make migrate-status`
- **WHEN** 执行 `make backend-migrate-status`
- **THEN** SHALL 显示当前迁移状态
- **THEN** SHALL 显示已执行和待执行的迁移
#### Scenario: 创建迁移命令
- **WHEN** 执行 `make migrate-create name=<name>`
- **THEN** SHALL 创建新的迁移文件模板
- **WHEN** 执行 `make backend-migrate-create`
- **THEN** SHALL 同时在 `migrations/sqlite/``migrations/mysql/` 两个目录创建新的迁移文件模板
- **THEN** SHALL 使用递增的版本号
#### Scenario: MySQL 迁移命令使用
- **WHEN** 使用 MySQL 驱动执行迁移
- **THEN** SHALL 设置 `DB_DRIVER=mysql`
- **THEN** SHALL 设置 `DB_DSN` 为 MySQL 连接串(如 `user:pass@tcp(localhost:3306)/nex`
### Requirement: 应用启动时迁移
应用 SHALL 在启动时执行迁移。
@@ -92,6 +109,9 @@
#### Scenario: 自动迁移
- **WHEN** 应用启动
- **THEN** SHALL 根据 `database.driver` 配置选择对应的迁移目录和 goose dialect
- **THEN** SHALL 在 `driver=sqlite` 时使用 `migrations/sqlite/` 目录goose dialect 为 `sqlite3`
- **THEN** SHALL 在 `driver=mysql` 时使用 `migrations/mysql/` 目录goose dialect 为 `mysql`
- **THEN** SHALL 自动执行待执行的迁移
- **THEN** SHALL 在迁移失败时拒绝启动
- **THEN** SHALL 记录迁移日志
@@ -149,5 +169,5 @@
#### Scenario: 迁移文件存储
- **WHEN** 创建迁移文件
- **THEN** SHALL 存储在 migrations/ 目录
- **THEN** SHALL 按 SQL 方言存储在对应子目录(`migrations/sqlite/``migrations/mysql/`
- **THEN** SHALL 提交到版本控制系统

View File

@@ -11,6 +11,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
系统 SHALL 支持作为桌面应用启动,将后端服务与前端静态资源打包为单一可执行文件。
#### Scenario: 双击启动
- **WHEN** 用户双击桌面应用可执行文件
- **THEN** 系统使用 `gofrs/flock` 尝试获取排他文件锁
- **AND** 锁文件路径为系统临时目录下的 `nex-gateway.lock`
@@ -19,12 +20,14 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
- **AND** 浏览器自动打开 `http://localhost:9826` 显示管理界面
#### Scenario: 单实例检查
- **WHEN** 用户尝试启动第二个实例
- **THEN** 系统检测到已有实例持有文件锁
- **AND** 显示错误提示"已有 Nex 实例运行"
- **AND** 新实例退出
#### Scenario: 退出释放锁
- **WHEN** 用户点击托盘菜单"退出"
- **THEN** 系统释放文件锁
- **AND** 应用进程退出
@@ -34,6 +37,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择。
#### Scenario: 托盘图标显示
- **WHEN** 桌面应用启动成功
- **THEN** 系统根据平台加载正确的图标格式
- **AND** 在 Windows 上加载 ICO 格式图标(`assets/icon.ico`
@@ -41,6 +45,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
- **AND** 托盘图标 tooltip 显示"AI Gateway"
#### Scenario: 托盘菜单显示
- **WHEN** 用户点击托盘图标(左键或右键)
- **THEN** 显示托盘菜单
- **AND** 菜单包含"打开管理界面"选项
@@ -50,15 +55,18 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
- **AND** 菜单包含"退出"选项
#### Scenario: 打开管理界面
- **WHEN** 用户点击托盘菜单"打开管理界面"
- **THEN** 系统在浏览器中打开 `http://localhost:9826`
#### Scenario: 浏览器打开失败
- **WHEN** 系统无法打开浏览器(浏览器未安装等)
- **THEN** 托盘菜单仍可正常使用
- **AND** 用户可手动访问 `http://localhost:9826`
#### Scenario: 退出应用
- **WHEN** 用户点击托盘菜单"退出"
- **THEN** 系统优雅关闭后端服务
- **AND** 托盘图标消失
@@ -69,14 +77,17 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
系统 SHALL 通过 Gin 同时服务 API 和前端静态资源。
#### Scenario: API 请求路由
- **WHEN** 请求路径以 `/api/``/v1/` 开头
- **THEN** 请求由现有业务 handler 处理
#### Scenario: 静态资源路由
- **WHEN** 请求路径为 `/assets/*`
- **THEN** 返回嵌入的前端静态资源文件
#### Scenario: SPA 路由回退
- **WHEN** 请求路径不匹配任何 API 或静态资源路由
- **THEN** 返回 `index.html`(支持前端 SPA 路由)
@@ -85,10 +96,12 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
系统 SHALL 在启动前检测端口是否可用。
#### Scenario: 端口可用
- **WHEN** 端口 9826 未被占用
- **THEN** 服务正常启动
#### Scenario: 端口被占用
- **WHEN** 端口 9826 已被其他程序占用
- **THEN** 显示错误提示"端口 9826 已被占用"
- **AND** 应用退出
@@ -98,17 +111,20 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台分离,产物文件名 SHALL 使用 `nex-{os}-{arch}[.exe]` 格式。
#### Scenario: macOS 构建
- **WHEN** 执行 `desktop-mac` 构建命令
- **WHEN** 执行 `desktop-build-mac` 构建命令
- **THEN** 生成 `nex-mac-arm64``nex-mac-amd64` 可执行文件
- **AND** 可打包为 `.app` bundle
#### Scenario: Windows 构建
- **WHEN** 执行 `desktop-win` 构建命令
- **WHEN** 执行 `desktop-build-win` 构建命令
- **THEN** 生成 `nex-win-amd64.exe` 可执行文件
- **AND** 使用 `-H=windowsgui` linker flag 隐藏控制台窗口
#### Scenario: Linux 构建
- **WHEN** 执行 `desktop-linux` 构建命令
- **WHEN** 执行 `desktop-build-linux` 构建命令
- **THEN** 生成 `nex-linux-amd64` 可执行文件
### Requirement: macOS .app 打包
@@ -116,11 +132,12 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
系统 SHALL 支持打包为 macOS .app bundle。
#### Scenario: .app 结构
- **WHEN** 执行打包脚本
- **THEN** 生成 `Nex.app` 目录结构
- **AND** 包含 `Contents/Info.plist` 元数据
- **AND** 包含 `Contents/MacOS/nex` 可执行文件
- **AND** 包含 `Contents/Resources/AppIcon.icns` 图标
- **AND** 包含 `Contents/Resources/icon.icns` 图标
- **AND** `Info.plist``LSUIElement``true`(不显示 Dock 图标)
### Requirement: 关于对话框
@@ -128,6 +145,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
系统 SHALL 提供关于对话框显示应用信息。在 Windows 上 SHALL 使用 `user32.dll``MessageBoxW` API 实现。
#### Scenario: 显示关于
- **WHEN** 用户点击托盘菜单"关于"
- **THEN** 显示对话框包含应用名称、项目链接
- **AND** 在 Windows 上使用 `MessageBoxW` 原生对话框实现
@@ -137,6 +155,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
系统 SHALL 在 Windows 上使用 `user32.dll``MessageBoxW` API 显示错误和关于对话框,替代 `msg *` 命令。
#### Scenario: 错误提示对话框
- **WHEN** 应用在 Windows 上遇到启动错误(端口占用、配置加载失败等)
- **THEN** 使用 `MessageBoxW` 显示模态对话框
- **AND** 对话框标题栏显示应用名称
@@ -144,6 +163,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
- **AND** 对话框显示错误图标MB_ICONERROR
#### Scenario: 关于对话框
- **WHEN** 用户在 Windows 上点击托盘菜单"关于"
- **THEN** 使用 `MessageBoxW` 显示模态对话框
- **AND** 对话框标题栏显示"关于 Nex Gateway"
@@ -151,5 +171,60 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
- **AND** 对话框显示信息图标MB_ICONINFORMATION
#### Scenario: 非 Windows 平台不受影响
- **WHEN** 应用运行在 macOS 或 Linux 上
- **THEN** 错误和关于对话框仍使用平台原有实现osascript / zenity
### Requirement: Linux 对话框降级策略
系统 SHALL 在 Linux 上按优先级检测并使用可用的对话框工具,确保在不同桌面环境下都能显示对话框。
#### Scenario: zenity 可用
- **WHEN** 系统检测到 `zenity` 命令可用
- **THEN** 使用 zenity 显示 GTK 风格对话框
- **AND** 错误对话框使用 `zenity --error` 命令
- **AND** 信息对话框使用 `zenity --info` 命令
#### Scenario: kdialog 可用
- **WHEN** zenity 不可用且 `kdialog` 命令可用
- **THEN** 使用 kdialog 显示 KDE 风格对话框
- **AND** 错误对话框使用 `kdialog --error` 命令
- **AND** 信息对话框使用 `kdialog --msgbox` 命令
#### Scenario: notify-send 可用
- **WHEN** zenity 和 kdialog 均不可用且 `notify-send` 命令可用
- **THEN** 使用 notify-send 显示系统通知
- **AND** 错误通知使用 `-u critical` 参数
- **AND** 信息通知使用默认参数
#### Scenario: xmessage 可用
- **WHEN** zenity、kdialog、notify-send 均不可用且 `xmessage` 命令可用
- **THEN** 使用 xmessage 显示基础 X11 对话框
- **AND** 对话框居中显示(`-center` 参数)
#### Scenario: 无对话框工具可用
- **WHEN** 所有对话框工具均不可用
- **THEN** 降级到标准错误输出
- **AND** 输出格式为 `错误: <title>: <message>`
#### Scenario: 工具检测缓存
- **WHEN** 应用启动
- **THEN** 系统检测一次可用对话框工具
- **AND** 检测结果缓存在包级变量
- **AND** 后续对话框调用直接使用缓存结果,不重复检测
### Requirement: macOS AppleScript 字符转义
系统 SHALL 对 AppleScript 对话框中的特殊字符进行转义,确保脚本正确执行。
#### Scenario: 转义反斜杠
- **WHEN** 对话框消息包含反斜杠字符 `\`
- **THEN** 转义为 `\\`
#### Scenario: 转义双引号
- **WHEN** 对话框消息包含双引号字符 `"`
- **THEN** 转义为 `\"`
#### Scenario: 多行文本处理
- **WHEN** 对话框消息包含换行符 `\n`
- **THEN** AppleScript 正确显示多行文本

View File

@@ -0,0 +1,117 @@
# 前端代码混淆
## Purpose
在生产构建时对前端业务代码进行混淆处理,增加逆向成本,防止代码被轻易复制。
## Requirements
### Requirement: 生产构建时代码混淆
前端 SHALL 在生产构建时对业务代码进行混淆处理。
#### Scenario: 安装混淆依赖
- **WHEN** 添加代码混淆功能
- **THEN** 前端 SHALL 使用 bun 安装 `vite-plugin-javascript-obfuscator``javascript-obfuscator` 依赖
- **THEN** 前端 SHALL NOT 使用 npm 或 pnpm 安装依赖
#### Scenario: 配置混淆插件
- **WHEN** 配置 Vite 构建流程
- **THEN** vite.config.ts SHALL 导入 `vite-plugin-javascript-obfuscator`
- **THEN** 插件 SHALL 配置 `apply: 'build'` 仅在构建时生效
- **THEN** 插件 SHALL 配置 `include` 仅包含业务代码src/**/*.ts, src/**/*.tsx, src/**/*.js, src/**/*.jsx
- **THEN** 插件 SHALL 配置 `exclude` 排除 node_modules
#### Scenario: 变量名混淆
- **WHEN** 生产构建执行
- **THEN** 业务代码中的变量名 SHALL 被转换为十六进制形式(如 _0x2b3c
- **THEN** identifierNamesGenerator SHALL 配置为 'mangled-shuffled'
#### Scenario: 字符串混淆
- **WHEN** 生产构建执行
- **THEN** 业务代码中的字符串 SHALL 被编码为字符串数组
- **THEN** stringArray SHALL 配置为 true
- **THEN** stringArrayEncoding SHALL 配置为 ['base64']
- **THEN** stringArrayRotate SHALL 配置为 true
- **THEN** stringArrayShuffle SHALL 配置为 true
- **THEN** stringArrayThreshold SHALL 配置为 0.75
- **THEN** splitStrings SHALL 配置为 true
- **THEN** splitStringsChunkLength SHALL 配置为 10
#### Scenario: 对象键混淆
- **WHEN** 生产构建执行
- **THEN** 业务代码中的对象键 SHALL 被混淆
- **THEN** transformObjectKeys SHALL 配置为 true
#### Scenario: 数字混淆
- **WHEN** 生产构建执行
- **THEN** 业务代码中的数字 SHALL 被转换为表达式
- **THEN** numbersToExpressions SHALL 配置为 true
#### Scenario: 调试保护
- **WHEN** 生产构建执行
- **THEN** 混淆代码 SHALL 禁用浏览器调试器
- **THEN** debugProtection SHALL 配置为 true
- **THEN** debugProtectionInterval SHALL 配置为 2000
#### Scenario: 禁用 console 输出
- **WHEN** 生产构建执行
- **THEN** 混淆代码 SHALL 移除所有 console 输出
- **THEN** disableConsoleOutput SHALL 配置为 true
#### Scenario: 不混淆第三方库
- **WHEN** 生产构建执行
- **THEN** node_modules 中的代码 SHALL NOT 被混淆
- **THEN** vendor chunksReact、TDesign、RechartsSHALL 保持原有压缩状态
#### Scenario: 不生成 Source Map
- **WHEN** 生产构建执行
- **THEN** 构建产物 SHALL NOT 包含 Source Map 文件
- **THEN** vite.config.ts 中 build.sourcemap SHALL 配置为 false
#### Scenario: 不启用控制流扁平化
- **WHEN** 配置混淆选项
- **THEN** controlFlowFlattening SHALL 配置为 false
- **THEN** 前端 SHALL NOT 使用控制流扁平化混淆
#### Scenario: 不注入死代码
- **WHEN** 配置混淆选项
- **THEN** deadCodeInjection SHALL 配置为 false
- **THEN** 前端 SHALL NOT 注入死代码
#### Scenario: 不启用自我保护
- **WHEN** 配置混淆选项
- **THEN** selfDefending SHALL 配置为 false
- **THEN** 前端 SHALL NOT 启用自我保护机制
#### Scenario: 构建产物验证
- **WHEN** 生产构建完成
- **THEN** 构建产物 SHALL 通过功能测试
- **THEN** 关键业务功能 SHALL 正常工作
- **THEN** 页面 SHALL 正常渲染
- **THEN** API 调用 SHALL 正常执行
### Requirement: 开发模式不混淆
前端 SHALL 仅在生产构建时启用混淆,开发模式不受影响。
#### Scenario: 开发模式跳过混淆
- **WHEN** 执行 vite dev 启动开发服务器
- **THEN** 代码 SHALL NOT 被混淆
- **THEN** 开发体验 SHALL 保持不变
- **THEN** 热更新 SHALL 正常工作

View File

@@ -502,6 +502,9 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 为生产构建前端
- **THEN** Vite SHALL 生成优化的静态文件
- **THEN** Vite SHALL 对业务代码执行混淆处理
- **THEN** 混淆 SHALL 仅应用于 src 目录下的业务代码
- **THEN** 混淆 SHALL NOT 应用于 node_modules 中的第三方库
### Requirement: 与后端 API 通信

View File

@@ -0,0 +1,92 @@
# GORM Logging
## Purpose
定义 GORM 日志适配器规范,将 GORM 日志桥接到 zap实现数据库操作日志统一格式和请求追踪。
## Requirements
### Requirement: GORM 日志适配器
系统 SHALL 提供 GORM 日志适配器,桥接到 zap。
#### Scenario: 适配器实现
- **WHEN** 初始化数据库连接
- **THEN** SHALL 使用 `logger.NewGormLogger(zapLogger)` 作为 GORM logger
- **THEN** 适配器 SHALL 实现 `gorm.logger.Interface` 接口
#### Scenario: 日志级别映射
- **WHEN** GORM 记录日志
- **THEN** GORM Silent SHALL 映射到不输出
- **THEN** GORM Error SHALL 映射到 `zap.ErrorLevel`
- **THEN** GORM Warn SHALL 映射到 `zap.WarnLevel`
- **THEN** GORM Info SHALL 映射到 `zap.DebugLevel`
#### Scenario: SQL 查询日志
- **WHEN** GORM 执行 SQL 查询
- **THEN** SHALL 使用 zap Debug 级别记录
- **THEN** SHALL 包含 SQL 语句
- **THEN** SHALL 包含执行耗时
- **THEN** SHALL 包含影响行数(如有)
### Requirement: Request ID 关联
GORM 日志 SHALL 支持 request_id 关联。
#### Scenario: 从 Context 提取 request_id
- **WHEN** GORM 记录日志
- **THEN** SHALL 从 `context.Context` 提取 request_id
- **THEN** 日志 SHALL 包含 request_id 字段
#### Scenario: Context 传播
- **WHEN** 使用 GORM 进行数据库操作
- **THEN** SHALL 使用 `db.WithContext(ctx)` 传递 context
- **THEN** GORM 日志 SHALL 自动包含 request_id
### Requirement: 模块标识
GORM 日志 SHALL 包含模块标识。
#### Scenario: 数据库模块标识
- **WHEN** GORM 记录日志
- **THEN** SHALL 包含 `logger: database` 标识
- **THEN** Console 格式 SHALL 输出为 `[database]`
### Requirement: 单行输出
GORM 日志 SHALL 单行输出。
#### Scenario: SQL 单行输出
- **WHEN** 记录 SQL 查询日志
- **THEN** SHALL 单行输出
- **THEN** SQL 语句中的换行 SHALL 替换为空格(除非保留原始格式)
#### Scenario: 错误日志单行
- **WHEN** 记录数据库错误日志
- **THEN** SHALL 单行输出
- **THEN** 错误详情 SHALL 作为字段值
### Requirement: 日志字段标准化
GORM 日志 SHALL 使用标准化字段。
#### Scenario: SQL 日志字段
- **WHEN** 记录 SQL 查询
- **THEN** SHALL 使用 `sql` 字段记录 SQL 语句
- **THEN** SHALL 使用 `rows_affected` 字段记录影响行数
- **THEN** SHALL 使用 `latency` 字段记录执行耗时
#### Scenario: 错误日志字段
- **WHEN** 记录数据库错误
- **THEN** SHALL 使用 `zap.Error(err)` 记录错误
- **THEN** SHALL NOT 使用 `zap.String("error", err.Error())`

View File

@@ -0,0 +1,140 @@
# Module Logging
## Purpose
定义模块化日志器规范,支持模块标识和 Context 传播,实现日志来源快速定位和请求追踪链完整性。
## Requirements
### Requirement: 模块标识注入
系统 SHALL 支持通过构造函数注入带模块标识的 logger。
#### Scenario: 构造函数注入模块 logger
- **WHEN** 创建需要记录日志的组件
- **THEN** 构造函数 SHALL 接受 `*zap.Logger` 参数
- **THEN** SHALL 使用 `logger.Named("module.name")` 绑定模块标识
- **THEN** SHALL 将 logger 存储在结构体字段中
#### Scenario: 模块标识格式
- **WHEN** 绑定模块标识
- **THEN** 单一职责包 SHALL 使用包名(如 `database`
- **THEN** 多实体包 SHALL 使用 `包名.实体名`(如 `handler.proxy`
- **THEN** 子包 SHALL 使用 `包名.子包名`(如 `handler.middleware`
#### Scenario: 模块标识输出
- **WHEN** 记录日志
- **THEN** Console 格式 SHALL 输出为 `[module.name]`
- **THEN** JSON 格式 SHALL 包含 `"logger":"module.name"` 字段
### Requirement: 禁止全局 logger
系统 SHALL 禁止在业务代码中使用全局 logger。
#### Scenario: 移除 zap.L() 调用
- **WHEN** 重构现有代码
- **THEN** SHALL 移除所有 `zap.L()` 调用
- **THEN** SHALL 通过构造函数注入 logger
- **THEN** 允许仅在测试代码中使用 `zap.L()``zap.NewNop()`
#### Scenario: 移除 zap.L() fallback
- **WHEN** 构造函数 logger 参数为 nil
- **THEN** SHALL NOT 使用 `zap.L()` 作为默认值
- **THEN** 调用方 SHALL 必须传入有效的 logger
### Requirement: Request ID 传播
系统 SHALL 通过 Context 传播 request_id。
#### Scenario: Context 注入 request_id
- **WHEN** 中间件生成 request_id
- **THEN** SHALL 存储到 `context.Context`
- **THEN** SHALL 使用类型安全的 context key
#### Scenario: 从 Context 提取 request_id
- **WHEN** 业务层需要记录日志
- **THEN** SHALL 从 `gin.Context` 提取 request_id
- **THEN** SHALL 使用 `logger.With(zap.String("request_id", id))` 创建带 request_id 的 logger
- **THEN** 日志 SHALL 自动包含 request_id 字段
#### Scenario: Request ID 辅助函数
- **WHEN** 使用 request_id
- **THEN** `pkg/logger` SHALL 提供 `RequestIDFromContext(ctx)` 辅助函数
- **THEN** 辅助函数 SHALL 返回 `zap.Field` 类型
### Requirement: 单行输出
系统 SHALL 保证所有日志单行输出。
#### Scenario: Console 单行输出
- **WHEN** 记录日志到 stdout
- **THEN** SHALL 单行输出
- **THEN** 字段之间 SHALL 使用空格分隔
#### Scenario: JSON 单行输出
- **WHEN** 记录日志到文件
- **THEN** SHALL 单行紧凑输出
- **THEN** SHALL NOT 使用美化缩进
#### Scenario: 多行数据保留
- **WHEN** 日志数据本身包含多行如堆栈跟踪、SQL 换行)
- **THEN** SHALL 保留原始多行格式
### Requirement: 模块命名规范
系统 SHALL 遵循模块命名规范。
#### Scenario: Handler 层命名
- **WHEN** 创建 handler 层 logger
- **THEN** ProxyHandler SHALL 使用 `handler.proxy`
- **THEN** ProviderHandler SHALL 使用 `handler.provider`
- **THEN** ModelHandler SHALL 使用 `handler.model`
- **THEN** StatsHandler SHALL 使用 `handler.stats`
- **THEN** Middleware SHALL 使用 `handler.middleware`
#### Scenario: Provider 层命名
- **WHEN** 创建 provider 层 logger
- **THEN** Client SHALL 使用 `provider.client`
#### Scenario: Conversion 层命名
- **WHEN** 创建 conversion 层 logger
- **THEN** Engine SHALL 使用 `conversion.engine`
- **THEN** OpenAI Adapter SHALL 使用 `conversion.openai`
- **THEN** Anthropic Adapter SHALL 使用 `conversion.anthropic`
#### Scenario: Service 层命名
- **WHEN** 创建 service 层 logger
- **THEN** RoutingCache SHALL 使用 `service.routing_cache`
- **THEN** StatsBuffer SHALL 使用 `service.stats_buffer`
- **THEN** ProviderService SHALL 使用 `service.provider`
- **THEN** ModelService SHALL 使用 `service.model`
- **THEN** RoutingService SHALL 使用 `service.routing`
- **THEN** StatsService SHALL 使用 `service.stats`
#### Scenario: Repository 层命名
- **WHEN** 创建 repository 层 logger
- **THEN** ProviderRepository SHALL 使用 `repository.provider`
- **THEN** ModelRepository SHALL 使用 `repository.model`
- **THEN** StatsRepository SHALL 使用 `repository.stats`
#### Scenario: Infrastructure 层命名
- **WHEN** 创建基础设施层 logger
- **THEN** Database SHALL 使用 `database`
- **THEN** Config SHALL 使用 `config`

View File

@@ -0,0 +1,107 @@
# MySQL Driver
## Purpose
支持 MySQL 作为可选数据库后端,通过配置选择 sqlite 或 mysql 驱动,提供 MySQL 连接管理、初始化和方言迁移文件。
## Requirements
### Requirement: MySQL 数据库驱动支持
系统 SHALL 支持通过配置项 `database.driver` 选择 `sqlite``mysql` 数据库驱动,默认值为 `sqlite`
#### Scenario: 默认使用 SQLite 驱动
- **WHEN** 配置中未指定 `database.driver`
- **THEN** SHALL 使用 `sqlite` 作为数据库驱动
- **THEN** SHALL 行为与现有逻辑完全一致
#### Scenario: 配置 MySQL 驱动
- **WHEN** 配置 `database.driver` 设置为 `mysql`
- **THEN** SHALL 使用 MySQL 驱动连接远程数据库
- **THEN** SHALL 使用 `gorm.io/driver/mysql` 打开连接
- **THEN** SHALL 构建 DSN 格式为 `{user}:{password}@tcp({host}:{port})/{dbname}?charset=utf8mb4&parseTime=true&loc=Local`
#### Scenario: driver 值不合法
- **WHEN** 配置 `database.driver` 不是 `sqlite``mysql`
- **THEN** SHALL 配置验证失败,拒绝启动
### Requirement: MySQL 连接配置
系统 SHALL 在 `DatabaseConfig` 中支持 MySQL 连接参数。
#### Scenario: MySQL 连接参数字段
- **WHEN** `database.driver``mysql`
- **THEN** SHALL 读取 `host`MySQL 主机地址,必填)
- **THEN** SHALL 读取 `port`MySQL 端口,默认 3306
- **THEN** SHALL 读取 `user`MySQL 用户名,必填)
- **THEN** SHALL 读取 `password`MySQL 密码,选填)
- **THEN** SHALL 读取 `dbname`(数据库名,必填)
#### Scenario: SQLite 模式忽略 MySQL 参数
- **WHEN** `database.driver``sqlite`
- **THEN** SHALL 忽略 MySQL 相关配置字段host/port/user/password/dbname
- **THEN** SHALL 仅使用 `path` 字段作为数据库文件路径
#### Scenario: MySQL 模式忽略 SQLite 参数
- **WHEN** `database.driver``mysql`
- **THEN** SHALL 忽略 `path` 字段
### Requirement: 数据库初始化公共包
系统 SHALL 提供 `internal/database` 公共包,封装数据库初始化、迁移执行和连接关闭逻辑,供 `cmd/server``cmd/desktop` 共同调用。
#### Scenario: 公共包 Init 函数
- **WHEN** 调用 `database.Init(cfg, logger)`
- **THEN** SHALL 根据 `cfg.Driver` 选择对应的 GORM 驱动打开连接
- **THEN** SHALL 执行对应方言的 goose 迁移
- **THEN** SHALL 配置连接池参数
- **THEN** SHALL 在 `driver=sqlite` 时执行 `PRAGMA journal_mode=WAL`
- **THEN** SHALL 在 `driver=mysql` 时跳过 SQLite 专有 PRAGMA
- **THEN** SHALL 返回 `*gorm.DB` 实例
#### Scenario: 公共包 Close 函数
- **WHEN** 调用 `database.Close(db)`
- **THEN** SHALL 获取底层 `sql.DB` 并关闭连接
#### Scenario: 迁移目录选择
- **WHEN** 执行迁移
- **THEN** SHALL 在 `driver=sqlite` 时使用 `migrations/sqlite/` 目录goose dialect 为 `sqlite3`
- **THEN** SHALL 在 `driver=mysql` 时使用 `migrations/mysql/` 目录goose dialect 为 `mysql`
### Requirement: MySQL 方言迁移文件
系统 SHALL 提供 MySQL 方言的初始迁移文件 `migrations/mysql/20260421000001_initial_schema.sql`
#### Scenario: providers 表
- **WHEN** 执行 MySQL 初始迁移
- **THEN** SHALL 创建 `providers` 表,字段:`id VARCHAR(36) PRIMARY KEY``name VARCHAR(255) NOT NULL``api_key VARCHAR(255) NOT NULL``base_url VARCHAR(255) NOT NULL``protocol VARCHAR(50) DEFAULT 'openai'``enabled BOOLEAN DEFAULT TRUE``created_at DATETIME(3)``updated_at DATETIME(3)`
#### Scenario: models 表
- **WHEN** 执行 MySQL 初始迁移
- **THEN** SHALL 创建 `models` 表,字段:`id VARCHAR(36) PRIMARY KEY``provider_id VARCHAR(36) NOT NULL``model_name VARCHAR(255) NOT NULL``enabled BOOLEAN DEFAULT TRUE``created_at DATETIME(3)`
- **THEN** SHALL 创建 `UNIQUE(provider_id, model_name)` 约束
- **THEN** SHALL 创建 `FOREIGN KEY (provider_id) REFERENCES providers(id) ON DELETE CASCADE` 约束
- **THEN** SHALL 创建 `idx_models_provider_id``idx_models_model_name` 索引
#### Scenario: usage_stats 表
- **WHEN** 执行 MySQL 初始迁移
- **THEN** SHALL 创建 `usage_stats` 表,字段:`id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY``provider_id VARCHAR(36) NOT NULL``model_name VARCHAR(255) NOT NULL``request_count INT DEFAULT 0``date DATE NOT NULL`
- **THEN** SHALL 创建 `UNIQUE(provider_id, model_name, date)` 约束
- **THEN** SHALL 创建 `idx_usage_stats_provider_model_date` 复合索引
#### Scenario: Down 迁移
- **WHEN** 执行 MySQL down 迁移
- **THEN** SHALL 按正确顺序删除索引和表usage_stats → models → providers

View File

@@ -0,0 +1,104 @@
# MySQL Testing
## Purpose
提供 MySQL 数据库专项测试能力,验证迁移正确性、外键约束、并发写入等数据库特定行为。
## Requirements
### Requirement: MySQL 测试环境可启动
系统 SHALL 提供 Docker Compose 配置以启动 MySQL 8.0 测试环境。
#### Scenario: 启动 MySQL 测试容器
- **WHEN** 执行 `make test-mysql-up`
- **THEN** 启动 MySQL 8.0 容器,端口 13306
- **AND** 创建数据库 `nex_test`
- **AND** 容器数据存储在内存盘tmpfs
#### Scenario: 销毁 MySQL 测试容器
- **WHEN** 执行 `make test-mysql-down`
- **THEN** 停止并删除容器
- **AND** 所有数据被销毁
### Requirement: MySQL 测试可通过 build tag 控制
MySQL 测试 SHALL 使用 `// +build mysql` build tag默认不运行。
#### Scenario: 默认测试不包含 MySQL 测试
- **WHEN** 执行 `go test ./...`
- **THEN** 不运行 `tests/mysql/` 下的测试
#### Scenario: 启用 MySQL 测试
- **WHEN** 执行 `go test -tags=mysql ./tests/mysql/...`
- **THEN** 运行所有 MySQL 测试
### Requirement: MySQL 迁移正确执行
MySQL 测试 SHALL 验证迁移脚本在 MySQL 环境下正确执行。
#### Scenario: 迁移创建所有表
- **WHEN** 运行 MySQL 迁移
- **THEN** 创建 `providers``models``usage_stats`
- **AND** 字段类型符合 MySQL 迁移文件定义VARCHAR、DATETIME(3)、BOOLEAN 等)
- **AND** 索引 `idx_models_provider_id``idx_models_model_name``idx_usage_stats_provider_model_date` 创建成功
#### Scenario: 迁移可重复执行
- **WHEN** 在已迁移的数据库上再次运行迁移
- **THEN** 不报错,数据库状态不变
### Requirement: MySQL 外键约束生效
MySQL 测试 SHALL 验证外键约束行为符合预期。
#### Scenario: 外键约束阻止无效引用
- **WHEN** 创建 model 时 `provider_id` 不存在
- **THEN** 操作失败,返回外键约束错误
#### Scenario: 级联删除生效
- **WHEN** 删除 provider
- **THEN** 该 provider 的所有 models 被级联删除
### Requirement: MySQL UNIQUE 约束生效
MySQL 测试 SHALL 验证 UNIQUE 约束行为符合预期。
#### Scenario: models 表 UNIQUE 约束
- **WHEN** 尝试创建相同 `(provider_id, model_name)` 组合的 model
- **THEN** 操作失败,返回唯一约束错误
#### Scenario: usage_stats 表 UNIQUE 约束
- **WHEN** 尝试创建相同 `(provider_id, model_name, date)` 组合的 usage_stats
- **THEN** 操作失败,返回唯一约束错误
### Requirement: MySQL 并发写入正确
MySQL 测试 SHALL 验证并发写入不丢失数据。
#### Scenario: 并发记录 usage_stats
- **WHEN** 10 个 goroutine 并发调用 `statsRepo.Record(providerID, modelName)`
- **THEN** 最终 `request_count` 等于 10
- **AND** 无数据丢失或重复
#### Scenario: 并发创建相同 provider
- **WHEN** 10 个 goroutine 并发创建相同 ID 的 provider
- **THEN** 仅 1 个成功,其他 9 个失败
#### Scenario: 并发创建相同 model
- **WHEN** 10 个 goroutine 并发创建相同 `(provider_id, model_name)` 的 model
- **THEN** 仅 1 个成功,其他 9 个失败
### Requirement: MySQL 测试命令完整
Makefile SHALL 提供完整的 MySQL 测试命令。
#### Scenario: 完整测试流程
- **WHEN** 执行 `make test-mysql`
- **THEN** 启动 Docker MySQL
- **AND** 等待 MySQL 就绪
- **AND** 运行所有 MySQL 测试
- **AND** 销毁容器
#### Scenario: 快速测试(容器已运行)
- **WHEN** 执行 `make test-mysql-quick`
- **THEN** 直接运行测试,不管理容器生命周期

View File

@@ -15,7 +15,7 @@
- **WHEN** 应用启动
- **THEN** SHALL 初始化 zap logger
- **THEN** SHALL 根据配置设置日志级别
- **THEN** SHALL 配置日志输出格式为 JSON
- **THEN** SHALL 配置日志输出格式为 JSON(文件)和 Consolestdout
#### Scenario: 日志字段
@@ -27,9 +27,9 @@
#### Scenario: 日志注入
- **WHEN** 创建需要记录日志的组件
- **THEN** SHALL 通过构造函数注入 *zap.Logger
- **THEN** SHALL 允许 logger 参数为 nil此时使用全局 logger zap.L()
- **THEN** SHALL NOT 直接使用全局 logger zap.L()(除非在构造函数默认值中
- **THEN** SHALL 通过构造函数注入 `*zap.Logger`
- **THEN** 调用方 SHALL 必须传入有效的 logger
- **THEN** SHALL NOT 使用全局 logger `zap.L()`(测试代码除外
### Requirement: 支持日志滚动
@@ -76,7 +76,8 @@
#### Scenario: 日志关联请求 ID
- **WHEN** 记录请求相关的日志
- **THEN** SHALL 自动包含请求 ID 字段
- **THEN** SHALL 从 Context 提取 request_id
- **THEN** SHALL 自动包含 request_id 字段
- **THEN** SHALL 支持通过请求 ID 检索日志
### Requirement: 记录请求日志
@@ -116,7 +117,7 @@
- **WHEN** 配置为生产模式
- **THEN** SHALL 使用 info 级别
- **THEN** SHALL 输出到文件
- **THEN** SHALL 输出到控制台和文件
### Requirement: 日志存储位置
@@ -141,12 +142,77 @@ ConversionEngine SHALL 通过依赖注入获取 logger。
#### Scenario: ConversionEngine 构造函数
- **WHEN** 创建 ConversionEngine 实例
- **THEN** 构造函数 SHALL 接受 *zap.Logger 参数
- **THEN** 参数为 nil 时 SHALL 使用 zap.L() 作为默认值
- **THEN** 构造函数 SHALL 接受 `*zap.Logger` 参数
- **THEN** 调用方 SHALL 必须传入有效的 logger
- **THEN** SHALL 将 logger 存储在结构体字段中
#### Scenario: ConversionEngine 日志使用
- **WHEN** ConversionEngine 记录日志
- **THEN** SHALL 使用注入的 logger 字段
- **THEN** SHALL NOT 直接调用 zap.L()
- **THEN** SHALL NOT 直接调用 `zap.L()`
### Requirement: 字段标准化
系统 SHALL 使用标准化字段定义。
#### Scenario: 标准字段常量
- **WHEN** 记录日志字段
- **THEN** SHALL 使用 `pkg/logger/field.go` 中定义的常量
- **THEN** 字段名 SHALL 包括:`request_id``provider_id``model_name``method``path``status``latency`
#### Scenario: 错误字段统一
- **WHEN** 记录错误日志
- **THEN** SHALL 使用 `zap.Error(err)`
- **THEN** SHALL NOT 使用 `zap.String("error", err.Error())`
#### Scenario: 字段构造函数
- **WHEN** 构造日志字段
- **THEN** SHALL 优先使用 `pkg/logger` 提供的辅助函数
- **THEN** 辅助函数 SHALL 返回 `zap.Field` 类型
### Requirement: 启动日志统一
系统 SHALL 在启动阶段使用结构化日志。
#### Scenario: 最小化 logger 初始化
- **WHEN** 应用启动时配置加载前
- **THEN** SHALL 初始化最小化 logger仅 stdoutconsole 格式)
- **THEN** SHALL 支持记录启动错误
#### Scenario: Logger 升级
- **WHEN** 配置加载完成
- **THEN** SHALL 升级为完整 logger文件 + stdout
- **THEN** SHALL 应用配置的日志级别和轮转策略
#### Scenario: 配置摘要结构化
- **WHEN** 打印配置摘要
- **THEN** SHALL 使用结构化日志记录
- **THEN** SHALL NOT 使用 `fmt.Printf``fmt.Println`
### Requirement: 单行输出
系统 SHALL 保证所有日志单行输出。
#### Scenario: Console 单行
- **WHEN** 输出到 stdout
- **THEN** SHALL 单行输出
- **THEN** 字段之间 SHALL 使用空格分隔
#### Scenario: JSON 单行
- **WHEN** 输出到文件
- **THEN** SHALL 单行紧凑 JSON
- **THEN** SHALL NOT 使用美化缩进
#### Scenario: 多行数据保留
- **WHEN** 日志数据本身包含多行堆栈跟踪、SQL 换行等)
- **THEN** SHALL 保留原始多行格式

View File

@@ -93,7 +93,21 @@
- **WHEN** 同时处理多个并发请求
- **THEN** 网关 SHALL 使用原子操作正确增加每个请求的计数
- **THEN** 不 SHALL 因并发写入而丢失统计
- **THEN** SHALL 使用 StatsBuffer 的内存计数器
- **THEN** SHALL 使用 upsert 操作保证原子性
#### Scenario: 并发调用 Record 方法
- **WHEN** 多个 goroutine 并发调用 StatsRepository.Record
- **THEN** SHALL 使用 INSERT ... ON DUPLICATE KEY UPDATE (MySQL) 或 INSERT ... ON CONFLICT DO UPDATE (SQLite)
- **THEN** SHALL 保证所有并发调用的计数都被正确累加
- **THEN** 不 SHALL 因 UNIQUE 约束冲突而丢失数据
#### Scenario: 并发调用 BatchUpdate 方法
- **WHEN** 多个 goroutine 并发调用 StatsRepository.BatchUpdate
- **THEN** SHALL 使用 upsert 操作保证原子性
- **THEN** SHALL 正确累加所有 delta 值
- **THEN** 不 SHALL 因并发写入而丢失统计
### Requirement: 使用 service 层处理业务逻辑
@@ -125,14 +139,14 @@ Service SHALL 通过 StatsRepository 访问数据。
- **WHEN** StatsBuffer 刷新统计
- **THEN** SHALL 调用 StatsRepository.BatchUpdate
- **THEN** SHALL 使用事务更新或创建统计记录
- **THEN** SHALL 使用 upsert 操作更新或创建统计记录
- **THEN** SHALL 支持增量更新request_count + delta
#### Scenario: 事务处理
#### Scenario: upsert 操作
- **WHEN** 记录统计
- **THEN** SHALL 在 repository 层使用数据库事务
- **THEN** SHALL 保并发安全
- **THEN** SHALL 在 repository 层使用 upsert 操作
- **THEN** SHALL 保证原子性和并发安全
### Requirement: 统计查询优化
@@ -168,11 +182,18 @@ StatsRepository SHALL 新增 BatchUpdate 方法支持批量增量更新。
#### Scenario: BatchUpdate 更新现有记录
- **WHEN** 调用 BatchUpdate 且当日记录存在
- **THEN** SHALL 使用事务更新 request_count = request_count + delta
- **THEN** SHALL 使用 upsert 操作更新 request_count = request_count + delta
- **THEN** SHALL 保证原子性,无竞态条件
- **THEN** SHALL 不创建新记录
#### Scenario: BatchUpdate 创建新记录
- **WHEN** 调用 BatchUpdate 且当日记录不存在
- **THEN** SHALL 创建新记录request_count = delta
- **THEN** SHALL 使用事务保证原子性
- **THEN** SHALL 使用 upsert 操作保证原子性
#### Scenario: BatchUpdate 并发安全
- **WHEN** 多个 BatchUpdate 调用同时执行
- **THEN** SHALL 保证所有 delta 都被正确累加
- **THEN** SHALL 不因并发冲突而丢失数据

View File

@@ -18,7 +18,7 @@ if [ -f "${BUILD_DIR}/nex-mac-arm64" ]; then
elif [ -f "${BUILD_DIR}/nex-mac-amd64" ]; then
cp "${BUILD_DIR}/nex-mac-amd64" "${BUILD_DIR}/${APP_NAME}.app/Contents/MacOS/nex"
else
echo "错误: 未找到 macOS 二进制文件,请先运行 make desktop-mac"
echo "错误: 未找到 macOS 二进制文件,请先运行 make desktop-build-mac"
exit 1
fi

354
scripts/init/init-dev-branch.py Executable file
View File

@@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""开发分支工作区初始化脚本
用于创建基于远端分支或新建的开发分支工作区。
"""
import argparse
import subprocess
import sys
from pathlib import Path
def run_git(args: list[str]) -> subprocess.CompletedProcess:
"""执行 git 命令
Args:
args: git 命令参数列表
Returns:
subprocess.CompletedProcess 对象
"""
return subprocess.run(
["git"] + args,
capture_output=True,
text=True,
check=False
)
def get_root_dir() -> Path:
"""获取 git 仓库根目录
Raises:
RuntimeError: 不在 git 仓库中
"""
result = run_git(["rev-parse", "--show-toplevel"])
if result.returncode != 0:
raise RuntimeError("不在 git 仓库中")
return Path(result.stdout.strip())
def fetch_remote() -> bool:
"""从远端获取最新分支信息
Returns:
是否成功获取
"""
result = run_git(["fetch", "--quiet"])
if result.returncode != 0:
print("警告: 无法获取远端信息,继续使用本地数据")
return False
return True
def get_all_remote_branches() -> list[str]:
"""获取所有远端分支列表
Returns:
远端分支列表
"""
result = run_git(["branch", "-r"])
if result.returncode != 0:
return []
return [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
def get_remote_branches(branch_name: str) -> list[str]:
"""获取远端匹配的分支列表
Args:
branch_name: 分支名称
Returns:
匹配的远端分支列表
"""
result = run_git(["branch", "-r"])
if result.returncode != 0:
return []
return [
line.strip()
for line in result.stdout.strip().split("\n")
if line.strip() and line.strip().endswith(f"/{branch_name}")
]
def branch_exists_local(branch_name: str) -> bool:
"""检查本地分支是否存在
Args:
branch_name: 分支名称
Returns:
是否存在
"""
result = run_git(["show-ref", "--verify", "--quiet", f"refs/heads/{branch_name}"])
return result.returncode == 0
def worktree_exists(worktree_path: Path) -> bool:
"""检查工作区是否存在
Args:
worktree_path: 工作区路径
Returns:
是否存在
"""
result = run_git(["worktree", "list"])
if result.returncode != 0:
return False
return str(worktree_path) in result.stdout
def extract_branch_name(remote_branch: str) -> str:
"""从远端分支名提取分支名
Args:
remote_branch: 远端分支名(如 origin/feature-123
Returns:
分支名(如 feature-123
"""
return remote_branch.split("/", 1)[1] if "/" in remote_branch else remote_branch
def validate_branch_creation(branch_name: str, worktree_path: Path) -> None:
"""验证是否可以创建分支和工作区
Args:
branch_name: 分支名称
worktree_path: 工作区路径
Raises:
RuntimeError: 验证失败
"""
if worktree_path.exists():
raise RuntimeError(f"工作区已存在于 {worktree_path}")
if worktree_exists(worktree_path):
raise RuntimeError(f"工作区 '{branch_name}' 已存在")
if branch_exists_local(branch_name):
raise RuntimeError(f"本地分支 '{branch_name}' 已存在")
def select_from_list(items: list[str], prompt: str, allow_create: bool = False) -> str | None:
"""让用户从列表中选择
Args:
items: 选项列表
prompt: 提示信息
allow_create: 是否允许创建新分支
Returns:
用户选择的项,或 None 表示创建新分支
"""
if not items:
return None
print(prompt)
for i, item in enumerate(items, 1):
print(f" {i}\t{item}")
if allow_create:
print(f" {len(items) + 1}\t创建新分支")
print()
max_choice = len(items) + 1 if allow_create else len(items)
while True:
try:
selection = int(input(f"请选择 (1-{max_choice}): "))
if 1 <= selection <= len(items):
selected = items[selection - 1]
print(f"已选择: {selected}")
return selected
if allow_create and selection == len(items) + 1:
return None
print(f"错误: 请输入 1-{max_choice} 之间的数字")
except ValueError:
print("错误: 请输入有效的数字")
except KeyboardInterrupt:
print("\n操作已取消")
sys.exit(1)
def input_branch_name() -> str:
"""让用户输入分支名称
Returns:
分支名称
"""
while True:
try:
name = input("请输入新分支名称: ").strip()
if name:
return name
print("错误: 分支名称不能为空")
except KeyboardInterrupt:
print("\n操作已取消")
sys.exit(1)
def create_worktree(branch_name: str, worktree_path: Path, base_branch: str | None = None) -> None:
"""创建工作区
Args:
branch_name: 新分支名称
worktree_path: 工作区路径
base_branch: 基础分支(可选)
Raises:
RuntimeError: 创建失败
"""
args = ["worktree", "add", "-b", branch_name, str(worktree_path)]
if base_branch:
args.append(base_branch)
result = run_git(args)
if result.returncode != 0:
raise RuntimeError(f"创建工作区失败: {result.stderr.strip()}")
def create_worktree_with_validation(
branch_name: str,
worktree_path: Path,
base_branch: str | None = None
) -> None:
"""验证并创建工作区
Args:
branch_name: 新分支名称
worktree_path: 工作区路径
base_branch: 基础分支(可选)
"""
validate_branch_creation(branch_name, worktree_path)
create_worktree(branch_name, worktree_path, base_branch)
print(f"工作区已创建于 {worktree_path}")
def handle_with_branch_name(branch_name: str, worktrees_dir: Path) -> None:
"""处理有分支名参数的情况
Args:
branch_name: 分支名称
worktrees_dir: 工作区目录
"""
worktree_path = worktrees_dir / branch_name
try:
validate_branch_creation(branch_name, worktree_path)
except RuntimeError as e:
print(f"错误: {e}")
sys.exit(1)
remote_branches = get_remote_branches(branch_name)
try:
if remote_branches:
selected_branch = select_from_list(
remote_branches,
"找到远端分支:",
allow_create=True
)
if selected_branch:
create_worktree(branch_name, worktree_path, selected_branch)
else:
create_worktree(branch_name, worktree_path)
else:
print("未找到远端分支,创建新分支")
create_worktree(branch_name, worktree_path)
print(f"工作区已创建于 {worktree_path}")
except RuntimeError as e:
print(f"错误: {e}")
sys.exit(1)
def handle_without_branch_name(worktrees_dir: Path) -> None:
"""处理无分支名参数的情况
Args:
worktrees_dir: 工作区目录
"""
remote_branches = get_all_remote_branches()
if not remote_branches:
print("未找到远端分支")
sys.exit(1)
selected_branch = select_from_list(
remote_branches,
"远端分支列表:",
allow_create=True
)
if selected_branch:
branch_name = extract_branch_name(selected_branch)
worktree_path = worktrees_dir / branch_name
try:
create_worktree_with_validation(branch_name, worktree_path, selected_branch)
except RuntimeError as e:
print(f"错误: {e}")
sys.exit(1)
else:
branch_name = input_branch_name()
worktree_path = worktrees_dir / branch_name
try:
create_worktree_with_validation(branch_name, worktree_path)
except RuntimeError as e:
print(f"错误: {e}")
sys.exit(1)
def main() -> None:
parser = argparse.ArgumentParser(
description="创建开发分支工作区",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
%(prog)s # 列出远端分支供选择
%(prog)s feature-123 # 创建 feature-123 工作区
%(prog)s bugfix-456 # 创建 bugfix-456 工作区
"""
)
parser.add_argument("branch_name", nargs="?", help="分支名称(可选)")
args = parser.parse_args()
try:
root_dir = get_root_dir()
except RuntimeError as e:
print(f"错误: {e}")
sys.exit(1)
worktrees_dir = root_dir / ".worktrees"
worktrees_dir.mkdir(parents=True, exist_ok=True)
print("正在从远端获取最新分支信息...")
fetch_remote()
if args.branch_name:
handle_with_branch_name(args.branch_name, worktrees_dir)
else:
handle_without_branch_name(worktrees_dir)
if __name__ == "__main__":
main()

View File

@@ -1,18 +0,0 @@
#!/bin/bash
set -euo pipefail
if [ $# -eq 0 ]; then
echo "Usage: $0 <branch-name>"
exit 1
fi
BRANCH_NAME="$1"
ROOT_DIR=$(git rev-parse --show-toplevel)
WORKTREES_DIR="$ROOT_DIR/.worktrees"
mkdir -p "$WORKTREES_DIR"
git worktree add -b "$BRANCH_NAME" "$WORKTREES_DIR/$BRANCH_NAME"
echo "Worktree created at $WORKTREES_DIR/$BRANCH_NAME"