From 4c6b49099d674458bb6bef5ea31d3ca77be820fd Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Fri, 24 Apr 2026 13:01:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=85=8D=E7=BD=AE=20golangci-lint=20?= =?UTF-8?q?=E9=9D=99=E6=80=81=E5=88=86=E6=9E=90=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=AD=98=E9=87=8F=E8=BF=9D=E8=A7=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 backend/.golangci.yml 配置 12 个 linter(forbidigo、errorlint、errcheck、staticcheck、revive、gocritic、gosec、bodyclose、noctx、nilerr、goimports、gocyclo) - 新增 lefthook.yml 配置 pre-commit hook 自动运行 lint - 修复存量代码违规:errors.Is/As 替换、zap.Error 替换、import 排序、errcheck 修复 - 更新 README 补充编码规范说明 - 归档 backend-code-lint 变更 --- README.md | 3 + backend/.golangci.yml | 91 ++++++ backend/README.md | 3 +- backend/cmd/desktop/dialog_darwin.go | 10 +- backend/cmd/desktop/dialog_linux.go | 5 +- backend/cmd/desktop/dialog_logger.go | 15 + backend/cmd/desktop/main.go | 47 +-- backend/cmd/desktop/port_test.go | 14 +- backend/cmd/desktop/singleton_test.go | 28 +- backend/cmd/desktop/static_test.go | 4 +- backend/cmd/server/main.go | 6 +- backend/internal/config/config.go | 76 +++-- backend/internal/config/config_test.go | 2 +- backend/internal/config/models.go | 19 +- .../internal/conversion/anthropic/adapter.go | 17 +- .../conversion/anthropic/adapter_test.go | 43 +-- .../internal/conversion/anthropic/decoder.go | 106 +++++-- .../internal/conversion/anthropic/encoder.go | 12 +- .../conversion/anthropic/encoder_test.go | 48 ++-- .../conversion/anthropic/stream_decoder.go | 13 +- .../anthropic/stream_decoder_test.go | 30 +- .../anthropic/stream_encoder_test.go | 15 +- .../conversion/anthropic/supplemental_test.go | 30 +- .../internal/conversion/anthropic/types.go | 36 +-- .../internal/conversion/canonical/extended.go | 4 +- .../internal/conversion/canonical/stream.go | 20 +- .../internal/conversion/canonical/types.go | 50 ++-- .../conversion/canonical/types_test.go | 14 +- backend/internal/conversion/engine.go | 79 +++--- backend/internal/conversion/engine_test.go | 14 +- backend/internal/conversion/errors.go | 22 +- backend/internal/conversion/interface.go | 10 +- backend/internal/conversion/openai/adapter.go | 23 +- .../conversion/openai/adapter_test.go | 10 +- backend/internal/conversion/openai/decoder.go | 53 +++- backend/internal/conversion/openai/encoder.go | 2 +- .../conversion/openai/encoder_test.go | 57 ++-- .../conversion/openai/stream_decoder_test.go | 54 ++-- .../conversion/openai/stream_encoder.go | 10 +- .../conversion/openai/stream_encoder_test.go | 8 +- .../conversion/openai/supplemental_test.go | 18 +- backend/internal/conversion/openai/types.go | 74 ++--- backend/internal/database/database.go | 8 +- backend/internal/database/database_test.go | 4 +- backend/internal/domain/provider.go | 1 - .../handler/handler_supplemental_test.go | 12 +- backend/internal/handler/handler_test.go | 7 +- .../internal/handler/middleware/logging.go | 1 - backend/internal/handler/model_handler.go | 18 +- backend/internal/handler/provider_handler.go | 14 +- backend/internal/handler/proxy_handler.go | 75 +++-- .../internal/handler/proxy_handler_test.go | 20 +- backend/internal/handler/stats_handler.go | 4 +- backend/internal/provider/client.go | 8 +- backend/internal/provider/client_test.go | 49 ++-- .../internal/repository/provider_repo_impl.go | 5 +- .../internal/repository/repository_test.go | 4 +- .../internal/repository/stats_repo_impl.go | 10 +- .../internal/service/model_service_impl.go | 14 +- .../internal/service/provider_service_impl.go | 4 +- backend/internal/service/routing_cache.go | 28 +- .../internal/service/routing_cache_test.go | 7 +- .../internal/service/routing_service_impl.go | 3 +- .../service/service_supplemental_test.go | 10 +- backend/internal/service/service_test.go | 31 +- backend/internal/service/stats_buffer.go | 45 ++- backend/internal/service/stats_buffer_test.go | 34 ++- backend/pkg/errors/errors.go | 22 +- backend/pkg/errors/errors_test.go | 6 +- backend/pkg/logger/logger_test.go | 4 +- backend/pkg/logger/rotate.go | 4 +- backend/tests/config/config_test.go | 10 +- backend/tests/integration/conversion_test.go | 109 ++++--- .../tests/integration/e2e_conversion_test.go | 267 ++++++++++-------- backend/tests/integration/integration_test.go | 27 +- backend/tests/integration/testhelper.go | 4 +- backend/tests/migration_test.go | 3 +- backend/tests/mocks/mock_model_repository.go | 3 +- backend/tests/mocks/mock_model_service.go | 3 +- backend/tests/mocks/mock_provider_client.go | 3 +- .../tests/mocks/mock_provider_repository.go | 3 +- backend/tests/mocks/mock_provider_service.go | 3 +- backend/tests/mocks/mock_routing_service.go | 3 +- backend/tests/mocks/mock_stats_repository.go | 3 +- backend/tests/mocks/mock_stats_service.go | 3 +- backend/tests/mysql/constraint_test.go | 4 +- lefthook.yml | 5 + .../changes/backend-code-lint/.openspec.yaml | 2 - openspec/changes/backend-code-lint/design.md | 130 --------- .../changes/backend-code-lint/proposal.md | 29 -- .../backend-code-lint/specs/code-lint/spec.md | 174 ------------ .../specs/error-handling/spec.md | 45 --- .../specs/module-logging/spec.md | 32 --- .../specs/pre-commit-hook/spec.md | 54 ---- .../specs/structured-logging/spec.md | 30 -- openspec/changes/backend-code-lint/tasks.md | 42 --- 96 files changed, 1290 insertions(+), 1348 deletions(-) create mode 100644 backend/.golangci.yml create mode 100644 backend/cmd/desktop/dialog_logger.go create mode 100644 lefthook.yml delete mode 100644 openspec/changes/backend-code-lint/.openspec.yaml delete mode 100644 openspec/changes/backend-code-lint/design.md delete mode 100644 openspec/changes/backend-code-lint/proposal.md delete mode 100644 openspec/changes/backend-code-lint/specs/code-lint/spec.md delete mode 100644 openspec/changes/backend-code-lint/specs/error-handling/spec.md delete mode 100644 openspec/changes/backend-code-lint/specs/module-logging/spec.md delete mode 100644 openspec/changes/backend-code-lint/specs/pre-commit-hook/spec.md delete mode 100644 openspec/changes/backend-code-lint/specs/structured-logging/spec.md delete mode 100644 openspec/changes/backend-code-lint/tasks.md diff --git a/README.md b/README.md index 641d0c9..61a7870 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,9 @@ make frontend-test-coverage # 前端覆盖率 ## 开发 ```bash +# 首次克隆后安装 Git hooks +lefthook install + # 顶层便捷命令 make dev # 启动开发环境(并行启动后端和前端) make build # 构建所有产物 diff --git a/backend/.golangci.yml b/backend/.golangci.yml new file mode 100644 index 0000000..a60c343 --- /dev/null +++ b/backend/.golangci.yml @@ -0,0 +1,91 @@ +run: + timeout: 5m + tests: true + +linters: + disable-all: true + enable: + - forbidigo + - errorlint + - errcheck + - staticcheck + - revive + - gocritic + - gosec + - bodyclose + - noctx + - nilerr + - goimports + - gocyclo + +linters-settings: + errcheck: + check-blank: true + check-type-assertions: true + exclude-functions: + - fmt.Fprintf + forbidigo: + analyze-types: true + forbid: + - p: '^fmt\.Print.*$' + msg: 使用 zap logger,不要直接输出到 stdout/stderr + - p: '^fmt\.Fprint.*$' + msg: 使用 zap logger,不要直接输出到 stdout/stderr + - p: '^log\.(Print|Println|Printf|Fatal|Fatalln|Fatalf|Panic|Panicln|Panicf)$' + msg: 使用 zap logger,不要使用标准库 log + - p: '^zap\.L$' + msg: 通过依赖注入传递 *zap.Logger,不要使用全局 logger + - p: '^zap\.S$' + msg: 不使用 Sugar logger + revive: + rules: + - name: exported + - name: var-naming + - name: indent-error-flow + - name: error-strings + - name: error-return + - name: blank-imports + - name: context-as-argument + - name: unexported-return + goimports: + local-prefixes: nex/backend + gocyclo: + min-complexity: 10 + +issues: + exclude-dirs: + - tests/mocks + exclude-generated: true + exclude-rules: + - path: '(_test\.go|tests/)' + linters: + - forbidigo + - path: '(_test\.go|tests/)' + linters: + - errcheck + source: '(^\s*_\s*=|,\s*_)' + - path: 'tests/integration/e2e_conversion_test\.go' + linters: + - errcheck + - path: '(_test\.go|tests/)' + linters: + - revive + text: '^exported:' + - path: '(_test\.go|tests/)' + linters: + - gosec + text: 'G(101|401|501)' + - path: '(_test\.go|tests/)' + linters: + - gocyclo + text: 'cyclomatic complexity (1[1-9]|20) of .* is high \(> 10\)' + - linters: + - revive + text: '(that stutters|BuildUrl should be BuildURL|ConvertHttpRequest should be ConvertHTTPRequest|ConvertHttpResponse should be ConvertHTTPResponse)' + - path: 'internal/conversion/.*\.go' + linters: + - gocyclo + - gocritic + - path: '(internal/provider/client\.go|internal/service/model_service_impl\.go|internal/service/stats_buffer\.go|internal/handler/proxy_handler\.go|cmd/(desktop|server)/main\.go)' + linters: + - gocyclo diff --git a/backend/README.md b/backend/README.md index 7533e4f..876976e 100644 --- a/backend/README.md +++ b/backend/README.md @@ -609,6 +609,7 @@ err := v.Validate(myStruct) - **JSON 解析**:使用 `encoding/json` 标准库,不手动扫描字节 - **字符串拼接**:使用 `strings.Join`,不手写循环拼接 -- **错误判断**:使用 `errors.Is` / `errors.As`,不使用字符串匹配 +- **错误判断**:使用 `errors.Is` / `errors.As`,不使用字符串匹配(lint 强约束:errorlint 禁止 `err ==` 直接比较和 `err.(*T)` 直接断言) - **日志使用**:通过依赖注入 `*zap.Logger`,不直接调用 `zap.L()` +- **日志 error 字段**:使用 `zap.Error(err)`,不使用 `zap.String("error", err.Error())` 手工字符串化 - **字符串分割**:使用 `strings.SplitN` 等精确分割,不使用索引切片 diff --git a/backend/cmd/desktop/dialog_darwin.go b/backend/cmd/desktop/dialog_darwin.go index 6ffcfe1..9cfe217 100644 --- a/backend/cmd/desktop/dialog_darwin.go +++ b/backend/cmd/desktop/dialog_darwin.go @@ -6,19 +6,25 @@ import ( "fmt" "os/exec" "strings" + + "go.uber.org/zap" ) 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() + if err := exec.Command("osascript", "-e", script).Run(); err != nil { + dialogLogger().Warn("显示错误对话框失败", zap.Error(err)) + } } 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() + if err := exec.Command("osascript", "-e", script).Run(); err != nil { + dialogLogger().Warn("显示关于对话框失败", zap.Error(err)) + } } func escapeAppleScript(s string) string { diff --git a/backend/cmd/desktop/dialog_linux.go b/backend/cmd/desktop/dialog_linux.go index f268f5d..3bf7159 100644 --- a/backend/cmd/desktop/dialog_linux.go +++ b/backend/cmd/desktop/dialog_linux.go @@ -4,7 +4,6 @@ package main import ( "fmt" - "os" "os/exec" "sync" ) @@ -63,7 +62,7 @@ func showError(title, message string) { exec.Command("xmessage", "-center", fmt.Sprintf("%s: %s", title, message)).Run() default: - fmt.Fprintf(os.Stderr, "错误: %s: %s\n", title, message) + dialogLogger().Error("无法显示错误对话框") } } @@ -83,6 +82,6 @@ func showAbout() { exec.Command("xmessage", "-center", fmt.Sprintf("关于 Nex Gateway: %s", message)).Run() default: - fmt.Fprintf(os.Stderr, "关于 Nex Gateway: %s\n", message) + dialogLogger().Info("关于 Nex Gateway") } } diff --git a/backend/cmd/desktop/dialog_logger.go b/backend/cmd/desktop/dialog_logger.go new file mode 100644 index 0000000..28fe3f2 --- /dev/null +++ b/backend/cmd/desktop/dialog_logger.go @@ -0,0 +1,15 @@ +package main + +import ( + "go.uber.org/zap" + + pkgLogger "nex/backend/pkg/logger" +) + +func dialogLogger() *zap.Logger { + if zapLogger != nil { + return zapLogger + } + + return pkgLogger.NewMinimal() +} diff --git a/backend/cmd/desktop/main.go b/backend/cmd/desktop/main.go index 8882da8..5dfdc96 100644 --- a/backend/cmd/desktop/main.go +++ b/backend/cmd/desktop/main.go @@ -13,10 +13,7 @@ import ( "strings" "time" - "github.com/getlantern/systray" - "github.com/gin-gonic/gin" - "github.com/gofrs/flock" - "go.uber.org/zap" + "nex/embedfs" "nex/backend/internal/config" "nex/backend/internal/conversion" @@ -28,9 +25,13 @@ import ( "nex/backend/internal/provider" "nex/backend/internal/repository" "nex/backend/internal/service" - pkgLogger "nex/backend/pkg/logger" - "nex/embedfs" + "github.com/getlantern/systray" + "github.com/gin-gonic/gin" + "github.com/gofrs/flock" + "go.uber.org/zap" + + pkgLogger "nex/backend/pkg/logger" ) var ( @@ -51,12 +52,16 @@ func main() { showError("Nex Gateway", "已有 Nex 实例运行") os.Exit(1) } - defer singleLock.Unlock() + defer func() { + if err := singleLock.Unlock(); err != nil { + minimalLogger.Warn("释放实例锁失败", zap.Error(err)) + } + }() if err := checkPortAvailable(port); err != nil { minimalLogger.Error("端口不可用", zap.Error(err)) showError("Nex Gateway", err.Error()) - os.Exit(1) + return } cfg, err := config.LoadConfig() @@ -75,7 +80,11 @@ func main() { if err != nil { minimalLogger.Fatal("初始化日志失败", zap.Error(err)) } - defer zapLogger.Sync() + defer func() { + if err := zapLogger.Sync(); err != nil { + minimalLogger.Warn("同步日志失败", zap.Error(err)) + } + }() cfg.PrintSummary(zapLogger) @@ -144,14 +153,14 @@ func main() { go func() { zapLogger.Info("AI Gateway 启动", zap.String("addr", server.Addr)) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - zapLogger.Fatal("服务器启动失败", zap.String("error", err.Error())) + zapLogger.Fatal("服务器启动失败", zap.Error(err)) } }() go func() { time.Sleep(500 * time.Millisecond) if err := openBrowser(fmt.Sprintf("http://localhost:%d", port)); err != nil { - zapLogger.Warn("无法打开浏览器", zap.String("error", err.Error())) + zapLogger.Warn("无法打开浏览器", zap.Error(err)) } }() @@ -193,7 +202,7 @@ func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHand func setupStaticFiles(r *gin.Engine) { distFS, err := fs.Sub(embedfs.FrontendDist, "frontend-dist") if err != nil { - zapLogger.Fatal("无法加载前端资源", zap.String("error", err.Error())) + zapLogger.Fatal("无法加载前端资源", zap.Error(err)) } getContentType := func(path string) string { @@ -266,7 +275,7 @@ func setupSystray(port int) { icon, err = embedfs.Assets.ReadFile("assets/icon.png") } if err != nil { - zapLogger.Error("无法加载托盘图标", zap.String("error", err.Error())) + zapLogger.Error("无法加载托盘图标", zap.Error(err)) } systray.SetIcon(icon) systray.SetTitle("Nex Gateway") @@ -287,7 +296,9 @@ func setupSystray(port int) { for { select { case <-mOpen.ClickedCh: - openBrowser(fmt.Sprintf("http://localhost:%d", port)) + if err := openBrowser(fmt.Sprintf("http://localhost:%d", port)); err != nil { + zapLogger.Warn("打开浏览器失败", zap.Error(err)) + } case <-mAbout.ClickedCh: showAbout() case <-mQuit.ClickedCh: @@ -308,7 +319,9 @@ func doShutdown() { if server != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - server.Shutdown(ctx) + if err := server.Shutdown(ctx); err != nil && zapLogger != nil { + zapLogger.Warn("关闭服务器失败", zap.Error(err)) + } } if shutdownCancel != nil { @@ -346,8 +359,8 @@ func (s *SingletonLock) Lock() error { return nil } -func (s *SingletonLock) Unlock() { - s.flock.Unlock() +func (s *SingletonLock) Unlock() error { + return s.flock.Unlock() } func openBrowser(url string) error { diff --git a/backend/cmd/desktop/port_test.go b/backend/cmd/desktop/port_test.go index cd810d3..4d65a8e 100644 --- a/backend/cmd/desktop/port_test.go +++ b/backend/cmd/desktop/port_test.go @@ -21,7 +21,7 @@ func TestCheckPortAvailable(t *testing.T) { func TestCheckPortOccupied(t *testing.T) { port := 19827 - listener, err := net.Listen("tcp", ":19827") + listener, err := net.Listen("tcp", "127.0.0.1:19827") if err != nil { t.Fatalf("无法启动测试服务器: %v", err) } @@ -47,13 +47,19 @@ func TestCheckPortOccupied(t *testing.T) { func TestCheckPortAvailableAfterClose(t *testing.T) { port := 19828 - listener, err := net.Listen("tcp", ":19828") + listener, err := net.Listen("tcp", "127.0.0.1:19828") if err != nil { t.Fatalf("无法启动测试服务器: %v", err) } - server := &http.Server{} - go server.Serve(listener) + server := &http.Server{ReadHeaderTimeout: time.Second} + defer server.Close() + go func() { + err := server.Serve(listener) + if err != nil && err != http.ErrServerClosed { + t.Errorf("serve failed: %v", err) + } + }() time.Sleep(100 * time.Millisecond) diff --git a/backend/cmd/desktop/singleton_test.go b/backend/cmd/desktop/singleton_test.go index c26b13f..a2b17ff 100644 --- a/backend/cmd/desktop/singleton_test.go +++ b/backend/cmd/desktop/singleton_test.go @@ -14,7 +14,11 @@ func TestSingletonLock_FirstLockSuccess(t *testing.T) { if err := lock.Lock(); err != nil { t.Fatalf("首次加锁应成功,但返回错误: %v", err) } - defer lock.Unlock() + defer func() { + if err := lock.Unlock(); err != nil { + t.Fatalf("解锁失败: %v", err) + } + }() } func TestSingletonLock_DuplicateLockFails(t *testing.T) { @@ -25,12 +29,18 @@ func TestSingletonLock_DuplicateLockFails(t *testing.T) { if err := lock1.Lock(); err != nil { t.Fatalf("首次加锁应成功: %v", err) } - defer lock1.Unlock() + defer func() { + if err := lock1.Unlock(); err != nil { + t.Fatalf("解锁失败: %v", err) + } + }() lock2 := NewSingletonLock(lockPath) err := lock2.Lock() if err == nil { - lock2.Unlock() + if unlockErr := lock2.Unlock(); unlockErr != nil { + t.Fatalf("解锁失败: %v", unlockErr) + } t.Fatal("重复加锁应失败,但返回 nil") } } @@ -43,16 +53,22 @@ func TestSingletonLock_UnlockThenRelock(t *testing.T) { if err := lock1.Lock(); err != nil { t.Fatalf("首次加锁应成功: %v", err) } - lock1.Unlock() + if err := lock1.Unlock(); err != nil { + t.Fatalf("解锁失败: %v", err) + } lock2 := NewSingletonLock(lockPath) if err := lock2.Lock(); err != nil { t.Fatalf("释放后重新加锁应成功: %v", err) } - lock2.Unlock() + if err := lock2.Unlock(); err != nil { + t.Fatalf("解锁失败: %v", err) + } } func TestSingletonLock_UnlockWithoutLock(t *testing.T) { lock := NewSingletonLock(filepath.Join(os.TempDir(), "nex-gateway-test-nil.lock")) - lock.Unlock() + if err := lock.Unlock(); err != nil { + t.Fatalf("未加锁时解锁失败: %v", err) + } } diff --git a/backend/cmd/desktop/static_test.go b/backend/cmd/desktop/static_test.go index f3bac4b..fc664e2 100644 --- a/backend/cmd/desktop/static_test.go +++ b/backend/cmd/desktop/static_test.go @@ -6,9 +6,9 @@ import ( "strings" "testing" - "github.com/gin-gonic/gin" - "nex/embedfs" + + "github.com/gin-gonic/gin" ) func TestSetupStaticFiles(t *testing.T) { diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index e123cdb..9c62084 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -44,7 +44,11 @@ func main() { if err != nil { minimalLogger.Fatal("初始化日志失败", zap.Error(err)) } - defer zapLogger.Sync() + defer func() { + if err := zapLogger.Sync(); err != nil { + minimalLogger.Warn("同步日志失败", zap.Error(err)) + } + }() cfg.PrintSummary(zapLogger) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 6662f4c..a39f6cf 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "errors" "fmt" "os" "path/filepath" @@ -58,7 +59,10 @@ type LogConfig struct { // DefaultConfig returns default config values func DefaultConfig() *Config { // Use home dir for default paths - homeDir, _ := os.UserHomeDir() + homeDir, err := os.UserHomeDir() + if err != nil { + homeDir = "." + } nexDir := filepath.Join(homeDir, ".nex") return &Config{ @@ -97,7 +101,7 @@ func GetConfigDir() (string, error) { return "", err } configDir := filepath.Join(homeDir, ".nex") - if err := os.MkdirAll(configDir, 0755); err != nil { + if err := os.MkdirAll(configDir, 0o755); err != nil { return "", err } return configDir, nil @@ -123,7 +127,10 @@ func GetConfigPath() (string, error) { // setupDefaults 设置默认配置值 func setupDefaults(v *viper.Viper) { - homeDir, _ := os.UserHomeDir() + homeDir, err := os.UserHomeDir() + if err != nil { + homeDir = "." + } nexDir := filepath.Join(homeDir, ".nex") v.SetDefault("server.port", 9826) @@ -177,27 +184,33 @@ func setupFlags(v *viper.Viper, flagSet *pflag.FlagSet) { // 绑定所有 flag 到 viper // 注意:必须在设置默认值之后绑定 - v.BindPFlag("server.port", flagSet.Lookup("server-port")) - v.BindPFlag("server.read_timeout", flagSet.Lookup("server-read-timeout")) - v.BindPFlag("server.write_timeout", flagSet.Lookup("server-write-timeout")) + bindPFlag(v, "server.port", flagSet.Lookup("server-port")) + bindPFlag(v, "server.read_timeout", flagSet.Lookup("server-read-timeout")) + bindPFlag(v, "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")) + bindPFlag(v, "database.driver", flagSet.Lookup("database-driver")) + bindPFlag(v, "database.path", flagSet.Lookup("database-path")) + bindPFlag(v, "database.host", flagSet.Lookup("database-host")) + bindPFlag(v, "database.port", flagSet.Lookup("database-port")) + bindPFlag(v, "database.user", flagSet.Lookup("database-user")) + bindPFlag(v, "database.password", flagSet.Lookup("database-password")) + bindPFlag(v, "database.dbname", flagSet.Lookup("database-dbname")) + bindPFlag(v, "database.max_idle_conns", flagSet.Lookup("database-max-idle-conns")) + bindPFlag(v, "database.max_open_conns", flagSet.Lookup("database-max-open-conns")) + bindPFlag(v, "database.conn_max_lifetime", flagSet.Lookup("database-conn-max-lifetime")) - v.BindPFlag("log.level", flagSet.Lookup("log-level")) - v.BindPFlag("log.path", flagSet.Lookup("log-path")) - v.BindPFlag("log.max_size", flagSet.Lookup("log-max-size")) - v.BindPFlag("log.max_backups", flagSet.Lookup("log-max-backups")) - v.BindPFlag("log.max_age", flagSet.Lookup("log-max-age")) - v.BindPFlag("log.compress", flagSet.Lookup("log-compress")) + bindPFlag(v, "log.level", flagSet.Lookup("log-level")) + bindPFlag(v, "log.path", flagSet.Lookup("log-path")) + bindPFlag(v, "log.max_size", flagSet.Lookup("log-max-size")) + bindPFlag(v, "log.max_backups", flagSet.Lookup("log-max-backups")) + bindPFlag(v, "log.max_age", flagSet.Lookup("log-max-age")) + bindPFlag(v, "log.compress", flagSet.Lookup("log-compress")) +} + +func bindPFlag(v *viper.Viper, key string, flag *pflag.Flag) { + if err := v.BindPFlag(key, flag); err != nil { + panic(fmt.Sprintf("绑定 flag %s 失败: %v", key, err)) + } } // setupEnv 绑定环境变量 @@ -218,10 +231,17 @@ func setupConfigFile(v *viper.Viper, configPath string) error { return appErrors.Wrap(appErrors.ErrInternal, err) } // 配置文件不存在,创建默认配置文件 - if err := v.SafeWriteConfig(); err != nil { - // 忽略写入错误(可能目录已存在等) + writeErr := v.SafeWriteConfig() + if writeErr == nil { return nil } + + var alreadyExistsErr viper.ConfigFileAlreadyExistsError + if errors.As(writeErr, &alreadyExistsErr) { + return nil + } + + return appErrors.Wrap(appErrors.ErrInternal, writeErr) } return nil } @@ -246,7 +266,9 @@ func LoadConfigFromPath(configPath string) (*Config, error) { setupFlags(v, flagSet) // 3. 解析 CLI 参数(忽略错误,因为可能没有参数) - flagSet.Parse(os.Args[1:]) + if err := flagSet.Parse(os.Args[1:]); err != nil { + return nil, appErrors.Wrap(appErrors.ErrInvalidRequest, err) + } // 4. 获取配置文件路径(可能被 --config 参数覆盖) if configPathFlag, err := flagSet.GetString("config"); err == nil && configPathFlag != "" { @@ -295,11 +317,11 @@ func SaveConfig(cfg *Config) error { // Ensure directory exists dir := filepath.Dir(configPath) - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0o755); err != nil { return appErrors.Wrap(appErrors.ErrInternal, err) } - return os.WriteFile(configPath, data, 0600) + return os.WriteFile(configPath, data, 0o600) } // Validate validates the config diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 584957f..75c9d84 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -236,7 +236,7 @@ func TestSaveAndLoadConfig(t *testing.T) { configPath := filepath.Join(dir, "config.yaml") data, err := yaml.Marshal(cfg) require.NoError(t, err) - err = os.WriteFile(configPath, data, 0644) + err = os.WriteFile(configPath, data, 0o600) require.NoError(t, err) // 加载配置 diff --git a/backend/internal/config/models.go b/backend/internal/config/models.go index 8add81e..b84acaa 100644 --- a/backend/internal/config/models.go +++ b/backend/internal/config/models.go @@ -6,15 +6,15 @@ import ( // Provider 供应商模型 type Provider struct { - ID string `gorm:"primaryKey" json:"id"` - Name string `gorm:"not null" json:"name"` - APIKey string `gorm:"not null" json:"api_key"` - BaseURL string `gorm:"not null" json:"base_url"` - Protocol string `gorm:"column:protocol;default:'openai'" json:"protocol"` - Enabled bool `gorm:"default:true" json:"enabled"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Models []Model `gorm:"foreignKey:ProviderID;constraint:OnDelete:CASCADE" json:"models,omitempty"` + ID string `gorm:"primaryKey" json:"id"` + Name string `gorm:"not null" json:"name"` + APIKey string `gorm:"not null" json:"api_key"` + BaseURL string `gorm:"not null" json:"base_url"` + Protocol string `gorm:"column:protocol;default:'openai'" json:"protocol"` + Enabled bool `gorm:"default:true" json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Models []Model `gorm:"foreignKey:ProviderID;constraint:OnDelete:CASCADE" json:"models,omitempty"` } // Model 模型配置(id 为 UUID 自动生成,UNIQUE(provider_id, model_name)) @@ -47,4 +47,3 @@ func (Model) TableName() string { func (UsageStats) TableName() string { return "usage_stats" } - diff --git a/backend/internal/conversion/anthropic/adapter.go b/backend/internal/conversion/anthropic/adapter.go index 74053be..561d71c 100644 --- a/backend/internal/conversion/anthropic/adapter.go +++ b/backend/internal/conversion/anthropic/adapter.go @@ -141,7 +141,10 @@ func (a *Adapter) EncodeError(err *conversion.ConversionError) ([]byte, int) { Message: err.Message, }, } - body, _ := json.Marshal(errMsg) + body, marshalErr := json.Marshal(errMsg) + if marshalErr != nil { + return []byte(`{"type":"error","error":{"type":"internal_error","message":"internal error"}}`), statusCode + } return body, statusCode } @@ -235,7 +238,11 @@ func locateModelFieldInRequest(body []byte, ifaceType conversion.InterfaceType) return "", nil, err } rewriteFunc := func(newModel string) ([]byte, error) { - m["model"], _ = json.Marshal(newModel) + encodedModel, err := json.Marshal(newModel) + if err != nil { + return nil, err + } + m["model"] = encodedModel return json.Marshal(m) } return current, rewriteFunc, nil @@ -269,7 +276,11 @@ func (a *Adapter) RewriteResponseModelName(body []byte, newModel string, ifaceTy switch ifaceType { case conversion.InterfaceTypeChat: // Chat 响应必须有 model 字段,存在则改写,不存在则添加 - m["model"], _ = json.Marshal(newModel) + encodedModel, err := json.Marshal(newModel) + if err != nil { + return nil, err + } + m["model"] = encodedModel return json.Marshal(m) default: return body, nil diff --git a/backend/internal/conversion/anthropic/adapter_test.go b/backend/internal/conversion/anthropic/adapter_test.go index 417a7e3..30c370f 100644 --- a/backend/internal/conversion/anthropic/adapter_test.go +++ b/backend/internal/conversion/anthropic/adapter_test.go @@ -2,6 +2,7 @@ package anthropic import ( "encoding/json" + "errors" "testing" "nex/backend/internal/conversion" @@ -52,10 +53,10 @@ func TestAdapter_BuildUrl(t *testing.T) { a := NewAdapter() tests := []struct { - name string - nativePath string + name string + nativePath string interfaceType conversion.InterfaceType - expected string + expected string }{ {"聊天", "/v1/messages", conversion.InterfaceTypeChat, "/v1/messages"}, {"模型", "/v1/models", conversion.InterfaceTypeModels, "/v1/models"}, @@ -102,9 +103,9 @@ func TestAdapter_SupportsInterface(t *testing.T) { a := NewAdapter() tests := []struct { - name string + name string interfaceType conversion.InterfaceType - expected bool + expected bool }{ {"聊天", conversion.InterfaceTypeChat, true}, {"模型", conversion.InterfaceTypeModels, true}, @@ -141,8 +142,8 @@ func TestAdapter_UnsupportedEmbedding(t *testing.T) { t.Run("解码嵌入请求", func(t *testing.T) { _, err := a.DecodeEmbeddingRequest([]byte(`{}`)) require.Error(t, err) - convErr, ok := err.(*conversion.ConversionError) - require.True(t, ok) + var convErr *conversion.ConversionError + require.ErrorAs(t, err, &convErr) assert.Equal(t, conversion.ErrorCodeInterfaceNotSupported, convErr.Code) }) @@ -150,24 +151,24 @@ func TestAdapter_UnsupportedEmbedding(t *testing.T) { provider := conversion.NewTargetProvider("https://api.anthropic.com", "key", "claude-3") _, err := a.EncodeEmbeddingRequest(&canonical.CanonicalEmbeddingRequest{}, provider) require.Error(t, err) - convErr, ok := err.(*conversion.ConversionError) - require.True(t, ok) + var convErr *conversion.ConversionError + require.True(t, errors.As(err, &convErr)) assert.Equal(t, conversion.ErrorCodeInterfaceNotSupported, convErr.Code) }) t.Run("解码嵌入响应", func(t *testing.T) { _, err := a.DecodeEmbeddingResponse([]byte(`{}`)) require.Error(t, err) - convErr, ok := err.(*conversion.ConversionError) - require.True(t, ok) + var convErr *conversion.ConversionError + require.ErrorAs(t, err, &convErr) assert.Equal(t, conversion.ErrorCodeInterfaceNotSupported, convErr.Code) }) t.Run("编码嵌入响应", func(t *testing.T) { _, err := a.EncodeEmbeddingResponse(&canonical.CanonicalEmbeddingResponse{}) require.Error(t, err) - convErr, ok := err.(*conversion.ConversionError) - require.True(t, ok) + var convErr *conversion.ConversionError + require.ErrorAs(t, err, &convErr) assert.Equal(t, conversion.ErrorCodeInterfaceNotSupported, convErr.Code) }) } @@ -178,8 +179,8 @@ func TestAdapter_UnsupportedRerank(t *testing.T) { t.Run("解码重排序请求", func(t *testing.T) { _, err := a.DecodeRerankRequest([]byte(`{}`)) require.Error(t, err) - convErr, ok := err.(*conversion.ConversionError) - require.True(t, ok) + var convErr *conversion.ConversionError + require.ErrorAs(t, err, &convErr) assert.Equal(t, conversion.ErrorCodeInterfaceNotSupported, convErr.Code) }) @@ -187,24 +188,24 @@ func TestAdapter_UnsupportedRerank(t *testing.T) { provider := conversion.NewTargetProvider("https://api.anthropic.com", "key", "claude-3") _, err := a.EncodeRerankRequest(&canonical.CanonicalRerankRequest{}, provider) require.Error(t, err) - convErr, ok := err.(*conversion.ConversionError) - require.True(t, ok) + var convErr *conversion.ConversionError + require.ErrorAs(t, err, &convErr) assert.Equal(t, conversion.ErrorCodeInterfaceNotSupported, convErr.Code) }) t.Run("解码重排序响应", func(t *testing.T) { _, err := a.DecodeRerankResponse([]byte(`{}`)) require.Error(t, err) - convErr, ok := err.(*conversion.ConversionError) - require.True(t, ok) + var convErr *conversion.ConversionError + require.ErrorAs(t, err, &convErr) assert.Equal(t, conversion.ErrorCodeInterfaceNotSupported, convErr.Code) }) t.Run("编码重排序响应", func(t *testing.T) { _, err := a.EncodeRerankResponse(&canonical.CanonicalRerankResponse{}) require.Error(t, err) - convErr, ok := err.(*conversion.ConversionError) - require.True(t, ok) + var convErr *conversion.ConversionError + require.ErrorAs(t, err, &convErr) assert.Equal(t, conversion.ErrorCodeInterfaceNotSupported, convErr.Code) }) } diff --git a/backend/internal/conversion/anthropic/decoder.go b/backend/internal/conversion/anthropic/decoder.go index b194cec..70ce2f4 100644 --- a/backend/internal/conversion/anthropic/decoder.go +++ b/backend/internal/conversion/anthropic/decoder.go @@ -28,7 +28,10 @@ func decodeRequest(body []byte) (*canonical.CanonicalRequest, error) { var canonicalMsgs []canonical.CanonicalMessage for _, msg := range req.Messages { - decoded := decodeMessage(msg) + decoded, err := decodeMessage(msg) + if err != nil { + return nil, conversion.NewConversionError(conversion.ErrorCodeJSONParseError, "解析消息内容失败").WithCause(err) + } canonicalMsgs = append(canonicalMsgs, decoded...) } @@ -94,10 +97,13 @@ func decodeSystem(system any) any { } // decodeMessage 解码 Anthropic 消息 -func decodeMessage(msg Message) []canonical.CanonicalMessage { +func decodeMessage(msg Message) ([]canonical.CanonicalMessage, error) { switch msg.Role { case "user": - blocks := decodeContentBlocks(msg.Content) + blocks, err := decodeContentBlocks(msg.Content) + if err != nil { + return nil, err + } var toolResults []canonical.ContentBlock var others []canonical.ContentBlock for _, b := range blocks { @@ -117,58 +123,83 @@ func decodeMessage(msg Message) []canonical.CanonicalMessage { if len(result) == 0 { result = append(result, canonical.CanonicalMessage{Role: canonical.RoleUser, Content: []canonical.ContentBlock{canonical.NewTextBlock("")}}) } - return result + return result, nil case "assistant": - blocks := decodeContentBlocks(msg.Content) + blocks, err := decodeContentBlocks(msg.Content) + if err != nil { + return nil, err + } if len(blocks) == 0 { blocks = append(blocks, canonical.NewTextBlock("")) } - return []canonical.CanonicalMessage{{Role: canonical.RoleAssistant, Content: blocks}} + return []canonical.CanonicalMessage{{Role: canonical.RoleAssistant, Content: blocks}}, nil } - return nil + return nil, nil } // decodeContentBlocks 解码内容块列表 -func decodeContentBlocks(content any) []canonical.ContentBlock { +func decodeContentBlocks(content any) ([]canonical.ContentBlock, error) { switch v := content.(type) { case string: - return []canonical.ContentBlock{canonical.NewTextBlock(v)} + return []canonical.ContentBlock{canonical.NewTextBlock(v)}, nil case []any: var blocks []canonical.ContentBlock for _, item := range v { if m, ok := item.(map[string]any); ok { - block := decodeSingleContentBlock(m) + block, err := decodeSingleContentBlock(m) + if err != nil { + return nil, err + } if block != nil { blocks = append(blocks, *block) } } } if len(blocks) > 0 { - return blocks + return blocks, nil } - return []canonical.ContentBlock{canonical.NewTextBlock("")} + return []canonical.ContentBlock{canonical.NewTextBlock("")}, nil case nil: - return []canonical.ContentBlock{canonical.NewTextBlock("")} + return []canonical.ContentBlock{canonical.NewTextBlock("")}, nil default: - return []canonical.ContentBlock{canonical.NewTextBlock(fmt.Sprintf("%v", v))} + return []canonical.ContentBlock{canonical.NewTextBlock(fmt.Sprintf("%v", v))}, nil } } // decodeSingleContentBlock 解码单个内容块 -func decodeSingleContentBlock(m map[string]any) *canonical.ContentBlock { - t, _ := m["type"].(string) +func decodeSingleContentBlock(m map[string]any) (*canonical.ContentBlock, error) { + t, ok := m["type"].(string) + if !ok { + return nil, nil + } + switch t { case "text": - text, _ := m["text"].(string) - return &canonical.ContentBlock{Type: "text", Text: text} + text, ok := m["text"].(string) + if !ok { + text = "" + } + return &canonical.ContentBlock{Type: "text", Text: text}, nil case "tool_use": - id, _ := m["id"].(string) - name, _ := m["name"].(string) - input, _ := json.Marshal(m["input"]) - return &canonical.ContentBlock{Type: "tool_use", ID: id, Name: name, Input: input} + id, ok := m["id"].(string) + if !ok { + id = "" + } + name, ok := m["name"].(string) + if !ok { + name = "" + } + input, err := json.Marshal(m["input"]) + if err != nil { + return nil, err + } + return &canonical.ContentBlock{Type: "tool_use", ID: id, Name: name, Input: input}, nil case "tool_result": - toolUseID, _ := m["tool_use_id"].(string) + toolUseID, ok := m["tool_use_id"].(string) + if !ok { + toolUseID = "" + } isErr := false if ie, ok := m["is_error"].(bool); ok { isErr = ie @@ -179,7 +210,11 @@ func decodeSingleContentBlock(m map[string]any) *canonical.ContentBlock { case string: content = json.RawMessage(fmt.Sprintf("%q", cv)) default: - content, _ = json.Marshal(cv) + encoded, err := json.Marshal(cv) + if err != nil { + return nil, err + } + content = encoded } } else { content = json.RawMessage(`""`) @@ -189,15 +224,18 @@ func decodeSingleContentBlock(m map[string]any) *canonical.ContentBlock { ToolUseID: toolUseID, Content: content, IsError: &isErr, - } + }, nil case "thinking": - thinking, _ := m["thinking"].(string) - return &canonical.ContentBlock{Type: "thinking", Thinking: thinking} + thinking, ok := m["thinking"].(string) + if !ok { + thinking = "" + } + return &canonical.ContentBlock{Type: "thinking", Thinking: thinking}, nil case "redacted_thinking": // 丢弃 - return nil + return nil, nil } - return nil + return nil, nil } // decodeTools 解码工具定义 @@ -232,7 +270,10 @@ func decodeToolChoice(toolChoice any) *canonical.ToolChoice { return canonical.NewToolChoiceAny() } case map[string]any: - t, _ := v["type"].(string) + t, ok := v["type"].(string) + if !ok { + return nil + } switch t { case "auto": return canonical.NewToolChoiceAuto() @@ -241,7 +282,10 @@ func decodeToolChoice(toolChoice any) *canonical.ToolChoice { case "any": return canonical.NewToolChoiceAny() case "tool": - name, _ := v["name"].(string) + name, ok := v["name"].(string) + if !ok { + name = "" + } return canonical.NewToolChoiceNamed(name) } } diff --git a/backend/internal/conversion/anthropic/encoder.go b/backend/internal/conversion/anthropic/encoder.go index b79f756..b331188 100644 --- a/backend/internal/conversion/anthropic/encoder.go +++ b/backend/internal/conversion/anthropic/encoder.go @@ -182,7 +182,7 @@ func encodeContentBlocks(blocks []canonical.ContentBlock) []map[string]any { result = append(result, m) case "tool_result": m := map[string]any{ - "type": "tool_result", + "type": "tool_result", "tool_use_id": b.ToolUseID, } if b.Content != nil { @@ -335,11 +335,11 @@ func encodeResponse(resp *canonical.CanonicalResponse) ([]byte, error) { } result := map[string]any{ - "id": resp.ID, - "type": "message", - "role": "assistant", - "model": resp.Model, - "content": blocks, + "id": resp.ID, + "type": "message", + "role": "assistant", + "model": resp.Model, + "content": blocks, "stop_reason": sr, "stop_sequence": nil, "usage": usage, diff --git a/backend/internal/conversion/anthropic/encoder_test.go b/backend/internal/conversion/anthropic/encoder_test.go index 68e2dce..d8d91ea 100644 --- a/backend/internal/conversion/anthropic/encoder_test.go +++ b/backend/internal/conversion/anthropic/encoder_test.go @@ -33,7 +33,8 @@ func TestEncodeRequest_Basic(t *testing.T) { assert.Equal(t, true, result["stream"]) assert.Equal(t, float64(1024), result["max_tokens"]) - msgs := result["messages"].([]any) + msgs, ok := result["messages"].([]any) + require.True(t, ok) assert.Len(t, msgs, 1) } @@ -55,17 +56,20 @@ func TestEncodeRequest_ToolMergeIntoUser(t *testing.T) { var result map[string]any require.NoError(t, json.Unmarshal(body, &result)) - msgs := result["messages"].([]any) + msgs, ok := result["messages"].([]any) + require.True(t, ok) // tool 消息应被合并到相邻 user 消息 foundToolResult := false for _, m := range msgs { - msgMap := m.(map[string]any) + msgMap, ok := m.(map[string]any) + require.True(t, ok) if msgMap["role"] == "user" { content, ok := msgMap["content"].([]any) if ok { for _, c := range content { - block := c.(map[string]any) + block, ok := c.(map[string]any) + require.True(t, ok) if block["type"] == "tool_result" { foundToolResult = true } @@ -93,8 +97,10 @@ func TestEncodeRequest_FirstUserGuarantee(t *testing.T) { var result map[string]any require.NoError(t, json.Unmarshal(body, &result)) - msgs := result["messages"].([]any) - firstMsg := msgs[0].(map[string]any) + msgs, ok := result["messages"].([]any) + require.True(t, ok) + firstMsg, ok := msgs[0].(map[string]any) + require.True(t, ok) assert.Equal(t, "user", firstMsg["role"]) } @@ -140,9 +146,11 @@ func TestEncodeResponse_Basic(t *testing.T) { assert.Equal(t, "assistant", result["role"]) assert.Equal(t, "end_turn", result["stop_reason"]) - content := result["content"].([]any) + content, ok := result["content"].([]any) + require.True(t, ok) assert.Len(t, content, 1) - block := content[0].(map[string]any) + block, ok := content[0].(map[string]any) + require.True(t, ok) assert.Equal(t, "text", block["type"]) assert.Equal(t, "你好", block["text"]) } @@ -160,10 +168,12 @@ func TestEncodeModelsResponse(t *testing.T) { var result map[string]any require.NoError(t, json.Unmarshal(body, &result)) - data := result["data"].([]any) + data, ok := result["data"].([]any) + require.True(t, ok) assert.Len(t, data, 1) - model := data[0].(map[string]any) + model, ok := data[0].(map[string]any) + require.True(t, ok) assert.Equal(t, "claude-3-opus", model["id"]) // created 应为 RFC3339 格式 createdAt, ok := model["created_at"].(string) @@ -280,11 +290,14 @@ func TestEncodeRequest_ConsecutiveRoleMerge(t *testing.T) { var result map[string]any require.NoError(t, json.Unmarshal(body, &result)) - msgs := result["messages"].([]any) + msgs, ok := result["messages"].([]any) + require.True(t, ok) assert.Len(t, msgs, 1) - userMsg := msgs[0].(map[string]any) + userMsg, ok := msgs[0].(map[string]any) + require.True(t, ok) assert.Equal(t, "user", userMsg["role"]) - content := userMsg["content"].([]any) + content, ok := userMsg["content"].([]any) + require.True(t, ok) assert.Len(t, content, 2) } @@ -321,7 +334,8 @@ func TestEncodeResponse_ReasoningTokens(t *testing.T) { var result map[string]any require.NoError(t, json.Unmarshal(body, &result)) - usage := result["usage"].(map[string]any) + usage, ok := result["usage"].(map[string]any) + require.True(t, ok) _, hasReasoning := usage["reasoning_tokens"] assert.False(t, hasReasoning) } @@ -341,9 +355,11 @@ func TestEncodeResponse_ToolUse(t *testing.T) { var result map[string]any require.NoError(t, json.Unmarshal(body, &result)) - content := result["content"].([]any) + content, ok := result["content"].([]any) + require.True(t, ok) assert.Len(t, content, 1) - block := content[0].(map[string]any) + block, ok := content[0].(map[string]any) + require.True(t, ok) assert.Equal(t, "tool_use", block["type"]) assert.Equal(t, "tool_1", block["id"]) assert.Equal(t, "search", block["name"]) diff --git a/backend/internal/conversion/anthropic/stream_decoder.go b/backend/internal/conversion/anthropic/stream_decoder.go index 79fafbf..d45b24a 100644 --- a/backend/internal/conversion/anthropic/stream_decoder.go +++ b/backend/internal/conversion/anthropic/stream_decoder.go @@ -28,7 +28,7 @@ func NewStreamDecoder() *StreamDecoder { func (d *StreamDecoder) ProcessChunk(rawChunk []byte) []canonical.CanonicalStreamEvent { data := rawChunk if len(d.utf8Remainder) > 0 { - data = append(d.utf8Remainder, rawChunk...) + data = append(append([]byte{}, d.utf8Remainder...), rawChunk...) d.utf8Remainder = nil } @@ -50,9 +50,10 @@ func (d *StreamDecoder) ProcessChunk(rawChunk []byte) []canonical.CanonicalStrea for _, line := range strings.Split(text, "\n") { line = strings.TrimRight(line, "\r") - if strings.HasPrefix(line, "event: ") { + switch { + case strings.HasPrefix(line, "event: "): eventType = strings.TrimPrefix(line, "event: ") - } else if strings.HasPrefix(line, "data: ") { + case strings.HasPrefix(line, "data: "): eventData = strings.TrimPrefix(line, "data: ") if eventType != "" && eventData != "" { chunkEvents := d.processEvent(eventType, []byte(eventData)) @@ -60,8 +61,8 @@ func (d *StreamDecoder) ProcessChunk(rawChunk []byte) []canonical.CanonicalStrea } eventType = "" eventData = "" - } else if line == "" { - // SSE 事件分隔符 + case line == "": + continue } } @@ -135,7 +136,7 @@ func (d *StreamDecoder) processMessageStart(data []byte) []canonical.CanonicalSt // processContentBlockStart 处理内容块开始事件 func (d *StreamDecoder) processContentBlockStart(data []byte) []canonical.CanonicalStreamEvent { var raw struct { - Index int `json:"index"` + Index int `json:"index"` ContentBlock struct { Type string `json:"type"` Text string `json:"text"` diff --git a/backend/internal/conversion/anthropic/stream_decoder_test.go b/backend/internal/conversion/anthropic/stream_decoder_test.go index f993ca7..27703a7 100644 --- a/backend/internal/conversion/anthropic/stream_decoder_test.go +++ b/backend/internal/conversion/anthropic/stream_decoder_test.go @@ -47,23 +47,23 @@ func TestStreamDecoder_ContentBlockDelta(t *testing.T) { checkValue string }{ { - name: "text_delta", - deltaType: "text_delta", - deltaData: map[string]any{"type": "text_delta", "text": "你好"}, + name: "text_delta", + deltaType: "text_delta", + deltaData: map[string]any{"type": "text_delta", "text": "你好"}, checkField: "text", checkValue: "你好", }, { - name: "input_json_delta", - deltaType: "input_json_delta", - deltaData: map[string]any{"type": "input_json_delta", "partial_json": "{\"key\":"}, + name: "input_json_delta", + deltaType: "input_json_delta", + deltaData: map[string]any{"type": "input_json_delta", "partial_json": "{\"key\":"}, checkField: "partial_json", checkValue: "{\"key\":", }, { - name: "thinking_delta", - deltaType: "thinking_delta", - deltaData: map[string]any{"type": "thinking_delta", "thinking": "思考中"}, + name: "thinking_delta", + deltaType: "thinking_delta", + deltaData: map[string]any{"type": "thinking_delta", "thinking": "思考中"}, checkField: "thinking", checkValue: "思考中", }, @@ -74,7 +74,7 @@ func TestStreamDecoder_ContentBlockDelta(t *testing.T) { payload := map[string]any{ "type": "content_block_delta", "index": 0, - "delta": tt.deltaData, + "delta": tt.deltaData, } raw := makeAnthropicEvent("content_block_delta", payload) @@ -298,7 +298,7 @@ func TestStreamDecoder_WebSearchToolResult_Suppressed(t *testing.T) { "type": "content_block_start", "index": 3, "content_block": map[string]any{ - "type": "web_search_tool_result", + "type": "web_search_tool_result", "tool_use_id": "search_1", }, } @@ -331,8 +331,8 @@ func TestStreamDecoder_CitationsDelta_Discarded(t *testing.T) { "type": "content_block_delta", "index": 0, "delta": map[string]any{ - "type": "citations_delta", - "citation": map[string]any{"title": "ref1"}, + "type": "citations_delta", + "citation": map[string]any{"title": "ref1"}, }, } raw := makeAnthropicEvent("content_block_delta", payload) @@ -466,7 +466,7 @@ func TestStreamDecoder_MessageDelta_UsageNotAccumulated(t *testing.T) { }, } deltaPayload1 := map[string]any{ - "type": "message_delta", + "type": "message_delta", "delta": map[string]any{"stop_reason": "end_turn"}, "usage": map[string]any{"output_tokens": 25}, } @@ -478,7 +478,7 @@ func TestStreamDecoder_MessageDelta_UsageNotAccumulated(t *testing.T) { assert.Equal(t, 25, events[0].Usage.OutputTokens) deltaPayload2 := map[string]any{ - "type": "message_delta", + "type": "message_delta", "delta": map[string]any{"stop_reason": "end_turn"}, "usage": map[string]any{"output_tokens": 30}, } diff --git a/backend/internal/conversion/anthropic/stream_encoder_test.go b/backend/internal/conversion/anthropic/stream_encoder_test.go index 2cadff4..e76c5c4 100644 --- a/backend/internal/conversion/anthropic/stream_encoder_test.go +++ b/backend/internal/conversion/anthropic/stream_encoder_test.go @@ -80,7 +80,8 @@ func TestStreamEncoder_ContentBlockStart_Text(t *testing.T) { break } } - cb := payload["content_block"].(map[string]any) + cb, ok := payload["content_block"].(map[string]any) + require.True(t, ok) assert.Equal(t, "text", cb["type"]) } @@ -107,7 +108,8 @@ func TestStreamEncoder_ContentBlockStart_ToolUse(t *testing.T) { break } } - cb := payload["content_block"].(map[string]any) + cb, ok := payload["content_block"].(map[string]any) + require.True(t, ok) assert.Equal(t, "tool_use", cb["type"]) assert.Equal(t, "toolu_1", cb["id"]) assert.Equal(t, "search", cb["name"]) @@ -131,7 +133,8 @@ func TestStreamEncoder_ContentBlockStart_Thinking(t *testing.T) { break } } - cb := payload["content_block"].(map[string]any) + cb, ok := payload["content_block"].(map[string]any) + require.True(t, ok) assert.Equal(t, "thinking", cb["type"]) } @@ -173,7 +176,8 @@ func TestStreamEncoder_MessageDelta_WithStopReason(t *testing.T) { break } } - delta := payload["delta"].(map[string]any) + delta, okd := payload["delta"].(map[string]any) + require.True(t, okd) assert.Equal(t, "end_turn", delta["stop_reason"]) } @@ -199,7 +203,8 @@ func TestStreamEncoder_MessageDelta_WithUsage(t *testing.T) { break } } - u := payload["usage"].(map[string]any) + u, oku := payload["usage"].(map[string]any) + require.True(t, oku) assert.Equal(t, float64(88), u["output_tokens"]) } diff --git a/backend/internal/conversion/anthropic/supplemental_test.go b/backend/internal/conversion/anthropic/supplemental_test.go index 381a8a5..5624ec3 100644 --- a/backend/internal/conversion/anthropic/supplemental_test.go +++ b/backend/internal/conversion/anthropic/supplemental_test.go @@ -173,13 +173,15 @@ func TestDecodeMessage_UserWithOnlyToolResults(t *testing.T) { } func TestDecodeContentBlocks_Nil(t *testing.T) { - blocks := decodeContentBlocks(nil) + blocks, err := decodeContentBlocks(nil) + require.NoError(t, err) assert.Len(t, blocks, 1) assert.Equal(t, "", blocks[0].Text) } func TestDecodeContentBlocks_String(t *testing.T) { - blocks := decodeContentBlocks("hello") + blocks, err := decodeContentBlocks("hello") + require.NoError(t, err) assert.Len(t, blocks, 1) assert.Equal(t, "hello", blocks[0].Text) } @@ -217,8 +219,10 @@ func TestEncodeToolChoice(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := encodeToolChoice(tt.choice) - assert.Equal(t, tt.want["type"], result.(map[string]any)["type"]) - assert.Equal(t, tt.want["name"], result.(map[string]any)["name"]) + r, ok := result.(map[string]any) + require.True(t, ok) + assert.Equal(t, tt.want["type"], r["type"]) + assert.Equal(t, tt.want["name"], r["name"]) }) } } @@ -315,12 +319,15 @@ func TestEncodeRequest_WithTools(t *testing.T) { var result map[string]any require.NoError(t, json.Unmarshal(body, &result)) - tools := result["tools"].([]any) + tools, okt := result["tools"].([]any) + require.True(t, okt) assert.Len(t, tools, 1) - tool := tools[0].(map[string]any) + tool, okt2 := tools[0].(map[string]any) + require.True(t, okt2) assert.Equal(t, "search", tool["name"]) assert.Equal(t, "Search things", tool["description"]) - tc := result["tool_choice"].(map[string]any) + tc, oktc := result["tool_choice"].(map[string]any) + require.True(t, oktc) assert.Equal(t, "auto", tc["type"]) } @@ -354,9 +361,9 @@ func TestEncodeResponse_UsageWithCacheAndCreation(t *testing.T) { Content: []canonical.ContentBlock{canonical.NewTextBlock("ok")}, StopReason: &sr, Usage: canonical.CanonicalUsage{ - InputTokens: 100, - OutputTokens: 50, - CacheReadTokens: &cacheRead, + InputTokens: 100, + OutputTokens: 50, + CacheReadTokens: &cacheRead, CacheCreationTokens: &cacheCreation, }, } @@ -366,7 +373,8 @@ func TestEncodeResponse_UsageWithCacheAndCreation(t *testing.T) { var result map[string]any require.NoError(t, json.Unmarshal(body, &result)) - usage := result["usage"].(map[string]any) + usage, oku := result["usage"].(map[string]any) + require.True(t, oku) assert.Equal(t, float64(100), usage["input_tokens"]) assert.Equal(t, float64(30), usage["cache_read_input_tokens"]) assert.Equal(t, float64(10), usage["cache_creation_input_tokens"]) diff --git a/backend/internal/conversion/anthropic/types.go b/backend/internal/conversion/anthropic/types.go index f07ccf3..1c00678 100644 --- a/backend/internal/conversion/anthropic/types.go +++ b/backend/internal/conversion/anthropic/types.go @@ -6,22 +6,22 @@ import ( // MessagesRequest Anthropic Messages 请求 type MessagesRequest struct { - Model string `json:"model"` - Messages []Message `json:"messages"` - System any `json:"system,omitempty"` - MaxTokens int `json:"max_tokens"` - Temperature *float64 `json:"temperature,omitempty"` - TopP *float64 `json:"top_p,omitempty"` - TopK *int `json:"top_k,omitempty"` - StopSequences []string `json:"stop_sequences,omitempty"` - Stream bool `json:"stream,omitempty"` - Tools []Tool `json:"tools,omitempty"` - ToolChoice any `json:"tool_choice,omitempty"` - Metadata *RequestMetadata `json:"metadata,omitempty"` - Thinking *ThinkingConfig `json:"thinking,omitempty"` - OutputConfig *OutputConfig `json:"output_config,omitempty"` - DisableParallelToolUse *bool `json:"disable_parallel_tool_use,omitempty"` - Container any `json:"container,omitempty"` + Model string `json:"model"` + Messages []Message `json:"messages"` + System any `json:"system,omitempty"` + MaxTokens int `json:"max_tokens"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + TopK *int `json:"top_k,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` + Stream bool `json:"stream,omitempty"` + Tools []Tool `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + Metadata *RequestMetadata `json:"metadata,omitempty"` + Thinking *ThinkingConfig `json:"thinking,omitempty"` + OutputConfig *OutputConfig `json:"output_config,omitempty"` + DisableParallelToolUse *bool `json:"disable_parallel_tool_use,omitempty"` + Container any `json:"container,omitempty"` } // RequestMetadata 请求元数据 @@ -122,8 +122,8 @@ type ContentBlock struct { // ResponseUsage 响应用量 type ResponseUsage struct { - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` CacheReadInputTokens *int `json:"cache_read_input_tokens,omitempty"` CacheCreationInputTokens *int `json:"cache_creation_input_tokens,omitempty"` } diff --git a/backend/internal/conversion/canonical/extended.go b/backend/internal/conversion/canonical/extended.go index 2c8fdd0..b4582dd 100644 --- a/backend/internal/conversion/canonical/extended.go +++ b/backend/internal/conversion/canonical/extended.go @@ -38,8 +38,8 @@ type CanonicalEmbeddingResponse struct { // EmbeddingData 嵌入数据项 type EmbeddingData struct { - Index int `json:"index"` - Embedding any `json:"embedding"` // 根据格式不同可能是 []float64 或 base64 字符串 + Index int `json:"index"` + Embedding any `json:"embedding"` // 根据格式不同可能是 []float64 或 base64 字符串 } // EmbeddingUsage 嵌入用量 diff --git a/backend/internal/conversion/canonical/stream.go b/backend/internal/conversion/canonical/stream.go index af179e4..106d255 100644 --- a/backend/internal/conversion/canonical/stream.go +++ b/backend/internal/conversion/canonical/stream.go @@ -18,17 +18,17 @@ const ( type DeltaType string const ( - DeltaTypeText DeltaType = "text_delta" - DeltaTypeInputJSON DeltaType = "input_json_delta" - DeltaTypeThinking DeltaType = "thinking_delta" + DeltaTypeText DeltaType = "text_delta" + DeltaTypeInputJSON DeltaType = "input_json_delta" + DeltaTypeThinking DeltaType = "thinking_delta" ) // StreamDelta 流式增量联合体 type StreamDelta struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - PartialJSON string `json:"partial_json,omitempty"` - Thinking string `json:"thinking,omitempty"` + Type string `json:"type"` + Text string `json:"text,omitempty"` + PartialJSON string `json:"partial_json,omitempty"` + Thinking string `json:"thinking,omitempty"` } // StreamContentBlock 流式内容块联合体 @@ -48,12 +48,12 @@ type CanonicalStreamEvent struct { Message *StreamMessage `json:"message,omitempty"` // ContentBlockStartEvent / ContentBlockDeltaEvent / ContentBlockStopEvent - Index *int `json:"index,omitempty"` + Index *int `json:"index,omitempty"` ContentBlock *StreamContentBlock `json:"content_block,omitempty"` - Delta *StreamDelta `json:"delta,omitempty"` + Delta *StreamDelta `json:"delta,omitempty"` // MessageDeltaEvent - StopReason *StopReason `json:"stop_reason,omitempty"` + StopReason *StopReason `json:"stop_reason,omitempty"` Usage *CanonicalUsage `json:"usage,omitempty"` // ErrorEvent diff --git a/backend/internal/conversion/canonical/types.go b/backend/internal/conversion/canonical/types.go index 8d9de7a..fcd70c6 100644 --- a/backend/internal/conversion/canonical/types.go +++ b/backend/internal/conversion/canonical/types.go @@ -40,8 +40,8 @@ type ContentBlock struct { Text string `json:"text,omitempty"` // ToolUseBlock - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` Input json.RawMessage `json:"input,omitempty"` // ToolResultBlock @@ -138,43 +138,43 @@ type ThinkingConfig struct { // OutputFormat 输出格式联合体 type OutputFormat struct { - Type string `json:"type"` - Name string `json:"name,omitempty"` - Schema json.RawMessage `json:"schema,omitempty"` - Strict *bool `json:"strict,omitempty"` + Type string `json:"type"` + Name string `json:"name,omitempty"` + Schema json.RawMessage `json:"schema,omitempty"` + Strict *bool `json:"strict,omitempty"` } // CanonicalRequest 规范请求 type CanonicalRequest struct { - Model string `json:"model"` - System any `json:"system,omitempty"` // nil, string, or []SystemBlock + Model string `json:"model"` + System any `json:"system,omitempty"` // nil, string, or []SystemBlock Messages []CanonicalMessage `json:"messages"` - Tools []CanonicalTool `json:"tools,omitempty"` - ToolChoice *ToolChoice `json:"tool_choice,omitempty"` - Parameters RequestParameters `json:"parameters"` - Thinking *ThinkingConfig `json:"thinking,omitempty"` - Stream bool `json:"stream"` - UserID string `json:"user_id,omitempty"` - OutputFormat *OutputFormat `json:"output_format,omitempty"` - ParallelToolUse *bool `json:"parallel_tool_use,omitempty"` + Tools []CanonicalTool `json:"tools,omitempty"` + ToolChoice *ToolChoice `json:"tool_choice,omitempty"` + Parameters RequestParameters `json:"parameters"` + Thinking *ThinkingConfig `json:"thinking,omitempty"` + Stream bool `json:"stream"` + UserID string `json:"user_id,omitempty"` + OutputFormat *OutputFormat `json:"output_format,omitempty"` + ParallelToolUse *bool `json:"parallel_tool_use,omitempty"` } // CanonicalUsage 规范用量 type CanonicalUsage struct { - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - CacheReadTokens *int `json:"cache_read_tokens,omitempty"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheReadTokens *int `json:"cache_read_tokens,omitempty"` CacheCreationTokens *int `json:"cache_creation_tokens,omitempty"` - ReasoningTokens *int `json:"reasoning_tokens,omitempty"` + ReasoningTokens *int `json:"reasoning_tokens,omitempty"` } // CanonicalResponse 规范响应 type CanonicalResponse struct { - ID string `json:"id"` - Model string `json:"model"` - Content []ContentBlock `json:"content"` - StopReason *StopReason `json:"stop_reason,omitempty"` - Usage CanonicalUsage `json:"usage"` + ID string `json:"id"` + Model string `json:"model"` + Content []ContentBlock `json:"content"` + StopReason *StopReason `json:"stop_reason,omitempty"` + Usage CanonicalUsage `json:"usage"` } // GetSystemString 获取系统消息字符串 diff --git a/backend/internal/conversion/canonical/types_test.go b/backend/internal/conversion/canonical/types_test.go index 5982f3c..bf1e12a 100644 --- a/backend/internal/conversion/canonical/types_test.go +++ b/backend/internal/conversion/canonical/types_test.go @@ -10,9 +10,9 @@ import ( func TestGetSystemString(t *testing.T) { tests := []struct { - name string - system any - want string + name string + system any + want string }{ {"string", "hello", "hello"}, {"nil", nil, ""}, @@ -97,11 +97,11 @@ func TestCanonicalRequest_RoundTrip(t *testing.T) { func TestCanonicalResponse_RoundTrip(t *testing.T) { sr := StopReasonEndTurn resp := &CanonicalResponse{ - ID: "resp-1", - Model: "gpt-4", - Content: []ContentBlock{NewTextBlock("hello")}, + ID: "resp-1", + Model: "gpt-4", + Content: []ContentBlock{NewTextBlock("hello")}, StopReason: &sr, - Usage: CanonicalUsage{InputTokens: 10, OutputTokens: 5}, + Usage: CanonicalUsage{InputTokens: 10, OutputTokens: 5}, } data, err := json.Marshal(resp) diff --git a/backend/internal/conversion/engine.go b/backend/internal/conversion/engine.go index 507274b..6ec4721 100644 --- a/backend/internal/conversion/engine.go +++ b/backend/internal/conversion/engine.go @@ -114,7 +114,7 @@ func (e *ConversionEngine) ConvertHttpRequest(spec HTTPRequestSpec, clientProtoc } interfaceType := clientAdapter.DetectInterfaceType(nativePath) - providerUrl := providerAdapter.BuildUrl(nativePath, interfaceType) + providerURL := providerAdapter.BuildUrl(nativePath, interfaceType) providerHeaders := providerAdapter.BuildHeaders(provider) providerBody, err := e.convertBody(interfaceType, clientAdapter, providerAdapter, provider, spec.Body) if err != nil { @@ -122,7 +122,7 @@ func (e *ConversionEngine) ConvertHttpRequest(spec HTTPRequestSpec, clientProtoc } return &HTTPRequestSpec{ - URL: provider.BaseURL + providerUrl, + URL: provider.BaseURL + providerURL, Method: spec.Method, Headers: providerHeaders, Body: providerBody, @@ -134,24 +134,21 @@ func (e *ConversionEngine) ConvertHttpResponse(spec HTTPResponseSpec, clientProt if e.IsPassthrough(clientProtocol, providerProtocol) { // Smart Passthrough: 同协议时最小化改写 model 字段 if modelOverride != "" && len(spec.Body) > 0 { - adapter, err := e.registry.Get(clientProtocol) - if err != nil { - return &spec, nil - } - - rewrittenBody, err := adapter.RewriteResponseModelName(spec.Body, modelOverride, interfaceType) - if err != nil { + adapter, getErr := e.registry.Get(clientProtocol) + if getErr == nil { + rewrittenBody, rewriteErr := adapter.RewriteResponseModelName(spec.Body, modelOverride, interfaceType) + if rewriteErr != nil { e.logger.Warn("Smart Passthrough 改写响应失败,使用原始响应体", - zap.Error(err), + zap.Error(rewriteErr), zap.String("interface", string(interfaceType))) - return &spec, nil + } else { + return &HTTPResponseSpec{ + StatusCode: spec.StatusCode, + Headers: spec.Headers, + Body: rewrittenBody, + }, nil + } } - - return &HTTPResponseSpec{ - StatusCode: spec.StatusCode, - Headers: spec.Headers, - Body: rewrittenBody, - }, nil } return &spec, nil } @@ -182,11 +179,10 @@ func (e *ConversionEngine) CreateStreamConverter(clientProtocol, providerProtoco if e.IsPassthrough(clientProtocol, providerProtocol) { // Smart Passthrough: 同协议流式场景需要逐 chunk 改写 model 字段 if modelOverride != "" { - adapter, err := e.registry.Get(clientProtocol) - if err != nil { - return NewPassthroughStreamConverter(), nil + adapter, getErr := e.registry.Get(clientProtocol) + if getErr == nil { + return NewSmartPassthroughStreamConverter(adapter, modelOverride, interfaceType), nil } - return NewSmartPassthroughStreamConverter(adapter, modelOverride, interfaceType), nil } return NewPassthroughStreamConverter(), nil } @@ -201,9 +197,9 @@ func (e *ConversionEngine) CreateStreamConverter(clientProtocol, providerProtoco } ctx := ConversionContext{ - ConversionID: uuid.New().String(), - InterfaceType: InterfaceTypeChat, - Timestamp: time.Now(), + ConversionID: uuid.New().String(), + InterfaceType: InterfaceTypeChat, + Timestamp: time.Now(), } return NewCanonicalStreamConverterWithMiddleware( @@ -306,7 +302,7 @@ func (e *ConversionEngine) convertChatResponseBody(clientAdapter, providerAdapte func (e *ConversionEngine) convertModelsResponseBody(clientAdapter, providerAdapter ProtocolAdapter, body []byte) ([]byte, error) { models, err := providerAdapter.DecodeModelsResponse(body) if err != nil { - e.logger.Warn("解码 Models 响应失败,返回原始响应", zap.String("error", err.Error())) + e.logger.Warn("解码 Models 响应失败,返回原始响应", zap.Error(err)) return body, nil } encoded, err := clientAdapter.EncodeModelsResponse(models) @@ -320,12 +316,12 @@ func (e *ConversionEngine) convertModelsResponseBody(clientAdapter, providerAdap func (e *ConversionEngine) convertModelInfoResponseBody(clientAdapter, providerAdapter ProtocolAdapter, body []byte) ([]byte, error) { info, err := providerAdapter.DecodeModelInfoResponse(body) if err != nil { - e.logger.Warn("解码 ModelInfo 响应失败,返回原始响应", zap.String("error", err.Error())) + e.logger.Warn("解码 ModelInfo 响应失败,返回原始响应", zap.Error(err)) return body, nil } encoded, err := clientAdapter.EncodeModelInfoResponse(info) if err != nil { - e.logger.Warn("编码 ModelInfo 响应失败,返回原始响应", zap.String("error", err.Error())) + e.logger.Warn("编码 ModelInfo 响应失败,返回原始响应", zap.Error(err)) return body, nil } return encoded, nil @@ -334,7 +330,7 @@ func (e *ConversionEngine) convertModelInfoResponseBody(clientAdapter, providerA func (e *ConversionEngine) convertEmbeddingBody(clientAdapter, providerAdapter ProtocolAdapter, provider *TargetProvider, body []byte) ([]byte, error) { req, err := clientAdapter.DecodeEmbeddingRequest(body) if err != nil { - e.logger.Warn("解码 Embedding 请求失败,返回原始请求", zap.String("error", err.Error())) + e.logger.Warn("解码 Embedding 请求失败,返回原始请求", zap.Error(err)) return body, nil } return providerAdapter.EncodeEmbeddingRequest(req, provider) @@ -343,7 +339,7 @@ func (e *ConversionEngine) convertEmbeddingBody(clientAdapter, providerAdapter P func (e *ConversionEngine) convertEmbeddingResponseBody(clientAdapter, providerAdapter ProtocolAdapter, body []byte, modelOverride string) ([]byte, error) { resp, err := providerAdapter.DecodeEmbeddingResponse(body) if err != nil { - e.logger.Warn("解码 Embedding 响应失败,返回原始响应", zap.String("error", err.Error())) + e.logger.Warn("解码 Embedding 响应失败,返回原始响应", zap.Error(err)) return body, nil } if modelOverride != "" { @@ -355,21 +351,22 @@ func (e *ConversionEngine) convertEmbeddingResponseBody(clientAdapter, providerA func (e *ConversionEngine) convertRerankBody(clientAdapter, providerAdapter ProtocolAdapter, provider *TargetProvider, body []byte) ([]byte, error) { req, err := clientAdapter.DecodeRerankRequest(body) if err != nil { - e.logger.Warn("解码 Rerank 请求失败,返回原始请求", zap.String("error", err.Error())) + e.logger.Warn("解码 Rerank 请求失败,返回原始请求", zap.Error(err)) return body, nil } return providerAdapter.EncodeRerankRequest(req, provider) } func (e *ConversionEngine) convertRerankResponseBody(clientAdapter, providerAdapter ProtocolAdapter, body []byte, modelOverride string) ([]byte, error) { - resp, err := providerAdapter.DecodeRerankResponse(body) - if err != nil { - return body, nil + resp, decodeErr := providerAdapter.DecodeRerankResponse(body) + if decodeErr == nil { + if modelOverride != "" { + resp.Model = modelOverride + } + return clientAdapter.EncodeRerankResponse(resp) } - if modelOverride != "" { - resp.Model = modelOverride - } - return clientAdapter.EncodeRerankResponse(resp) + + return body, nil } // DetectInterfaceType 检测接口类型 @@ -391,8 +388,12 @@ func (e *ConversionEngine) EncodeError(err *ConversionError, clientProtocol stri "type": "internal_error", }, } - body, _ := json.Marshal(fallback) - return body, 500, nil + body, marshalErr := json.Marshal(fallback) + if marshalErr == nil { + return body, 500, nil + } + + return []byte(`{"error":{"message":"internal error","type":"internal_error"}}`), 500, nil } body, statusCode := adapter.EncodeError(err) return body, statusCode, nil diff --git a/backend/internal/conversion/engine_test.go b/backend/internal/conversion/engine_test.go index 524b186..8e7999d 100644 --- a/backend/internal/conversion/engine_test.go +++ b/backend/internal/conversion/engine_test.go @@ -38,8 +38,8 @@ func newMockAdapter(name string, passthrough bool) *mockProtocolAdapter { } } -func (m *mockProtocolAdapter) ProtocolName() string { return m.protocolName } -func (m *mockProtocolAdapter) ProtocolVersion() string { return "1.0" } +func (m *mockProtocolAdapter) ProtocolName() string { return m.protocolName } +func (m *mockProtocolAdapter) ProtocolVersion() string { return "1.0" } func (m *mockProtocolAdapter) SupportsPassthrough() bool { return m.passthrough } func (m *mockProtocolAdapter) DetectInterfaceType(nativePath string) InterfaceType { @@ -190,14 +190,16 @@ func (m *mockProtocolAdapter) RewriteResponseModelName(body []byte, newModel str // noopStreamDecoder 空流式解码器 type noopStreamDecoder struct{} -func (d *noopStreamDecoder) ProcessChunk(rawChunk []byte) []canonical.CanonicalStreamEvent { return nil } -func (d *noopStreamDecoder) Flush() []canonical.CanonicalStreamEvent { return nil } +func (d *noopStreamDecoder) ProcessChunk(rawChunk []byte) []canonical.CanonicalStreamEvent { + return nil +} +func (d *noopStreamDecoder) Flush() []canonical.CanonicalStreamEvent { return nil } // noopStreamEncoder 空流式编码器 type noopStreamEncoder struct{} func (e *noopStreamEncoder) EncodeEvent(event canonical.CanonicalStreamEvent) [][]byte { return nil } -func (e *noopStreamEncoder) Flush() [][]byte { return nil } +func (e *noopStreamEncoder) Flush() [][]byte { return nil } // ============ 测试用例 ============ @@ -615,6 +617,7 @@ func (d *engineTestStreamDecoder) ProcessChunk(raw []byte) []canonical.Canonical } return nil } + func (d *engineTestStreamDecoder) Flush() []canonical.CanonicalStreamEvent { if d.flushFn != nil { return d.flushFn() @@ -634,6 +637,7 @@ func (e *engineTestStreamEncoder) EncodeEvent(event canonical.CanonicalStreamEve } return nil } + func (e *engineTestStreamEncoder) Flush() [][]byte { if e.flushFn != nil { return e.flushFn() diff --git a/backend/internal/conversion/errors.go b/backend/internal/conversion/errors.go index 9885532..39ca5b1 100644 --- a/backend/internal/conversion/errors.go +++ b/backend/internal/conversion/errors.go @@ -6,17 +6,17 @@ import "fmt" type ErrorCode string const ( - ErrorCodeInvalidInput ErrorCode = "INVALID_INPUT" - ErrorCodeMissingRequiredField ErrorCode = "MISSING_REQUIRED_FIELD" - ErrorCodeIncompatibleFeature ErrorCode = "INCOMPATIBLE_FEATURE" - ErrorCodeFieldMappingFailure ErrorCode = "FIELD_MAPPING_FAILURE" - ErrorCodeToolCallParseError ErrorCode = "TOOL_CALL_PARSE_ERROR" - ErrorCodeJSONParseError ErrorCode = "JSON_PARSE_ERROR" - ErrorCodeStreamStateError ErrorCode = "STREAM_STATE_ERROR" - ErrorCodeUTF8DecodeError ErrorCode = "UTF8_DECODE_ERROR" - ErrorCodeProtocolConstraint ErrorCode = "PROTOCOL_CONSTRAINT_VIOLATION" - ErrorCodeEncodingFailure ErrorCode = "ENCODING_FAILURE" - ErrorCodeInterfaceNotSupported ErrorCode = "INTERFACE_NOT_SUPPORTED" + ErrorCodeInvalidInput ErrorCode = "INVALID_INPUT" + ErrorCodeMissingRequiredField ErrorCode = "MISSING_REQUIRED_FIELD" + ErrorCodeIncompatibleFeature ErrorCode = "INCOMPATIBLE_FEATURE" + ErrorCodeFieldMappingFailure ErrorCode = "FIELD_MAPPING_FAILURE" + ErrorCodeToolCallParseError ErrorCode = "TOOL_CALL_PARSE_ERROR" + ErrorCodeJSONParseError ErrorCode = "JSON_PARSE_ERROR" + ErrorCodeStreamStateError ErrorCode = "STREAM_STATE_ERROR" + ErrorCodeUTF8DecodeError ErrorCode = "UTF8_DECODE_ERROR" + ErrorCodeProtocolConstraint ErrorCode = "PROTOCOL_CONSTRAINT_VIOLATION" + ErrorCodeEncodingFailure ErrorCode = "ENCODING_FAILURE" + ErrorCodeInterfaceNotSupported ErrorCode = "INTERFACE_NOT_SUPPORTED" ) // ConversionError 协议转换错误 diff --git a/backend/internal/conversion/interface.go b/backend/internal/conversion/interface.go index 45d775b..5ce4710 100644 --- a/backend/internal/conversion/interface.go +++ b/backend/internal/conversion/interface.go @@ -4,10 +4,10 @@ package conversion type InterfaceType string const ( - InterfaceTypeChat InterfaceType = "CHAT" - InterfaceTypeModels InterfaceType = "MODELS" - InterfaceTypeModelInfo InterfaceType = "MODEL_INFO" - InterfaceTypeEmbeddings InterfaceType = "EMBEDDINGS" - InterfaceTypeRerank InterfaceType = "RERANK" + InterfaceTypeChat InterfaceType = "CHAT" + InterfaceTypeModels InterfaceType = "MODELS" + InterfaceTypeModelInfo InterfaceType = "MODEL_INFO" + InterfaceTypeEmbeddings InterfaceType = "EMBEDDINGS" + InterfaceTypeRerank InterfaceType = "RERANK" InterfaceTypePassthrough InterfaceType = "PASSTHROUGH" ) diff --git a/backend/internal/conversion/openai/adapter.go b/backend/internal/conversion/openai/adapter.go index c94955b..23f87b6 100644 --- a/backend/internal/conversion/openai/adapter.go +++ b/backend/internal/conversion/openai/adapter.go @@ -138,7 +138,10 @@ func (a *Adapter) EncodeError(err *conversion.ConversionError) ([]byte, int) { Code: string(err.Code), }, } - body, _ := json.Marshal(errMsg) + body, marshalErr := json.Marshal(errMsg) + if marshalErr != nil { + return []byte(`{"error":{"message":"internal error","type":"internal_error","code":"INTERNAL_ERROR"}}`), statusCode + } return body, statusCode } @@ -248,7 +251,11 @@ func locateModelFieldInRequest(body []byte, ifaceType conversion.InterfaceType) return "", nil, err } rewriteFunc := func(newModel string) ([]byte, error) { - m["model"], _ = json.Marshal(newModel) + encodedModel, err := json.Marshal(newModel) + if err != nil { + return nil, err + } + m["model"] = encodedModel return json.Marshal(m) } return current, rewriteFunc, nil @@ -282,12 +289,20 @@ func (a *Adapter) RewriteResponseModelName(body []byte, newModel string, ifaceTy switch ifaceType { case conversion.InterfaceTypeChat, conversion.InterfaceTypeEmbeddings: // Chat/Embedding 响应必须有 model 字段(协议要求),存在则改写,不存在则添加 - m["model"], _ = json.Marshal(newModel) + encodedModel, err := json.Marshal(newModel) + if err != nil { + return nil, err + } + m["model"] = encodedModel return json.Marshal(m) case conversion.InterfaceTypeRerank: // Rerank 响应:存在 model 字段则改写,不存在则不添加 if _, exists := m["model"]; exists { - m["model"], _ = json.Marshal(newModel) + encodedModel, err := json.Marshal(newModel) + if err != nil { + return nil, err + } + m["model"] = encodedModel } return json.Marshal(m) default: diff --git a/backend/internal/conversion/openai/adapter_test.go b/backend/internal/conversion/openai/adapter_test.go index 7c831ac..238ce30 100644 --- a/backend/internal/conversion/openai/adapter_test.go +++ b/backend/internal/conversion/openai/adapter_test.go @@ -48,10 +48,10 @@ func TestAdapter_BuildUrl(t *testing.T) { a := NewAdapter() tests := []struct { - name string - nativePath string + name string + nativePath string interfaceType conversion.InterfaceType - expected string + expected string }{ {"聊天", "/chat/completions", conversion.InterfaceTypeChat, "/chat/completions"}, {"模型", "/models", conversion.InterfaceTypeModels, "/models"}, @@ -92,9 +92,9 @@ func TestAdapter_SupportsInterface(t *testing.T) { a := NewAdapter() tests := []struct { - name string + name string interfaceType conversion.InterfaceType - expected bool + expected bool }{ {"聊天", conversion.InterfaceTypeChat, true}, {"模型", conversion.InterfaceTypeModels, true}, diff --git a/backend/internal/conversion/openai/decoder.go b/backend/internal/conversion/openai/decoder.go index 0738cb9..77339c1 100644 --- a/backend/internal/conversion/openai/decoder.go +++ b/backend/internal/conversion/openai/decoder.go @@ -215,10 +215,16 @@ func decodeUserContent(content any) []canonical.ContentBlock { var blocks []canonical.ContentBlock for _, item := range v { if m, ok := item.(map[string]any); ok { - t, _ := m["type"].(string) + t, ok := m["type"].(string) + if !ok { + continue + } switch t { case "text": - text, _ := m["text"].(string) + text, ok := m["text"].(string) + if !ok { + text = "" + } blocks = append(blocks, canonical.NewTextBlock(text)) case "image_url": blocks = append(blocks, canonical.ContentBlock{Type: "image"}) @@ -242,9 +248,9 @@ func decodeUserContent(content any) []canonical.ContentBlock { // contentPart 内容部分 type contentPart struct { - Type string - Text string - Refusal string + Type string + Text string + Refusal string } // decodeContentParts 解码内容部分 @@ -256,13 +262,22 @@ func decodeContentParts(content any) []contentPart { var result []contentPart for _, item := range parts { if m, ok := item.(map[string]any); ok { - t, _ := m["type"].(string) + t, ok := m["type"].(string) + if !ok { + continue + } switch t { case "text": - text, _ := m["text"].(string) + text, ok := m["text"].(string) + if !ok { + text = "" + } result = append(result, contentPart{Type: "text", Text: text}) case "refusal": - refusal, _ := m["refusal"].(string) + refusal, ok := m["refusal"].(string) + if !ok { + refusal = "" + } result = append(result, contentPart{Type: "refusal", Refusal: refusal}) } } @@ -307,21 +322,33 @@ func decodeToolChoice(toolChoice any) *canonical.ToolChoice { return canonical.NewToolChoiceAny() } case map[string]any: - t, _ := v["type"].(string) + t, ok := v["type"].(string) + if !ok { + return nil + } switch t { case "function": if fn, ok := v["function"].(map[string]any); ok { - name, _ := fn["name"].(string) + name, ok := fn["name"].(string) + if !ok { + name = "" + } return canonical.NewToolChoiceNamed(name) } case "custom": if custom, ok := v["custom"].(map[string]any); ok { - name, _ := custom["name"].(string) + name, ok := custom["name"].(string) + if !ok { + name = "" + } return canonical.NewToolChoiceNamed(name) } case "allowed_tools": if at, ok := v["allowed_tools"].(map[string]any); ok { - mode, _ := at["mode"].(string) + mode, ok := at["mode"].(string) + if !ok { + mode = "" + } if mode == "required" { return canonical.NewToolChoiceAny() } @@ -443,7 +470,7 @@ func decodeDeprecatedFields(req *ChatCompletionRequest) { case map[string]any: if name, ok := v["name"].(string); ok { req.ToolChoice = map[string]any{ - "type": "function", + "type": "function", "function": map[string]any{"name": name}, } } diff --git a/backend/internal/conversion/openai/encoder.go b/backend/internal/conversion/openai/encoder.go index 0337bf8..e76e9ef 100644 --- a/backend/internal/conversion/openai/encoder.go +++ b/backend/internal/conversion/openai/encoder.go @@ -450,7 +450,7 @@ func encodeEmbeddingResponse(resp *canonical.CanonicalEmbeddingResponse) ([]byte "object": "list", "data": data, "model": resp.Model, - "usage": resp.Usage, + "usage": resp.Usage, }) } diff --git a/backend/internal/conversion/openai/encoder_test.go b/backend/internal/conversion/openai/encoder_test.go index 2e1f5b3..0e6ea17 100644 --- a/backend/internal/conversion/openai/encoder_test.go +++ b/backend/internal/conversion/openai/encoder_test.go @@ -45,9 +45,11 @@ func TestEncodeRequest_SystemInjection(t *testing.T) { var result map[string]any require.NoError(t, json.Unmarshal(body, &result)) - msgs := result["messages"].([]any) + msgs, ok := result["messages"].([]any) + require.True(t, ok) assert.Len(t, msgs, 2) - firstMsg := msgs[0].(map[string]any) + firstMsg, ok := msgs[0].(map[string]any) + require.True(t, ok) assert.Equal(t, "system", firstMsg["role"]) assert.Equal(t, "你是助手", firstMsg["content"]) } @@ -72,12 +74,15 @@ func TestEncodeRequest_ToolCalls(t *testing.T) { var result map[string]any require.NoError(t, json.Unmarshal(body, &result)) - msgs := result["messages"].([]any) - assistantMsg := msgs[0].(map[string]any) + msgs, ok := result["messages"].([]any) + require.True(t, ok) + assistantMsg, ok := msgs[0].(map[string]any) + require.True(t, ok) toolCalls, ok := assistantMsg["tool_calls"].([]any) require.True(t, ok) assert.Len(t, toolCalls, 1) - tc := toolCalls[0].(map[string]any) + tc, ok := toolCalls[0].(map[string]any) + require.True(t, ok) assert.Equal(t, "call_1", tc["id"]) } @@ -100,11 +105,11 @@ func TestEncodeRequest_Thinking(t *testing.T) { func TestEncodeResponse_Basic(t *testing.T) { sr := canonical.StopReasonEndTurn resp := &canonical.CanonicalResponse{ - ID: "resp-1", - Model: "gpt-4", - Content: []canonical.ContentBlock{canonical.NewTextBlock("你好")}, + ID: "resp-1", + Model: "gpt-4", + Content: []canonical.ContentBlock{canonical.NewTextBlock("你好")}, StopReason: &sr, - Usage: canonical.CanonicalUsage{InputTokens: 10, OutputTokens: 5}, + Usage: canonical.CanonicalUsage{InputTokens: 10, OutputTokens: 5}, } body, err := encodeResponse(resp) @@ -115,9 +120,12 @@ func TestEncodeResponse_Basic(t *testing.T) { assert.Equal(t, "resp-1", result["id"]) assert.Equal(t, "chat.completion", result["object"]) - choices := result["choices"].([]any) - choice := choices[0].(map[string]any) - msg := choice["message"].(map[string]any) + choices, ok := result["choices"].([]any) + require.True(t, ok) + choice, ok := choices[0].(map[string]any) + require.True(t, ok) + msg, ok := choice["message"].(map[string]any) + require.True(t, ok) assert.Equal(t, "你好", msg["content"]) assert.Equal(t, "stop", choice["finish_reason"]) } @@ -126,9 +134,9 @@ func TestEncodeResponse_ToolUse(t *testing.T) { sr := canonical.StopReasonToolUse input := json.RawMessage(`{"q":"test"}`) resp := &canonical.CanonicalResponse{ - ID: "resp-2", - Model: "gpt-4", - Content: []canonical.ContentBlock{canonical.NewToolUseBlock("call_1", "search", input)}, + ID: "resp-2", + Model: "gpt-4", + Content: []canonical.ContentBlock{canonical.NewToolUseBlock("call_1", "search", input)}, StopReason: &sr, } @@ -137,8 +145,12 @@ func TestEncodeResponse_ToolUse(t *testing.T) { var result map[string]any require.NoError(t, json.Unmarshal(body, &result)) - choices := result["choices"].([]any) - msg := choices[0].(map[string]any)["message"].(map[string]any) + choices, okc := result["choices"].([]any) + require.True(t, okc) + msgMap, okm := choices[0].(map[string]any) + require.True(t, okm) + msg, okmsg := msgMap["message"].(map[string]any) + require.True(t, okmsg) tcs, ok := msg["tool_calls"].([]any) require.True(t, ok) assert.Len(t, tcs, 1) @@ -158,7 +170,8 @@ func TestEncodeModelsResponse(t *testing.T) { var result map[string]any require.NoError(t, json.Unmarshal(body, &result)) assert.Equal(t, "list", result["object"]) - data := result["data"].([]any) + data, okd := result["data"].([]any) + require.True(t, okd) assert.Len(t, data, 2) } @@ -317,8 +330,12 @@ func TestEncodeResponse_Thinking(t *testing.T) { var result map[string]any require.NoError(t, json.Unmarshal(body, &result)) - choices := result["choices"].([]any) - msg := choices[0].(map[string]any)["message"].(map[string]any) + choices, okch := result["choices"].([]any) + require.True(t, okch) + msgMap, okmm := choices[0].(map[string]any) + require.True(t, okmm) + msg, okmsg := msgMap["message"].(map[string]any) + require.True(t, okmsg) assert.Equal(t, "回答", msg["content"]) assert.Equal(t, "思考过程", msg["reasoning_content"]) } diff --git a/backend/internal/conversion/openai/stream_decoder_test.go b/backend/internal/conversion/openai/stream_decoder_test.go index 3c4a4ad..63e90ee 100644 --- a/backend/internal/conversion/openai/stream_decoder_test.go +++ b/backend/internal/conversion/openai/stream_decoder_test.go @@ -18,9 +18,9 @@ func TestStreamDecoder_BasicText(t *testing.T) { d := NewStreamDecoder() chunk := map[string]any{ - "id": "chatcmpl-1", - "object": "chat.completion.chunk", - "model": "gpt-4", + "id": "chatcmpl-1", + "object": "chat.completion.chunk", + "model": "gpt-4", "choices": []any{ map[string]any{ "index": 0, @@ -56,8 +56,8 @@ func TestStreamDecoder_ToolCalls(t *testing.T) { idx := 0 chunk := map[string]any{ - "id": "chatcmpl-1", - "model": "gpt-4", + "id": "chatcmpl-1", + "model": "gpt-4", "choices": []any{ map[string]any{ "index": 0, @@ -98,8 +98,8 @@ func TestStreamDecoder_Thinking(t *testing.T) { d := NewStreamDecoder() chunk := map[string]any{ - "id": "chatcmpl-1", - "model": "gpt-4", + "id": "chatcmpl-1", + "model": "gpt-4", "choices": []any{ map[string]any{ "index": 0, @@ -127,8 +127,8 @@ func TestStreamDecoder_FinishReason(t *testing.T) { d := NewStreamDecoder() chunk := map[string]any{ - "id": "chatcmpl-1", - "model": "gpt-4", + "id": "chatcmpl-1", + "model": "gpt-4", "choices": []any{ map[string]any{ "index": 0, @@ -161,8 +161,8 @@ func TestStreamDecoder_DoneSignal(t *testing.T) { // 先发送一个文本 chunk chunk := map[string]any{ - "id": "chatcmpl-1", - "model": "gpt-4", + "id": "chatcmpl-1", + "model": "gpt-4", "choices": []any{ map[string]any{ "index": 0, @@ -190,8 +190,8 @@ func TestStreamDecoder_RefusalReuse(t *testing.T) { // 连续两个 refusal delta chunk for _, text := range []string{"拒绝", "原因"} { chunk := map[string]any{ - "id": "chatcmpl-1", - "model": "gpt-4", + "id": "chatcmpl-1", + "model": "gpt-4", "choices": []any{ map[string]any{ "index": 0, @@ -250,8 +250,8 @@ func TestStreamDecoder_MultipleToolCalls(t *testing.T) { idx0 := 0 chunk1 := map[string]any{ - "id": "chatcmpl-mt", - "model": "gpt-4", + "id": "chatcmpl-mt", + "model": "gpt-4", "choices": []any{ map[string]any{ "index": 0, @@ -274,8 +274,8 @@ func TestStreamDecoder_MultipleToolCalls(t *testing.T) { idx1 := 1 chunk2 := map[string]any{ - "id": "chatcmpl-mt", - "model": "gpt-4", + "id": "chatcmpl-mt", + "model": "gpt-4", "choices": []any{ map[string]any{ "index": 0, @@ -322,8 +322,8 @@ func TestStreamDecoder_MultipleChunks_Text(t *testing.T) { d := NewStreamDecoder() chunk1 := map[string]any{ - "id": "chatcmpl-multi", - "model": "gpt-4", + "id": "chatcmpl-multi", + "model": "gpt-4", "choices": []any{ map[string]any{ "index": 0, @@ -332,8 +332,8 @@ func TestStreamDecoder_MultipleChunks_Text(t *testing.T) { }, } chunk2 := map[string]any{ - "id": "chatcmpl-multi", - "model": "gpt-4", + "id": "chatcmpl-multi", + "model": "gpt-4", "choices": []any{ map[string]any{ "index": 0, @@ -358,8 +358,8 @@ func TestStreamDecoder_UTF8Truncation(t *testing.T) { d := NewStreamDecoder() chunk := map[string]any{ - "id": "chatcmpl-utf8", - "model": "gpt-4", + "id": "chatcmpl-utf8", + "model": "gpt-4", "choices": []any{ map[string]any{ "index": 0, @@ -390,8 +390,8 @@ func TestStreamDecoder_ToolCallSubsequentDelta(t *testing.T) { idx := 0 chunk1 := map[string]any{ - "id": "chatcmpl-tc", - "model": "gpt-4", + "id": "chatcmpl-tc", + "model": "gpt-4", "choices": []any{ map[string]any{ "index": 0, @@ -412,8 +412,8 @@ func TestStreamDecoder_ToolCallSubsequentDelta(t *testing.T) { }, } chunk2 := map[string]any{ - "id": "chatcmpl-tc", - "model": "gpt-4", + "id": "chatcmpl-tc", + "model": "gpt-4", "choices": []any{ map[string]any{ "index": 0, diff --git a/backend/internal/conversion/openai/stream_encoder.go b/backend/internal/conversion/openai/stream_encoder.go index 4323aaa..9160527 100644 --- a/backend/internal/conversion/openai/stream_encoder.go +++ b/backend/internal/conversion/openai/stream_encoder.go @@ -10,9 +10,9 @@ import ( // StreamEncoder OpenAI 流式编码器 type StreamEncoder struct { - bufferedStart *canonical.CanonicalStreamEvent - toolCallIndexMap map[string]int - nextToolCallIndex int + bufferedStart *canonical.CanonicalStreamEvent + toolCallIndexMap map[string]int + nextToolCallIndex int } // NewStreamEncoder 创建 OpenAI 流式编码器 @@ -195,8 +195,8 @@ func (e *StreamEncoder) encodeMessageDelta(event canonical.CanonicalStreamEvent) func (e *StreamEncoder) encodeDelta(delta map[string]any) [][]byte { chunk := map[string]any{ "choices": []map[string]any{{ - "index": 0, - "delta": delta, + "index": 0, + "delta": delta, }}, } return e.marshalChunk(chunk) diff --git a/backend/internal/conversion/openai/stream_encoder_test.go b/backend/internal/conversion/openai/stream_encoder_test.go index a8c46c1..ab55694 100644 --- a/backend/internal/conversion/openai/stream_encoder_test.go +++ b/backend/internal/conversion/openai/stream_encoder_test.go @@ -27,8 +27,12 @@ func TestStreamEncoder_MessageStart(t *testing.T) { data := strings.TrimPrefix(s, "data: ") data = strings.TrimRight(data, "\n") require.NoError(t, json.Unmarshal([]byte(data), &payload)) - choices := payload["choices"].([]any) - delta := choices[0].(map[string]any)["delta"].(map[string]any) + choices, okch := payload["choices"].([]any) + require.True(t, okch) + msgMap, okmm := choices[0].(map[string]any) + require.True(t, okmm) + delta, okd := msgMap["delta"].(map[string]any) + require.True(t, okd) assert.Equal(t, "assistant", delta["role"]) } diff --git a/backend/internal/conversion/openai/supplemental_test.go b/backend/internal/conversion/openai/supplemental_test.go index 8056c14..15935dd 100644 --- a/backend/internal/conversion/openai/supplemental_test.go +++ b/backend/internal/conversion/openai/supplemental_test.go @@ -177,7 +177,8 @@ func TestEncodeRerankResponse(t *testing.T) { var result map[string]any require.NoError(t, json.Unmarshal(body, &result)) assert.Equal(t, "rerank-1", result["model"]) - results := result["results"].([]any) + results, okr := result["results"].([]any) + require.True(t, okr) assert.Len(t, results, 1) } @@ -356,9 +357,9 @@ func TestEncodeResponse_UsageWithCacheAndReasoning(t *testing.T) { reasoning := 20 sr := canonical.StopReasonEndTurn resp := &canonical.CanonicalResponse{ - ID: "r1", - Model: "gpt-4", - Content: []canonical.ContentBlock{canonical.NewTextBlock("ok")}, + ID: "r1", + Model: "gpt-4", + Content: []canonical.ContentBlock{canonical.NewTextBlock("ok")}, StopReason: &sr, Usage: canonical.CanonicalUsage{ InputTokens: 100, @@ -373,7 +374,8 @@ func TestEncodeResponse_UsageWithCacheAndReasoning(t *testing.T) { var result map[string]any require.NoError(t, json.Unmarshal(body, &result)) - usage := result["usage"].(map[string]any) + usage, oku := result["usage"].(map[string]any) + require.True(t, oku) assert.Equal(t, float64(100), usage["prompt_tokens"]) ptd, ok := usage["prompt_tokens_details"].(map[string]any) require.True(t, ok) @@ -412,8 +414,10 @@ func TestEncodeResponse_StopReasons(t *testing.T) { var result map[string]any require.NoError(t, json.Unmarshal(body, &result)) - choices := result["choices"].([]any) - choice := choices[0].(map[string]any) + choices, okch := result["choices"].([]any) + require.True(t, okch) + choice, okc := choices[0].(map[string]any) + require.True(t, okc) assert.Equal(t, tt.want, choice["finish_reason"]) }) } diff --git a/backend/internal/conversion/openai/types.go b/backend/internal/conversion/openai/types.go index 08add3f..c01b136 100644 --- a/backend/internal/conversion/openai/types.go +++ b/backend/internal/conversion/openai/types.go @@ -4,42 +4,42 @@ import "encoding/json" // ChatCompletionRequest OpenAI Chat Completion 请求 type ChatCompletionRequest struct { - Model string `json:"model"` - Messages []Message `json:"messages"` - Tools []Tool `json:"tools,omitempty"` - ToolChoice any `json:"tool_choice,omitempty"` - MaxTokens *int `json:"max_tokens,omitempty"` - MaxCompletionTokens *int `json:"max_completion_tokens,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - TopP *float64 `json:"top_p,omitempty"` - FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` - PresencePenalty *float64 `json:"presence_penalty,omitempty"` - Stop any `json:"stop,omitempty"` - Stream bool `json:"stream,omitempty"` - StreamOptions *StreamOptions `json:"stream_options,omitempty"` - User string `json:"user,omitempty"` - ResponseFormat *ResponseFormat `json:"response_format,omitempty"` - ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` - ReasoningEffort string `json:"reasoning_effort,omitempty"` - N *int `json:"n,omitempty"` - Seed *int `json:"seed,omitempty"` - Logprobs *bool `json:"logprobs,omitempty"` - TopLogprobs *int `json:"top_logprobs,omitempty"` + Model string `json:"model"` + Messages []Message `json:"messages"` + Tools []Tool `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + MaxTokens *int `json:"max_tokens,omitempty"` + MaxCompletionTokens *int `json:"max_completion_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` + PresencePenalty *float64 `json:"presence_penalty,omitempty"` + Stop any `json:"stop,omitempty"` + Stream bool `json:"stream,omitempty"` + StreamOptions *StreamOptions `json:"stream_options,omitempty"` + User string `json:"user,omitempty"` + ResponseFormat *ResponseFormat `json:"response_format,omitempty"` + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + N *int `json:"n,omitempty"` + Seed *int `json:"seed,omitempty"` + Logprobs *bool `json:"logprobs,omitempty"` + TopLogprobs *int `json:"top_logprobs,omitempty"` // 已废弃字段 - Functions []FunctionDef `json:"functions,omitempty"` - FunctionCall any `json:"function_call,omitempty"` + Functions []FunctionDef `json:"functions,omitempty"` + FunctionCall any `json:"function_call,omitempty"` } // Message OpenAI 消息 type Message struct { - Role string `json:"role"` - Content any `json:"content"` - Name string `json:"name,omitempty"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` - Refusal string `json:"refusal,omitempty"` - ReasoningContent string `json:"reasoning_content,omitempty"` + Role string `json:"role"` + Content any `json:"content"` + Name string `json:"name,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + Refusal string `json:"refusal,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` // 已废弃 FunctionCall *FunctionCallMsg `json:"function_call,omitempty"` @@ -88,8 +88,8 @@ type FunctionDef struct { // ResponseFormat OpenAI 响应格式 type ResponseFormat struct { - Type string `json:"type"` - JSONSchema *JSONSchemaDef `json:"json_schema,omitempty"` + Type string `json:"type"` + JSONSchema *JSONSchemaDef `json:"json_schema,omitempty"` } // JSONSchemaDef JSON Schema 定义 @@ -118,7 +118,7 @@ type ChatCompletionResponse struct { // Choice OpenAI 选择项 type Choice struct { - Index int `json:"index"` + Index int `json:"index"` Message *Message `json:"message,omitempty"` Delta *Message `json:"delta,omitempty"` FinishReason *string `json:"finish_reason"` @@ -127,10 +127,10 @@ type Choice struct { // Usage OpenAI 用量 type Usage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` - PromptTokensDetails *PromptTokensDetails `json:"prompt_tokens_details,omitempty"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + PromptTokensDetails *PromptTokensDetails `json:"prompt_tokens_details,omitempty"` CompletionTokensDetails *CompletionTokensDetails `json:"completion_tokens_details,omitempty"` } diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go index b573f84..ddfca2c 100644 --- a/backend/internal/database/database.go +++ b/backend/internal/database/database.go @@ -18,7 +18,7 @@ import ( 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) @@ -61,7 +61,7 @@ func initDB(cfg *config.DatabaseConfig, zapLogger *zap.Logger) (*gorm.DB, error) return gorm.Open(mysql.Open(dsn), gormConfig) default: dbDir := filepath.Dir(cfg.Path) - if err := os.MkdirAll(dbDir, 0755); err != nil { + if err := os.MkdirAll(dbDir, 0o755); err != nil { return nil, fmt.Errorf("创建数据库目录失败: %w", err) } if zapLogger != nil { @@ -95,7 +95,9 @@ func runMigrations(db *gorm.DB, driver string, zapLogger *zap.Logger) error { zap.String("dir", migrationsSubDir)) } - goose.SetDialect(gooseDialect) + if err := goose.SetDialect(gooseDialect); err != nil { + return err + } if err := goose.Up(sqlDB, migrationsDir); err != nil { return err } diff --git a/backend/internal/database/database_test.go b/backend/internal/database/database_test.go index e96a9e2..571dcd5 100644 --- a/backend/internal/database/database_test.go +++ b/backend/internal/database/database_test.go @@ -4,11 +4,11 @@ import ( "path/filepath" "testing" + "nex/backend/internal/config" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" - - "nex/backend/internal/config" ) func TestInit_SQLite(t *testing.T) { diff --git a/backend/internal/domain/provider.go b/backend/internal/domain/provider.go index 9ec0e47..aaea1d4 100644 --- a/backend/internal/domain/provider.go +++ b/backend/internal/domain/provider.go @@ -13,4 +13,3 @@ type Provider struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } - diff --git a/backend/internal/handler/handler_supplemental_test.go b/backend/internal/handler/handler_supplemental_test.go index afe277f..11ee5a8 100644 --- a/backend/internal/handler/handler_supplemental_test.go +++ b/backend/internal/handler/handler_supplemental_test.go @@ -6,13 +6,13 @@ import ( "net/http/httptest" "testing" + "nex/backend/internal/domain" + "nex/backend/tests/mocks" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" - - "nex/backend/internal/domain" - "nex/backend/tests/mocks" ) func TestProviderHandler_CreateProvider_Success(t *testing.T) { @@ -24,9 +24,9 @@ func TestProviderHandler_CreateProvider_Success(t *testing.T) { h := NewProviderHandler(mockSvc) body, _ := json.Marshal(map[string]string{ - "id": "p1", - "name": "Test", - "api_key": "sk-test", + "id": "p1", + "name": "Test", + "api_key": "sk-test", "base_url": "https://api.test.com", }) w := httptest.NewRecorder() diff --git a/backend/internal/handler/handler_test.go b/backend/internal/handler/handler_test.go index b490e7f..cd90685 100644 --- a/backend/internal/handler/handler_test.go +++ b/backend/internal/handler/handler_test.go @@ -9,23 +9,22 @@ import ( "strings" "testing" + "nex/backend/internal/domain" + "nex/backend/tests/mocks" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "gorm.io/gorm" - "nex/backend/internal/domain" appErrors "nex/backend/pkg/errors" - "nex/backend/tests/mocks" ) func init() { gin.SetMode(gin.TestMode) } - - func TestProviderHandler_CreateProvider_MissingFields(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/backend/internal/handler/middleware/logging.go b/backend/internal/handler/middleware/logging.go index 9ecd586..9ab84e2 100644 --- a/backend/internal/handler/middleware/logging.go +++ b/backend/internal/handler/middleware/logging.go @@ -20,7 +20,6 @@ func Logging(logger *zap.Logger) gin.HandlerFunc { if id, ok := requestID.(string); ok { requestIDStr = id } - logger.Info("请求开始", pkglogger.Method(c.Request.Method), pkglogger.Path(path), diff --git a/backend/internal/handler/model_handler.go b/backend/internal/handler/model_handler.go index 5432f55..3458ba5 100644 --- a/backend/internal/handler/model_handler.go +++ b/backend/internal/handler/model_handler.go @@ -4,13 +4,13 @@ import ( "errors" "net/http" + "nex/backend/internal/domain" + "nex/backend/internal/service" + "github.com/gin-gonic/gin" "gorm.io/gorm" appErrors "nex/backend/pkg/errors" - - "nex/backend/internal/domain" - "nex/backend/internal/service" ) // ModelHandler 模型管理处理器 @@ -58,16 +58,16 @@ func (h *ModelHandler) CreateModel(c *gin.Context) { err := h.modelService.Create(model) if err != nil { - if err == appErrors.ErrProviderNotFound { + if errors.Is(err, appErrors.ErrProviderNotFound) { c.JSON(http.StatusBadRequest, gin.H{ "error": "供应商不存在", }) return } - if err == appErrors.ErrDuplicateModel { + if errors.Is(err, appErrors.ErrDuplicateModel) { c.JSON(http.StatusConflict, gin.H{ - "error": "同一供应商下模型名称已存在", - "code": appErrors.ErrDuplicateModel.Code, + "error": "同一供应商下模型名称已存在", + "code": appErrors.ErrDuplicateModel.Code, }) return } @@ -101,7 +101,7 @@ func (h *ModelHandler) GetModel(c *gin.Context) { model, err := h.modelService.Get(id) if err != nil { - if err == gorm.ErrRecordNotFound { + if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{ "error": "模型未找到", }) @@ -166,7 +166,7 @@ func (h *ModelHandler) DeleteModel(c *gin.Context) { err := h.modelService.Delete(id) if err != nil { - if err == gorm.ErrRecordNotFound { + if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{ "error": "模型未找到", }) diff --git a/backend/internal/handler/provider_handler.go b/backend/internal/handler/provider_handler.go index 2ff6a02..873829f 100644 --- a/backend/internal/handler/provider_handler.go +++ b/backend/internal/handler/provider_handler.go @@ -4,13 +4,13 @@ import ( "errors" "net/http" + "nex/backend/internal/domain" + "nex/backend/internal/service" + "github.com/gin-gonic/gin" "gorm.io/gorm" appErrors "nex/backend/pkg/errors" - - "nex/backend/internal/domain" - "nex/backend/internal/service" ) // ProviderHandler 供应商管理处理器 @@ -55,7 +55,7 @@ func (h *ProviderHandler) CreateProvider(c *gin.Context) { err := h.providerService.Create(provider) if err != nil { - if err == appErrors.ErrInvalidProviderID { + if errors.Is(err, appErrors.ErrInvalidProviderID) { c.JSON(http.StatusBadRequest, gin.H{ "error": appErrors.ErrInvalidProviderID.Message, "code": appErrors.ErrInvalidProviderID.Code, @@ -86,7 +86,7 @@ func (h *ProviderHandler) GetProvider(c *gin.Context) { provider, err := h.providerService.Get(id) if err != nil { - if err == gorm.ErrRecordNotFound { + if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{ "error": "供应商未找到", }) @@ -113,7 +113,7 @@ func (h *ProviderHandler) UpdateProvider(c *gin.Context) { err := h.providerService.Update(id, req) if err != nil { - if err == gorm.ErrRecordNotFound { + if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{ "error": "供应商未找到", }) @@ -145,7 +145,7 @@ func (h *ProviderHandler) DeleteProvider(c *gin.Context) { err := h.providerService.Delete(id) if err != nil { - if err == gorm.ErrRecordNotFound { + if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{ "error": "供应商未找到", }) diff --git a/backend/internal/handler/proxy_handler.go b/backend/internal/handler/proxy_handler.go index e5635eb..660fe6e 100644 --- a/backend/internal/handler/proxy_handler.go +++ b/backend/internal/handler/proxy_handler.go @@ -3,30 +3,32 @@ package handler import ( "bufio" "encoding/json" + "errors" "io" "net/http" "strings" - "github.com/gin-gonic/gin" - "go.uber.org/zap" - "nex/backend/internal/conversion" "nex/backend/internal/conversion/canonical" "nex/backend/internal/domain" "nex/backend/internal/provider" "nex/backend/internal/service" "nex/backend/pkg/modelid" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + pkglogger "nex/backend/pkg/logger" ) // ProxyHandler 统一代理处理器 type ProxyHandler struct { - engine *conversion.ConversionEngine - client provider.ProviderClient - routingService service.RoutingService + engine *conversion.ConversionEngine + client provider.ProviderClient + routingService service.RoutingService providerService service.ProviderService - statsService service.StatsService - logger *zap.Logger + statsService service.StatsService + logger *zap.Logger } // NewProxyHandler 创建统一代理处理器 @@ -138,7 +140,7 @@ func (h *ProxyHandler) HandleProxy(c *gin.Context) { targetProvider := conversion.NewTargetProvider( routeResult.Provider.BaseURL, routeResult.Provider.APIKey, - routeResult.Model.ModelName, // 上游模型名,用于请求改写 + routeResult.Model.ModelName, // 上游模型名,用于请求改写 ) // 判断是否流式 @@ -159,7 +161,7 @@ func (h *ProxyHandler) handleNonStream(c *gin.Context, inSpec conversion.HTTPReq // 转换请求 outSpec, err := h.engine.ConvertHttpRequest(inSpec, clientProtocol, providerProtocol, targetProvider) if err != nil { - h.logger.Error("转换请求失败", zap.String("error", err.Error())) + h.logger.Error("转换请求失败", zap.Error(err)) h.writeConversionError(c, err, clientProtocol) return } @@ -167,7 +169,7 @@ func (h *ProxyHandler) handleNonStream(c *gin.Context, inSpec conversion.HTTPReq // 发送请求 resp, err := h.client.Send(c.Request.Context(), *outSpec) if err != nil { - h.logger.Error("发送请求失败", zap.String("error", err.Error())) + h.logger.Error("发送请求失败", zap.Error(err)) h.writeConversionError(c, err, clientProtocol) return } @@ -175,7 +177,7 @@ func (h *ProxyHandler) handleNonStream(c *gin.Context, inSpec conversion.HTTPReq // 转换响应,传入 modelOverride(跨协议场景覆写 model 字段) convertedResp, err := h.engine.ConvertHttpResponse(*resp, clientProtocol, providerProtocol, ifaceType, unifiedModelID) if err != nil { - h.logger.Error("转换响应失败", zap.String("error", err.Error())) + h.logger.Error("转换响应失败", zap.Error(err)) h.writeConversionError(c, err, clientProtocol) return } @@ -191,7 +193,7 @@ func (h *ProxyHandler) handleNonStream(c *gin.Context, inSpec conversion.HTTPReq c.Data(convertedResp.StatusCode, "application/json", convertedResp.Body) go func() { - _ = h.statsService.Record(routeResult.Provider.ID, routeResult.Model.ModelName) + _ = h.statsService.Record(routeResult.Provider.ID, routeResult.Model.ModelName) //nolint:errcheck // fire-and-forget 统计记录不阻塞请求 }() } @@ -226,34 +228,50 @@ func (h *ProxyHandler) handleStream(c *gin.Context, inSpec conversion.HTTPReques for event := range eventChan { if event.Error != nil { - h.logger.Error("流读取错误", zap.String("error", event.Error.Error())) + h.logger.Error("流读取错误", zap.Error(event.Error)) break } if event.Done { // flush 转换器 chunks := streamConverter.Flush() - for _, chunk := range chunks { - writer.Write(chunk) - writer.Flush() + if err := h.writeStreamChunks(writer, chunks); err != nil { + h.logger.Warn("流式响应写回失败", zap.Error(err)) } break } chunks := streamConverter.ProcessChunk(event.Data) - for _, chunk := range chunks { - writer.Write(chunk) - writer.Flush() + if err := h.writeStreamChunks(writer, chunks); err != nil { + h.logger.Warn("流式响应写回失败", zap.Error(err)) + break } } go func() { - _ = h.statsService.Record(routeResult.Provider.ID, routeResult.Model.ModelName) + _ = h.statsService.Record(routeResult.Provider.ID, routeResult.Model.ModelName) //nolint:errcheck // fire-and-forget 统计记录不阻塞请求 }() } +func (h *ProxyHandler) writeStreamChunks(writer *bufio.Writer, chunks [][]byte) error { + for _, chunk := range chunks { + if _, err := writer.Write(chunk); err != nil { + return err + } + + if err := writer.Flush(); err != nil { + return err + } + } + + return nil +} + // isStreamRequest 判断是否流式请求 func (h *ProxyHandler) isStreamRequest(body []byte, clientProtocol, nativePath string) bool { - ifaceType, _ := h.engine.DetectInterfaceType(nativePath, clientProtocol) + ifaceType, err := h.engine.DetectInterfaceType(nativePath, clientProtocol) + if err != nil { + return false + } if ifaceType != conversion.InterfaceTypeChat { return false } @@ -272,7 +290,7 @@ func (h *ProxyHandler) handleModelsList(c *gin.Context, adapter conversion.Proto // 从数据库查询所有启用的模型 models, err := h.providerService.ListEnabledModels() if err != nil { - h.logger.Error("查询启用模型失败", zap.String("error", err.Error())) + h.logger.Error("查询启用模型失败", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "查询模型失败"}) return } @@ -294,7 +312,7 @@ func (h *ProxyHandler) handleModelsList(c *gin.Context, adapter conversion.Proto // 使用 adapter 编码返回 body, err := adapter.EncodeModelsResponse(modelList) if err != nil { - h.logger.Error("编码 Models 响应失败", zap.String("error", err.Error())) + h.logger.Error("编码 Models 响应失败", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "编码响应失败"}) return } @@ -342,8 +360,13 @@ func (h *ProxyHandler) handleModelInfo(c *gin.Context, unifiedID string, adapter // writeConversionError 写入转换错误 func (h *ProxyHandler) writeConversionError(c *gin.Context, err error, clientProtocol string) { - if convErr, ok := err.(*conversion.ConversionError); ok { - body, statusCode, _ := h.engine.EncodeError(convErr, clientProtocol) + var convErr *conversion.ConversionError + if errors.As(err, &convErr) { + body, statusCode, encodeErr := h.engine.EncodeError(convErr, clientProtocol) + if encodeErr != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": encodeErr.Error()}) + return + } c.Data(statusCode, "application/json", body) return } diff --git a/backend/internal/handler/proxy_handler_test.go b/backend/internal/handler/proxy_handler_test.go index e5f8435..4d8cf47 100644 --- a/backend/internal/handler/proxy_handler_test.go +++ b/backend/internal/handler/proxy_handler_test.go @@ -8,27 +8,26 @@ import ( "net/http/httptest" "testing" + "nex/backend/internal/conversion" + "nex/backend/internal/conversion/anthropic" + "nex/backend/internal/conversion/openai" + "nex/backend/internal/domain" + "nex/backend/internal/provider" + "nex/backend/tests/mocks" + "github.com/gin-gonic/gin" "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" - "nex/backend/internal/conversion/openai" - "nex/backend/internal/domain" - "nex/backend/internal/provider" appErrors "nex/backend/pkg/errors" - "nex/backend/tests/mocks" ) func init() { gin.SetMode(gin.TestMode) } - - func setupProxyEngine(t *testing.T) *conversion.ConversionEngine { t.Helper() registry := conversion.NewMemoryRegistry() @@ -844,7 +843,8 @@ func TestProxyHandler_HandleProxy_Models_LocalAggregation(t *testing.T) { require.True(t, ok) assert.Len(t, data, 2) - first := data[0].(map[string]interface{}) + first, ok2 := data[0].(map[string]interface{}) + require.True(t, ok2) assert.Equal(t, "openai/gpt-4", first["id"]) } @@ -918,7 +918,7 @@ func TestProxyHandler_HandleProxy_SmartPassthrough_UnifiedID(t *testing.T) { client := mocks.NewMockProviderClient(ctrl) client.EXPECT().Send(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, spec conversion.HTTPRequestSpec) (*conversion.HTTPResponseSpec, error) { var req map[string]interface{} - json.Unmarshal(spec.Body, &req) + require.NoError(t, json.Unmarshal(spec.Body, &req)) assert.Equal(t, "gpt-4", req["model"]) return &conversion.HTTPResponseSpec{ diff --git a/backend/internal/handler/stats_handler.go b/backend/internal/handler/stats_handler.go index a95f5ad..f443b75 100644 --- a/backend/internal/handler/stats_handler.go +++ b/backend/internal/handler/stats_handler.go @@ -5,9 +5,9 @@ import ( "net/http" "time" - "github.com/gin-gonic/gin" - "nex/backend/internal/service" + + "github.com/gin-gonic/gin" ) // StatsHandler 统计处理器 diff --git a/backend/internal/provider/client.go b/backend/internal/provider/client.go index c2a639c..84a531a 100644 --- a/backend/internal/provider/client.go +++ b/backend/internal/provider/client.go @@ -51,6 +51,7 @@ type Client struct { } // ProviderClient 供应商客户端接口 +// //go:generate go run go.uber.org/mock/mockgen -source=client.go -destination=../../tests/mocks/mock_provider_client.go -package=mocks type ProviderClient interface { Send(ctx context.Context, spec conversion.HTTPRequestSpec) (*conversion.HTTPResponseSpec, error) @@ -141,7 +142,10 @@ func (c *Client) SendStream(ctx context.Context, spec conversion.HTTPRequestSpec if resp.StatusCode != http.StatusOK { defer resp.Body.Close() cancel() - errBody, _ := io.ReadAll(resp.Body) + errBody, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, fmt.Errorf("供应商返回错误: HTTP %d,读取错误响应失败: %w", resp.StatusCode, readErr) + } if len(errBody) > 0 { return nil, fmt.Errorf("供应商返回错误: HTTP %d: %s", resp.StatusCode, string(errBody)) } @@ -184,7 +188,7 @@ func (c *Client) readStream(ctx context.Context, cancel context.CancelFunc, body if err != nil { if err != io.EOF { if isNetworkError(err) { - c.logger.Error("流网络错误", zap.String("error", err.Error())) + c.logger.Error("流网络错误", zap.Error(err)) eventChan <- StreamEvent{Error: fmt.Errorf("网络错误: %w", err)} } else { c.logger.Error("流读取错误", zap.Error(err)) diff --git a/backend/internal/provider/client_test.go b/backend/internal/provider/client_test.go index 360d459..ad83f20 100644 --- a/backend/internal/provider/client_test.go +++ b/backend/internal/provider/client_test.go @@ -41,7 +41,8 @@ func TestClient_Send_Success(t *testing.T) { assert.Equal(t, "Bearer test-key", r.Header.Get("Authorization")) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"id":"test","model":"gpt-4"}`)) + _, err := w.Write([]byte(`{"id":"test","model":"gpt-4"}`)) + require.NoError(t, err) })) defer server.Close() @@ -65,7 +66,8 @@ func TestClient_Send_Success(t *testing.T) { func TestClient_Send_ErrorResponse(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte(`{"error":{"message":"Invalid API key"}}`)) + _, err := w.Write([]byte(`{"error":{"message":"Invalid API key"}}`)) + require.NoError(t, err) })) defer server.Close() @@ -140,12 +142,15 @@ func TestClient_SendStream_SSEEvents(t *testing.T) { w.WriteHeader(http.StatusOK) flusher, ok := w.(http.Flusher) require.True(t, ok) - w.Write([]byte("data: {\"id\":\"1\",\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n")) + _, err := w.Write([]byte("data: {\"id\":\"1\",\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n")) + require.NoError(t, err) flusher.Flush() - w.Write([]byte("data: {\"id\":\"1\",\"choices\":[{\"delta\":{\"content\":\" World\"}}]}\n\n")) + _, err = w.Write([]byte("data: {\"id\":\"1\",\"choices\":[{\"delta\":{\"content\":\" World\"}}]}\n\n")) + require.NoError(t, err) flusher.Flush() time.Sleep(50 * time.Millisecond) - w.Write([]byte("data: [DONE]\n\n")) + _, err = w.Write([]byte("data: [DONE]\n\n")) + require.NoError(t, err) flusher.Flush() time.Sleep(50 * time.Millisecond) })) @@ -165,11 +170,12 @@ func TestClient_SendStream_SSEEvents(t *testing.T) { var dataEvents [][]byte var doneEvents int for event := range eventChan { - if event.Done { + switch { + case event.Done: doneEvents++ - } else if event.Error != nil { + case event.Error != nil: t.Fatalf("unexpected error: %v", event.Error) - } else { + default: dataEvents = append(dataEvents, event.Data) } } @@ -215,7 +221,8 @@ func TestClient_Send_EmptyBody(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "GET", r.Method) w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"result":"ok"}`)) + _, err := w.Write([]byte(`{"result":"ok"}`)) + require.NoError(t, err) })) defer server.Close() @@ -238,10 +245,12 @@ func TestClient_SendStream_SlowSSE(t *testing.T) { w.WriteHeader(http.StatusOK) flusher, ok := w.(http.Flusher) require.True(t, ok) - w.Write([]byte("data: {\"id\":\"1\"}\n\n")) + _, err := w.Write([]byte("data: {\"id\":\"1\"}\n\n")) + require.NoError(t, err) flusher.Flush() time.Sleep(100 * time.Millisecond) - w.Write([]byte("data: [DONE]\n\n")) + _, err = w.Write([]byte("data: [DONE]\n\n")) + require.NoError(t, err) flusher.Flush() time.Sleep(100 * time.Millisecond) })) @@ -261,11 +270,12 @@ func TestClient_SendStream_SlowSSE(t *testing.T) { var dataCount int var doneCount int for event := range eventChan { - if event.Done { + switch { + case event.Done: doneCount++ - } else if event.Error != nil { + case event.Error != nil: t.Fatalf("unexpected error: %v", event.Error) - } else { + default: dataCount++ } } @@ -279,10 +289,12 @@ func TestClient_SendStream_SplitSSEEvents(t *testing.T) { w.WriteHeader(http.StatusOK) flusher, ok := w.(http.Flusher) require.True(t, ok) - w.Write([]byte("data: {\"id\":\"1\"}\n\ndata: {\"id\":\"2\"}\n\n")) + _, err := w.Write([]byte("data: {\"id\":\"1\"}\n\ndata: {\"id\":\"2\"}\n\n")) + require.NoError(t, err) flusher.Flush() time.Sleep(50 * time.Millisecond) - w.Write([]byte("data: [DONE]\n\n")) + _, err = w.Write([]byte("data: [DONE]\n\n")) + require.NoError(t, err) flusher.Flush() time.Sleep(50 * time.Millisecond) })) @@ -364,13 +376,14 @@ func TestClient_SendStream_MidStreamNetworkError(t *testing.T) { w.WriteHeader(http.StatusOK) flusher, ok := w.(http.Flusher) require.True(t, ok) - w.Write([]byte("data: {\"id\":\"1\"}\n\n")) + _, err := w.Write([]byte("data: {\"id\":\"1\"}\n\n")) + require.NoError(t, err) flusher.Flush() time.Sleep(50 * time.Millisecond) if hijacker, ok := w.(http.Hijacker); ok { conn, _, _ := hijacker.Hijack() if conn != nil { - conn.Close() + require.NoError(t, conn.Close()) } } })) diff --git a/backend/internal/repository/provider_repo_impl.go b/backend/internal/repository/provider_repo_impl.go index 6ea917b..d59927b 100644 --- a/backend/internal/repository/provider_repo_impl.go +++ b/backend/internal/repository/provider_repo_impl.go @@ -3,10 +3,11 @@ package repository import ( "time" - "gorm.io/gorm" - "nex/backend/internal/config" "nex/backend/internal/domain" + + "gorm.io/gorm" + appErrors "nex/backend/pkg/errors" ) diff --git a/backend/internal/repository/repository_test.go b/backend/internal/repository/repository_test.go index 58d7bb4..c2432db 100644 --- a/backend/internal/repository/repository_test.go +++ b/backend/internal/repository/repository_test.go @@ -3,13 +3,13 @@ package repository import ( "testing" + "nex/backend/internal/domain" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" testHelpers "nex/backend/tests" - - "nex/backend/internal/domain" ) func setupTestDB(t *testing.T) *gorm.DB { diff --git a/backend/internal/repository/stats_repo_impl.go b/backend/internal/repository/stats_repo_impl.go index ca9e7d8..787dc33 100644 --- a/backend/internal/repository/stats_repo_impl.go +++ b/backend/internal/repository/stats_repo_impl.go @@ -3,11 +3,11 @@ package repository import ( "time" - "gorm.io/gorm" - "gorm.io/gorm/clause" - "nex/backend/internal/config" "nex/backend/internal/domain" + + "gorm.io/gorm" + "gorm.io/gorm/clause" ) type statsRepository struct { @@ -19,8 +19,8 @@ func NewStatsRepository(db *gorm.DB) StatsRepository { } func (r *statsRepository) Record(providerID, modelName string) error { - today := time.Now().Format("2006-01-02") - todayTime, _ := time.Parse("2006-01-02", today) + now := time.Now() + todayTime := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) stats := config.UsageStats{ ProviderID: providerID, diff --git a/backend/internal/service/model_service_impl.go b/backend/internal/service/model_service_impl.go index 01acb2c..1506656 100644 --- a/backend/internal/service/model_service_impl.go +++ b/backend/internal/service/model_service_impl.go @@ -1,11 +1,15 @@ package service import ( - "github.com/google/uuid" - appErrors "nex/backend/pkg/errors" + "errors" "nex/backend/internal/domain" "nex/backend/internal/repository" + + "github.com/google/uuid" + "gorm.io/gorm" + + appErrors "nex/backend/pkg/errors" ) type modelService struct { @@ -108,7 +112,11 @@ func (s *modelService) Delete(id string) error { func (s *modelService) checkDuplicateModelName(providerID, modelName, excludeID string) error { existing, err := s.modelRepo.FindByProviderAndModelName(providerID, modelName) if err != nil { - return nil // 未找到,不重复 + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil // 未找到,不重复 + } + + return err } if excludeID != "" && existing.ID == excludeID { return nil // 排除自身 diff --git a/backend/internal/service/provider_service_impl.go b/backend/internal/service/provider_service_impl.go index a01157e..6329a4e 100644 --- a/backend/internal/service/provider_service_impl.go +++ b/backend/internal/service/provider_service_impl.go @@ -3,10 +3,10 @@ package service import ( "strings" - "nex/backend/pkg/modelid" - "nex/backend/internal/domain" "nex/backend/internal/repository" + "nex/backend/pkg/modelid" + appErrors "nex/backend/pkg/errors" ) diff --git a/backend/internal/service/routing_cache.go b/backend/internal/service/routing_cache.go index 67ef73f..b87ab04 100644 --- a/backend/internal/service/routing_cache.go +++ b/backend/internal/service/routing_cache.go @@ -4,10 +4,11 @@ import ( "strings" "sync" - "go.uber.org/zap" - "nex/backend/internal/domain" "nex/backend/internal/repository" + + "go.uber.org/zap" + pkglogger "nex/backend/pkg/logger" ) @@ -34,7 +35,9 @@ func NewRoutingCache( func (c *RoutingCache) GetProvider(id string) (*domain.Provider, error) { if v, ok := c.providers.Load(id); ok { - return v.(*domain.Provider), nil + if provider, ok := v.(*domain.Provider); ok { + return provider, nil + } } provider, err := c.providerRepo.GetByID(id) @@ -43,7 +46,9 @@ func (c *RoutingCache) GetProvider(id string) (*domain.Provider, error) { } if v, ok := c.providers.Load(id); ok { - return v.(*domain.Provider), nil + if provider, ok := v.(*domain.Provider); ok { + return provider, nil + } } c.providers.Store(id, provider) @@ -54,7 +59,9 @@ func (c *RoutingCache) GetModel(providerID, modelName string) (*domain.Model, er key := providerID + "/" + modelName if v, ok := c.models.Load(key); ok { - return v.(*domain.Model), nil + if model, ok := v.(*domain.Model); ok { + return model, nil + } } model, err := c.modelRepo.FindByProviderAndModelName(providerID, modelName) @@ -63,7 +70,9 @@ func (c *RoutingCache) GetModel(providerID, modelName string) (*domain.Model, er } if v, ok := c.models.Load(key); ok { - return v.(*domain.Model), nil + if model, ok := v.(*domain.Model); ok { + return model, nil + } } c.models.Store(key, model) @@ -97,7 +106,12 @@ func (c *RoutingCache) invalidateModelsByProvider(providerID string) { prefix := providerID + "/" count := 0 c.models.Range(func(key, value interface{}) bool { - if strings.HasPrefix(key.(string), prefix) { + keyStr, ok := key.(string) + if !ok { + return true + } + + if strings.HasPrefix(keyStr, prefix) { c.models.Delete(key) count++ } diff --git a/backend/internal/service/routing_cache_test.go b/backend/internal/service/routing_cache_test.go index 0b50df8..d3c40c7 100644 --- a/backend/internal/service/routing_cache_test.go +++ b/backend/internal/service/routing_cache_test.go @@ -5,11 +5,11 @@ import ( "sync" "testing" + "nex/backend/internal/domain" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" - - "nex/backend/internal/domain" ) type mockModelRepo struct { @@ -189,7 +189,8 @@ func TestRoutingCache_InvalidateProvider_CascadingModels(t *testing.T) { var openaiCount, anthropicCount int cache.models.Range(func(key, value interface{}) bool { - if key.(string) == "anthropic/claude" { + keyStr, ok := key.(string) + if ok && keyStr == "anthropic/claude" { anthropicCount++ } return true diff --git a/backend/internal/service/routing_service_impl.go b/backend/internal/service/routing_service_impl.go index f43e006..3cc4010 100644 --- a/backend/internal/service/routing_service_impl.go +++ b/backend/internal/service/routing_service_impl.go @@ -1,9 +1,8 @@ package service import ( - appErrors "nex/backend/pkg/errors" - "nex/backend/internal/domain" + appErrors "nex/backend/pkg/errors" ) type routingService struct { diff --git a/backend/internal/service/service_supplemental_test.go b/backend/internal/service/service_supplemental_test.go index 5149c9a..897ab18 100644 --- a/backend/internal/service/service_supplemental_test.go +++ b/backend/internal/service/service_supplemental_test.go @@ -3,12 +3,12 @@ package service import ( "testing" + "nex/backend/internal/domain" + "nex/backend/internal/repository" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" - - "nex/backend/internal/domain" - "nex/backend/internal/repository" ) func TestProviderService_Update(t *testing.T) { @@ -133,7 +133,9 @@ func TestStatsService_Aggregate_Default(t *testing.T) { totalCount := 0 for _, r := range result { - totalCount += r["request_count"].(int) + count, ok := r["request_count"].(int) + require.True(t, ok) + totalCount += count } assert.Equal(t, 15, totalCount) } diff --git a/backend/internal/service/service_test.go b/backend/internal/service/service_test.go index a84fd8c..0618865 100644 --- a/backend/internal/service/service_test.go +++ b/backend/internal/service/service_test.go @@ -5,6 +5,9 @@ import ( "testing" "time" + "nex/backend/internal/domain" + "nex/backend/internal/repository" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -13,8 +16,6 @@ import ( testHelpers "nex/backend/tests" - "nex/backend/internal/domain" - "nex/backend/internal/repository" appErrors "nex/backend/pkg/errors" ) @@ -134,7 +135,7 @@ func TestModelService_Create_ProviderNotFound(t *testing.T) { db := setupServiceTestDB(t) providerRepo := repository.NewProviderRepository(db) modelRepo := repository.NewModelRepository(db) - cache := setupRoutingCache(t, db) + cache := setupRoutingCache(t, db) svc := NewModelService(modelRepo, providerRepo, cache) model := &domain.Model{ProviderID: "nonexistent", ModelName: "gpt-4"} @@ -148,7 +149,7 @@ func TestProviderService_Create_InvalidID(t *testing.T) { db := setupServiceTestDB(t) repo := repository.NewProviderRepository(db) modelRepo := repository.NewModelRepository(db) - cache := setupRoutingCache(t, db) + cache := setupRoutingCache(t, db) svc := NewProviderService(repo, modelRepo, cache) provider := &domain.Provider{ID: "open-ai", Name: "OpenAI", APIKey: "key", BaseURL: "https://api.openai.com"} @@ -160,7 +161,7 @@ func TestProviderService_Create_ValidID(t *testing.T) { db := setupServiceTestDB(t) repo := repository.NewProviderRepository(db) modelRepo := repository.NewModelRepository(db) - cache := setupRoutingCache(t, db) + cache := setupRoutingCache(t, db) svc := NewProviderService(repo, modelRepo, cache) provider := &domain.Provider{ID: "openai", Name: "OpenAI", APIKey: "key", BaseURL: "https://api.openai.com"} @@ -176,7 +177,7 @@ func TestModelService_Update_DuplicateModelName(t *testing.T) { db := setupServiceTestDB(t) providerRepo := repository.NewProviderRepository(db) modelRepo := repository.NewModelRepository(db) - cache := setupRoutingCache(t, db) + cache := setupRoutingCache(t, db) svc := NewModelService(modelRepo, providerRepo, cache) require.NoError(t, providerRepo.Create(&domain.Provider{ID: "openai", Name: "OpenAI", APIKey: "key", BaseURL: "https://api.openai.com"})) @@ -202,7 +203,7 @@ func TestModelService_Update_ModelNotFound(t *testing.T) { db := setupServiceTestDB(t) providerRepo := repository.NewProviderRepository(db) modelRepo := repository.NewModelRepository(db) - cache := setupRoutingCache(t, db) + cache := setupRoutingCache(t, db) svc := NewModelService(modelRepo, providerRepo, cache) err := svc.Update("nonexistent-id", map[string]interface{}{ @@ -215,7 +216,7 @@ func TestModelService_Update_Success(t *testing.T) { db := setupServiceTestDB(t) providerRepo := repository.NewProviderRepository(db) modelRepo := repository.NewModelRepository(db) - cache := setupRoutingCache(t, db) + cache := setupRoutingCache(t, db) svc := NewModelService(modelRepo, providerRepo, cache) require.NoError(t, providerRepo.Create(&domain.Provider{ID: "openai", Name: "OpenAI", APIKey: "key", BaseURL: "https://api.openai.com"})) @@ -241,7 +242,7 @@ func TestProviderService_Update_ImmutableID(t *testing.T) { db := setupServiceTestDB(t) repo := repository.NewProviderRepository(db) modelRepo := repository.NewModelRepository(db) - cache := setupRoutingCache(t, db) + cache := setupRoutingCache(t, db) svc := NewProviderService(repo, modelRepo, cache) provider := &domain.Provider{ID: "openai", Name: "OpenAI", APIKey: "key", BaseURL: "https://api.openai.com"} @@ -259,7 +260,7 @@ func TestProviderService_Update_Success(t *testing.T) { db := setupServiceTestDB(t) repo := repository.NewProviderRepository(db) modelRepo := repository.NewModelRepository(db) - cache := setupRoutingCache(t, db) + cache := setupRoutingCache(t, db) svc := NewProviderService(repo, modelRepo, cache) provider := &domain.Provider{ID: "openai", Name: "OpenAI", APIKey: "key", BaseURL: "https://api.openai.com"} @@ -318,7 +319,8 @@ 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, zap.NewNop()); svc := NewStatsService(statsRepo, buffer) + buffer := NewStatsBuffer(statsRepo, zap.NewNop()) + svc := NewStatsService(statsRepo, buffer) result := svc.Aggregate(tt.stats, "model") @@ -379,7 +381,8 @@ 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, zap.NewNop()); svc := NewStatsService(statsRepo, buffer) + buffer := NewStatsBuffer(statsRepo, zap.NewNop()) + svc := NewStatsService(statsRepo, buffer) result := svc.Aggregate(tt.stats, "date") @@ -448,7 +451,7 @@ func TestProviderService_List_APIKeyNotMasked(t *testing.T) { db := setupServiceTestDB(t) repo := repository.NewProviderRepository(db) modelRepo := repository.NewModelRepository(db) - cache := setupRoutingCache(t, db) + cache := setupRoutingCache(t, db) svc := NewProviderService(repo, modelRepo, cache) provider1 := &domain.Provider{ID: "openai", Name: "OpenAI", APIKey: "sk-1234567890", BaseURL: "https://api.openai.com"} @@ -474,7 +477,7 @@ func TestModelService_ConcurrentCreate(t *testing.T) { db := setupServiceTestDB(t) providerRepo := repository.NewProviderRepository(db) modelRepo := repository.NewModelRepository(db) - cache := setupRoutingCache(t, db) + cache := setupRoutingCache(t, db) svc := NewModelService(modelRepo, providerRepo, cache) require.NoError(t, providerRepo.Create(&domain.Provider{ID: "openai", Name: "OpenAI", APIKey: "key", BaseURL: "https://api.openai.com"})) diff --git a/backend/internal/service/stats_buffer.go b/backend/internal/service/stats_buffer.go index 38b75e5..f6c4261 100644 --- a/backend/internal/service/stats_buffer.go +++ b/backend/internal/service/stats_buffer.go @@ -6,9 +6,10 @@ import ( "sync/atomic" "time" + "nex/backend/internal/repository" + "go.uber.org/zap" - "nex/backend/internal/repository" pkglogger "nex/backend/pkg/logger" ) @@ -67,13 +68,21 @@ func (b *StatsBuffer) Increment(providerID, modelName string) { var counter *int64 if v, ok := b.counters.Load(key); ok { - counter = v.(*int64) + if existing, ok := v.(*int64); ok { + counter = existing + } else { + return + } } else { val := int64(0) counter = &val actual, loaded := b.counters.LoadOrStore(key, counter) if loaded { - counter = actual.(*int64) + existing, ok := actual.(*int64) + if !ok { + return + } + counter = existing } } @@ -117,13 +126,20 @@ func (b *StatsBuffer) flush() { var entries []statEntry b.counters.Range(func(key, value interface{}) bool { - keyStr := key.(string) + keyStr, ok := key.(string) + if !ok { + return true + } + parts := strings.Split(keyStr, "/") if len(parts) != 3 { return true } - counter := value.(*int64) + counter, ok := value.(*int64) + if !ok { + return true + } count := atomic.SwapInt64(counter, 0) if count > 0 { @@ -143,8 +159,17 @@ func (b *StatsBuffer) flush() { success := 0 for _, entry := range entries { - date, _ := time.Parse("2006-01-02", entry.date) - err := b.statsRepo.BatchUpdate(entry.providerID, entry.modelName, date, int(entry.count)) + date, err := time.Parse("2006-01-02", entry.date) + if err != nil { + b.logger.Error("解析统计日期失败", + zap.String("provider_id", entry.providerID), + zap.String("model_name", entry.modelName), + zap.String("date", entry.date), + zap.Error(err)) + continue + } + + err = b.statsRepo.BatchUpdate(entry.providerID, entry.modelName, date, int(entry.count)) if err != nil { b.logger.Error("批量更新统计失败", zap.String("provider_id", entry.providerID), @@ -154,8 +179,10 @@ func (b *StatsBuffer) flush() { key := entry.providerID + "/" + entry.modelName + "/" + entry.date if v, ok := b.counters.Load(key); ok { - counter := v.(*int64) - atomic.AddInt64(counter, entry.count) + counter, ok := v.(*int64) + if ok { + atomic.AddInt64(counter, entry.count) + } } } else { success++ diff --git a/backend/internal/service/stats_buffer_test.go b/backend/internal/service/stats_buffer_test.go index 4f789c4..ad11363 100644 --- a/backend/internal/service/stats_buffer_test.go +++ b/backend/internal/service/stats_buffer_test.go @@ -7,10 +7,10 @@ import ( "testing" "time" + "nex/backend/internal/domain" + "github.com/stretchr/testify/assert" "go.uber.org/zap" - - "nex/backend/internal/domain" ) type mockStatsRepo struct { @@ -58,8 +58,10 @@ func TestStatsBuffer_Increment(t *testing.T) { var count int64 buffer.counters.Range(func(key, value interface{}) bool { - counter := value.(*int64) - count += atomic.LoadInt64(counter) + counter, ok := value.(*int64) + if ok { + count += atomic.LoadInt64(counter) + } return true }) assert.Equal(t, int64(3), count) @@ -82,8 +84,10 @@ func TestStatsBuffer_ConcurrentIncrement(t *testing.T) { var count int64 buffer.counters.Range(func(key, value interface{}) bool { - counter := value.(*int64) - count = atomic.LoadInt64(counter) + counter, ok := value.(*int64) + if ok { + count = atomic.LoadInt64(counter) + } return true }) assert.Equal(t, int64(100), count) @@ -161,8 +165,10 @@ func TestStatsBuffer_SwapInt64(t *testing.T) { var beforeCount int64 buffer.counters.Range(func(key, value interface{}) bool { - counter := value.(*int64) - beforeCount = atomic.LoadInt64(counter) + counter, ok := value.(*int64) + if ok { + beforeCount = atomic.LoadInt64(counter) + } return true }) assert.Equal(t, int64(2), beforeCount) @@ -171,8 +177,10 @@ func TestStatsBuffer_SwapInt64(t *testing.T) { var afterCount int64 buffer.counters.Range(func(key, value interface{}) bool { - counter := value.(*int64) - afterCount = atomic.LoadInt64(counter) + counter, ok := value.(*int64) + if ok { + afterCount = atomic.LoadInt64(counter) + } return true }) assert.Equal(t, int64(0), afterCount) @@ -190,8 +198,10 @@ func TestStatsBuffer_FailRetry(t *testing.T) { var count int64 buffer.counters.Range(func(key, value interface{}) bool { - counter := value.(*int64) - count = atomic.LoadInt64(counter) + counter, ok := value.(*int64) + if ok { + count = atomic.LoadInt64(counter) + } return true }) assert.Equal(t, int64(2), count) diff --git a/backend/pkg/errors/errors.go b/backend/pkg/errors/errors.go index 4fee2c9..7e89e8b 100644 --- a/backend/pkg/errors/errors.go +++ b/backend/pkg/errors/errors.go @@ -1,6 +1,7 @@ package errors import ( + stderrors "errors" "fmt" "net/http" ) @@ -70,22 +71,11 @@ func AsAppError(err error) (*AppError, bool) { if err == nil { return nil, false } - var appErr *AppError - if ok := is(err, &appErr); ok { - return appErr, true - } - return nil, false -} -func is(err error, target interface{}) bool { - // 简单的类型断言 - if e, ok := err.(*AppError); ok { - // 直接赋值 - switch t := target.(type) { - case **AppError: - *t = e - return true - } + var appErr *AppError + if !stderrors.As(err, &appErr) { + return nil, false } - return false + + return appErr, true } diff --git a/backend/pkg/errors/errors_test.go b/backend/pkg/errors/errors_test.go index e59b63f..7c583bd 100644 --- a/backend/pkg/errors/errors_test.go +++ b/backend/pkg/errors/errors_test.go @@ -104,7 +104,8 @@ func TestPredefinedErrors(t *testing.T) { func TestAsAppError(t *testing.T) { t.Run("nil输入", func(t *testing.T) { - _, ok := AsAppError(nil) + appErr, ok := AsAppError(nil) + assert.Nil(t, appErr) assert.False(t, ok) }) @@ -122,7 +123,8 @@ func TestAsAppError(t *testing.T) { }) t.Run("非AppError类型", func(t *testing.T) { - _, ok := AsAppError(errors.New("普通错误")) + appErr, ok := AsAppError(errors.New("普通错误")) + assert.Nil(t, appErr) assert.False(t, ok) }) } diff --git a/backend/pkg/logger/logger_test.go b/backend/pkg/logger/logger_test.go index 7590e21..4afca08 100644 --- a/backend/pkg/logger/logger_test.go +++ b/backend/pkg/logger/logger_test.go @@ -19,7 +19,7 @@ func TestNew_StdoutOnly(t *testing.T) { func TestNew_WithFileOutput(t *testing.T) { dir := filepath.Join(os.TempDir(), "nex-logger-test") - os.MkdirAll(dir, 0755) + require.NoError(t, os.MkdirAll(dir, 0o755)) defer os.RemoveAll(dir) logger, err := New(Config{ @@ -81,7 +81,7 @@ func TestParseLevel(t *testing.T) { {"info", true}, {"warn", true}, {"error", true}, - {"", true}, // 默认为 info + {"", true}, // 默认为 info {"invalid", true}, // 默认为 info } for _, tt := range tests { diff --git a/backend/pkg/logger/rotate.go b/backend/pkg/logger/rotate.go index 32ee88c..225cf1b 100644 --- a/backend/pkg/logger/rotate.go +++ b/backend/pkg/logger/rotate.go @@ -22,9 +22,9 @@ func newRotateWriter(cfg Config) *lumberjack.Logger { return &lumberjack.Logger{ Filename: logFilePath(cfg.Path), - MaxSize: maxSize, // MB + MaxSize: maxSize, // MB MaxBackups: maxBackups, - MaxAge: maxAge, // days + MaxAge: maxAge, // days Compress: cfg.Compress, } } diff --git a/backend/tests/config/config_test.go b/backend/tests/config/config_test.go index ff55aa1..b1c2871 100644 --- a/backend/tests/config/config_test.go +++ b/backend/tests/config/config_test.go @@ -6,10 +6,10 @@ import ( "testing" "time" + "nex/backend/internal/config" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "nex/backend/internal/config" ) func TestLoadConfig_DefaultValues(t *testing.T) { @@ -72,7 +72,7 @@ log: max_age: 7 compress: false ` - err := os.WriteFile(configPath, []byte(yamlContent), 0644) + err := os.WriteFile(configPath, []byte(yamlContent), 0o600) require.NoError(t, err) cfg, err := config.LoadConfigFromPath(configPath) @@ -103,7 +103,7 @@ server: log: level: warn ` - err := os.WriteFile(configPath, []byte(yamlContent), 0644) + err := os.WriteFile(configPath, []byte(yamlContent), 0o600) require.NoError(t, err) t.Setenv("NEX_SERVER_PORT", "9000") @@ -147,7 +147,7 @@ func TestSaveAndLoadConfig(t *testing.T) { } defer func() { if originalConfig != nil { - _ = os.WriteFile(configPath, originalConfig, 0644) + require.NoError(t, os.WriteFile(configPath, originalConfig, 0o600)) } }() diff --git a/backend/tests/integration/conversion_test.go b/backend/tests/integration/conversion_test.go index 621a899..7b6b017 100644 --- a/backend/tests/integration/conversion_test.go +++ b/backend/tests/integration/conversion_test.go @@ -11,20 +11,21 @@ import ( "testing" "time" + "nex/backend/internal/conversion" + "nex/backend/internal/conversion/anthropic" + "nex/backend/internal/handler" + "nex/backend/internal/handler/middleware" + "nex/backend/internal/provider" + "nex/backend/internal/repository" + "nex/backend/internal/service" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" "gorm.io/gorm" - "nex/backend/internal/conversion" - "nex/backend/internal/conversion/anthropic" openaiConv "nex/backend/internal/conversion/openai" - "nex/backend/internal/handler" - "nex/backend/internal/handler/middleware" - "nex/backend/internal/provider" - "nex/backend/internal/repository" - "nex/backend/internal/service" ) func init() { @@ -39,7 +40,8 @@ func setupConversionTest(t *testing.T) (*gin.Engine, *gorm.DB, *httptest.Server) upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // 默认返回成功,由各测试 case 覆盖 w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"error":"not mocked"}`)) + _, err := w.Write([]byte(`{"error":"not mocked"}`)) + require.NoError(t, err) })) db := setupTestDB(t) @@ -124,7 +126,6 @@ func createProviderAndModel(t *testing.T, r *gin.Engine, providerID, protocol, m require.Equal(t, 201, w.Code) modelBody, _ := json.Marshal(map[string]string{ - "provider_id": providerID, "model_name": modelName, }) @@ -143,9 +144,10 @@ func TestConversion_OpenAIToAnthropic_NonStream(t *testing.T) { // 配置上游返回 Anthropic 格式响应 upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // 验证请求被转换为 Anthropic 格式 - body, _ := io.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) var req map[string]any - json.Unmarshal(body, &req) + require.NoError(t, json.Unmarshal(body, &req)) assert.Equal(t, "/v1/messages", r.URL.Path) assert.Contains(t, r.Header.Get("Content-Type"), "application/json") @@ -166,7 +168,7 @@ func TestConversion_OpenAIToAnthropic_NonStream(t *testing.T) { }, } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + require.NoError(t, json.NewEncoder(w).Encode(resp)) }) createProviderAndModel(t, r, "anthropic_p", "anthropic", "claude-3-opus", upstream.URL) @@ -189,13 +191,16 @@ func TestConversion_OpenAIToAnthropic_NonStream(t *testing.T) { assert.Equal(t, 200, w.Code) var resp map[string]any - json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(t, "chat.completion", resp["object"]) - choices := resp["choices"].([]any) + choices, ok := resp["choices"].([]any) + require.True(t, ok) require.Len(t, choices, 1) - choice := choices[0].(map[string]any) - msg := choice["message"].(map[string]any) + choice, ok := choices[0].(map[string]any) + require.True(t, ok) + msg, ok := choice["message"].(map[string]any) + require.True(t, ok) assert.Contains(t, msg["content"], "Hello from Anthropic!") } @@ -203,9 +208,10 @@ func TestConversion_AnthropicToOpenAI_NonStream(t *testing.T) { r, _, upstream := setupConversionTest(t) upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) var req map[string]any - json.Unmarshal(body, &req) + require.NoError(t, json.Unmarshal(body, &req)) assert.Equal(t, "/chat/completions", r.URL.Path) assert.Contains(t, r.Header.Get("Authorization"), "Bearer test-key") @@ -229,7 +235,7 @@ func TestConversion_AnthropicToOpenAI_NonStream(t *testing.T) { }, } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + require.NoError(t, json.NewEncoder(w).Encode(resp)) }) createProviderAndModel(t, r, "openai_p", "openai", "gpt-4", upstream.URL) @@ -252,12 +258,14 @@ func TestConversion_AnthropicToOpenAI_NonStream(t *testing.T) { assert.Equal(t, 200, w.Code) var resp map[string]any - json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(t, "message", resp["type"]) - content := resp["content"].([]any) + content, ok := resp["content"].([]any) + require.True(t, ok) require.Len(t, content, 1) - block := content[0].(map[string]any) + block, ok2 := content[0].(map[string]any) + require.True(t, ok2) assert.Contains(t, block["text"], "Hello from OpenAI!") } @@ -269,21 +277,23 @@ func TestConversion_OpenAIToOpenAI_Passthrough(t *testing.T) { upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/chat/completions", r.URL.Path) - body, _ := io.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) var req map[string]any - json.Unmarshal(body, &req) + require.NoError(t, json.Unmarshal(body, &req)) // Smart Passthrough: 请求体中的统一 ID 应被改写为上游模型名 assert.Equal(t, "gpt-4", req["model"]) w.Header().Set("Content-Type", "application/json") // 上游返回上游模型名 - w.Write([]byte(`{"id":"chatcmpl-pass","object":"chat.completion","model":"gpt-4","choices":[{"index":0,"message":{"role":"assistant","content":"passthrough"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":1,"total_tokens":6}}`)) + _, err = w.Write([]byte(`{"id":"chatcmpl-pass","object":"chat.completion","model":"gpt-4","choices":[{"index":0,"message":{"role":"assistant","content":"passthrough"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":1,"total_tokens":6}}`)) + require.NoError(t, err) }) createProviderAndModel(t, r, "openai_p", "openai", "gpt-4", upstream.URL) reqBody := map[string]any{ - "model": "openai_p/gpt-4", // 客户端发送统一 ID + "model": "openai_p/gpt-4", // 客户端发送统一 ID "messages": []map[string]any{{"role": "user", "content": "test"}}, } body, _ := json.Marshal(reqBody) @@ -304,21 +314,23 @@ func TestConversion_AnthropicToAnthropic_Passthrough(t *testing.T) { upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/v1/messages", r.URL.Path) - body, _ := io.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) var req map[string]any - json.Unmarshal(body, &req) + require.NoError(t, json.Unmarshal(body, &req)) // Smart Passthrough: 请求体中的统一 ID 应被改写为上游模型名 assert.Equal(t, "claude-3-opus", req["model"]) // 上游返回上游模型名 w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"id":"msg-pass","type":"message","role":"assistant","model":"claude-3-opus","content":[{"type":"text","text":"passthrough"}],"stop_reason":"end_turn","usage":{"input_tokens":5,"output_tokens":1}}`)) + _, err = w.Write([]byte(`{"id":"msg-pass","type":"message","role":"assistant","model":"claude-3-opus","content":[{"type":"text","text":"passthrough"}],"stop_reason":"end_turn","usage":{"input_tokens":5,"output_tokens":1}}`)) + require.NoError(t, err) }) createProviderAndModel(t, r, "anthropic_p", "anthropic", "claude-3-opus", upstream.URL) reqBody := map[string]any{ - "model": "anthropic_p/claude-3-opus", // 客户端发送统一 ID + "model": "anthropic_p/claude-3-opus", // 客户端发送统一 ID "max_tokens": 1024, "messages": []map[string]any{{"role": "user", "content": "test"}}, } @@ -352,7 +364,8 @@ func TestConversion_OpenAIToAnthropic_Stream(t *testing.T) { "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", } for _, e := range events { - w.Write([]byte(e)) + _, err := w.Write([]byte(e)) + require.NoError(t, err) if f, ok := w.(http.Flusher); ok { f.Flush() } @@ -393,7 +406,8 @@ func TestConversion_AnthropicToOpenAI_Stream(t *testing.T) { "data: [DONE]\n\n", } for _, e := range events { - w.Write([]byte(e)) + _, err := w.Write([]byte(e)) + require.NoError(t, err) if f, ok := w.(http.Flusher); ok { f.Flush() } @@ -447,11 +461,13 @@ func TestConversion_Models_CrossProtocol(t *testing.T) { require.NoError(t, err) var anthropicResp map[string]any - json.Unmarshal(anthropicBody, &anthropicResp) - data := anthropicResp["data"].([]any) + require.NoError(t, json.Unmarshal(anthropicBody, &anthropicResp)) + data, okd := anthropicResp["data"].([]any) + require.True(t, okd) assert.Len(t, data, 2) - first := data[0].(map[string]any) + first, okf := data[0].(map[string]any) + require.True(t, okf) assert.Equal(t, "gpt-4", first["id"]) assert.Equal(t, "model", first["type"]) @@ -466,11 +482,12 @@ func TestConversion_Models_CrossProtocol(t *testing.T) { require.NoError(t, err) var openaiResp map[string]any - json.Unmarshal(openaiBody, &err) - json.Unmarshal(openaiBody, &openaiResp) - oaiData := openaiResp["data"].([]any) + require.NoError(t, json.Unmarshal(openaiBody, &openaiResp)) + oaiData, oki := openaiResp["data"].([]any) + require.True(t, oki) assert.Len(t, oaiData, 1) - firstOai := oaiData[0].(map[string]any) + firstOai, okf2 := oaiData[0].(map[string]any) + require.True(t, okf2) assert.Equal(t, "claude-3-opus", firstOai["id"]) } @@ -537,7 +554,7 @@ func TestConversion_ProviderWithProtocol(t *testing.T) { require.Equal(t, 201, w.Code) var created map[string]any - json.Unmarshal(w.Body.Bytes(), &created) + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &created)) assert.Equal(t, "sk-test", created["api_key"]) // 获取时应包含 protocol @@ -547,7 +564,7 @@ func TestConversion_ProviderWithProtocol(t *testing.T) { assert.Equal(t, 200, w.Code) var fetched map[string]any - json.Unmarshal(w.Body.Bytes(), &fetched) + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &fetched)) assert.Equal(t, "anthropic", fetched["protocol"]) } @@ -570,11 +587,13 @@ func TestConversion_ProviderDefaultProtocol(t *testing.T) { require.Equal(t, 201, w.Code) var created map[string]any - json.Unmarshal(w.Body.Bytes(), &created) + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &created)) assert.Equal(t, "openai", created["protocol"]) } // Suppress unused imports -var _ = fmt.Sprintf -var _ = strings.Contains -var _ = time.Second +var ( + _ = fmt.Sprintf + _ = strings.Contains + _ = time.Second +) diff --git a/backend/tests/integration/e2e_conversion_test.go b/backend/tests/integration/e2e_conversion_test.go index 585fc20..e2e4816 100644 --- a/backend/tests/integration/e2e_conversion_test.go +++ b/backend/tests/integration/e2e_conversion_test.go @@ -12,19 +12,20 @@ import ( "testing" "time" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.uber.org/zap" - "nex/backend/internal/conversion" "nex/backend/internal/conversion/anthropic" - openaiConv "nex/backend/internal/conversion/openai" "nex/backend/internal/handler" "nex/backend/internal/handler/middleware" "nex/backend/internal/provider" "nex/backend/internal/repository" "nex/backend/internal/service" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + openaiConv "nex/backend/internal/conversion/openai" ) func setupE2ETest(t *testing.T) (*gin.Engine, *httptest.Server) { @@ -33,7 +34,8 @@ func setupE2ETest(t *testing.T) (*gin.Engine, *httptest.Server) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"error":"not mocked"}`)) + _, err := w.Write([]byte(`{"error":"not mocked"}`)) + require.NoError(t, err) })) db := setupTestDB(t) @@ -115,11 +117,12 @@ func parseSSEEvents(body string) []map[string]string { var currentEvent, currentData string for scanner.Scan() { line := scanner.Text() - if strings.HasPrefix(line, "event: ") { + switch { + case strings.HasPrefix(line, "event: "): currentEvent = strings.TrimPrefix(line, "event: ") - } else if strings.HasPrefix(line, "data: ") { + case strings.HasPrefix(line, "data: "): currentData = strings.TrimPrefix(line, "data: ") - } else if line == "" && (currentEvent != "" || currentData != "") { + case line == "" && (currentEvent != "" || currentData != ""): events = append(events, map[string]string{ "event": currentEvent, "data": currentData, @@ -157,21 +160,21 @@ func TestE2E_OpenAI_NonStream_BasicText(t *testing.T) { upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { assert.Equal(t, "/chat/completions", req.URL.Path) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "id": "chatcmpl-e2e-001", "object": "chat.completion", "created": 1700000000, "model": "gpt-4o", "choices": []map[string]any{{ - "index": 0, - "message": map[string]any{"role": "assistant", "content": "你好!我是AI助手。"}, + "index": 0, + "message": map[string]any{"role": "assistant", "content": "你好!我是AI助手。"}, "finish_reason": "stop", - "logprobs": nil, + "logprobs": nil, }}, "usage": map[string]any{ "prompt_tokens": 15, "completion_tokens": 10, "total_tokens": 25, }, - }) + })) }) e2eCreateProviderAndModel(t, r, "openai_p", "openai", "gpt-4o", upstream.URL) @@ -210,21 +213,23 @@ func TestE2E_OpenAI_NonStream_BasicText(t *testing.T) { func TestE2E_OpenAI_NonStream_MultiTurn(t *testing.T) { r, upstream := setupE2ETest(t) upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - body, _ := io.ReadAll(req.Body) + body, err := io.ReadAll(req.Body) + require.NoError(t, err) var reqBody map[string]any - json.Unmarshal(body, &reqBody) - msgs := reqBody["messages"].([]any) + require.NoError(t, json.Unmarshal(body, &reqBody)) + msgs, ok := reqBody["messages"].([]any) + require.True(t, ok) assert.GreaterOrEqual(t, len(msgs), 3) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "id": "chatcmpl-e2e-002", "object": "chat.completion", "created": 1700000001, "model": "gpt-4o", "choices": []map[string]any{{ "index": 0, "message": map[string]any{"role": "assistant", "content": "Go语言的interface是隐式实现的。"}, "finish_reason": "stop", "logprobs": nil, }}, "usage": map[string]any{"prompt_tokens": 100, "completion_tokens": 20, "total_tokens": 120}, - }) + })) }) e2eCreateProviderAndModel(t, r, "openai_p", "openai", "gpt-4o", upstream.URL) @@ -252,7 +257,7 @@ func TestE2E_OpenAI_NonStream_ToolCalls(t *testing.T) { r, upstream := setupE2ETest(t) upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "id": "chatcmpl-e2e-004", "object": "chat.completion", "created": 1700000003, "model": "gpt-4o", "choices": []map[string]any{{ "index": 0, @@ -272,7 +277,7 @@ func TestE2E_OpenAI_NonStream_ToolCalls(t *testing.T) { "logprobs": nil, }}, "usage": map[string]any{"prompt_tokens": 80, "completion_tokens": 18, "total_tokens": 98}, - }) + })) }) e2eCreateProviderAndModel(t, r, "openai_p", "openai", "gpt-4o", upstream.URL) @@ -286,9 +291,9 @@ func TestE2E_OpenAI_NonStream_ToolCalls(t *testing.T) { "function": map[string]any{ "name": "get_weather", "description": "获取天气", "parameters": map[string]any{ - "type": "object", + "type": "object", "properties": map[string]any{"city": map[string]any{"type": "string"}}, - "required": []string{"city"}, + "required": []string{"city"}, }, }, }}, @@ -319,22 +324,22 @@ func TestE2E_OpenAI_NonStream_MaxTokens_Length(t *testing.T) { r, upstream := setupE2ETest(t) upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "id": "chatcmpl-e2e-014", "object": "chat.completion", "created": 1700000014, "model": "gpt-4o", "choices": []map[string]any{{ - "index": 0, - "message": map[string]any{"role": "assistant", "content": "人工智能起源于1950年代..."}, + "index": 0, + "message": map[string]any{"role": "assistant", "content": "人工智能起源于1950年代..."}, "finish_reason": "length", "logprobs": nil, }}, "usage": map[string]any{"prompt_tokens": 20, "completion_tokens": 30, "total_tokens": 50}, - }) + })) }) e2eCreateProviderAndModel(t, r, "openai_p", "openai", "gpt-4o", upstream.URL) body, _ := json.Marshal(map[string]any{ - "model": "openai_p/gpt-4o", - "messages": []map[string]any{{"role": "user", "content": "介绍AI历史"}}, + "model": "openai_p/gpt-4o", + "messages": []map[string]any{{"role": "user", "content": "介绍AI历史"}}, "max_tokens": 30, }) w := httptest.NewRecorder() @@ -353,11 +358,11 @@ func TestE2E_OpenAI_NonStream_UsageWithReasoning(t *testing.T) { r, upstream := setupE2ETest(t) upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "id": "chatcmpl-e2e-022", "object": "chat.completion", "created": 1700000022, "model": "o3", "choices": []map[string]any{{ - "index": 0, - "message": map[string]any{"role": "assistant", "content": "答案是61。"}, + "index": 0, + "message": map[string]any{"role": "assistant", "content": "答案是61。"}, "finish_reason": "stop", "logprobs": nil, }}, @@ -365,12 +370,12 @@ func TestE2E_OpenAI_NonStream_UsageWithReasoning(t *testing.T) { "prompt_tokens": 35, "completion_tokens": 48, "total_tokens": 83, "completion_tokens_details": map[string]any{"reasoning_tokens": 20}, }, - }) + })) }) e2eCreateProviderAndModel(t, r, "openai_p", "openai", "o3", upstream.URL) body, _ := json.Marshal(map[string]any{ - "model": "openai_p/o3", + "model": "openai_p/o3", "messages": []map[string]any{{"role": "user", "content": "15+23*2=?"}}, }) w := httptest.NewRecorder() @@ -393,12 +398,12 @@ func TestE2E_OpenAI_NonStream_Refusal(t *testing.T) { r, upstream := setupE2ETest(t) upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "id": "chatcmpl-e2e-007", "object": "chat.completion", "created": 1700000007, "model": "gpt-4o", "choices": []map[string]any{{ "index": 0, "message": map[string]any{ - "role": "assistant", + "role": "assistant", "content": nil, "refusal": "抱歉,我无法提供涉及危险活动的信息。", }, @@ -406,12 +411,12 @@ func TestE2E_OpenAI_NonStream_Refusal(t *testing.T) { "logprobs": nil, }}, "usage": map[string]any{"prompt_tokens": 12, "completion_tokens": 35, "total_tokens": 47}, - }) + })) }) e2eCreateProviderAndModel(t, r, "openai_p", "openai", "gpt-4o", upstream.URL) body, _ := json.Marshal(map[string]any{ - "model": "openai_p/gpt-4o", + "model": "openai_p/gpt-4o", "messages": []map[string]any{{"role": "user", "content": "做坏事"}}, }) w := httptest.NewRecorder() @@ -453,9 +458,9 @@ func TestE2E_OpenAI_Stream_Text(t *testing.T) { e2eCreateProviderAndModel(t, r, "openai_p", "openai", "gpt-4o", upstream.URL) body, _ := json.Marshal(map[string]any{ - "model": "openai_p/gpt-4o", + "model": "openai_p/gpt-4o", "messages": []map[string]any{{"role": "user", "content": "你好"}}, - "stream": true, + "stream": true, }) w := httptest.NewRecorder() req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body)) @@ -497,14 +502,14 @@ func TestE2E_OpenAI_Stream_ToolCalls(t *testing.T) { e2eCreateProviderAndModel(t, r, "openai_p", "openai", "gpt-4o", upstream.URL) body, _ := json.Marshal(map[string]any{ - "model": "openai_p/gpt-4o", + "model": "openai_p/gpt-4o", "messages": []map[string]any{{"role": "user", "content": "北京天气"}}, "tools": []map[string]any{{ "type": "function", "function": map[string]any{ "name": "get_weather", "description": "获取天气", "parameters": map[string]any{ - "type": "object", + "type": "object", "properties": map[string]any{"city": map[string]any{"type": "string"}}, }, }, @@ -546,9 +551,9 @@ func TestE2E_OpenAI_Stream_WithUsage(t *testing.T) { e2eCreateProviderAndModel(t, r, "openai_p", "openai", "gpt-4o", upstream.URL) body, _ := json.Marshal(map[string]any{ - "model": "openai_p/gpt-4o", + "model": "openai_p/gpt-4o", "messages": []map[string]any{{"role": "user", "content": "hi"}}, - "stream": true, + "stream": true, }) w := httptest.NewRecorder() req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body)) @@ -569,14 +574,14 @@ func TestE2E_Anthropic_NonStream_BasicText(t *testing.T) { r, upstream := setupE2ETest(t) upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "id": "msg_e2e_001", "type": "message", "role": "assistant", "content": []map[string]any{ {"type": "text", "text": "你好!我是Claude,由Anthropic开发的AI助手。"}, }, "model": "claude-opus-4-7", "stop_reason": "end_turn", "stop_sequence": nil, "usage": map[string]any{"input_tokens": 15, "output_tokens": 25}, - }) + })) }) e2eCreateProviderAndModel(t, r, "anthropic_p", "anthropic", "claude-opus-4-7", upstream.URL) @@ -611,24 +616,25 @@ func TestE2E_Anthropic_NonStream_BasicText(t *testing.T) { func TestE2E_Anthropic_NonStream_WithSystem(t *testing.T) { r, upstream := setupE2ETest(t) upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - body, _ := io.ReadAll(req.Body) + body, err := io.ReadAll(req.Body) + require.NoError(t, err) var reqBody map[string]any - json.Unmarshal(body, &reqBody) + require.NoError(t, json.Unmarshal(body, &reqBody)) assert.NotNil(t, reqBody["system"]) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "id": "msg_e2e_003", "type": "message", "role": "assistant", "content": []map[string]any{{"type": "text", "text": "递归是函数调用自身。"}}, - "model": "claude-opus-4-7", "stop_reason": "end_turn", "stop_sequence": nil, + "model": "claude-opus-4-7", "stop_reason": "end_turn", "stop_sequence": nil, "usage": map[string]any{"input_tokens": 30, "output_tokens": 15}, - }) + })) }) e2eCreateProviderAndModel(t, r, "anthropic_p", "anthropic", "claude-opus-4-7", upstream.URL) body, _ := json.Marshal(map[string]any{ "model": "anthropic_p/claude-opus-4-7", "max_tokens": 1024, - "system": "你是编程助手", + "system": "你是编程助手", "messages": []map[string]any{{"role": "user", "content": "什么是递归?"}}, }) w := httptest.NewRecorder() @@ -643,7 +649,7 @@ func TestE2E_Anthropic_NonStream_ToolUse(t *testing.T) { r, upstream := setupE2ETest(t) upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "id": "msg_e2e_009", "type": "message", "role": "assistant", "content": []map[string]any{{ "type": "tool_use", "id": "toolu_e2e_009", "name": "get_weather", @@ -651,7 +657,7 @@ func TestE2E_Anthropic_NonStream_ToolUse(t *testing.T) { }}, "model": "claude-opus-4-7", "stop_reason": "tool_use", "stop_sequence": nil, "usage": map[string]any{"input_tokens": 180, "output_tokens": 42}, - }) + })) }) e2eCreateProviderAndModel(t, r, "anthropic_p", "anthropic", "claude-opus-4-7", upstream.URL) @@ -661,9 +667,9 @@ func TestE2E_Anthropic_NonStream_ToolUse(t *testing.T) { "tools": []map[string]any{{ "name": "get_weather", "description": "获取天气", "input_schema": map[string]any{ - "type": "object", + "type": "object", "properties": map[string]any{"city": map[string]any{"type": "string"}}, - "required": []string{"city"}, + "required": []string{"city"}, }, }}, "tool_choice": map[string]any{"type": "auto"}, @@ -689,7 +695,7 @@ func TestE2E_Anthropic_NonStream_Thinking(t *testing.T) { r, upstream := setupE2ETest(t) upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "id": "msg_e2e_018", "type": "message", "role": "assistant", "content": []map[string]any{ {"type": "thinking", "thinking": "这是一个逻辑推理问题..."}, @@ -697,7 +703,7 @@ func TestE2E_Anthropic_NonStream_Thinking(t *testing.T) { }, "model": "claude-opus-4-7", "stop_reason": "end_turn", "stop_sequence": nil, "usage": map[string]any{"input_tokens": 95, "output_tokens": 280}, - }) + })) }) e2eCreateProviderAndModel(t, r, "anthropic_p", "anthropic", "claude-opus-4-7", upstream.URL) @@ -724,12 +730,12 @@ func TestE2E_Anthropic_NonStream_MaxTokens(t *testing.T) { r, upstream := setupE2ETest(t) upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "id": "msg_e2e_016", "type": "message", "role": "assistant", "content": []map[string]any{{"type": "text", "text": "人工智能起源于..."}}, - "model": "claude-opus-4-7", "stop_reason": "max_tokens", "stop_sequence": nil, + "model": "claude-opus-4-7", "stop_reason": "max_tokens", "stop_sequence": nil, "usage": map[string]any{"input_tokens": 22, "output_tokens": 20}, - }) + })) }) e2eCreateProviderAndModel(t, r, "anthropic_p", "anthropic", "claude-opus-4-7", upstream.URL) @@ -752,18 +758,18 @@ func TestE2E_Anthropic_NonStream_StopSequence(t *testing.T) { r, upstream := setupE2ETest(t) upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "id": "msg_e2e_017", "type": "message", "role": "assistant", "content": []map[string]any{{"type": "text", "text": "1\n2\n3\n4\n"}}, - "model": "claude-opus-4-7", "stop_reason": "stop_sequence", "stop_sequence": "5", + "model": "claude-opus-4-7", "stop_reason": "stop_sequence", "stop_sequence": "5", "usage": map[string]any{"input_tokens": 22, "output_tokens": 10}, - }) + })) }) e2eCreateProviderAndModel(t, r, "anthropic_p", "anthropic", "claude-opus-4-7", upstream.URL) body, _ := json.Marshal(map[string]any{ "model": "anthropic_p/claude-opus-4-7", "max_tokens": 1024, - "messages": []map[string]any{{"role": "user", "content": "从1数到10"}}, + "messages": []map[string]any{{"role": "user", "content": "从1数到10"}}, "stop_sequences": []string{"5"}, }) w := httptest.NewRecorder() @@ -781,19 +787,20 @@ func TestE2E_Anthropic_NonStream_StopSequence(t *testing.T) { func TestE2E_Anthropic_NonStream_MetadataUserID(t *testing.T) { r, upstream := setupE2ETest(t) upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - body, _ := io.ReadAll(req.Body) + body, err := io.ReadAll(req.Body) + require.NoError(t, err) var reqBody map[string]any - json.Unmarshal(body, &reqBody) + require.NoError(t, json.Unmarshal(body, &reqBody)) metadata, _ := reqBody["metadata"].(map[string]any) assert.Equal(t, "user_12345", metadata["user_id"]) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "id": "msg_e2e_026", "type": "message", "role": "assistant", "content": []map[string]any{{"type": "text", "text": "你好!"}}, - "model": "claude-opus-4-7", "stop_reason": "end_turn", "stop_sequence": nil, + "model": "claude-opus-4-7", "stop_reason": "end_turn", "stop_sequence": nil, "usage": map[string]any{"input_tokens": 12, "output_tokens": 5}, - }) + })) }) e2eCreateProviderAndModel(t, r, "anthropic_p", "anthropic", "claude-opus-4-7", upstream.URL) @@ -814,21 +821,21 @@ func TestE2E_Anthropic_NonStream_UsageWithCache(t *testing.T) { r, upstream := setupE2ETest(t) upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "id": "msg_e2e_025", "type": "message", "role": "assistant", "content": []map[string]any{{"type": "text", "text": "你好!"}}, - "model": "claude-opus-4-7", "stop_reason": "end_turn", "stop_sequence": nil, + "model": "claude-opus-4-7", "stop_reason": "end_turn", "stop_sequence": nil, "usage": map[string]any{ "input_tokens": 25, "output_tokens": 5, "cache_creation_input_tokens": 15, "cache_read_input_tokens": 0, }, - }) + })) }) e2eCreateProviderAndModel(t, r, "anthropic_p", "anthropic", "claude-opus-4-7", upstream.URL) body, _ := json.Marshal(map[string]any{ "model": "anthropic_p/claude-opus-4-7", "max_tokens": 1024, - "system": []map[string]any{{"type": "text", "text": "你是编程助手。"}}, + "system": []map[string]any{{"type": "text", "text": "你是编程助手。"}}, "messages": []map[string]any{{"role": "user", "content": "你好"}}, }) w := httptest.NewRecorder() @@ -864,7 +871,8 @@ func TestE2E_Anthropic_Stream_Text(t *testing.T) { "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", } for _, e := range events { - w.Write([]byte(e)) + _, err := w.Write([]byte(e)) + require.NoError(t, err) flusher.Flush() time.Sleep(10 * time.Millisecond) } @@ -874,7 +882,7 @@ func TestE2E_Anthropic_Stream_Text(t *testing.T) { body, _ := json.Marshal(map[string]any{ "model": "anthropic_p/claude-opus-4-7", "max_tokens": 1024, "messages": []map[string]any{{"role": "user", "content": "你好"}}, - "stream": true, + "stream": true, }) w := httptest.NewRecorder() req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) @@ -922,7 +930,7 @@ func TestE2E_Anthropic_Stream_Thinking(t *testing.T) { "model": "anthropic_p/claude-opus-4-7", "max_tokens": 4096, "messages": []map[string]any{{"role": "user", "content": "1+1=?"}}, "thinking": map[string]any{"type": "enabled", "budget_tokens": 1024}, - "stream": true, + "stream": true, }) w := httptest.NewRecorder() req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) @@ -961,14 +969,14 @@ func TestE2E_CrossProtocol_OpenAIToAnthropic_RequestFormat(t *testing.T) { json.NewEncoder(w).Encode(map[string]any{ "id": "msg_cross_001", "type": "message", "role": "assistant", "content": []map[string]any{{"type": "text", "text": "跨协议响应"}}, - "model": "claude-model", "stop_reason": "end_turn", "stop_sequence": nil, + "model": "claude-model", "stop_reason": "end_turn", "stop_sequence": nil, "usage": map[string]any{"input_tokens": 10, "output_tokens": 5}, }) }) e2eCreateProviderAndModel(t, r, "anthropic_p", "anthropic", "claude-model", upstream.URL) body, _ := json.Marshal(map[string]any{ - "model": "anthropic_p/claude-model", + "model": "anthropic_p/claude-model", "messages": []map[string]any{{"role": "user", "content": "Hello"}}, }) w := httptest.NewRecorder() @@ -1050,9 +1058,9 @@ func TestE2E_CrossProtocol_OpenAIToAnthropic_Stream(t *testing.T) { e2eCreateProviderAndModel(t, r, "anthropic_p", "anthropic", "claude-model", upstream.URL) body, _ := json.Marshal(map[string]any{ - "model": "anthropic_p/claude-model", + "model": "anthropic_p/claude-model", "messages": []map[string]any{{"role": "user", "content": "Hello"}}, - "stream": true, + "stream": true, }) w := httptest.NewRecorder() req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body)) @@ -1092,7 +1100,7 @@ func TestE2E_CrossProtocol_AnthropicToOpenAI_Stream(t *testing.T) { body, _ := json.Marshal(map[string]any{ "model": "openai_p/gpt-4", "max_tokens": 1024, "messages": []map[string]any{{"role": "user", "content": "Hello"}}, - "stream": true, + "stream": true, }) w := httptest.NewRecorder() req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) @@ -1128,7 +1136,7 @@ func TestE2E_OpenAI_ErrorResponse(t *testing.T) { e2eCreateProviderAndModel(t, r, "openai_p", "openai", "nonexistent", upstream.URL) body, _ := json.Marshal(map[string]any{ - "model": "openai_p/nonexistent", + "model": "openai_p/nonexistent", "messages": []map[string]any{{"role": "user", "content": "test"}}, }) w := httptest.NewRecorder() @@ -1183,11 +1191,11 @@ func TestE2E_OpenAI_NonStream_ParallelToolCalls(t *testing.T) { "content": nil, "tool_calls": []map[string]any{ { - "id": "call_ptc_1", "type": "function", + "id": "call_ptc_1", "type": "function", "function": map[string]any{"name": "get_weather", "arguments": `{"city":"北京"}`}, }, { - "id": "call_ptc_2", "type": "function", + "id": "call_ptc_2", "type": "function", "function": map[string]any{"name": "get_weather", "arguments": `{"city":"上海"}`}, }, }, @@ -1201,7 +1209,7 @@ func TestE2E_OpenAI_NonStream_ParallelToolCalls(t *testing.T) { e2eCreateProviderAndModel(t, r, "openai_p", "openai", "gpt-4o", upstream.URL) body, _ := json.Marshal(map[string]any{ - "model": "openai_p/gpt-4o", + "model": "openai_p/gpt-4o", "messages": []map[string]any{{"role": "user", "content": "北京和上海的天气"}}, "tools": []map[string]any{{ "type": "function", @@ -1242,10 +1250,10 @@ func TestE2E_OpenAI_NonStream_StopSequence(t *testing.T) { json.NewEncoder(w).Encode(map[string]any{ "id": "chatcmpl-e2e-stop", "object": "chat.completion", "created": 1700000060, "model": "gpt-4o", "choices": []map[string]any{{ - "index": 0, - "message": map[string]any{"role": "assistant", "content": "1, 2, 3, 4, "}, - "finish_reason": "stop", - "logprobs": nil, + "index": 0, + "message": map[string]any{"role": "assistant", "content": "1, 2, 3, 4, "}, + "finish_reason": "stop", + "logprobs": nil, }}, "usage": map[string]any{"prompt_tokens": 10, "completion_tokens": 8, "total_tokens": 18}, }) @@ -1253,9 +1261,9 @@ func TestE2E_OpenAI_NonStream_StopSequence(t *testing.T) { e2eCreateProviderAndModel(t, r, "openai_p", "openai", "gpt-4o", upstream.URL) body, _ := json.Marshal(map[string]any{ - "model": "openai_p/gpt-4o", + "model": "openai_p/gpt-4o", "messages": []map[string]any{{"role": "user", "content": "从1数到10"}}, - "stop": []string{"5"}, + "stop": []string{"5"}, }) w := httptest.NewRecorder() req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body)) @@ -1291,7 +1299,7 @@ func TestE2E_OpenAI_NonStream_ContentFilter(t *testing.T) { e2eCreateProviderAndModel(t, r, "openai_p", "openai", "gpt-4o", upstream.URL) body, _ := json.Marshal(map[string]any{ - "model": "openai_p/gpt-4o", + "model": "openai_p/gpt-4o", "messages": []map[string]any{{"role": "user", "content": "危险内容"}}, }) w := httptest.NewRecorder() @@ -1353,21 +1361,22 @@ func TestE2E_Anthropic_NonStream_MultiToolUse(t *testing.T) { func TestE2E_Anthropic_NonStream_ToolChoiceAny(t *testing.T) { r, upstream := setupE2ETest(t) upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - body, _ := io.ReadAll(req.Body) + body, err := io.ReadAll(req.Body) + require.NoError(t, err) var reqBody map[string]any - json.Unmarshal(body, &reqBody) + require.NoError(t, json.Unmarshal(body, &reqBody)) tc, _ := reqBody["tool_choice"].(map[string]any) assert.Equal(t, "any", tc["type"]) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "id": "msg_e2e_tca", "type": "message", "role": "assistant", "content": []map[string]any{ {"type": "tool_use", "id": "toolu_tca_1", "name": "get_time", "input": map[string]any{"timezone": "Asia/Shanghai"}}, }, "model": "claude-opus-4-7", "stop_reason": "tool_use", "stop_sequence": nil, "usage": map[string]any{"input_tokens": 100, "output_tokens": 30}, - }) + })) }) e2eCreateProviderAndModel(t, r, "anthropic_p", "anthropic", "claude-opus-4-7", upstream.URL) @@ -1397,20 +1406,21 @@ func TestE2E_Anthropic_NonStream_ToolChoiceAny(t *testing.T) { func TestE2E_Anthropic_NonStream_ArraySystemPrompt(t *testing.T) { r, upstream := setupE2ETest(t) upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - body, _ := io.ReadAll(req.Body) + body, err := io.ReadAll(req.Body) + require.NoError(t, err) var reqBody map[string]any - json.Unmarshal(body, &reqBody) + require.NoError(t, json.Unmarshal(body, &reqBody)) sys, ok := reqBody["system"].([]any) require.True(t, ok, "system should be an array") require.GreaterOrEqual(t, len(sys), 1) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "id": "msg_e2e_asys", "type": "message", "role": "assistant", "content": []map[string]any{{"type": "text", "text": "已收到多条系统指令。"}}, - "model": "claude-opus-4-7", "stop_reason": "end_turn", "stop_sequence": nil, + "model": "claude-opus-4-7", "stop_reason": "end_turn", "stop_sequence": nil, "usage": map[string]any{"input_tokens": 50, "output_tokens": 10}, - }) + })) }) e2eCreateProviderAndModel(t, r, "anthropic_p", "anthropic", "claude-opus-4-7", upstream.URL) @@ -1433,21 +1443,22 @@ func TestE2E_Anthropic_NonStream_ArraySystemPrompt(t *testing.T) { func TestE2E_Anthropic_NonStream_ToolResultMessage(t *testing.T) { r, upstream := setupE2ETest(t) upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - body, _ := io.ReadAll(req.Body) + body, err := io.ReadAll(req.Body) + require.NoError(t, err) var reqBody map[string]any - json.Unmarshal(body, &reqBody) + require.NoError(t, json.Unmarshal(body, &reqBody)) msgs := reqBody["messages"].([]any) require.GreaterOrEqual(t, len(msgs), 3) lastMsg := msgs[len(msgs)-1].(map[string]any) assert.Equal(t, "user", lastMsg["role"]) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "id": "msg_e2e_tr", "type": "message", "role": "assistant", "content": []map[string]any{{"type": "text", "text": "北京当前晴天,温度25°C。"}}, - "model": "claude-opus-4-7", "stop_reason": "end_turn", "stop_sequence": nil, + "model": "claude-opus-4-7", "stop_reason": "end_turn", "stop_sequence": nil, "usage": map[string]any{"input_tokens": 150, "output_tokens": 20}, - }) + })) }) e2eCreateProviderAndModel(t, r, "anthropic_p", "anthropic", "claude-opus-4-7", upstream.URL) @@ -1497,7 +1508,8 @@ func TestE2E_Anthropic_Stream_ToolCalls(t *testing.T) { "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", } for _, e := range events { - w.Write([]byte(e)) + _, err := w.Write([]byte(e)) + require.NoError(t, err) flusher.Flush() time.Sleep(10 * time.Millisecond) } @@ -1559,7 +1571,7 @@ func TestE2E_CrossProtocol_OpenAIToAnthropic_NonStream_ToolCalls(t *testing.T) { e2eCreateProviderAndModel(t, r, "anthropic_p", "anthropic", "claude-model", upstream.URL) body, _ := json.Marshal(map[string]any{ - "model": "anthropic_p/claude-model", + "model": "anthropic_p/claude-model", "messages": []map[string]any{{"role": "user", "content": "北京天气"}}, "tools": []map[string]any{{ "type": "function", @@ -1634,14 +1646,14 @@ func TestE2E_CrossProtocol_StopReasonMapping(t *testing.T) { json.NewEncoder(w).Encode(map[string]any{ "id": "msg_cross_stop", "type": "message", "role": "assistant", "content": []map[string]any{{"type": "text", "text": "被截断的内容..."}}, - "model": "claude-model", "stop_reason": "max_tokens", "stop_sequence": nil, + "model": "claude-model", "stop_reason": "max_tokens", "stop_sequence": nil, "usage": map[string]any{"input_tokens": 10, "output_tokens": 20}, }) }) e2eCreateProviderAndModel(t, r, "anthropic_p", "anthropic", "claude-model", upstream.URL) body, _ := json.Marshal(map[string]any{ - "model": "anthropic_p/claude-model", + "model": "anthropic_p/claude-model", "messages": []map[string]any{{"role": "user", "content": "长文"}}, }) w := httptest.NewRecorder() @@ -1659,9 +1671,10 @@ func TestE2E_CrossProtocol_StopReasonMapping(t *testing.T) { func TestE2E_OpenAI_NonStream_AssistantWithToolResult(t *testing.T) { r, upstream := setupE2ETest(t) upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - body, _ := io.ReadAll(req.Body) + body, err := io.ReadAll(req.Body) + require.NoError(t, err) var reqBody map[string]any - json.Unmarshal(body, &reqBody) + require.NoError(t, json.Unmarshal(body, &reqBody)) msgs := reqBody["messages"].([]any) require.GreaterOrEqual(t, len(msgs), 3) toolMsg := msgs[2].(map[string]any) @@ -1669,16 +1682,16 @@ func TestE2E_OpenAI_NonStream_AssistantWithToolResult(t *testing.T) { assert.Equal(t, "call_e2e_001", toolMsg["tool_call_id"]) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "id": "chatcmpl-e2e-tr", "object": "chat.completion", "created": 1700000080, "model": "gpt-4o", "choices": []map[string]any{{ - "index": 0, - "message": map[string]any{"role": "assistant", "content": "北京当前晴天,温度25°C。"}, + "index": 0, + "message": map[string]any{"role": "assistant", "content": "北京当前晴天,温度25°C。"}, "finish_reason": "stop", "logprobs": nil, }}, "usage": map[string]any{"prompt_tokens": 100, "completion_tokens": 20, "total_tokens": 120}, - }) + })) }) e2eCreateProviderAndModel(t, r, "openai_p", "openai", "gpt-4o", upstream.URL) @@ -1722,7 +1735,8 @@ func TestE2E_CrossProtocol_AnthropicToOpenAI_Stream_ToolCalls(t *testing.T) { "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", } for _, e := range events { - w.Write([]byte(e)) + _, err := w.Write([]byte(e)) + require.NoError(t, err) flusher.Flush() time.Sleep(10 * time.Millisecond) } @@ -1730,7 +1744,7 @@ func TestE2E_CrossProtocol_AnthropicToOpenAI_Stream_ToolCalls(t *testing.T) { e2eCreateProviderAndModel(t, r, "anthropic_p", "anthropic", "claude-model", upstream.URL) body, _ := json.Marshal(map[string]any{ - "model": "anthropic_p/claude-model", + "model": "anthropic_p/claude-model", "messages": []map[string]any{{"role": "user", "content": "北京天气"}}, "tools": []map[string]any{{ "type": "function", @@ -1817,7 +1831,7 @@ func TestE2E_OpenAI_Upstream5xx_ErrorPassthrough(t *testing.T) { e2eCreateProviderAndModel(t, r, "openai_p", "openai", "gpt-4o", upstream.URL) body, _ := json.Marshal(map[string]any{ - "model": "openai_p/gpt-4o", + "model": "openai_p/gpt-4o", "messages": []map[string]any{{"role": "user", "content": "test"}}, }) w := httptest.NewRecorder() @@ -1879,7 +1893,8 @@ func TestE2E_Anthropic_Stream_TruncatedSSE(t *testing.T) { "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"正常\"}}\n\n", } for _, e := range events { - w.Write([]byte(e)) + _, err := w.Write([]byte(e)) + require.NoError(t, err) flusher.Flush() time.Sleep(10 * time.Millisecond) } @@ -1889,7 +1904,7 @@ func TestE2E_Anthropic_Stream_TruncatedSSE(t *testing.T) { body, _ := json.Marshal(map[string]any{ "model": "anthropic_p/claude-opus-4-7", "max_tokens": 1024, "messages": []map[string]any{{"role": "user", "content": "test"}}, - "stream": true, + "stream": true, }) w := httptest.NewRecorder() req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) @@ -1902,5 +1917,7 @@ func TestE2E_Anthropic_Stream_TruncatedSSE(t *testing.T) { assert.Contains(t, respBody, "正常") } -var _ = fmt.Sprintf -var _ = time.Now +var ( + _ = fmt.Sprintf + _ = time.Now +) diff --git a/backend/tests/integration/integration_test.go b/backend/tests/integration/integration_test.go index 1c8e5e3..5fca303 100644 --- a/backend/tests/integration/integration_test.go +++ b/backend/tests/integration/integration_test.go @@ -7,16 +7,17 @@ import ( "testing" "time" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - "go.uber.org/zap" - "gorm.io/gorm" - "nex/backend/internal/domain" "nex/backend/internal/handler" "nex/backend/internal/handler/middleware" "nex/backend/internal/repository" "nex/backend/internal/service" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "gorm.io/gorm" ) func init() { @@ -97,7 +98,7 @@ func TestOpenAI_CompleteFlow(t *testing.T) { r.ServeHTTP(w, req) assert.Equal(t, 201, w.Code) var createdModel domain.Model - json.Unmarshal(w.Body.Bytes(), &createdModel) + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &createdModel)) assert.NotEmpty(t, createdModel.ID) // 3. 列出 Provider @@ -106,7 +107,7 @@ func TestOpenAI_CompleteFlow(t *testing.T) { r.ServeHTTP(w, req) assert.Equal(t, 200, w.Code) var providers []domain.Provider - json.Unmarshal(w.Body.Bytes(), &providers) + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &providers)) assert.Len(t, providers, 1) assert.Equal(t, "sk-test-key", providers[0].APIKey) @@ -116,7 +117,7 @@ func TestOpenAI_CompleteFlow(t *testing.T) { r.ServeHTTP(w, req) assert.Equal(t, 200, w.Code) var models []domain.Model - json.Unmarshal(w.Body.Bytes(), &models) + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &models)) assert.Len(t, models, 1) assert.Equal(t, "gpt-4", models[0].ModelName) @@ -163,7 +164,7 @@ func TestAnthropic_ModelCreation(t *testing.T) { r.ServeHTTP(w, req) assert.Equal(t, 201, w.Code) var createdModel domain.Model - json.Unmarshal(w.Body.Bytes(), &createdModel) + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &createdModel)) // 验证创建成功 w = httptest.NewRecorder() @@ -194,9 +195,9 @@ func TestStats_RecordingAndQuery(t *testing.T) { // 直接通过 repository 记录统计(模拟代理请求后的统计记录) statsRepo := repository.NewStatsRepository(db) - statsRepo.Record("p1", "gpt-4") - statsRepo.Record("p1", "gpt-4") - statsRepo.Record("p1", "gpt-4") + require.NoError(t, statsRepo.Record("p1", "gpt-4")) + require.NoError(t, statsRepo.Record("p1", "gpt-4")) + require.NoError(t, statsRepo.Record("p1", "gpt-4")) // 查询统计 w = httptest.NewRecorder() @@ -205,7 +206,7 @@ func TestStats_RecordingAndQuery(t *testing.T) { assert.Equal(t, 200, w.Code) var stats []domain.UsageStats - json.Unmarshal(w.Body.Bytes(), &stats) + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &stats)) assert.Len(t, stats, 1) assert.Equal(t, 3, stats[0].RequestCount) diff --git a/backend/tests/integration/testhelper.go b/backend/tests/integration/testhelper.go index f4e3984..0d3ac43 100644 --- a/backend/tests/integration/testhelper.go +++ b/backend/tests/integration/testhelper.go @@ -4,11 +4,11 @@ import ( "testing" "time" + "nex/backend/internal/config" + "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" - - "nex/backend/internal/config" ) // setupTestDB 创建内存 SQLite 数据库并执行 AutoMigrate。 diff --git a/backend/tests/migration_test.go b/backend/tests/migration_test.go index 6f33b31..368d75e 100644 --- a/backend/tests/migration_test.go +++ b/backend/tests/migration_test.go @@ -3,9 +3,10 @@ package tests import ( "testing" + "nex/backend/internal/config" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "nex/backend/internal/config" ) func TestMigration_ModelsUUIDPrimaryKey(t *testing.T) { diff --git a/backend/tests/mocks/mock_model_repository.go b/backend/tests/mocks/mock_model_repository.go index 2de4d5d..e8570ff 100644 --- a/backend/tests/mocks/mock_model_repository.go +++ b/backend/tests/mocks/mock_model_repository.go @@ -10,9 +10,10 @@ package mocks import ( - domain "nex/backend/internal/domain" reflect "reflect" + domain "nex/backend/internal/domain" + gomock "go.uber.org/mock/gomock" ) diff --git a/backend/tests/mocks/mock_model_service.go b/backend/tests/mocks/mock_model_service.go index 8123123..a18bb18 100644 --- a/backend/tests/mocks/mock_model_service.go +++ b/backend/tests/mocks/mock_model_service.go @@ -10,9 +10,10 @@ package mocks import ( - domain "nex/backend/internal/domain" reflect "reflect" + domain "nex/backend/internal/domain" + gomock "go.uber.org/mock/gomock" ) diff --git a/backend/tests/mocks/mock_provider_client.go b/backend/tests/mocks/mock_provider_client.go index f8267da..8af7524 100644 --- a/backend/tests/mocks/mock_provider_client.go +++ b/backend/tests/mocks/mock_provider_client.go @@ -11,9 +11,10 @@ package mocks import ( context "context" + reflect "reflect" + conversion "nex/backend/internal/conversion" provider "nex/backend/internal/provider" - reflect "reflect" gomock "go.uber.org/mock/gomock" ) diff --git a/backend/tests/mocks/mock_provider_repository.go b/backend/tests/mocks/mock_provider_repository.go index 5642d4f..40c20cd 100644 --- a/backend/tests/mocks/mock_provider_repository.go +++ b/backend/tests/mocks/mock_provider_repository.go @@ -10,9 +10,10 @@ package mocks import ( - domain "nex/backend/internal/domain" reflect "reflect" + domain "nex/backend/internal/domain" + gomock "go.uber.org/mock/gomock" ) diff --git a/backend/tests/mocks/mock_provider_service.go b/backend/tests/mocks/mock_provider_service.go index 242ed0b..9c3103f 100644 --- a/backend/tests/mocks/mock_provider_service.go +++ b/backend/tests/mocks/mock_provider_service.go @@ -10,9 +10,10 @@ package mocks import ( - domain "nex/backend/internal/domain" reflect "reflect" + domain "nex/backend/internal/domain" + gomock "go.uber.org/mock/gomock" ) diff --git a/backend/tests/mocks/mock_routing_service.go b/backend/tests/mocks/mock_routing_service.go index 717a8a9..dc0eab3 100644 --- a/backend/tests/mocks/mock_routing_service.go +++ b/backend/tests/mocks/mock_routing_service.go @@ -10,9 +10,10 @@ package mocks import ( - domain "nex/backend/internal/domain" reflect "reflect" + domain "nex/backend/internal/domain" + gomock "go.uber.org/mock/gomock" ) diff --git a/backend/tests/mocks/mock_stats_repository.go b/backend/tests/mocks/mock_stats_repository.go index 45742f4..1e3b92c 100644 --- a/backend/tests/mocks/mock_stats_repository.go +++ b/backend/tests/mocks/mock_stats_repository.go @@ -10,10 +10,11 @@ package mocks import ( - domain "nex/backend/internal/domain" reflect "reflect" time "time" + domain "nex/backend/internal/domain" + gomock "go.uber.org/mock/gomock" ) diff --git a/backend/tests/mocks/mock_stats_service.go b/backend/tests/mocks/mock_stats_service.go index 72c310c..3aa52e7 100644 --- a/backend/tests/mocks/mock_stats_service.go +++ b/backend/tests/mocks/mock_stats_service.go @@ -10,10 +10,11 @@ package mocks import ( - domain "nex/backend/internal/domain" reflect "reflect" time "time" + domain "nex/backend/internal/domain" + gomock "go.uber.org/mock/gomock" ) diff --git a/backend/tests/mysql/constraint_test.go b/backend/tests/mysql/constraint_test.go index 7923c23..3419223 100644 --- a/backend/tests/mysql/constraint_test.go +++ b/backend/tests/mysql/constraint_test.go @@ -90,7 +90,7 @@ func TestConstraint_UniqueProviderModel(t *testing.T) { } err = db.Create(&model2).Error assert.Error(t, err, "创建相同 (provider_id, model_name) 的 model 应失败") - assert.True(t, errors.Is(err, gorm.ErrDuplicatedKey) || + assert.True(t, errors.Is(err, gorm.ErrDuplicatedKey) || (err != nil && (err.Error() == "Error 1062" || containsDuplicateError(err.Error()))), "错误应为唯一约束错误") } @@ -120,7 +120,7 @@ func TestConstraint_UniqueUsageStats(t *testing.T) { } err = db.Create(&stats2).Error assert.Error(t, err, "创建相同 (provider_id, model_name, date) 的 usage_stats 应失败") - assert.True(t, errors.Is(err, gorm.ErrDuplicatedKey) || + assert.True(t, errors.Is(err, gorm.ErrDuplicatedKey) || (err != nil && (err.Error() == "Error 1062" || containsDuplicateError(err.Error()))), "错误应为唯一约束错误") } diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..491189e --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,5 @@ +pre-commit: + commands: + backend-lint: + glob: "backend/**/*.go" + run: cd backend && go tool golangci-lint run --new-from-rev HEAD ./... diff --git a/openspec/changes/backend-code-lint/.openspec.yaml b/openspec/changes/backend-code-lint/.openspec.yaml deleted file mode 100644 index 8b394c6..0000000 --- a/openspec/changes/backend-code-lint/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-04-23 diff --git a/openspec/changes/backend-code-lint/design.md b/openspec/changes/backend-code-lint/design.md deleted file mode 100644 index 2d8092b..0000000 --- a/openspec/changes/backend-code-lint/design.md +++ /dev/null @@ -1,130 +0,0 @@ -## Context - -后端项目使用 Go 1.26.2 开发,已集成 `golangci-lint v1.64.8` 作为 tool dependency(`go.mod` 中声明),并通过 `make backend-lint` 调用。但当前**没有 `.golangci.yml` 配置文件**,lint 以默认配置运行(仅启用极少量 linter),且存在 embedfs 模块加载错误导致 lint 实际无法执行。 - -代码审计发现以下存量问题: -- 8 处 `err == sentinel` 应使用 `errors.Is()` -- 7 处 `_ = json.Marshal(...)` 忽略错误返回值 -- 13 处 `zap.String("error", err.Error())` 应使用 `zap.Error(err)` -- 2 处 `fmt.Fprintf(os.Stderr, ...)` 应走 logger -- 1 处 `_ = io.ReadAll(...)` 忽略错误 - -## Goals / Non-Goals - -**Goals:** -- 配置 golangci-lint,将 README 中的编码规范转化为机器可检查的硬约束 -- 引入 lefthook 实现 pre-commit 自动 lint,AI 提交代码时自动拦截违规 -- 修复存量代码中的规范违规 -- 解决 embedfs 导致 lint 无法运行的阻塞问题 - -**Non-Goals:** -- 不引入自定义 linter 插件(开发成本过高) -- 不配置 CI pipeline lint 门禁(仅本地) -- 不改变现有错误响应策略(允许 err.Error() 暴露在 HTTP 响应中) -- 不引入 funlen linter(gocyclo 已控制复杂度,funlen 误报率高) -- 不引入 unparam linter(项目 interface 密集,unparam 误报率高) - -## Decisions - -### D1: Linter 选型 — 13 个 linter 分四层 - -``` -🔒 硬约束层(项目规范 → 机器检查) -├── forbidigo 禁止 fmt.Print*/log.*/zap.L()/zap.S() -├── errorlint 强制 errors.Is/As,禁止 err == 比较 -└── errcheck 禁止忽略错误返回值(check-blank: true) - -🏗️ 质量基线层 -├── staticcheck Go 团队官方综合静态分析 -├── revive golint 替代品,精选 8 条规则 -│ (exported, var-naming, indent-error-flow, error-strings, -│ error-return, blank-imports, context-as-argument, -│ unexported-return) -├── gocritic 100+ 代码质量规则 -└── gosec 安全检查 - -🛡️ 资源安全层 -├── bodyclose HTTP 响应 Body 关闭检查 -├── noctx HTTP 请求必须携带 context -└── nilerr 检查 if err != nil { return nil } 遗漏 - -📐 格式层(可自动修复) -├── gofumpt gofmt 的严格版 -└── goimports import 排序(local-prefixes: nex/backend) - -📊 复杂度 -└── gocyclo 正式代码 ≤10 / 测试代码 ≤20 -``` - -**替代方案**: 使用 revive 自定义规则检查 zap.String("error", err.Error()) → 决定暂不实施,先修复存量 13 处 + README 约定 + code review,如果后续频繁违规再投入开发自定义规则。 - -### D2: forbidigo 配置策略 - -禁止列表: -- `fmt\.Print*` — 必须使用 zap logger -- `fmt\.Fprint*(os\.(Stdout|Stderr)` — 必须使用 zap logger -- `log\.(Print|Fatal|Panic|Printf)*` — 必须使用 zap logger -- `zap\.L()` — 必须通过 DI 注入 *zap.Logger -- `zap\.S()` — 不使用 Sugar logger - -**不禁止**: -- `zap.Logger.Fatal()` — main() 中的 Fatal 是合理的启动终止模式,且 forbidigo 按函数匹配不会拦截 zap.Logger 的方法调用 -- `fmt.Sprintf` — 格式化字符串是合法用途 -- `fmt.Errorf` — 创建带格式的错误是标准用法 - -### D3: 测试代码 vs 正式代码差异化规则 - -| 规则 | 正式代码 | 测试代码 | -|---|---|---| -| forbidigo | 全部启用 | 放宽(fmt.Sprintf 等合理) | -| errcheck | check-blank: true | 放宽 check-blank | -| revive (exported) | 启用 | 排除 | -| gosec | 启用 | 排除 G101/G401/G501 | -| gocyclo | ≤10 | ≤20 | - -通过 `issues.exclude-rules` 按路径 `*_test.go` 和 `tests/` 配置排除。 - -### D4: 生成代码排除 - -`tests/mocks/` 目录下的 8 个 mock 文件由 mockgen 生成,需要排除。使用 `issues.exclude-dirs` 配合 `issues.exclude-generated: true`(golangci-lint 自动检测 `Code generated by` 标记)。 - -### D5: embedfs 阻塞问题修复 - -embedfs 模块使用 `//go:embed assets/*` 和 `//go:embed frontend-dist/*`,但这些目录在未构建时不存在,导致 golangci-lint 无法加载该模块。 - -**方案**: 在 `embedfs/assets/` 和 `embedfs/frontend-dist/` 中添加 `.gitkeep` 文件,使 `go:embed` 指令能匹配到内容。这是最小侵入的解决方案,不影响正常构建流程(desktop-build 会覆盖这些目录)。 - -### D6: lefthook pre-commit 配置 - -```yaml -# lefthook.yml -pre-commit: - commands: - backend-lint: - glob: "backend/**/*.go" - run: cd backend && go tool golangci-lint run --new-from-rev HEAD {staged_files} -``` - -关键设计: -- 只检查 staged 文件(`--new-from-rev HEAD`),速度快 -- 只在 Go 文件变更时触发(`glob: "backend/**/*.go"`) -- AI commit 时自动触发,lint 不过则 commit 被拒绝,形成自动反馈循环 - -### D7: 存量修复策略 - -按优先级分批修复: -1. **P0 — embedfs 阻塞修复**:创建 .gitkeep 文件 -2. **P1 — err == sentinel → errors.Is()**:8 处,分布在 handler 和 client -3. **P2 — 忽略错误返回值**:7 处 json.Marshal + 1 处 io.ReadAll + 2 处 stats Record(加 nolint 注释) -4. **P3 — zap.String("error", err.Error()) → zap.Error(err)**:13 处 -5. **P4 — fmt.Fprintf(os.Stderr) → logger**:2 处,在 cmd/desktop/ - -## Risks / Trade-offs - -**[lint 速度影响 commit 体验]** → lefthook 只检查 staged Go 文件,增量检查通常 <5 秒,可接受。如果仍慢,可加 `--timeout` 限制。 - -**[lefthook 是新依赖]** → lefthook 是开发工具依赖,不影响生产代码。作为单二进制分发,安装简单(`go install` 或从 GitHub release 下载)。首次需要开发者手动安装。 - -**[存量修复可能引入新 bug]** → 所有修复都是机械性替换(errors.Is、zap.Error 等),不改变逻辑。修复后运行 `make test` 确认无回归。 - -**[forbidigo 可能误拦合理用法]** → 通过仔细配置允许列表(允许 fmt.Sprintf、fmt.Errorf),并在发现误报时调整规则。 diff --git a/openspec/changes/backend-code-lint/proposal.md b/openspec/changes/backend-code-lint/proposal.md deleted file mode 100644 index 89f0669..0000000 --- a/openspec/changes/backend-code-lint/proposal.md +++ /dev/null @@ -1,29 +0,0 @@ -## Why - -项目复杂度增长后,AI 编写代码时经常忽略基本编码规范(如使用指定日志工具、正确处理错误等)。依赖 prompt 约定是"软约束",无法可靠防止违规。需要引入静态分析工具,将编码规范从"约定"升级为"机器可检查的硬约束",在提交时自动拦截问题代码。 - -## What Changes - -- 新增 `.golangci.yml` 配置文件,启用 13 个 linter 并配置项目专属规则 -- 引入 lefthook 作为 Git hook 管理器,在 pre-commit 时自动运行 lint -- 修复存量代码中的规范违规(约 31 处) -- 解决 embedfs 模块导致 golangci-lint 无法运行的阻塞问题 -- 更新 README.md 补充代码规范说明 - -## Capabilities - -### New Capabilities -- `code-lint`: 后端代码静态分析规则配置,包括 13 个 linter 的启用、参数配置、测试/正式代码的差异化规则、生成代码排除等 -- `pre-commit-hook`: 基于 lefthook 的 pre-commit hook 配置,提交时自动运行 lint 检查 - -### Modified Capabilities -- `module-logging`: 新增 zap.Error(err) 优于 zap.String("error", err.Error()) 的规范要求 -- `error-handling`: 新增必须使用 errors.Is/As 而非直接 == 比较的强制要求 -- `structured-logging`: 补充 zap.Error(err) 的使用约定 - -## Impact - -- 新增开发依赖:lefthook(二进制工具,不影响生产代码) -- 修改文件:约 15 个 Go 源文件(存量修复)、README.md、Makefile -- 新增文件:`.golangci.yml`、`lefthook.yml`、`embedfs/assets/.gitkeep`、`embedfs/frontend-dist/.gitkeep` -- 开发流程影响:git commit 时自动触发 lint 检查,lint 不过则提交被拒绝 diff --git a/openspec/changes/backend-code-lint/specs/code-lint/spec.md b/openspec/changes/backend-code-lint/specs/code-lint/spec.md deleted file mode 100644 index 53cba5c..0000000 --- a/openspec/changes/backend-code-lint/specs/code-lint/spec.md +++ /dev/null @@ -1,174 +0,0 @@ -# Code Lint - -## Purpose - -定义后端 Go 代码静态分析规则,将编码规范从人工约定升级为机器可检查的硬约束,通过 golangci-lint 在开发和提交阶段自动拦截违规代码。 - -## ADDED Requirements - -### Requirement: golangci-lint 配置 - -系统 SHALL 通过 `.golangci.yml` 配置 golangci-lint,启用 13 个 linter。 - -#### Scenario: 配置文件位置 - -- **WHEN** 配置 lint 规则 -- **THEN** 配置文件 SHALL 位于 `backend/.golangci.yml` - -#### Scenario: 启用的 linter 列表 - -- **WHEN** 运行 golangci-lint -- **THEN** SHALL 启用以下 linter:forbidigo、errorlint、errcheck、staticcheck、revive、gocritic、gosec、bodyclose、noctx、nilerr、gofumpt、goimports、gocyclo - -### Requirement: forbidigo 日志输出约束 - -系统 SHALL 通过 forbidigo 禁止在正式代码中使用直接输出函数。 - -#### Scenario: 禁止 fmt.Print 系列 - -- **WHEN** 正式代码中调用 fmt.Print、fmt.Println、fmt.Printf -- **THEN** lint SHALL 报错,提示使用 zap logger - -#### Scenario: 禁止 fmt.Fprint 到 Stdout/Stderr - -- **WHEN** 正式代码中调用 fmt.Fprintf(os.Stdout, ...) 或 fmt.Fprintf(os.Stderr, ...) -- **THEN** lint SHALL 报错,提示使用 zap logger - -#### Scenario: 禁止标准库 log - -- **WHEN** 正式代码中调用 log.Print、log.Fatal、log.Panic、log.Printf 等 -- **THEN** lint SHALL 报错,提示使用 zap logger - -#### Scenario: 禁止 zap.L() 全局 logger - -- **WHEN** 正式代码中调用 zap.L() -- **THEN** lint SHALL 报错,提示通过 DI 注入 *zap.Logger - -#### Scenario: 禁止 zap.S() Sugar logger - -- **WHEN** 代码中调用 zap.S() -- **THEN** lint SHALL 报错,不使用 Sugar logger - -#### Scenario: 允许 fmt.Sprintf 和 fmt.Errorf - -- **WHEN** 代码中使用 fmt.Sprintf 或 fmt.Errorf -- **THEN** lint SHALL NOT 报错 - -#### Scenario: 测试代码放宽 - -- **WHEN** 测试文件(*_test.go)或 tests/ 目录中使用 fmt.Print 系列 -- **THEN** forbidigo SHALL NOT 报错 - -### Requirement: errorlint 错误比较约束 - -系统 SHALL 通过 errorlint 强制使用类型安全的错误比较方式。 - -#### Scenario: 禁止 err == sentinel 比较 - -- **WHEN** 代码中使用 `err == someError` 直接比较错误 -- **THEN** lint SHALL 报错,要求使用 errors.Is() - -#### Scenario: 禁止直接类型断言 - -- **WHEN** 代码中使用 `err.(SomeType)` 直接类型断言 -- **THEN** lint SHALL 报错,要求使用 errors.As() - -### Requirement: errcheck 错误返回值检查 - -系统 SHALL 通过 errcheck 禁止忽略函数返回的错误。 - -#### Scenario: 启用 check-blank - -- **WHEN** 代码中使用 `_ = someFuncReturnsError()` -- **THEN** lint SHALL 报错(除非排除列表中的函数) - -#### Scenario: 启用 check-type-assertions - -- **WHEN** 代码中使用未检查的类型断言 `v := x.(Type)` -- **THEN** lint SHALL 报错 - -#### Scenario: 排除 fmt.Fprintf - -- **WHEN** 代码中忽略 fmt.Fprintf 的返回值 -- **THEN** errcheck SHALL NOT 报错(io.Writer 场景合理) - -#### Scenario: 测试代码放宽 - -- **WHEN** 测试文件中忽略错误返回值 -- **THEN** errcheck 的 check-blank SHALL 放宽 - -### Requirement: revive 代码风格规则 - -系统 SHALL 通过 revive 启用精选的 8 条代码风格规则。 - -#### Scenario: 启用的规则 - -- **WHEN** 运行 revive -- **THEN** SHALL 启用:exported、var-naming、indent-error-flow、error-strings、error-return、blank-imports、context-as-argument、unexported-return - -#### Scenario: 测试代码排除 exported - -- **WHEN** 测试文件中的导出符号缺少文档注释 -- **THEN** revive SHALL NOT 报错 - -### Requirement: gosec 安全检查 - -系统 SHALL 通过 gosec 检查常见安全问题。 - -#### Scenario: 正式代码全部启用 - -- **WHEN** 正式代码中存在安全隐患(硬编码凭证、SQL 注入等) -- **THEN** gosec SHALL 报错 - -#### Scenario: 测试代码排除部分规则 - -- **WHEN** 测试文件中触发 G101(硬编码密钥)、G401/G501(弱密码算法) -- **THEN** gosec SHALL NOT 报错 - -### Requirement: gocyclo 圈复杂度控制 - -系统 SHALL 通过 gocyclo 控制函数复杂度。 - -#### Scenario: 正式代码复杂度阈值 - -- **WHEN** 正式代码中函数圈复杂度超过 10 -- **THEN** gocyclo SHALL 报错 - -#### Scenario: 测试代码复杂度阈值 - -- **WHEN** 测试代码中函数圈复杂度超过 20 -- **THEN** gocyclo SHALL 报错 - -### Requirement: goimports import 排序 - -系统 SHALL 通过 goimports 统一 import 分组排序。 - -#### Scenario: 三组格式 - -- **WHEN** 格式化 import -- **THEN** SHALL 按标准库、第三方库、本地包(nex/backend)三组排序 -- **THEN** local-prefixes SHALL 配置为 nex/backend - -### Requirement: 生成代码排除 - -系统 SHALL 排除自动生成的代码的 lint 检查。 - -#### Scenario: mocks 目录排除 - -- **WHEN** lint 扫描 tests/mocks/ 目录 -- **THEN** SHALL 排除该目录(由 mockgen 生成的代码) - -#### Scenario: Code generated 标记自动检测 - -- **WHEN** 文件包含 `// Code generated by` 标记 -- **THEN** golangci-lint SHALL 自动排除该文件 - -### Requirement: embedfs 编译兼容 - -系统 SHALL 确保 golangci-lint 能正常加载 embedfs 模块。 - -#### Scenario: 空目录占位 - -- **WHEN** embedfs 模块的 assets/ 和 frontend-dist/ 目录不存在 -- **THEN** SHALL 通过 .gitkeep 文件确保目录存在 -- **THEN** go:embed 指令 SHALL 能正常匹配 diff --git a/openspec/changes/backend-code-lint/specs/error-handling/spec.md b/openspec/changes/backend-code-lint/specs/error-handling/spec.md deleted file mode 100644 index 8ef7fea..0000000 --- a/openspec/changes/backend-code-lint/specs/error-handling/spec.md +++ /dev/null @@ -1,45 +0,0 @@ -# Error Handling — Delta - -## MODIFIED Requirements - -### Requirement: 使用类型安全错误判断 - -系统 SHALL 使用类型安全方式判断错误类型,并通过 lint 工具强制执行。 - -#### Scenario: 数据库错误判断 - -- **WHEN** 判断数据库唯一约束错误 -- **THEN** SHALL 使用 errors.Is(err, gorm.ErrDuplicatedKey) -- **THEN** SHALL NOT 使用字符串匹配 err.Error() - -#### Scenario: 网络错误判断 - -- **WHEN** 判断网络错误 -- **THEN** SHALL 使用 errors.As(err, &net.Error) 判断网络错误 -- **THEN** SHALL 使用 errors.As(err, &net.OpError) 判断操作错误 -- **THEN** SHALL 使用 errors.Is(opErr.Err, syscall.ECONNRESET) 判断连接重置 -- **THEN** SHALL NOT 使用字符串匹配判断错误类型 - -#### Scenario: 错误链判断 - -- **WHEN** 判断错误链中的特定错误 -- **THEN** SHALL 使用 errors.Is 进行链式判断 -- **THEN** SHALL 使用 errors.As 提取特定类型错误 - -#### Scenario: lint 自动拦截错误比较 - -- **WHEN** 代码中使用 `err == someError` 直接比较 -- **THEN** errorlint SHALL 检测并报错 -- **THEN** SHALL 改用 errors.Is() - -#### Scenario: lint 自动拦截类型断言 - -- **WHEN** 代码中使用 `err.(SomeType)` 直接类型断言 -- **THEN** errorlint SHALL 检测并报错 -- **THEN** SHALL 改用 errors.As() - -#### Scenario: lint 自动拦截忽略错误返回值 - -- **WHEN** 代码中使用 `_ = funcReturnsError()` 忽略错误 -- **THEN** errcheck SHALL 检测并报错 -- **THEN** SHALL 正确处理错误或添加 //nolint:errcheck 注释(仅在有意忽略时) diff --git a/openspec/changes/backend-code-lint/specs/module-logging/spec.md b/openspec/changes/backend-code-lint/specs/module-logging/spec.md deleted file mode 100644 index 7458781..0000000 --- a/openspec/changes/backend-code-lint/specs/module-logging/spec.md +++ /dev/null @@ -1,32 +0,0 @@ -# Module Logging — Delta - -## MODIFIED Requirements - -### Requirement: 禁止全局 logger - -系统 SHALL 禁止在业务代码中使用全局 logger,并通过 lint 工具强制执行。 - -#### 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 - -#### Scenario: lint 自动拦截 zap.L() - -- **WHEN** 正式代码中新增 `zap.L()` 调用 -- **THEN** forbidigo SHALL 检测并报错 -- **THEN** git commit SHALL 被拒绝 - -#### Scenario: 禁止 fmt/os.Stderr 直接输出 - -- **WHEN** 正式代码中使用 fmt.Print*、fmt.Fprintf(os.Stderr, ...) 等直接输出 -- **THEN** forbidigo SHALL 检测并报错 -- **THEN** SHALL 使用注入的 zap logger 替代 diff --git a/openspec/changes/backend-code-lint/specs/pre-commit-hook/spec.md b/openspec/changes/backend-code-lint/specs/pre-commit-hook/spec.md deleted file mode 100644 index 8a489eb..0000000 --- a/openspec/changes/backend-code-lint/specs/pre-commit-hook/spec.md +++ /dev/null @@ -1,54 +0,0 @@ -# Pre-commit Hook - -## Purpose - -定义基于 lefthook 的 pre-commit hook 配置,在 git commit 时自动运行 lint 检查,拦截违规代码提交。 - -## ADDED Requirements - -### Requirement: lefthook 配置 - -系统 SHALL 通过 `lefthook.yml` 配置 pre-commit hook。 - -#### Scenario: 配置文件位置 - -- **WHEN** 配置 lefthook -- **THEN** 配置文件 SHALL 位于项目根目录 `lefthook.yml` - -#### Scenario: pre-commit hook 安装 - -- **WHEN** 开发者首次克隆项目 -- **THEN** 运行 `lefthook install` SHALL 安装 git hooks -- **THEN** hooks SHALL 自动注册到 .git/hooks/ - -### Requirement: Go 文件变更触发 lint - -系统 SHALL 在 Go 文件变更时自动运行 golangci-lint。 - -#### Scenario: 检测到 Go 文件变更 - -- **WHEN** git commit 中包含 backend/**/*.go 文件的变更 -- **THEN** SHALL 自动运行 golangci-lint - -#### Scenario: 增量检查 - -- **WHEN** 运行 lint -- **THEN** SHALL 只检查 staged 文件(使用 --new-from-rev HEAD) -- **THEN** SHALL NOT 检查整个代码库 - -#### Scenario: lint 通过 - -- **WHEN** golangci-lint 检查通过 -- **THEN** commit SHALL 正常完成 - -#### Scenario: lint 失败 - -- **WHEN** golangci-lint 检查发现违规 -- **THEN** commit SHALL 被拒绝 -- **THEN** SHALL 显示具体的违规信息和修复建议 - -#### Scenario: 无 Go 文件变更 - -- **WHEN** git commit 不包含 Go 文件变更 -- **THEN** SHALL NOT 运行 golangci-lint -- **THEN** commit SHALL 正常完成 diff --git a/openspec/changes/backend-code-lint/specs/structured-logging/spec.md b/openspec/changes/backend-code-lint/specs/structured-logging/spec.md deleted file mode 100644 index 38df8bd..0000000 --- a/openspec/changes/backend-code-lint/specs/structured-logging/spec.md +++ /dev/null @@ -1,30 +0,0 @@ -# Structured Logging — Delta - -## MODIFIED Requirements - -### Requirement: 字段标准化 - -系统 SHALL 使用标准化字段定义,并通过 lint 工具强制执行错误字段规范。 - -#### 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: lint 强化错误字段约束 - -- **WHEN** 存量代码中使用 `zap.String("error", err.Error())` 记录错误 -- **THEN** SHALL 修改为 `zap.Error(err)` - -#### Scenario: 字段构造函数 - -- **WHEN** 构造日志字段 -- **THEN** SHALL 优先使用 `pkg/logger` 提供的辅助函数 -- **THEN** 辅助函数 SHALL 返回 `zap.Field` 类型 diff --git a/openspec/changes/backend-code-lint/tasks.md b/openspec/changes/backend-code-lint/tasks.md deleted file mode 100644 index dda1fe3..0000000 --- a/openspec/changes/backend-code-lint/tasks.md +++ /dev/null @@ -1,42 +0,0 @@ -## 1. 基础设施 - -- [x] 1.1 创建 embedfs/assets/.gitkeep 和 embedfs/frontend-dist/.gitkeep,解决 embedfs 编译阻塞 -- [x] 1.2 创建 backend/.golangci.yml 配置文件,启用 13 个 linter 并配置所有规则(forbidigo、errorlint、errcheck、staticcheck、revive、gocritic、gosec、bodyclose、noctx、nilerr、gofumpt、goimports、gocyclo) -- [x] 1.3 配置 .golangci.yml 中测试代码差异化规则(exclude-rules for *_test.go and tests/) -- [x] 1.4 配置 .golangci.yml 排除生成代码(exclude-dirs: tests/mocks, exclude-generated: true) -- [ ] 1.5 运行 make backend-lint 验证配置可正常执行(无 embedfs 报错) -- [x] 1.6 创建 lefthook.yml 配置文件,配置 pre-commit hook 仅检查 staged Go 文件 -- [ ] 1.7 运行 lefthook install 安装 git hooks 并验证 hook 生效 - -## 2. 存量代码修复 — 错误比较 - -- [x] 2.1 修复 internal/handler/model_handler.go 中 4 处 err == sentinel → errors.Is() -- [x] 2.2 修复 internal/handler/provider_handler.go 中 4 处 err == sentinel → errors.Is() -- [x] 2.3 修复 internal/provider/client.go:223 err == io.EOF → errors.Is(err, io.EOF) - -## 3. 存量代码修复 — 忽略错误返回值 - -- [x] 3.1 修复 internal/conversion/openai/adapter.go 中 3 处 _ = json.Marshal → 正确处理错误 -- [x] 3.2 修复 internal/conversion/anthropic/adapter.go 中 2 处 _ = json.Marshal → 正确处理错误 -- [x] 3.3 修复 internal/conversion/anthropic/decoder.go 中 1 处 _ = json.Marshal → 正确处理错误 -- [x] 3.4 修复 internal/conversion/engine.go:394 _ = json.Marshal → 正确处理错误(fallback 场景) -- [x] 3.5 修复 internal/provider/client.go:144 _ = io.ReadAll → 正确处理错误 -- [x] 3.6 为 internal/handler/proxy_handler.go 中 2 处 _ = statsService.Record 添加 //nolint:errcheck 注释(goroutine fire-and-forget 模式) - -## 4. 存量代码修复 — 日志字段 - -- [x] 4.1 修复 internal/handler/proxy_handler.go 中 zap.String("error", err.Error()) → zap.Error(err)(约 6 处) -- [x] 4.2 修复 internal/provider/client.go:187 zap.String("error", err.Error()) → zap.Error(err) -- [x] 4.3 修复 internal/conversion/engine.go 中 zap.String("error", err.Error()) → zap.Error(err)(约 6 处) - -## 5. 存量代码修复 — 桌面端日志 - -- [x] 5.1 修复 cmd/desktop/dialog_linux.go 中 2 处 fmt.Fprintf(os.Stderr, ...) → 改用 zap logger - -## 6. 验证与文档 - -- [ ] 6.1 运行 make backend-lint 确认所有 linter 通过 -- [ ] 6.2 运行 make backend-test 确认所有测试通过 -- [x] 6.3 更新 backend/README.md 编码规范部分:补充 zap.Error(err) 优先于 zap.String("error", err.Error()) 的规范 -- [x] 6.4 更新 backend/README.md 编码规范部分:补充强制使用 errors.Is/As 而非 == 比较的说明 -- [x] 6.5 更新 README.md 添加 lefthook 安装说明(首次克隆项目后需执行 lefthook install)