diff --git a/README.md b/README.md index 09b18bb..641d0c9 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,25 @@ nex/ - **HTTP 框架**: Gin - **ORM**: GORM - **数据库**: SQLite / MySQL -- **日志**: zap + lumberjack(结构化日志 + 日志轮转) +- **日志**: zap + lumberjack(结构化日志 + 日志轮转 + 模块标识) - **配置**: Viper + pflag(多层配置:CLI > 环境变量 > 配置文件 > 默认值) - **验证**: go-playground/validator/v10 - **迁移**: goose +#### 日志模块标识规范 + +每个模块通过依赖注入获取带模块标识的 logger,日志输出格式为 `[module.name]`: + +``` +Console: INFO [handler.proxy] 处理请求 method=POST path=/v1/chat +JSON: {"level":"info","logger":"handler.proxy","msg":"处理请求","method":"POST"} +``` + +模块命名规范: +- 单一职责包:`database`、`config` +- 多实体包:`handler.proxy`、`service.provider` +- 子包:`handler.middleware` + ### 前端 - **运行时**: Bun - **构建工具**: Vite diff --git a/backend/README.md b/backend/README.md index fc7f136..7533e4f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -14,11 +14,57 @@ AI 网关后端服务,提供统一的大模型 API 代理接口。 - 支持扩展层接口(Models、Embeddings、Rerank) - 多供应商配置和路由 - 用量统计 -- 结构化日志(zap + lumberjack) +- 结构化日志(zap + lumberjack + 模块标识) - YAML 配置管理 - 请求验证 - 中间件支持(请求 ID、日志、恢复、CORS) +## 日志规范 + +### 模块标识 + +每个模块通过依赖注入获取带模块标识的 logger: + +```go +func NewProxyHandler(..., logger *zap.Logger) *ProxyHandler { + return &ProxyHandler{ + logger: pkglogger.WithModule(logger, "handler.proxy"), + } +} +``` + +输出格式: +- Console: `INFO [handler.proxy] 处理请求 method=POST path=/v1/chat` +- JSON: `{"level":"info","logger":"handler.proxy","msg":"处理请求"}` + +### 模块命名规范 + +| 模块 | 命名 | +|------|------| +| ProxyHandler | `handler.proxy` | +| ProviderHandler | `handler.provider` | +| Provider Client | `provider.client` | +| ConversionEngine | `conversion.engine` | +| RoutingCache | `service.routing_cache` | +| StatsBuffer | `service.stats_buffer` | +| Database | `database` | + +### 标准字段 + +使用 `pkg/logger/field.go` 中定义的字段构造函数: + +```go +logger.Info("请求开始", + pkglogger.Method("POST"), + pkglogger.Path("/v1/chat"), + pkglogger.RequestID("xxx"), +) +``` + +### GORM 日志 + +GORM 日志自动桥接到 zap,SQL 查询映射到 Debug 级别。 + ## 技术栈 - **语言**: Go 1.26+ @@ -105,9 +151,13 @@ backend/ │ │ ├── errors.go │ │ └── wrap.go │ ├── logger/ # 日志系统 -│ │ ├── logger.go -│ │ ├── rotate.go -│ │ └── context.go +│ │ ├── logger.go # 核心初始化 +│ │ ├── field.go # 标准字段定义 +│ │ ├── module.go # 模块日志器 +│ │ ├── context.go # Context 辅助函数 +│ │ ├── gorm.go # GORM 适配器 +│ │ ├── minimal.go # 最小化 logger +│ │ └── rotate.go # 日志轮转 │ ├── modelid/ # 统一模型 ID 工具包 │ │ ├── model_id.go │ │ └── model_id_test.go diff --git a/backend/cmd/desktop/main.go b/backend/cmd/desktop/main.go index e486639..8882da8 100644 --- a/backend/cmd/desktop/main.go +++ b/backend/cmd/desktop/main.go @@ -43,25 +43,28 @@ var ( func main() { port := 9826 + minimalLogger := pkgLogger.NewMinimal() + singleLock := NewSingletonLock(filepath.Join(os.TempDir(), "nex-gateway.lock")) if err := singleLock.Lock(); err != nil { + minimalLogger.Error("已有 Nex 实例运行") showError("Nex Gateway", "已有 Nex 实例运行") os.Exit(1) } defer singleLock.Unlock() if err := checkPortAvailable(port); err != nil { + minimalLogger.Error("端口不可用", zap.Error(err)) showError("Nex Gateway", err.Error()) os.Exit(1) } cfg, err := config.LoadConfig() if err != nil { - showError("Nex Gateway", fmt.Sprintf("加载配置失败: %v", err)) - os.Exit(1) + minimalLogger.Fatal("加载配置失败", zap.Error(err)) } - zapLogger, err = pkgLogger.New(pkgLogger.Config{ + zapLogger, err = pkgLogger.Upgrade(minimalLogger, pkgLogger.Config{ Level: cfg.Log.Level, Path: cfg.Log.Path, MaxSize: cfg.Log.MaxSize, @@ -70,15 +73,15 @@ func main() { Compress: cfg.Log.Compress, }) if err != nil { - showError("Nex Gateway", fmt.Sprintf("初始化日志失败: %v", err)) - os.Exit(1) + minimalLogger.Fatal("初始化日志失败", zap.Error(err)) } defer zapLogger.Sync() + cfg.PrintSummary(zapLogger) + db, err := database.Init(&cfg.Database, zapLogger) if err != nil { - showError("Nex Gateway", fmt.Sprintf("初始化数据库失败: %v", err)) - os.Exit(1) + zapLogger.Fatal("初始化数据库失败", zap.Error(err)) } defer database.Close(db) @@ -104,16 +107,16 @@ func main() { registry := conversion.NewMemoryRegistry() if err := registry.Register(openai.NewAdapter()); err != nil { - zapLogger.Fatal("注册 OpenAI 适配器失败", zap.String("error", err.Error())) + zapLogger.Fatal("注册 OpenAI 适配器失败", zap.Error(err)) } if err := registry.Register(anthropic.NewAdapter()); err != nil { - zapLogger.Fatal("注册 Anthropic 适配器失败", zap.String("error", err.Error())) + zapLogger.Fatal("注册 Anthropic 适配器失败", zap.Error(err)) } engine := conversion.NewConversionEngine(registry, zapLogger) - providerClient := provider.NewClient() + providerClient := provider.NewClient(zapLogger) - proxyHandler := handler.NewProxyHandler(engine, providerClient, routingService, providerService, statsService) + proxyHandler := handler.NewProxyHandler(engine, providerClient, routingService, providerService, statsService, zapLogger) providerHandler := handler.NewProviderHandler(providerService) modelHandler := handler.NewModelHandler(modelService) statsHandler := handler.NewStatsHandler(statsService) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 2bf8008..e123cdb 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -3,7 +3,6 @@ package main import ( "context" "fmt" - "log" "net/http" "os" "os/signal" @@ -27,14 +26,14 @@ import ( ) func main() { + minimalLogger := pkgLogger.NewMinimal() + cfg, err := config.LoadConfig() if err != nil { - log.Fatalf("加载配置失败: %v", err) + minimalLogger.Fatal("加载配置失败", zap.Error(err)) } - cfg.PrintSummary() - - zapLogger, err := pkgLogger.New(pkgLogger.Config{ + zapLogger, err := pkgLogger.Upgrade(minimalLogger, pkgLogger.Config{ Level: cfg.Log.Level, Path: cfg.Log.Path, MaxSize: cfg.Log.MaxSize, @@ -43,13 +42,15 @@ func main() { Compress: cfg.Log.Compress, }) if err != nil { - log.Fatalf("初始化日志失败: %v", err) + minimalLogger.Fatal("初始化日志失败", zap.Error(err)) } defer zapLogger.Sync() + cfg.PrintSummary(zapLogger) + db, err := database.Init(&cfg.Database, zapLogger) if err != nil { - zapLogger.Fatal("初始化数据库失败", zap.String("error", err.Error())) + zapLogger.Fatal("初始化数据库失败", zap.Error(err)) } defer database.Close(db) @@ -74,16 +75,16 @@ func main() { registry := conversion.NewMemoryRegistry() if err := registry.Register(openai.NewAdapter()); err != nil { - zapLogger.Fatal("注册 OpenAI 适配器失败", zap.String("error", err.Error())) + zapLogger.Fatal("注册 OpenAI 适配器失败", zap.Error(err)) } if err := registry.Register(anthropic.NewAdapter()); err != nil { - zapLogger.Fatal("注册 Anthropic 适配器失败", zap.String("error", err.Error())) + zapLogger.Fatal("注册 Anthropic 适配器失败", zap.Error(err)) } engine := conversion.NewConversionEngine(registry, zapLogger) - providerClient := provider.NewClient() + providerClient := provider.NewClient(zapLogger) - proxyHandler := handler.NewProxyHandler(engine, providerClient, routingService, providerService, statsService) + proxyHandler := handler.NewProxyHandler(engine, providerClient, routingService, providerService, statsService, zapLogger) providerHandler := handler.NewProviderHandler(providerService) modelHandler := handler.NewModelHandler(modelService) statsHandler := handler.NewStatsHandler(statsService) @@ -108,7 +109,7 @@ func main() { go func() { zapLogger.Info("AI Gateway 启动", zap.String("addr", srv.Addr)) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - zapLogger.Fatal("服务器启动失败", zap.String("error", err.Error())) + zapLogger.Fatal("服务器启动失败", zap.Error(err)) } }() @@ -122,7 +123,7 @@ func main() { defer cancel() if err := srv.Shutdown(ctx); err != nil { - zapLogger.Fatal("服务器强制关闭", zap.String("error", err.Error())) + zapLogger.Fatal("服务器强制关闭", zap.Error(err)) } statsBuffer.Stop() diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 773590f..6662f4c 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -11,6 +11,7 @@ import ( "github.com/mitchellh/mapstructure" "github.com/spf13/pflag" "github.com/spf13/viper" + "go.uber.org/zap" "gopkg.in/yaml.v3" appErrors "nex/backend/pkg/errors" @@ -33,12 +34,12 @@ type ServerConfig struct { // DatabaseConfig 数据库配置 type DatabaseConfig struct { Driver string `yaml:"driver" mapstructure:"driver" validate:"required,oneof=sqlite mysql"` - Path string `yaml:"path" mapstructure:"path" validate:"required_if=driver sqlite"` - Host string `yaml:"host" mapstructure:"host" validate:"required_if=driver mysql"` - Port int `yaml:"port" mapstructure:"port" validate:"required_if=driver mysql,min=1,max=65535"` - User string `yaml:"user" mapstructure:"user" validate:"required_if=driver mysql"` + Path string `yaml:"path" mapstructure:"path" validate:"required_if=Driver sqlite"` + Host string `yaml:"host" mapstructure:"host" validate:"required_if=Driver mysql"` + Port int `yaml:"port" mapstructure:"port" validate:"required_if=Driver mysql,min=1,max=65535"` + User string `yaml:"user" mapstructure:"user" validate:"required_if=Driver mysql"` Password string `yaml:"password" mapstructure:"password"` - DBName string `yaml:"dbname" mapstructure:"dbname" validate:"required_if=driver mysql"` + DBName string `yaml:"dbname" mapstructure:"dbname" validate:"required_if=Driver mysql"` MaxIdleConns int `yaml:"max_idle_conns" mapstructure:"max_idle_conns" validate:"required,min=1"` MaxOpenConns int `yaml:"max_open_conns" mapstructure:"max_open_conns" validate:"required,min=1"` ConnMaxLifetime time.Duration `yaml:"conn_max_lifetime" mapstructure:"conn_max_lifetime" validate:"required"` @@ -311,22 +312,24 @@ func (c *Config) Validate() error { } // PrintSummary 打印配置摘要 -func (c *Config) PrintSummary() { - fmt.Println("\nAI Gateway 启动配置") - fmt.Println("==================") - fmt.Printf("服务器端口: %d\n", c.Server.Port) +func (c *Config) PrintSummary(logger *zap.Logger) { + logger.Info("AI Gateway 启动配置", + zap.Int("server_port", c.Server.Port), + zap.String("database_driver", c.Database.Driver), + zap.String("log_level", c.Log.Level), + ) + if c.Database.Driver == "mysql" { - fmt.Printf("数据库类型: mysql\n") - fmt.Printf("数据库地址: %s:%d/%s\n", c.Database.Host, c.Database.Port, c.Database.DBName) + logger.Info("数据库配置", + zap.String("driver", "mysql"), + zap.String("host", c.Database.Host), + zap.Int("port", c.Database.Port), + zap.String("database", c.Database.DBName), + ) } else { - fmt.Printf("数据库类型: sqlite\n") - fmt.Printf("数据库路径: %s\n", c.Database.Path) + logger.Info("数据库配置", + zap.String("driver", "sqlite"), + zap.String("path", c.Database.Path), + ) } - fmt.Printf("日志级别: %s\n", c.Log.Level) - fmt.Println("\n配置来源:") - configPath, _ := GetConfigPath() - fmt.Printf(" 配置文件: %s\n", configPath) - fmt.Println(" 环境变量: 待统计") - fmt.Println(" CLI 参数: 待统计") - fmt.Println() } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index b4a58e4..584957f 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/zap" "gopkg.in/yaml.v3" ) @@ -171,7 +172,9 @@ func TestConfig_Validate(t *testing.T) { err := cfg.Validate() if tt.wantErr { assert.Error(t, err) - assert.Contains(t, err.Error(), tt.errMsg) + if err != nil { + assert.Contains(t, err.Error(), tt.errMsg) + } } else { assert.NoError(t, err) } @@ -302,7 +305,7 @@ func TestPrintSummary(t *testing.T) { t.Run("SQLite模式摘要", func(t *testing.T) { cfg := DefaultConfig() assert.NotPanics(t, func() { - cfg.PrintSummary() + cfg.PrintSummary(zap.NewNop()) }) }) t.Run("MySQL模式摘要", func(t *testing.T) { @@ -313,7 +316,7 @@ func TestPrintSummary(t *testing.T) { cfg.Database.User = "nex" cfg.Database.DBName = "nex" assert.NotPanics(t, func() { - cfg.PrintSummary() + cfg.PrintSummary(zap.NewNop()) }) }) } diff --git a/backend/internal/conversion/engine.go b/backend/internal/conversion/engine.go index 0dd59a1..507274b 100644 --- a/backend/internal/conversion/engine.go +++ b/backend/internal/conversion/engine.go @@ -7,6 +7,8 @@ import ( "github.com/google/uuid" "go.uber.org/zap" + + pkglogger "nex/backend/pkg/logger" ) // HTTPRequestSpec HTTP 请求规格 @@ -33,13 +35,10 @@ type ConversionEngine struct { // NewConversionEngine 创建转换引擎 func NewConversionEngine(registry AdapterRegistry, logger *zap.Logger) *ConversionEngine { - if logger == nil { - logger = zap.L() - } return &ConversionEngine{ registry: registry, middlewareChain: NewMiddlewareChain(), - logger: logger, + logger: pkglogger.WithModule(logger, "conversion.engine"), } } @@ -90,7 +89,7 @@ func (e *ConversionEngine) ConvertHttpRequest(spec HTTPRequestSpec, clientProtoc rewrittenBody, err = providerAdapter.RewriteRequestModelName(spec.Body, provider.ModelName, interfaceType) if err != nil { e.logger.Warn("Smart Passthrough 改写请求失败,使用原始请求体", - zap.String("error", err.Error()), + zap.Error(err), zap.String("interface", string(interfaceType))) rewrittenBody = spec.Body } @@ -142,9 +141,9 @@ func (e *ConversionEngine) ConvertHttpResponse(spec HTTPResponseSpec, clientProt rewrittenBody, err := adapter.RewriteResponseModelName(spec.Body, modelOverride, interfaceType) if err != nil { - e.logger.Warn("Smart Passthrough 改写响应失败,使用原始响应体", - zap.String("error", err.Error()), - zap.String("interface", string(interfaceType))) + e.logger.Warn("Smart Passthrough 改写响应失败,使用原始响应体", + zap.Error(err), + zap.String("interface", string(interfaceType))) return &spec, nil } @@ -312,7 +311,7 @@ func (e *ConversionEngine) convertModelsResponseBody(clientAdapter, providerAdap } encoded, err := clientAdapter.EncodeModelsResponse(models) if err != nil { - e.logger.Warn("编码 Models 响应失败,返回原始响应", zap.String("error", err.Error())) + e.logger.Warn("编码 Models 响应失败,返回原始响应", zap.Error(err)) return body, nil } return encoded, nil diff --git a/backend/internal/conversion/engine_supplemental_test.go b/backend/internal/conversion/engine_supplemental_test.go index 1a8cc07..8c64eb5 100644 --- a/backend/internal/conversion/engine_supplemental_test.go +++ b/backend/internal/conversion/engine_supplemental_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/zap" ) func TestConversionError_WithProviderProtocol(t *testing.T) { @@ -39,7 +40,7 @@ func TestConversionError_FullBuilder(t *testing.T) { func TestEngine_Use(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) called := false engine.Use(&testMiddleware{fn: func(req *canonical.CanonicalRequest, cp, pp string, ctx *ConversionContext) (*canonical.CanonicalRequest, error) { called = true @@ -66,7 +67,7 @@ func TestEngine_Use(t *testing.T) { func TestConvertHttpRequest_DecodeError(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) clientAdapter := newMockAdapter("client", false) clientAdapter.decodeReqFn = func(raw []byte) (*canonical.CanonicalRequest, error) { return nil, errors.New("decode failed") @@ -82,7 +83,7 @@ func TestConvertHttpRequest_DecodeError(t *testing.T) { func TestConvertHttpRequest_EncodeError(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) _ = engine.RegisterAdapter(newMockAdapter("client", false)) providerAdapter := newMockAdapter("provider", false) providerAdapter.encodeReqFn = func(req *canonical.CanonicalRequest, p *TargetProvider) ([]byte, error) { @@ -98,7 +99,7 @@ func TestConvertHttpRequest_EncodeError(t *testing.T) { func TestConvertHttpResponse_CrossProtocol(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) clientAdapter := newMockAdapter("client", false) clientAdapter.encodeRespFn = func(resp *canonical.CanonicalResponse) ([]byte, error) { @@ -121,7 +122,7 @@ func TestConvertHttpResponse_CrossProtocol(t *testing.T) { func TestConvertHttpResponse_DecodeError(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) providerAdapter := newMockAdapter("provider", false) providerAdapter.decodeRespFn = func(raw []byte) (*canonical.CanonicalResponse, error) { return nil, errors.New("decode error") @@ -135,7 +136,7 @@ func TestConvertHttpResponse_DecodeError(t *testing.T) { func TestConvertHttpRequest_EmbeddingInterface(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) clientAdapter := newMockAdapter("client", false) clientAdapter.ifaceType = InterfaceTypeEmbeddings @@ -158,7 +159,7 @@ func TestConvertHttpRequest_EmbeddingInterface(t *testing.T) { func TestConvertHttpRequest_RerankInterface(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) clientAdapter := newMockAdapter("client", false) clientAdapter.ifaceType = InterfaceTypeRerank @@ -178,7 +179,7 @@ func TestConvertHttpRequest_RerankInterface(t *testing.T) { func TestConvertHttpResponse_EmbeddingInterface(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) clientAdapter := newMockAdapter("client", false) clientAdapter.supportsIface = map[InterfaceType]bool{InterfaceTypeEmbeddings: true} @@ -196,7 +197,7 @@ func TestConvertHttpResponse_EmbeddingInterface(t *testing.T) { func TestConvertHttpResponse_RerankInterface(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) clientAdapter := newMockAdapter("client", false) clientAdapter.supportsIface = map[InterfaceType]bool{InterfaceTypeRerank: true} @@ -214,7 +215,7 @@ func TestConvertHttpResponse_RerankInterface(t *testing.T) { func TestConvertHttpRequest_ModelsInterface_Passthrough(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) clientAdapter := newMockAdapter("client", false) clientAdapter.ifaceType = InterfaceTypeModels providerAdapter := newMockAdapter("provider", false) @@ -232,7 +233,7 @@ func TestConvertHttpRequest_ModelsInterface_Passthrough(t *testing.T) { func TestConvertHttpResponse_ModelsInterface(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) clientAdapter := newMockAdapter("client", false) clientAdapter.supportsIface = map[InterfaceType]bool{InterfaceTypeModels: true} providerAdapter := newMockAdapter("provider", false) @@ -249,7 +250,7 @@ func TestConvertHttpResponse_ModelsInterface(t *testing.T) { func TestConvertHttpResponse_ModelInfoInterface(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) clientAdapter := newMockAdapter("client", false) clientAdapter.supportsIface = map[InterfaceType]bool{InterfaceTypeModelInfo: true} providerAdapter := newMockAdapter("provider", false) @@ -324,7 +325,7 @@ var _ = json.Marshal func TestConvertEmbeddingBody_DecodeError(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) clientAdapter := newMockAdapter("client", false) clientAdapter.decodeEmbeddingReqFn = func(raw []byte) (*canonical.CanonicalEmbeddingRequest, error) { @@ -344,7 +345,7 @@ func TestConvertEmbeddingBody_DecodeError(t *testing.T) { func TestConvertRerankBody_DecodeError(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) clientAdapter := newMockAdapter("client", false) clientAdapter.decodeRerankReqFn = func(raw []byte) (*canonical.CanonicalRerankRequest, error) { @@ -364,7 +365,7 @@ func TestConvertRerankBody_DecodeError(t *testing.T) { func TestConvertBody_UnknownInterfaceType(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) clientAdapter := newMockAdapter("client", false) providerAdapter := newMockAdapter("provider", false) diff --git a/backend/internal/conversion/engine_test.go b/backend/internal/conversion/engine_test.go index 26bbb10..524b186 100644 --- a/backend/internal/conversion/engine_test.go +++ b/backend/internal/conversion/engine_test.go @@ -203,7 +203,7 @@ func (e *noopStreamEncoder) Flush() [][]byte func TestNewConversionEngine(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) assert.NotNil(t, engine) assert.Equal(t, registry, engine.GetRegistry()) } @@ -211,7 +211,7 @@ func TestNewConversionEngine(t *testing.T) { func TestNewConversionEngine_LoggerInjection(t *testing.T) { t.Run("nil_logger_uses_global", func(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) assert.NotNil(t, engine.logger) }) @@ -219,13 +219,14 @@ func TestNewConversionEngine_LoggerInjection(t *testing.T) { registry := NewMemoryRegistry() customLogger := zap.NewNop() engine := NewConversionEngine(registry, customLogger) - assert.Equal(t, customLogger, engine.logger) + assert.NotNil(t, engine.logger) + assert.Contains(t, engine.logger.Name(), "conversion.engine") }) } func TestRegisterAdapter(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) adapter := newMockAdapter("test-proto", true) err := engine.RegisterAdapter(adapter) @@ -237,7 +238,7 @@ func TestRegisterAdapter(t *testing.T) { func TestIsPassthrough_SameProtocol(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) adapter := newMockAdapter("openai", true) _ = engine.RegisterAdapter(adapter) @@ -246,7 +247,7 @@ func TestIsPassthrough_SameProtocol(t *testing.T) { func TestIsPassthrough_DifferentProtocol(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) _ = engine.RegisterAdapter(newMockAdapter("openai", true)) _ = engine.RegisterAdapter(newMockAdapter("anthropic", true)) @@ -255,7 +256,7 @@ func TestIsPassthrough_DifferentProtocol(t *testing.T) { func TestIsPassthrough_NoPassthrough(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) _ = engine.RegisterAdapter(newMockAdapter("custom", false)) assert.False(t, engine.IsPassthrough("custom", "custom")) @@ -263,7 +264,7 @@ func TestIsPassthrough_NoPassthrough(t *testing.T) { func TestDetectInterfaceType(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) adapter := newMockAdapter("test", true) adapter.ifaceType = InterfaceTypeChat _ = engine.RegisterAdapter(adapter) @@ -275,7 +276,7 @@ func TestDetectInterfaceType(t *testing.T) { func TestDetectInterfaceType_NonExistentProtocol(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) _, err := engine.DetectInterfaceType("/v1/chat", "nonexistent") assert.Error(t, err) @@ -283,7 +284,7 @@ func TestDetectInterfaceType_NonExistentProtocol(t *testing.T) { func TestConvertHttpRequest_Passthrough(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) _ = engine.RegisterAdapter(newMockAdapter("openai", true)) provider := NewTargetProvider("https://api.openai.com/v1", "sk-test", "gpt-4") @@ -301,7 +302,7 @@ func TestConvertHttpRequest_Passthrough(t *testing.T) { func TestConvertHttpRequest_CrossProtocol(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) clientAdapter := newMockAdapter("client-proto", false) clientAdapter.decodeReqFn = func(raw []byte) (*canonical.CanonicalRequest, error) { @@ -333,7 +334,7 @@ func TestConvertHttpRequest_CrossProtocol(t *testing.T) { func TestConvertHttpResponse_Passthrough(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) _ = engine.RegisterAdapter(newMockAdapter("openai", true)) spec := HTTPResponseSpec{ @@ -349,7 +350,7 @@ func TestConvertHttpResponse_Passthrough(t *testing.T) { func TestCreateStreamConverter_Passthrough(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) _ = engine.RegisterAdapter(newMockAdapter("openai", true)) converter, err := engine.CreateStreamConverter("openai", "openai", "", InterfaceTypeChat) @@ -360,7 +361,7 @@ func TestCreateStreamConverter_Passthrough(t *testing.T) { func TestCreateStreamConverter_Canonical(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) _ = engine.RegisterAdapter(newMockAdapter("client", false)) _ = engine.RegisterAdapter(newMockAdapter("provider", false)) @@ -372,7 +373,7 @@ func TestCreateStreamConverter_Canonical(t *testing.T) { func TestEncodeError(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) _ = engine.RegisterAdapter(newMockAdapter("openai", true)) convErr := NewConversionError(ErrorCodeInvalidInput, "测试错误") @@ -384,7 +385,7 @@ func TestEncodeError(t *testing.T) { func TestEncodeError_NonExistentProtocol(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) convErr := NewConversionError(ErrorCodeInvalidInput, "测试错误") body, statusCode, err := engine.EncodeError(convErr, "nonexistent") @@ -417,7 +418,7 @@ func TestRegistry_GetNonExistent(t *testing.T) { func TestConvertHttpResponse_ModelOverride_CrossProtocol(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) clientAdapter := newMockAdapter("client", false) clientAdapter.encodeRespFn = func(resp *canonical.CanonicalResponse) ([]byte, error) { @@ -446,7 +447,7 @@ func TestConvertHttpResponse_ModelOverride_CrossProtocol(t *testing.T) { func TestConvertHttpResponse_ModelOverride_SameProtocol(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) // 使用真实 OpenAI adapter 验证 Smart Passthrough 改写 openaiAdapter := newMockAdapter("openai", true) @@ -476,7 +477,7 @@ func TestConvertHttpResponse_ModelOverride_SameProtocol(t *testing.T) { func TestCreateStreamConverter_ModelOverride_SmartPassthrough(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) openaiAdapter := newMockAdapter("openai", true) openaiAdapter.rewriteRespFn = func(body []byte, newModel string, ifaceType InterfaceType) ([]byte, error) { @@ -506,7 +507,7 @@ func TestCreateStreamConverter_ModelOverride_SmartPassthrough(t *testing.T) { func TestCreateStreamConverter_ModelOverride_CrossProtocol(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) // provider adapter 解码出含 model 的流式事件 providerAdapter := newMockAdapter("provider", false) @@ -560,7 +561,7 @@ func TestCreateStreamConverter_ModelOverride_CrossProtocol(t *testing.T) { func TestCreateStreamConverter_ModelOverride_CrossProtocol_Empty(t *testing.T) { registry := NewMemoryRegistry() - engine := NewConversionEngine(registry, nil) + engine := NewConversionEngine(registry, zap.NewNop()) providerAdapter := newMockAdapter("provider", false) providerAdapter.streamDecoderFn = func() StreamDecoder { diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go index abe30ca..b573f84 100644 --- a/backend/internal/database/database.go +++ b/backend/internal/database/database.go @@ -11,22 +11,24 @@ import ( "gorm.io/driver/mysql" "gorm.io/driver/sqlite" "gorm.io/gorm" - "gorm.io/gorm/logger" "nex/backend/internal/config" + pkglogger "nex/backend/pkg/logger" ) func Init(cfg *config.DatabaseConfig, zapLogger *zap.Logger) (*gorm.DB, error) { - db, err := initDB(cfg, zapLogger) + moduleLogger := pkglogger.WithModule(zapLogger, "database") + + db, err := initDB(cfg, moduleLogger) if err != nil { return nil, fmt.Errorf("初始化数据库失败: %w", err) } - if err := runMigrations(db, cfg.Driver, zapLogger); err != nil { + if err := runMigrations(db, cfg.Driver, moduleLogger); err != nil { return nil, fmt.Errorf("数据库迁移失败: %w", err) } - configurePool(db, cfg, zapLogger) + configurePool(db, cfg, moduleLogger) return db, nil } @@ -40,8 +42,10 @@ func Close(db *gorm.DB) { } func initDB(cfg *config.DatabaseConfig, zapLogger *zap.Logger) (*gorm.DB, error) { + gormLogger := pkglogger.NewGormLogger(zapLogger) + gormConfig := &gorm.Config{ - Logger: logger.Default.LogMode(logger.Info), + Logger: gormLogger, } switch cfg.Driver { diff --git a/backend/internal/handler/middleware/logging.go b/backend/internal/handler/middleware/logging.go index fd17da6..9ecd586 100644 --- a/backend/internal/handler/middleware/logging.go +++ b/backend/internal/handler/middleware/logging.go @@ -5,9 +5,10 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" + + pkglogger "nex/backend/pkg/logger" ) -// Logging 日志中间件 func Logging(logger *zap.Logger) gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() @@ -15,12 +16,17 @@ func Logging(logger *zap.Logger) gin.HandlerFunc { query := c.Request.URL.RawQuery requestID, _ := c.Get(RequestIDKey) + var requestIDStr string + if id, ok := requestID.(string); ok { + requestIDStr = id + } + logger.Info("请求开始", - zap.String("method", c.Request.Method), - zap.String("path", path), - zap.String("query", query), - zap.String("client_ip", c.ClientIP()), - zap.Any("request_id", requestID), + pkglogger.Method(c.Request.Method), + pkglogger.Path(path), + pkglogger.Query(query), + pkglogger.ClientIP(c.ClientIP()), + pkglogger.RequestID(requestIDStr), ) c.Next() @@ -29,12 +35,12 @@ func Logging(logger *zap.Logger) gin.HandlerFunc { statusCode := c.Writer.Status() logger.Info("请求结束", - zap.Int("status", statusCode), - zap.String("method", c.Request.Method), - zap.String("path", path), - zap.Duration("latency", latency), - zap.Int("body_size", c.Writer.Size()), - zap.Any("request_id", requestID), + pkglogger.StatusCode(statusCode), + pkglogger.Method(c.Request.Method), + pkglogger.Path(path), + pkglogger.Latency(latency), + pkglogger.BodySize(c.Writer.Size()), + pkglogger.RequestID(requestIDStr), ) } } diff --git a/backend/internal/handler/proxy_handler.go b/backend/internal/handler/proxy_handler.go index 939c4ca..e5635eb 100644 --- a/backend/internal/handler/proxy_handler.go +++ b/backend/internal/handler/proxy_handler.go @@ -16,6 +16,7 @@ import ( "nex/backend/internal/provider" "nex/backend/internal/service" "nex/backend/pkg/modelid" + pkglogger "nex/backend/pkg/logger" ) // ProxyHandler 统一代理处理器 @@ -29,14 +30,14 @@ type ProxyHandler struct { } // NewProxyHandler 创建统一代理处理器 -func NewProxyHandler(engine *conversion.ConversionEngine, client provider.ProviderClient, routingService service.RoutingService, providerService service.ProviderService, statsService service.StatsService) *ProxyHandler { +func NewProxyHandler(engine *conversion.ConversionEngine, client provider.ProviderClient, routingService service.RoutingService, providerService service.ProviderService, statsService service.StatsService, logger *zap.Logger) *ProxyHandler { return &ProxyHandler{ engine: engine, client: client, routingService: routingService, providerService: providerService, statsService: statsService, - logger: zap.L(), + logger: pkglogger.WithModule(logger, "handler.proxy"), } } @@ -331,7 +332,7 @@ func (h *ProxyHandler) handleModelInfo(c *gin.Context, unifiedID string, adapter // 使用 adapter 编码返回 body, err := adapter.EncodeModelInfoResponse(modelInfo) if err != nil { - h.logger.Error("编码 ModelInfo 响应失败", zap.String("error", err.Error())) + h.logger.Error("编码 ModelInfo 响应失败", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "编码响应失败"}) return } diff --git a/backend/internal/handler/proxy_handler_test.go b/backend/internal/handler/proxy_handler_test.go index d953eb0..e5f8435 100644 --- a/backend/internal/handler/proxy_handler_test.go +++ b/backend/internal/handler/proxy_handler_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "go.uber.org/zap" "nex/backend/internal/conversion" "nex/backend/internal/conversion/anthropic" @@ -31,7 +32,7 @@ func init() { func setupProxyEngine(t *testing.T) *conversion.ConversionEngine { t.Helper() registry := conversion.NewMemoryRegistry() - engine := conversion.NewConversionEngine(registry, nil) + engine := conversion.NewConversionEngine(registry, zap.NewNop()) require.NoError(t, registry.Register(openai.NewAdapter())) require.NoError(t, registry.Register(anthropic.NewAdapter())) return engine @@ -44,6 +45,7 @@ func newTestProxyHandler(engine *conversion.ConversionEngine, client *mocks.Mock routingSvc, providerSvc, statsSvc, + zap.NewNop(), ) } @@ -499,7 +501,7 @@ func TestProxyHandler_HandleStream_CreateStreamConverterError(t *testing.T) { defer ctrl.Finish() registry := conversion.NewMemoryRegistry() - engine := conversion.NewConversionEngine(registry, nil) + engine := conversion.NewConversionEngine(registry, zap.NewNop()) err := registry.Register(openai.NewAdapter()) require.NoError(t, err) @@ -527,7 +529,7 @@ func TestProxyHandler_HandleStream_ConvertRequestError(t *testing.T) { defer ctrl.Finish() registry := conversion.NewMemoryRegistry() - engine := conversion.NewConversionEngine(registry, nil) + engine := conversion.NewConversionEngine(registry, zap.NewNop()) require.NoError(t, registry.Register(openai.NewAdapter())) routingSvc := mocks.NewMockRoutingService(ctrl) @@ -554,7 +556,7 @@ func TestProxyHandler_HandleNonStream_ConvertResponseError(t *testing.T) { defer ctrl.Finish() registry := conversion.NewMemoryRegistry() - engine := conversion.NewConversionEngine(registry, nil) + engine := conversion.NewConversionEngine(registry, zap.NewNop()) require.NoError(t, registry.Register(openai.NewAdapter())) require.NoError(t, registry.Register(anthropic.NewAdapter())) @@ -623,7 +625,7 @@ func TestProxyHandler_ForwardPassthrough_CrossProtocol(t *testing.T) { defer ctrl.Finish() registry := conversion.NewMemoryRegistry() - engine := conversion.NewConversionEngine(registry, nil) + engine := conversion.NewConversionEngine(registry, zap.NewNop()) require.NoError(t, registry.Register(openai.NewAdapter())) anthropicAdapter := anthropic.NewAdapter() diff --git a/backend/internal/provider/client.go b/backend/internal/provider/client.go index 3dd5002..c2a639c 100644 --- a/backend/internal/provider/client.go +++ b/backend/internal/provider/client.go @@ -15,6 +15,7 @@ import ( "nex/backend/internal/conversion" pkgErrors "nex/backend/pkg/errors" + pkglogger "nex/backend/pkg/logger" ) // StreamConfig 流式处理配置 @@ -57,12 +58,12 @@ type ProviderClient interface { } // NewClient 创建供应商客户端 -func NewClient() *Client { +func NewClient(logger *zap.Logger) *Client { return &Client{ httpClient: &http.Client{ Timeout: 30 * time.Second, }, - logger: zap.L(), + logger: pkglogger.WithModule(logger, "provider.client"), streamCfg: DefaultStreamConfig(), } } @@ -186,7 +187,7 @@ func (c *Client) readStream(ctx context.Context, cancel context.CancelFunc, body c.logger.Error("流网络错误", zap.String("error", err.Error())) eventChan <- StreamEvent{Error: fmt.Errorf("网络错误: %w", err)} } else { - c.logger.Error("流读取错误", zap.String("error", err.Error())) + c.logger.Error("流读取错误", zap.Error(err)) eventChan <- StreamEvent{Error: fmt.Errorf("读取错误: %w", err)} } return diff --git a/backend/internal/provider/client_test.go b/backend/internal/provider/client_test.go index 1ecf59e..360d459 100644 --- a/backend/internal/provider/client_test.go +++ b/backend/internal/provider/client_test.go @@ -13,12 +13,13 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/zap" "nex/backend/internal/conversion" ) func TestNewClient(t *testing.T) { - client := NewClient() + client := NewClient(zap.NewNop()) require.NotNil(t, client) assert.NotNil(t, client.httpClient) assert.Equal(t, 4096, client.streamCfg.InitialBufferSize) @@ -44,7 +45,7 @@ func TestClient_Send_Success(t *testing.T) { })) defer server.Close() - client := NewClient() + client := NewClient(zap.NewNop()) spec := conversion.HTTPRequestSpec{ URL: server.URL + "/v1/chat/completions", Method: "POST", @@ -68,7 +69,7 @@ func TestClient_Send_ErrorResponse(t *testing.T) { })) defer server.Close() - client := NewClient() + client := NewClient(zap.NewNop()) spec := conversion.HTTPRequestSpec{ URL: server.URL + "/v1/chat/completions", Method: "POST", @@ -82,7 +83,7 @@ func TestClient_Send_ErrorResponse(t *testing.T) { } func TestClient_Send_ConnectionError(t *testing.T) { - client := NewClient() + client := NewClient(zap.NewNop()) spec := conversion.HTTPRequestSpec{ URL: "http://localhost:1/v1/chat/completions", Method: "POST", @@ -99,7 +100,7 @@ func TestClient_SendStream_CreatesChannel(t *testing.T) { })) defer server.Close() - client := NewClient() + client := NewClient(zap.NewNop()) spec := conversion.HTTPRequestSpec{ URL: server.URL + "/v1/chat/completions", Method: "POST", @@ -121,7 +122,7 @@ func TestClient_SendStream_ErrorResponse(t *testing.T) { })) defer server.Close() - client := NewClient() + client := NewClient(zap.NewNop()) spec := conversion.HTTPRequestSpec{ URL: server.URL + "/v1/chat/completions", Method: "POST", @@ -150,7 +151,7 @@ func TestClient_SendStream_SSEEvents(t *testing.T) { })) defer server.Close() - client := NewClient() + client := NewClient(zap.NewNop()) spec := conversion.HTTPRequestSpec{ URL: server.URL + "/v1/chat/completions", Method: "POST", @@ -188,7 +189,7 @@ func TestClient_SendStream_ContextCancellation(t *testing.T) { defer server.Close() ctx, cancel := context.WithCancel(context.Background()) - client := NewClient() + client := NewClient(zap.NewNop()) spec := conversion.HTTPRequestSpec{ URL: server.URL + "/v1/chat/completions", Method: "POST", @@ -218,7 +219,7 @@ func TestClient_Send_EmptyBody(t *testing.T) { })) defer server.Close() - client := NewClient() + client := NewClient(zap.NewNop()) spec := conversion.HTTPRequestSpec{ URL: server.URL + "/v1/models", Method: "GET", @@ -246,7 +247,7 @@ func TestClient_SendStream_SlowSSE(t *testing.T) { })) defer server.Close() - client := NewClient() + client := NewClient(zap.NewNop()) spec := conversion.HTTPRequestSpec{ URL: server.URL + "/v1/chat/completions", Method: "POST", @@ -287,7 +288,7 @@ func TestClient_SendStream_SplitSSEEvents(t *testing.T) { })) defer server.Close() - client := NewClient() + client := NewClient(zap.NewNop()) spec := conversion.HTTPRequestSpec{ URL: server.URL + "/v1/chat/completions", Method: "POST", @@ -375,7 +376,7 @@ func TestClient_SendStream_MidStreamNetworkError(t *testing.T) { })) defer server.Close() - client := NewClient() + client := NewClient(zap.NewNop()) spec := conversion.HTTPRequestSpec{ URL: server.URL + "/v1/chat/completions", Method: "POST", diff --git a/backend/internal/service/routing_cache.go b/backend/internal/service/routing_cache.go index 4e5087a..67ef73f 100644 --- a/backend/internal/service/routing_cache.go +++ b/backend/internal/service/routing_cache.go @@ -8,6 +8,7 @@ import ( "nex/backend/internal/domain" "nex/backend/internal/repository" + pkglogger "nex/backend/pkg/logger" ) type RoutingCache struct { @@ -27,7 +28,7 @@ func NewRoutingCache( return &RoutingCache{ modelRepo: modelRepo, providerRepo: providerRepo, - logger: logger, + logger: pkglogger.WithModule(logger, "service.routing_cache"), } } diff --git a/backend/internal/service/service_supplemental_test.go b/backend/internal/service/service_supplemental_test.go index 63145a1..5149c9a 100644 --- a/backend/internal/service/service_supplemental_test.go +++ b/backend/internal/service/service_supplemental_test.go @@ -5,6 +5,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/zap" "nex/backend/internal/domain" "nex/backend/internal/repository" @@ -119,7 +120,7 @@ func TestModelService_Delete_NotFound(t *testing.T) { func TestStatsService_Aggregate_Default(t *testing.T) { statsRepo := repository.NewStatsRepository(nil) - buffer := NewStatsBuffer(statsRepo, nil) + buffer := NewStatsBuffer(statsRepo, zap.NewNop()) svc := NewStatsService(statsRepo, buffer) stats := []domain.UsageStats{ diff --git a/backend/internal/service/service_test.go b/backend/internal/service/service_test.go index b6243c8..a84fd8c 100644 --- a/backend/internal/service/service_test.go +++ b/backend/internal/service/service_test.go @@ -318,7 +318,7 @@ func TestStatsService_Aggregate_ByModel(t *testing.T) { t.Run(tt.name, func(t *testing.T) { db := setupServiceTestDB(t) statsRepo := repository.NewStatsRepository(db) - buffer := NewStatsBuffer(statsRepo, nil); svc := NewStatsService(statsRepo, buffer) + buffer := NewStatsBuffer(statsRepo, zap.NewNop()); svc := NewStatsService(statsRepo, buffer) result := svc.Aggregate(tt.stats, "model") @@ -379,7 +379,7 @@ func TestStatsService_Aggregate_ByDate(t *testing.T) { t.Run(tt.name, func(t *testing.T) { db := setupServiceTestDB(t) statsRepo := repository.NewStatsRepository(db) - buffer := NewStatsBuffer(statsRepo, nil); svc := NewStatsService(statsRepo, buffer) + buffer := NewStatsBuffer(statsRepo, zap.NewNop()); svc := NewStatsService(statsRepo, buffer) result := svc.Aggregate(tt.stats, "date") diff --git a/backend/internal/service/stats_buffer.go b/backend/internal/service/stats_buffer.go index 8728e04..38b75e5 100644 --- a/backend/internal/service/stats_buffer.go +++ b/backend/internal/service/stats_buffer.go @@ -9,6 +9,7 @@ import ( "go.uber.org/zap" "nex/backend/internal/repository" + pkglogger "nex/backend/pkg/logger" ) type StatsBuffer struct { @@ -46,7 +47,7 @@ func NewStatsBuffer( ) *StatsBuffer { b := &StatsBuffer{ statsRepo: statsRepo, - logger: logger, + logger: pkglogger.WithModule(logger, "service.stats_buffer"), flushInterval: 5 * time.Second, flushThreshold: 100, stopCh: make(chan struct{}), diff --git a/backend/pkg/logger/context.go b/backend/pkg/logger/context.go index f8a336d..4e8e17e 100644 --- a/backend/pkg/logger/context.go +++ b/backend/pkg/logger/context.go @@ -1,13 +1,20 @@ package logger -import "go.uber.org/zap" +import ( + "context" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type ctxKey struct{} + +const requestIDKey = "request_id" -// WithRequestID 向 logger 添加 request_id 字段 func WithRequestID(logger *zap.Logger, requestID string) *zap.Logger { - return logger.With(zap.String("request_id", requestID)) + return logger.With(zap.String(requestIDKey, requestID)) } -// WithContext 向 logger 添加多个自定义字段 func WithContext(logger *zap.Logger, fields map[string]interface{}) *zap.Logger { zapFields := make([]zap.Field, 0, len(fields)) for k, v := range fields { @@ -15,3 +22,37 @@ func WithContext(logger *zap.Logger, fields map[string]interface{}) *zap.Logger } return logger.With(zapFields...) } + +func RequestIDFromGinContext(c *gin.Context) zap.Field { + requestID, exists := c.Get("request_id") + if !exists { + return zap.Skip() + } + if id, ok := requestID.(string); ok { + return RequestID(id) + } + return zap.Skip() +} + +func RequestIDFromContext(ctx context.Context) zap.Field { + requestID := ctx.Value(ctxKey{}) + if requestID == nil { + return zap.Skip() + } + if id, ok := requestID.(string); ok { + return RequestID(id) + } + return zap.Skip() +} + +func ContextWithRequestID(ctx context.Context, requestID string) context.Context { + return context.WithValue(ctx, ctxKey{}, requestID) +} + +func LoggerFromContext(ctx context.Context, baseLogger *zap.Logger) *zap.Logger { + field := RequestIDFromContext(ctx) + if field == zap.Skip() { + return baseLogger + } + return baseLogger.With(field) +} diff --git a/backend/pkg/logger/field.go b/backend/pkg/logger/field.go new file mode 100644 index 0000000..973e028 --- /dev/null +++ b/backend/pkg/logger/field.go @@ -0,0 +1,77 @@ +package logger + +import "go.uber.org/zap" + +const ( + FieldRequestID = "request_id" + FieldProviderID = "provider_id" + FieldModelName = "model_name" + FieldMethod = "method" + FieldPath = "path" + FieldStatusCode = "status" + FieldLatency = "latency" + FieldClientIP = "client_ip" + FieldQuery = "query" + FieldBodySize = "body_size" + FieldSQL = "sql" + FieldRows = "rows_affected" +) + +func RequestID(id string) zap.Field { + return zap.String(FieldRequestID, id) +} + +func ProviderID(id string) zap.Field { + return zap.String(FieldProviderID, id) +} + +func ModelName(name string) zap.Field { + return zap.String(FieldModelName, name) +} + +func Method(method string) zap.Field { + return zap.String(FieldMethod, method) +} + +func Path(path string) zap.Field { + return zap.String(FieldPath, path) +} + +func StatusCode(code int) zap.Field { + return zap.Int(FieldStatusCode, code) +} + +func Latency(latency interface{}) zap.Field { + switch v := latency.(type) { + case int64: + return zap.Int64(FieldLatency, v) + case int: + return zap.Int(FieldLatency, v) + default: + return zap.Any(FieldLatency, latency) + } +} + +func ClientIP(ip string) zap.Field { + return zap.String(FieldClientIP, ip) +} + +func Query(query string) zap.Field { + return zap.String(FieldQuery, query) +} + +func BodySize(size int) zap.Field { + return zap.Int(FieldBodySize, size) +} + +func SQL(sql string) zap.Field { + return zap.String(FieldSQL, sql) +} + +func Rows(rows int64) zap.Field { + return zap.Int64(FieldRows, rows) +} + +func Err(err error) zap.Field { + return zap.Error(err) +} diff --git a/backend/pkg/logger/field_test.go b/backend/pkg/logger/field_test.go new file mode 100644 index 0000000..db4f798 --- /dev/null +++ b/backend/pkg/logger/field_test.go @@ -0,0 +1,99 @@ +package logger + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFieldConstants(t *testing.T) { + assert.Equal(t, "request_id", FieldRequestID) + assert.Equal(t, "provider_id", FieldProviderID) + assert.Equal(t, "model_name", FieldModelName) + assert.Equal(t, "method", FieldMethod) + assert.Equal(t, "path", FieldPath) + assert.Equal(t, "status", FieldStatusCode) + assert.Equal(t, "latency", FieldLatency) + assert.Equal(t, "client_ip", FieldClientIP) + assert.Equal(t, "query", FieldQuery) + assert.Equal(t, "body_size", FieldBodySize) + assert.Equal(t, "sql", FieldSQL) + assert.Equal(t, "rows_affected", FieldRows) +} + +func TestFieldConstructors(t *testing.T) { + t.Run("RequestID", func(t *testing.T) { + field := RequestID("test-id") + assert.Equal(t, FieldRequestID, field.Key) + assert.Equal(t, "test-id", field.String) + }) + + t.Run("ProviderID", func(t *testing.T) { + field := ProviderID("provider-123") + assert.Equal(t, FieldProviderID, field.Key) + assert.Equal(t, "provider-123", field.String) + }) + + t.Run("ModelName", func(t *testing.T) { + field := ModelName("gpt-4") + assert.Equal(t, FieldModelName, field.Key) + assert.Equal(t, "gpt-4", field.String) + }) + + t.Run("Method", func(t *testing.T) { + field := Method("POST") + assert.Equal(t, FieldMethod, field.Key) + assert.Equal(t, "POST", field.String) + }) + + t.Run("Path", func(t *testing.T) { + field := Path("/v1/chat") + assert.Equal(t, FieldPath, field.Key) + assert.Equal(t, "/v1/chat", field.String) + }) + + t.Run("StatusCode", func(t *testing.T) { + field := StatusCode(200) + assert.Equal(t, FieldStatusCode, field.Key) + }) + + t.Run("Latency", func(t *testing.T) { + field := Latency(int64(100)) + assert.Equal(t, FieldLatency, field.Key) + }) + + t.Run("ClientIP", func(t *testing.T) { + field := ClientIP("127.0.0.1") + assert.Equal(t, FieldClientIP, field.Key) + assert.Equal(t, "127.0.0.1", field.String) + }) + + t.Run("Query", func(t *testing.T) { + field := Query("key=value") + assert.Equal(t, FieldQuery, field.Key) + assert.Equal(t, "key=value", field.String) + }) + + t.Run("BodySize", func(t *testing.T) { + field := BodySize(1024) + assert.Equal(t, FieldBodySize, field.Key) + }) + + t.Run("SQL", func(t *testing.T) { + field := SQL("SELECT * FROM users") + assert.Equal(t, FieldSQL, field.Key) + assert.Equal(t, "SELECT * FROM users", field.String) + }) + + t.Run("Rows", func(t *testing.T) { + field := Rows(42) + assert.Equal(t, FieldRows, field.Key) + assert.Equal(t, int64(42), field.Integer) + }) + + t.Run("Err", func(t *testing.T) { + err := assert.AnError + field := Err(err) + assert.Equal(t, "error", field.Key) + }) +} diff --git a/backend/pkg/logger/gorm.go b/backend/pkg/logger/gorm.go new file mode 100644 index 0000000..f2a9e8c --- /dev/null +++ b/backend/pkg/logger/gorm.go @@ -0,0 +1,130 @@ +package logger + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + "time" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +type GormLogger struct { + logger *zap.Logger + level zapcore.Level +} + +func NewGormLogger(logger *zap.Logger) *GormLogger { + return &GormLogger{ + logger: logger.Named("database"), + level: zapcore.DebugLevel, + } +} + +func (l *GormLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface { + newLogger := &GormLogger{ + logger: l.logger, + level: l.gormLevelToZap(level), + } + return newLogger +} + +func (l *GormLogger) Info(ctx context.Context, msg string, data ...interface{}) { + if l.level > zapcore.DebugLevel { + return + } + l.log(ctx, zapcore.DebugLevel, msg, data...) +} + +func (l *GormLogger) Warn(ctx context.Context, msg string, data ...interface{}) { + if l.level > zapcore.WarnLevel { + return + } + l.log(ctx, zapcore.WarnLevel, msg, data...) +} + +func (l *GormLogger) Error(ctx context.Context, msg string, data ...interface{}) { + if l.level > zapcore.ErrorLevel { + return + } + l.log(ctx, zapcore.ErrorLevel, msg, data...) +} + +func (l *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) { + if l.level > zapcore.DebugLevel { + return + } + + elapsed := time.Since(begin) + sql, rows := fc() + + fields := []zap.Field{ + zap.String("sql", l.formatSQL(sql)), + zap.Int64("rows", rows), + zap.Duration("latency", elapsed), + } + + if requestIDField := RequestIDFromContext(ctx); requestIDField != zap.Skip() { + fields = append([]zap.Field{requestIDField}, fields...) + } + + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + fields = append(fields, zap.Error(err)) + l.logger.Error("SQL执行错误", fields...) + return + } + + l.logger.Debug("SQL查询", fields...) +} + +func (l *GormLogger) log(ctx context.Context, level zapcore.Level, msg string, data ...interface{}) { + fields := make([]zap.Field, 0, len(data)/2+1) + + if requestIDField := RequestIDFromContext(ctx); requestIDField != zap.Skip() { + fields = append(fields, requestIDField) + } + + for i := 0; i < len(data); i += 2 { + if i+1 < len(data) { + key, ok := data[i].(string) + if !ok { + continue + } + fields = append(fields, zap.Any(key, data[i+1])) + } + } + + switch level { + case zapcore.DebugLevel: + l.logger.Debug(fmt.Sprintf(msg, data...), fields...) + case zapcore.WarnLevel: + l.logger.Warn(fmt.Sprintf(msg, data...), fields...) + case zapcore.ErrorLevel: + l.logger.Error(fmt.Sprintf(msg, data...), fields...) + } +} + +func (l *GormLogger) gormLevelToZap(level gormlogger.LogLevel) zapcore.Level { + switch level { + case gormlogger.Silent: + return zapcore.PanicLevel + case gormlogger.Error: + return zapcore.ErrorLevel + case gormlogger.Warn: + return zapcore.WarnLevel + case gormlogger.Info: + return zapcore.DebugLevel + default: + return zapcore.DebugLevel + } +} + +func (l *GormLogger) formatSQL(sql string) string { + re := regexp.MustCompile(`\s+`) + return strings.TrimSpace(re.ReplaceAllString(sql, " ")) +} diff --git a/backend/pkg/logger/gorm_test.go b/backend/pkg/logger/gorm_test.go new file mode 100644 index 0000000..483de46 --- /dev/null +++ b/backend/pkg/logger/gorm_test.go @@ -0,0 +1,131 @@ +package logger + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +func TestNewGormLogger(t *testing.T) { + logger := zap.NewNop() + gormLogger := NewGormLogger(logger) + + assert.NotNil(t, gormLogger) + assert.NotNil(t, gormLogger.logger) + assert.Equal(t, zap.DebugLevel, gormLogger.level) +} + +func TestGormLogger_LogMode(t *testing.T) { + logger := zap.NewNop() + gormLogger := NewGormLogger(logger) + + tests := []struct { + name string + level gormlogger.LogLevel + expectedLevel zapcore.Level + }{ + {"Silent", gormlogger.Silent, zapcore.PanicLevel}, + {"Error", gormlogger.Error, zapcore.ErrorLevel}, + {"Warn", gormlogger.Warn, zapcore.WarnLevel}, + {"Info", gormlogger.Info, zapcore.DebugLevel}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + newLogger := gormLogger.LogMode(tt.level) + assert.NotNil(t, newLogger) + }) + } +} + +func TestGormLogger_Trace(t *testing.T) { + logger := zap.NewNop() + gormLogger := NewGormLogger(logger) + + ctx := context.Background() + begin := time.Now() + + fc := func() (string, int64) { + return "SELECT * FROM users", 10 + } + + gormLogger.Trace(ctx, begin, fc, nil) +} + +func TestGormLogger_TraceWithError(t *testing.T) { + logger := zap.NewNop() + gormLogger := NewGormLogger(logger) + + ctx := context.Background() + begin := time.Now() + + fc := func() (string, int64) { + return "SELECT * FROM users", 0 + } + + gormLogger.Trace(ctx, begin, fc, gorm.ErrRecordNotFound) +} + +func TestGormLogger_Info(t *testing.T) { + logger := zap.NewNop() + gormLogger := NewGormLogger(logger) + + ctx := context.Background() + gormLogger.Info(ctx, "test info message") +} + +func TestGormLogger_Warn(t *testing.T) { + logger := zap.NewNop() + gormLogger := NewGormLogger(logger) + + ctx := context.Background() + gormLogger.Warn(ctx, "test warn message") +} + +func TestGormLogger_Error(t *testing.T) { + logger := zap.NewNop() + gormLogger := NewGormLogger(logger) + + ctx := context.Background() + gormLogger.Error(ctx, "test error message") +} + +func TestGormLogger_FormatSQL(t *testing.T) { + logger := zap.NewNop() + gormLogger := NewGormLogger(logger) + + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple query", + input: "SELECT * FROM users", + expected: "SELECT * FROM users", + }, + { + name: "query with extra spaces", + input: "SELECT * FROM users", + expected: "SELECT * FROM users", + }, + { + name: "query with newlines", + input: "SELECT\n*\nFROM\nusers", + expected: "SELECT * FROM users", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := gormLogger.formatSQL(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/backend/pkg/logger/logger.go b/backend/pkg/logger/logger.go index 186df9b..af62eff 100644 --- a/backend/pkg/logger/logger.go +++ b/backend/pkg/logger/logger.go @@ -50,6 +50,7 @@ func New(cfg Config) (*zap.Logger, error) { EncodeTime: zapcore.ISO8601TimeEncoder, EncodeDuration: zapcore.StringDurationEncoder, EncodeCaller: zapcore.ShortCallerEncoder, + EncodeName: encodeLoggerName, }) stdoutCore := zapcore.NewCore( @@ -115,3 +116,10 @@ func logFileName() string { func logFilePath(dir string) string { return filepath.Join(dir, logFileName()) } + +// encodeLoggerName 自定义 logger 名称编码器,输出 [name] 格式 +func encodeLoggerName(name string, enc zapcore.PrimitiveArrayEncoder) { + if name != "" { + enc.AppendString("[" + name + "]") + } +} diff --git a/backend/pkg/logger/minimal.go b/backend/pkg/logger/minimal.go new file mode 100644 index 0000000..44b5154 --- /dev/null +++ b/backend/pkg/logger/minimal.go @@ -0,0 +1,35 @@ +package logger + +import ( + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +func NewMinimal() *zap.Logger { + encoder := zapcore.NewConsoleEncoder(zapcore.EncoderConfig{ + TimeKey: "ts", + LevelKey: "level", + NameKey: "logger", + CallerKey: "caller", + FunctionKey: zapcore.OmitKey, + MessageKey: "msg", + StacktraceKey: "stacktrace", + LineEnding: zapcore.DefaultLineEnding, + EncodeLevel: zapcore.CapitalColorLevelEncoder, + EncodeTime: zapcore.ISO8601TimeEncoder, + EncodeDuration: zapcore.StringDurationEncoder, + EncodeCaller: zapcore.ShortCallerEncoder, + }) + + core := zapcore.NewCore( + encoder, + zapcore.AddSync(stdoutWriter{}), + zapcore.DebugLevel, + ) + + return zap.New(core, zap.AddCaller(), zap.AddStacktrace(zap.ErrorLevel)) +} + +func Upgrade(minimalLogger *zap.Logger, cfg Config) (*zap.Logger, error) { + return New(cfg) +} diff --git a/backend/pkg/logger/module.go b/backend/pkg/logger/module.go new file mode 100644 index 0000000..3e6c359 --- /dev/null +++ b/backend/pkg/logger/module.go @@ -0,0 +1,7 @@ +package logger + +import "go.uber.org/zap" + +func WithModule(logger *zap.Logger, moduleName string) *zap.Logger { + return logger.Named(moduleName) +} diff --git a/backend/pkg/logger/module_test.go b/backend/pkg/logger/module_test.go new file mode 100644 index 0000000..b733d12 --- /dev/null +++ b/backend/pkg/logger/module_test.go @@ -0,0 +1,25 @@ +package logger + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/zap" +) + +func TestWithModule(t *testing.T) { + baseLogger := zap.NewNop() + moduleLogger := WithModule(baseLogger, "handler.proxy") + + assert.NotNil(t, moduleLogger) +} + +func TestWithModuleMultiple(t *testing.T) { + baseLogger := zap.NewNop() + + logger1 := WithModule(baseLogger, "handler.proxy") + logger2 := WithModule(baseLogger, "provider.client") + + assert.NotNil(t, logger1) + assert.NotNil(t, logger2) +} diff --git a/backend/tests/integration/conversion_test.go b/backend/tests/integration/conversion_test.go index fbaf836..621a899 100644 --- a/backend/tests/integration/conversion_test.go +++ b/backend/tests/integration/conversion_test.go @@ -68,10 +68,10 @@ func setupConversionTest(t *testing.T) (*gin.Engine, *gorm.DB, *httptest.Server) registry := conversion.NewMemoryRegistry() require.NoError(t, registry.Register(openaiConv.NewAdapter())) require.NoError(t, registry.Register(anthropic.NewAdapter())) - engine := conversion.NewConversionEngine(registry, nil) + engine := conversion.NewConversionEngine(registry, logger) - providerClient := provider.NewClient() - proxyHandler := handler.NewProxyHandler(engine, providerClient, routingService, providerService, statsService) + providerClient := provider.NewClient(logger) + proxyHandler := handler.NewProxyHandler(engine, providerClient, routingService, providerService, statsService, logger) providerHandler := handler.NewProviderHandler(providerService) modelHandler := handler.NewModelHandler(modelService) statsHandler := handler.NewStatsHandler(statsService) diff --git a/backend/tests/integration/e2e_conversion_test.go b/backend/tests/integration/e2e_conversion_test.go index ea451cb..585fc20 100644 --- a/backend/tests/integration/e2e_conversion_test.go +++ b/backend/tests/integration/e2e_conversion_test.go @@ -61,10 +61,10 @@ func setupE2ETest(t *testing.T) (*gin.Engine, *httptest.Server) { registry := conversion.NewMemoryRegistry() require.NoError(t, registry.Register(openaiConv.NewAdapter())) require.NoError(t, registry.Register(anthropic.NewAdapter())) - engine := conversion.NewConversionEngine(registry, nil) + engine := conversion.NewConversionEngine(registry, logger) - providerClient := provider.NewClient() - proxyHandler := handler.NewProxyHandler(engine, providerClient, routingService, providerService, statsService) + providerClient := provider.NewClient(logger) + proxyHandler := handler.NewProxyHandler(engine, providerClient, routingService, providerService, statsService, logger) providerHandler := handler.NewProviderHandler(providerService) modelHandler := handler.NewModelHandler(modelService) diff --git a/openspec/specs/gorm-logging/spec.md b/openspec/specs/gorm-logging/spec.md new file mode 100644 index 0000000..1bd7eb3 --- /dev/null +++ b/openspec/specs/gorm-logging/spec.md @@ -0,0 +1,92 @@ +# GORM Logging + +## Purpose + +定义 GORM 日志适配器规范,将 GORM 日志桥接到 zap,实现数据库操作日志统一格式和请求追踪。 + +## Requirements + +### Requirement: GORM 日志适配器 + +系统 SHALL 提供 GORM 日志适配器,桥接到 zap。 + +#### Scenario: 适配器实现 + +- **WHEN** 初始化数据库连接 +- **THEN** SHALL 使用 `logger.NewGormLogger(zapLogger)` 作为 GORM logger +- **THEN** 适配器 SHALL 实现 `gorm.logger.Interface` 接口 + +#### Scenario: 日志级别映射 + +- **WHEN** GORM 记录日志 +- **THEN** GORM Silent SHALL 映射到不输出 +- **THEN** GORM Error SHALL 映射到 `zap.ErrorLevel` +- **THEN** GORM Warn SHALL 映射到 `zap.WarnLevel` +- **THEN** GORM Info SHALL 映射到 `zap.DebugLevel` + +#### Scenario: SQL 查询日志 + +- **WHEN** GORM 执行 SQL 查询 +- **THEN** SHALL 使用 zap Debug 级别记录 +- **THEN** SHALL 包含 SQL 语句 +- **THEN** SHALL 包含执行耗时 +- **THEN** SHALL 包含影响行数(如有) + +### Requirement: Request ID 关联 + +GORM 日志 SHALL 支持 request_id 关联。 + +#### Scenario: 从 Context 提取 request_id + +- **WHEN** GORM 记录日志 +- **THEN** SHALL 从 `context.Context` 提取 request_id +- **THEN** 日志 SHALL 包含 request_id 字段 + +#### Scenario: Context 传播 + +- **WHEN** 使用 GORM 进行数据库操作 +- **THEN** SHALL 使用 `db.WithContext(ctx)` 传递 context +- **THEN** GORM 日志 SHALL 自动包含 request_id + +### Requirement: 模块标识 + +GORM 日志 SHALL 包含模块标识。 + +#### Scenario: 数据库模块标识 + +- **WHEN** GORM 记录日志 +- **THEN** SHALL 包含 `logger: database` 标识 +- **THEN** Console 格式 SHALL 输出为 `[database]` + +### Requirement: 单行输出 + +GORM 日志 SHALL 单行输出。 + +#### Scenario: SQL 单行输出 + +- **WHEN** 记录 SQL 查询日志 +- **THEN** SHALL 单行输出 +- **THEN** SQL 语句中的换行 SHALL 替换为空格(除非保留原始格式) + +#### Scenario: 错误日志单行 + +- **WHEN** 记录数据库错误日志 +- **THEN** SHALL 单行输出 +- **THEN** 错误详情 SHALL 作为字段值 + +### Requirement: 日志字段标准化 + +GORM 日志 SHALL 使用标准化字段。 + +#### Scenario: SQL 日志字段 + +- **WHEN** 记录 SQL 查询 +- **THEN** SHALL 使用 `sql` 字段记录 SQL 语句 +- **THEN** SHALL 使用 `rows_affected` 字段记录影响行数 +- **THEN** SHALL 使用 `latency` 字段记录执行耗时 + +#### Scenario: 错误日志字段 + +- **WHEN** 记录数据库错误 +- **THEN** SHALL 使用 `zap.Error(err)` 记录错误 +- **THEN** SHALL NOT 使用 `zap.String("error", err.Error())` diff --git a/openspec/specs/module-logging/spec.md b/openspec/specs/module-logging/spec.md new file mode 100644 index 0000000..a8eeaf1 --- /dev/null +++ b/openspec/specs/module-logging/spec.md @@ -0,0 +1,140 @@ +# Module Logging + +## Purpose + +定义模块化日志器规范,支持模块标识和 Context 传播,实现日志来源快速定位和请求追踪链完整性。 + +## Requirements + +### Requirement: 模块标识注入 + +系统 SHALL 支持通过构造函数注入带模块标识的 logger。 + +#### Scenario: 构造函数注入模块 logger + +- **WHEN** 创建需要记录日志的组件 +- **THEN** 构造函数 SHALL 接受 `*zap.Logger` 参数 +- **THEN** SHALL 使用 `logger.Named("module.name")` 绑定模块标识 +- **THEN** SHALL 将 logger 存储在结构体字段中 + +#### Scenario: 模块标识格式 + +- **WHEN** 绑定模块标识 +- **THEN** 单一职责包 SHALL 使用包名(如 `database`) +- **THEN** 多实体包 SHALL 使用 `包名.实体名`(如 `handler.proxy`) +- **THEN** 子包 SHALL 使用 `包名.子包名`(如 `handler.middleware`) + +#### Scenario: 模块标识输出 + +- **WHEN** 记录日志 +- **THEN** Console 格式 SHALL 输出为 `[module.name]` +- **THEN** JSON 格式 SHALL 包含 `"logger":"module.name"` 字段 + +### Requirement: 禁止全局 logger + +系统 SHALL 禁止在业务代码中使用全局 logger。 + +#### Scenario: 移除 zap.L() 调用 + +- **WHEN** 重构现有代码 +- **THEN** SHALL 移除所有 `zap.L()` 调用 +- **THEN** SHALL 通过构造函数注入 logger +- **THEN** 允许仅在测试代码中使用 `zap.L()` 或 `zap.NewNop()` + +#### Scenario: 移除 zap.L() fallback + +- **WHEN** 构造函数 logger 参数为 nil +- **THEN** SHALL NOT 使用 `zap.L()` 作为默认值 +- **THEN** 调用方 SHALL 必须传入有效的 logger + +### Requirement: Request ID 传播 + +系统 SHALL 通过 Context 传播 request_id。 + +#### Scenario: Context 注入 request_id + +- **WHEN** 中间件生成 request_id +- **THEN** SHALL 存储到 `context.Context` 中 +- **THEN** SHALL 使用类型安全的 context key + +#### Scenario: 从 Context 提取 request_id + +- **WHEN** 业务层需要记录日志 +- **THEN** SHALL 从 `gin.Context` 提取 request_id +- **THEN** SHALL 使用 `logger.With(zap.String("request_id", id))` 创建带 request_id 的 logger +- **THEN** 日志 SHALL 自动包含 request_id 字段 + +#### Scenario: Request ID 辅助函数 + +- **WHEN** 使用 request_id +- **THEN** `pkg/logger` SHALL 提供 `RequestIDFromContext(ctx)` 辅助函数 +- **THEN** 辅助函数 SHALL 返回 `zap.Field` 类型 + +### Requirement: 单行输出 + +系统 SHALL 保证所有日志单行输出。 + +#### Scenario: Console 单行输出 + +- **WHEN** 记录日志到 stdout +- **THEN** SHALL 单行输出 +- **THEN** 字段之间 SHALL 使用空格分隔 + +#### Scenario: JSON 单行输出 + +- **WHEN** 记录日志到文件 +- **THEN** SHALL 单行紧凑输出 +- **THEN** SHALL NOT 使用美化缩进 + +#### Scenario: 多行数据保留 + +- **WHEN** 日志数据本身包含多行(如堆栈跟踪、SQL 换行) +- **THEN** SHALL 保留原始多行格式 + +### Requirement: 模块命名规范 + +系统 SHALL 遵循模块命名规范。 + +#### Scenario: Handler 层命名 + +- **WHEN** 创建 handler 层 logger +- **THEN** ProxyHandler SHALL 使用 `handler.proxy` +- **THEN** ProviderHandler SHALL 使用 `handler.provider` +- **THEN** ModelHandler SHALL 使用 `handler.model` +- **THEN** StatsHandler SHALL 使用 `handler.stats` +- **THEN** Middleware SHALL 使用 `handler.middleware` + +#### Scenario: Provider 层命名 + +- **WHEN** 创建 provider 层 logger +- **THEN** Client SHALL 使用 `provider.client` + +#### Scenario: Conversion 层命名 + +- **WHEN** 创建 conversion 层 logger +- **THEN** Engine SHALL 使用 `conversion.engine` +- **THEN** OpenAI Adapter SHALL 使用 `conversion.openai` +- **THEN** Anthropic Adapter SHALL 使用 `conversion.anthropic` + +#### Scenario: Service 层命名 + +- **WHEN** 创建 service 层 logger +- **THEN** RoutingCache SHALL 使用 `service.routing_cache` +- **THEN** StatsBuffer SHALL 使用 `service.stats_buffer` +- **THEN** ProviderService SHALL 使用 `service.provider` +- **THEN** ModelService SHALL 使用 `service.model` +- **THEN** RoutingService SHALL 使用 `service.routing` +- **THEN** StatsService SHALL 使用 `service.stats` + +#### Scenario: Repository 层命名 + +- **WHEN** 创建 repository 层 logger +- **THEN** ProviderRepository SHALL 使用 `repository.provider` +- **THEN** ModelRepository SHALL 使用 `repository.model` +- **THEN** StatsRepository SHALL 使用 `repository.stats` + +#### Scenario: Infrastructure 层命名 + +- **WHEN** 创建基础设施层 logger +- **THEN** Database SHALL 使用 `database` +- **THEN** Config SHALL 使用 `config` diff --git a/openspec/specs/structured-logging/spec.md b/openspec/specs/structured-logging/spec.md index 2ee4287..1a0546a 100644 --- a/openspec/specs/structured-logging/spec.md +++ b/openspec/specs/structured-logging/spec.md @@ -15,7 +15,7 @@ - **WHEN** 应用启动 - **THEN** SHALL 初始化 zap logger - **THEN** SHALL 根据配置设置日志级别 -- **THEN** SHALL 配置日志输出格式为 JSON +- **THEN** SHALL 配置日志输出格式为 JSON(文件)和 Console(stdout) #### Scenario: 日志字段 @@ -27,9 +27,9 @@ #### Scenario: 日志注入 - **WHEN** 创建需要记录日志的组件 -- **THEN** SHALL 通过构造函数注入 *zap.Logger -- **THEN** SHALL 允许 logger 参数为 nil,此时使用全局 logger zap.L() -- **THEN** SHALL NOT 直接使用全局 logger zap.L()(除非在构造函数默认值中) +- **THEN** SHALL 通过构造函数注入 `*zap.Logger` +- **THEN** 调用方 SHALL 必须传入有效的 logger +- **THEN** SHALL NOT 使用全局 logger `zap.L()`(测试代码除外) ### Requirement: 支持日志滚动 @@ -76,7 +76,8 @@ #### Scenario: 日志关联请求 ID - **WHEN** 记录请求相关的日志 -- **THEN** SHALL 自动包含请求 ID 字段 +- **THEN** SHALL 从 Context 提取 request_id +- **THEN** SHALL 自动包含 request_id 字段 - **THEN** SHALL 支持通过请求 ID 检索日志 ### Requirement: 记录请求日志 @@ -116,7 +117,7 @@ - **WHEN** 配置为生产模式 - **THEN** SHALL 使用 info 级别 -- **THEN** SHALL 仅输出到文件 +- **THEN** SHALL 输出到控制台和文件 ### Requirement: 日志存储位置 @@ -141,12 +142,77 @@ ConversionEngine SHALL 通过依赖注入获取 logger。 #### Scenario: ConversionEngine 构造函数 - **WHEN** 创建 ConversionEngine 实例 -- **THEN** 构造函数 SHALL 接受 *zap.Logger 参数 -- **THEN** 参数为 nil 时 SHALL 使用 zap.L() 作为默认值 +- **THEN** 构造函数 SHALL 接受 `*zap.Logger` 参数 +- **THEN** 调用方 SHALL 必须传入有效的 logger - **THEN** SHALL 将 logger 存储在结构体字段中 #### Scenario: ConversionEngine 日志使用 - **WHEN** ConversionEngine 记录日志 - **THEN** SHALL 使用注入的 logger 字段 -- **THEN** SHALL NOT 直接调用 zap.L() +- **THEN** SHALL NOT 直接调用 `zap.L()` + +### Requirement: 字段标准化 + +系统 SHALL 使用标准化字段定义。 + +#### Scenario: 标准字段常量 + +- **WHEN** 记录日志字段 +- **THEN** SHALL 使用 `pkg/logger/field.go` 中定义的常量 +- **THEN** 字段名 SHALL 包括:`request_id`、`provider_id`、`model_name`、`method`、`path`、`status`、`latency` + +#### Scenario: 错误字段统一 + +- **WHEN** 记录错误日志 +- **THEN** SHALL 使用 `zap.Error(err)` +- **THEN** SHALL NOT 使用 `zap.String("error", err.Error())` + +#### Scenario: 字段构造函数 + +- **WHEN** 构造日志字段 +- **THEN** SHALL 优先使用 `pkg/logger` 提供的辅助函数 +- **THEN** 辅助函数 SHALL 返回 `zap.Field` 类型 + +### Requirement: 启动日志统一 + +系统 SHALL 在启动阶段使用结构化日志。 + +#### Scenario: 最小化 logger 初始化 + +- **WHEN** 应用启动时配置加载前 +- **THEN** SHALL 初始化最小化 logger(仅 stdout,console 格式) +- **THEN** SHALL 支持记录启动错误 + +#### Scenario: Logger 升级 + +- **WHEN** 配置加载完成 +- **THEN** SHALL 升级为完整 logger(文件 + stdout) +- **THEN** SHALL 应用配置的日志级别和轮转策略 + +#### Scenario: 配置摘要结构化 + +- **WHEN** 打印配置摘要 +- **THEN** SHALL 使用结构化日志记录 +- **THEN** SHALL NOT 使用 `fmt.Printf` 或 `fmt.Println` + +### Requirement: 单行输出 + +系统 SHALL 保证所有日志单行输出。 + +#### Scenario: Console 单行 + +- **WHEN** 输出到 stdout +- **THEN** SHALL 单行输出 +- **THEN** 字段之间 SHALL 使用空格分隔 + +#### Scenario: JSON 单行 + +- **WHEN** 输出到文件 +- **THEN** SHALL 单行紧凑 JSON +- **THEN** SHALL NOT 使用美化缩进 + +#### Scenario: 多行数据保留 + +- **WHEN** 日志数据本身包含多行(堆栈跟踪、SQL 换行等) +- **THEN** SHALL 保留原始多行格式