refactor: 后端日志系统重构
- 新增模块化日志器(pkg/logger/module.go) - 新增 GORM 日志适配器 - 统一日志入口,移除所有 zap.L() 全局 logger 调用 - 字段标准化 - 启动阶段使用结构化日志 - 更新所有相关测试
This commit is contained in:
16
README.md
16
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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{}),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
77
backend/pkg/logger/field.go
Normal file
77
backend/pkg/logger/field.go
Normal 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)
|
||||
}
|
||||
99
backend/pkg/logger/field_test.go
Normal file
99
backend/pkg/logger/field_test.go
Normal 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
130
backend/pkg/logger/gorm.go
Normal 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, " "))
|
||||
}
|
||||
131
backend/pkg/logger/gorm_test.go
Normal file
131
backend/pkg/logger/gorm_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 + "]")
|
||||
}
|
||||
}
|
||||
|
||||
35
backend/pkg/logger/minimal.go
Normal file
35
backend/pkg/logger/minimal.go
Normal 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)
|
||||
}
|
||||
7
backend/pkg/logger/module.go
Normal file
7
backend/pkg/logger/module.go
Normal 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)
|
||||
}
|
||||
25
backend/pkg/logger/module_test.go
Normal file
25
backend/pkg/logger/module_test.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
92
openspec/specs/gorm-logging/spec.md
Normal file
92
openspec/specs/gorm-logging/spec.md
Normal 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())`
|
||||
140
openspec/specs/module-logging/spec.md
Normal file
140
openspec/specs/module-logging/spec.md
Normal 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`
|
||||
@@ -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 保留原始多行格式
|
||||
|
||||
Reference in New Issue
Block a user