feat: 实现统一错误处理系统 (003-error-handling)

- 新增统一错误码定义和管理 (pkg/errors/codes.go)
- 新增全局错误处理器和中间件 (pkg/errors/handler.go, internal/middleware/error_handler.go)
- 新增错误上下文管理 (pkg/errors/context.go)
- 增强 Panic 恢复中间件 (internal/middleware/recover.go)
- 新增完整的单元测试和集成测试
- 新增功能文档 (docs/003-error-handling/)
- 新增功能规范 (specs/003-error-handling/)
- 更新 CLAUDE.md 和 README.md
This commit is contained in:
2025-11-15 12:17:44 +08:00
parent a371f1cd21
commit fb83c9a706
33 changed files with 7373 additions and 52 deletions

258
pkg/errors/context_test.go Normal file
View File

@@ -0,0 +1,258 @@
package errors
import (
"testing"
"github.com/gofiber/fiber/v2"
"github.com/valyala/fasthttp"
)
// TestFromFiberContext 测试从 Fiber Context 提取错误上下文
func TestFromFiberContext(t *testing.T) {
app := fiber.New()
tests := []struct {
name string
setupRequest func(*fasthttp.RequestCtx)
expectedMethod string
expectedPath string
hasRequestID bool
}{
{
name: "GET 请求",
setupRequest: func(ctx *fasthttp.RequestCtx) {
ctx.Request.Header.SetMethod("GET")
ctx.Request.SetRequestURI("/api/v1/users")
ctx.Request.Header.Set("X-Request-ID", "test-request-id-123")
},
expectedMethod: "GET",
expectedPath: "/api/v1/users",
hasRequestID: true,
},
{
name: "POST 请求带查询参数",
setupRequest: func(ctx *fasthttp.RequestCtx) {
ctx.Request.Header.SetMethod("POST")
ctx.Request.SetRequestURI("/api/v1/orders?status=pending")
ctx.Request.Header.Set("X-Request-ID", "post-request-456")
},
expectedMethod: "POST",
expectedPath: "/api/v1/orders",
hasRequestID: true,
},
{
name: "无 Request ID",
setupRequest: func(ctx *fasthttp.RequestCtx) {
ctx.Request.Header.SetMethod("DELETE")
ctx.Request.SetRequestURI("/api/v1/tasks/123")
},
expectedMethod: "DELETE",
expectedPath: "/api/v1/tasks/123",
hasRequestID: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 创建 fasthttp 请求上下文
fctx := &fasthttp.RequestCtx{}
tt.setupRequest(fctx)
// 创建 Fiber 上下文
c := app.AcquireCtx(fctx)
defer app.ReleaseCtx(c)
// 提取错误上下文
errCtx := FromFiberContext(c)
// 验证方法
if errCtx.Method != tt.expectedMethod {
t.Errorf("Method = %q, expected %q", errCtx.Method, tt.expectedMethod)
}
// 验证路径
if errCtx.Path != tt.expectedPath {
t.Errorf("Path = %q, expected %q", errCtx.Path, tt.expectedPath)
}
// 验证 Request ID
if tt.hasRequestID && errCtx.RequestID == "" {
t.Error("Expected Request ID, but got empty string")
}
if !tt.hasRequestID && errCtx.RequestID != "" {
t.Errorf("Expected no Request ID, but got %q", errCtx.RequestID)
}
// 验证 IP 地址不为空
if errCtx.IP == "" {
t.Error("Expected IP address, but got empty string")
}
})
}
}
// TestErrorContextToLogFields 测试错误上下文转换为日志字段
func TestErrorContextToLogFields(t *testing.T) {
tests := []struct {
name string
ctx *ErrorContext
expectedFields int // 期望的字段数量
hasQuery bool
hasUserAgent bool
hasUserID bool
}{
{
name: "完整的错误上下文",
ctx: &ErrorContext{
RequestID: "test-123",
Method: "POST",
Path: "/api/v1/users",
IP: "192.168.1.100",
Query: "status=active",
UserAgent: "Mozilla/5.0",
UserID: "user-456",
},
expectedFields: 7, // request_id, method, path, ip, query, user_agent, user_id
hasQuery: true,
hasUserAgent: true,
hasUserID: true,
},
{
name: "无查询参数",
ctx: &ErrorContext{
RequestID: "test-456",
Method: "GET",
Path: "/api/v1/orders",
IP: "10.0.0.1",
Query: "",
},
expectedFields: 4, // request_id, method, path, ip
hasQuery: false,
hasUserAgent: false,
hasUserID: false,
},
{
name: "空 Request ID",
ctx: &ErrorContext{
RequestID: "",
Method: "DELETE",
Path: "/api/v1/tasks/123",
IP: "127.0.0.1",
Query: "",
},
expectedFields: 4, // request_id (空字符串), method, path, ip
hasQuery: false,
hasUserAgent: false,
hasUserID: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fields := tt.ctx.ToLogFields()
// 验证字段数量
if len(fields) != tt.expectedFields {
t.Errorf("Field count = %d, expected %d", len(fields), tt.expectedFields)
}
// 验证必需字段存在
if len(fields) < 4 {
t.Error("Expected at least 4 required fields (request_id, method, path, ip)")
}
})
}
}
// TestFromFiberContextWithUserAgent 测试带 User-Agent 的错误上下文提取
func TestFromFiberContextWithUserAgent(t *testing.T) {
app := fiber.New()
tests := []struct {
name string
method string
path string
userAgent string
expectedUserAgent bool
}{
{
name: "有 User-Agent",
method: "GET",
path: "/api/v1/users",
userAgent: "Mozilla/5.0",
expectedUserAgent: true,
},
{
name: "无 User-Agent",
method: "GET",
path: "/api/v1/users/123",
userAgent: "",
expectedUserAgent: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 创建 fasthttp 请求上下文
fctx := &fasthttp.RequestCtx{}
fctx.Request.Header.SetMethod(tt.method)
fctx.Request.SetRequestURI(tt.path)
if tt.userAgent != "" {
fctx.Request.Header.Set("User-Agent", tt.userAgent)
}
// 创建 Fiber 上下文
c := app.AcquireCtx(fctx)
defer app.ReleaseCtx(c)
// 提取错误上下文
errCtx := FromFiberContext(c)
// 验证 User-Agent
if tt.expectedUserAgent && errCtx.UserAgent == "" {
t.Error("Expected User-Agent, but got empty")
}
if !tt.expectedUserAgent && errCtx.UserAgent != "" {
t.Errorf("Expected no User-Agent, but got %q", errCtx.UserAgent)
}
})
}
}
// BenchmarkFromFiberContext 基准测试错误上下文提取性能
func BenchmarkFromFiberContext(b *testing.B) {
app := fiber.New()
// 创建测试请求
fctx := &fasthttp.RequestCtx{}
fctx.Request.Header.SetMethod("POST")
fctx.Request.SetRequestURI("/api/v1/users?status=active&limit=10")
fctx.Request.Header.Set("X-Request-ID", "benchmark-request-id")
fctx.Request.SetBodyString(`{"username":"test","email":"test@example.com"}`)
c := app.AcquireCtx(fctx)
defer app.ReleaseCtx(c)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = FromFiberContext(c)
}
}
// BenchmarkErrorContextToLogFields 基准测试日志字段转换性能
func BenchmarkErrorContextToLogFields(b *testing.B) {
ctx := &ErrorContext{
RequestID: "benchmark-123",
Method: "POST",
Path: "/api/v1/users",
IP: "192.168.1.100",
Query: "status=active&limit=10",
UserAgent: "Mozilla/5.0",
UserID: "user-456",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = ctx.ToLogFields()
}
}