1
0

refactor: 后端日志系统重构

- 新增模块化日志器(pkg/logger/module.go)
- 新增 GORM 日志适配器
- 统一日志入口,移除所有 zap.L() 全局 logger 调用
- 字段标准化
- 启动阶段使用结构化日志
- 更新所有相关测试
This commit is contained in:
2026-04-23 18:37:51 +08:00
parent 8c075194e5
commit 280099b89c
33 changed files with 1105 additions and 161 deletions

View File

@@ -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

View File

@@ -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 日志自动桥接到 zapSQL 查询映射到 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

View File

@@ -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)

View File

@@ -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()

View File

@@ -11,6 +11,7 @@ import (
"github.com/mitchellh/mapstructure"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
appErrors "nex/backend/pkg/errors"
@@ -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()
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
@@ -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())
})
})
}

View File

@@ -7,6 +7,8 @@ import (
"github.com/google/uuid"
"go.uber.org/zap"
pkglogger "nex/backend/pkg/logger"
)
// HTTPRequestSpec HTTP 请求规格
@@ -33,13 +35,10 @@ type ConversionEngine struct {
// NewConversionEngine 创建转换引擎
func NewConversionEngine(registry AdapterRegistry, logger *zap.Logger) *ConversionEngine {
if logger == nil {
logger = zap.L()
}
return &ConversionEngine{
registry: registry,
middlewareChain: NewMiddlewareChain(),
logger: logger,
logger: pkglogger.WithModule(logger, "conversion.engine"),
}
}
@@ -90,7 +89,7 @@ func (e *ConversionEngine) ConvertHttpRequest(spec HTTPRequestSpec, clientProtoc
rewrittenBody, err = providerAdapter.RewriteRequestModelName(spec.Body, provider.ModelName, interfaceType)
if err != nil {
e.logger.Warn("Smart Passthrough 改写请求失败,使用原始请求体",
zap.String("error", err.Error()),
zap.Error(err),
zap.String("interface", string(interfaceType)))
rewrittenBody = spec.Body
}
@@ -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

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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