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

View File

@@ -0,0 +1,376 @@
# Research: Fiber 错误处理集成
**Feature**: 003-error-handling
**Date**: 2025-11-14
**Status**: Complete
## 研究目标
解决实施 Fiber 错误处理集成时的技术不确定性和最佳实践。
## 研究任务
### 1. Fiber 框架错误处理机制
**研究问题**: Fiber 如何实现全局错误处理?如何与中间件链配合?
**决策**: 使用 Fiber 的 `ErrorHandler` 配置项实现全局错误处理
**技术方案**:
```go
app := fiber.New(fiber.Config{
ErrorHandler: customErrorHandler,
// ... 其他配置
})
func customErrorHandler(c *fiber.Ctx, err error) error {
// 1. 检查是否已发送响应
if c.Response().StatusCode() != fiber.StatusOK {
// 已发送响应,仅记录日志
logger.Error("响应已发送后发生错误", zap.Error(err))
return nil
}
// 2. 处理不同类型的错误
// 3. 返回统一格式的错误响应
}
```
**理由**:
- Fiber 的 `ErrorHandler` 是捕获所有返回错误的最后一道防线
- 与中间件链自然集成,所有 `c.Next()` 返回的错误都会被捕获
- 可以统一处理来自不同层Handler、Middleware的错误
**参考资料**:
- [Fiber Error Handling](https://docs.gofiber.io/guide/error-handling)
- 现有代码: `cmd/api/main.go` 中的 Fiber 应用配置
---
### 2. ErrorHandler 自身保护机制
**研究问题**: 如何防止 ErrorHandler 本身发生错误或 panic 导致无限循环或服务崩溃?
**决策**: 使用 defer + recover 保护 ErrorHandler失败时返回最简响应
**技术方案**:
```go
func SafeErrorHandler(logger *zap.Logger) fiber.ErrorHandler {
return func(c *fiber.Ctx, err error) error {
defer func() {
if r := recover(); r != nil {
// ErrorHandler 自身 panic返回空响应避免崩溃
logger.Error("ErrorHandler panic",
zap.Any("panic", r),
zap.String("stack", string(debug.Stack())),
)
_ = c.Status(500).SendString("") // 空响应体
}
}()
// 正常的错误处理逻辑
return handleError(c, err, logger)
}
}
```
**理由**:
- 符合 spec.md FR-009 要求ErrorHandler 必须使用 defer + recover 保护
- 当 ErrorHandler 失败时返回 HTTP 500 空响应体,避免泄露错误信息
- 确保即使 ErrorHandler 崩溃也不会影响服务可用性
- 将 panic 详情记录到日志以供排查
**边界情况**:
- 日志系统不可用:静默失败,丢弃日志(符合 spec.md clarification
- JSON 序列化失败:已被 defer/recover 捕获,返回空响应
---
### 3. 敏感信息识别和隐藏
**研究问题**: 如何自动识别并隐藏错误消息中的敏感信息(数据库错误、文件路径、密钥等)?
**决策**: 为所有内部错误返回通用错误消息,原始错误仅记录到日志
**技术方案**:
```go
func sanitizeErrorMessage(err error, code int) string {
// 所有 5xx 错误返回通用消息
if code >= 500 {
return "内部服务器错误"
}
// 4xx 错误可以返回具体的业务错误消息
// 但必须使用预定义的错误码和消息,不直接暴露原始错误
if appErr, ok := err.(*errors.AppError); ok {
return appErr.Message
}
// 其他错误返回通用消息
return "请求处理失败"
}
// 错误处理流程
func handleError(c *fiber.Ctx, err error, logger *zap.Logger) error {
// 1. 完整错误记录到日志(包含敏感信息)
logger.Error("请求处理错误",
zap.Error(err),
zap.String("path", c.Path()),
// ... 更多上下文
)
// 2. 返回脱敏的错误消息给客户端
sanitized := sanitizeErrorMessage(err, code)
return c.Status(httpStatus).JSON(Response{
Code: code,
Message: sanitized, // 不包含原始错误详情
// ...
})
}
```
**理由**:
- 符合 spec.md FR-007: 隐藏内部实现细节和敏感信息
- 符合 clarification: 为所有错误返回通用消息,原始详情仅记录到日志
- 避免泄露数据库结构、文件路径、堆栈跟踪等敏感信息
- 日志系统已配置访问控制(运维团队可访问),符合安全要求
**不采用的方案**:
- ❌ 正则表达式过滤敏感信息:复杂、易遗漏、性能开销
- ❌ 白名单机制:维护成本高,容易过时
- ✅ 统一返回通用消息:简单、安全、可靠
---
### 4. 响应已发送后的错误处理
**研究问题**: 当响应已经部分发送给客户端后发生错误,如何处理?
**决策**: 检测响应状态,已发送则仅记录日志不修改响应
**技术方案**:
```go
func handleError(c *fiber.Ctx, err error, logger *zap.Logger) error {
// 检查响应是否已发送
if c.Response().StatusCode() != fiber.StatusOK ||
len(c.Response().Body()) > 0 {
// 响应已发送,仅记录日志
logger.Error("响应已发送后发生错误",
zap.Error(err),
zap.Int("status", c.Response().StatusCode()),
zap.Int("body_size", len(c.Response().Body())),
)
return nil // 不再修改响应
}
// 响应未发送,正常处理错误
return buildErrorResponse(c, err)
}
```
**理由**:
- 符合 spec.md FR-001 和 edge case: 响应已部分发送时仅记录日志
- 修改已发送的响应会导致响应格式损坏(如 JSON 不完整)
- Fiber 的 `c.Response().StatusCode()``c.Response().Body()` 可以检测发送状态
- 静默失败策略确保不会因错误处理导致更严重的问题
**替代方案(不采用)**:
- ❌ 尝试清空响应重新发送Fiber 不支持,会导致客户端接收损坏数据
- ❌ 抛出 panic违反设计原则应该优雅降级
---
### 5. 日志系统集成
**研究问题**: 如何确保错误处理不因日志系统失败而阻塞请求?
**决策**: 日志记录采用静默失败策略,日志失败不影响响应
**技术方案**:
```go
func logError(logger *zap.Logger, fields ...zap.Field) {
defer func() {
if r := recover(); r != nil {
// 日志系统 panic静默丢弃
// 不记录到任何地方,避免无限循环
}
}()
// 尝试记录日志
logger.Error("错误", fields...)
}
func handleError(c *fiber.Ctx, err error, logger *zap.Logger) error {
// 1. 尝试记录日志(可能失败)
logError(logger,
zap.Error(err),
zap.String("path", c.Path()),
)
// 2. 无论日志是否成功,都继续返回响应
return buildErrorResponse(c, err)
}
```
**理由**:
- 符合 spec.md FR-005 clarification: 日志失败时静默处理
- 符合 edge case: 日志系统不可用时丢弃日志,确保请求不受影响
- Zap logger 本身已经有 panic 保护,但显式的 defer/recover 提供额外保障
- 请求处理优先级高于日志记录
**现有代码分析**:
- `pkg/logger/logger.go` 已使用 Zap支持异步日志
- `internal/middleware/recover.go` 已正确处理日志记录
- 需要确保 ErrorHandler 中的日志调用也采用相同策略
---
### 6. 错误分类和 HTTP 状态码映射
**研究问题**: 如何将不同类型的错误映射到合适的 HTTP 状态码和日志级别?
**决策**: 基于错误码范围分类,统一映射规则
**技术方案**:
```go
// pkg/errors/codes.go
const (
// 成功
CodeSuccess = 0
// 客户端错误 (1000-1999) -> 4xx
CodeInvalidParam = 1001 // 400
CodeUnauthorized = 1002 // 401
CodeForbidden = 1003 // 403
CodeNotFound = 1004 // 404
// 服务端错误 (2000-2999) -> 5xx
CodeInternalError = 2001 // 500
CodeDatabaseError = 2002 // 500
CodeServiceUnavailable = 2003 // 503
)
func GetHTTPStatus(code int) int {
switch {
case code >= 1000 && code < 2000:
return mapClientError(code)
case code >= 2000 && code < 3000:
return mapServerError(code)
default:
return 500
}
}
func GetLogLevel(code int) string {
if code >= 2000 {
return "error" // 服务端错误
} else if code >= 1000 {
return "warn" // 客户端错误
}
return "info"
}
```
**理由**:
- 符合 spec.md FR-006: 区分客户端和服务端错误
- 错误码范围映射清晰,易于扩展
- 日志级别与错误严重性匹配(客户端错误 = Warn服务端错误 = Error
- 便于监控和告警(可以基于错误码范围设置不同的告警策略)
**现有代码扩展**:
- `pkg/errors/codes.go` 需要定义完整的错误码枚举
- `pkg/errors/errors.go` 已有 AppError 类型,无需修改
---
### 7. Request ID 传递
**研究问题**: 如何在错误响应中关联 Request ID
**决策**: Request ID 仅在响应 Header 中传递X-Request-ID不在响应体中
**技术方案**:
```go
func handleError(c *fiber.Ctx, err error, logger *zap.Logger) error {
// 1. 获取 Request ID
requestID := c.Get("X-Request-ID", "")
if requestID == "" {
if rid := c.Locals(constants.ContextKeyRequestID); rid != nil {
requestID = rid.(string)
}
}
// 2. 设置响应 Header
c.Set("X-Request-ID", requestID)
// 3. 日志中包含 Request ID
logger.Error("请求处理错误",
zap.String("request_id", requestID),
zap.Error(err),
)
// 4. 响应体不包含 request_id 字段
return c.Status(httpStatus).JSON(Response{
Code: code,
Message: message,
// 不包含 request_id
})
}
```
**理由**:
- 符合 spec.md FR-008 clarification: 不在响应体中包含 request_id
- 通过响应 Header X-Request-ID 传递,客户端可以获取用于追踪
- 日志中包含 request_id可以关联同一请求的所有日志条目
- 符合 HTTP 标准实践Request ID 通常在 Header 中)
**现有代码集成**:
- `cmd/api/main.go` 已使用 `requestid.New()` 中间件生成 Request ID
- `internal/middleware/recover.go` 已从 `c.Locals()` 获取 Request ID
- ErrorHandler 需要采用相同的获取方式
---
## 研究总结
### 技术栈确认
- **框架**: Fiber v2 (已使用)
- **日志**: Zap (已使用)
- **错误包**: 标准库 errors + 自定义 pkg/errors (已有基础)
- **JSON**: sonic (已配置)
### 核心设计决策
1. **全局错误处理**: 使用 Fiber ErrorHandler + defer/recover 双层保护
2. **敏感信息隐藏**: 统一返回通用错误消息,原始错误仅记录日志
3. **日志策略**: 异步日志 + 静默失败,不阻塞请求
4. **错误分类**: 基于错误码范围映射 HTTP 状态码和日志级别
5. **Request ID**: 通过 Header 传递,不在响应体中
### 需要实现的组件
| 组件 | 路径 | 描述 |
|------|------|------|
| 错误码定义 | pkg/errors/codes.go | 完整的错误码枚举和消息映射 |
| 全局 ErrorHandler | pkg/errors/handler.go | Fiber ErrorHandler 实现 |
| Recover 中间件增强 | internal/middleware/recover.go | 已有,可能需要小幅调整 |
| 错误辅助函数 | pkg/errors/helpers.go | HTTP 状态码映射、日志级别映射 |
### 性能考虑
- 错误处理延迟目标: < 1ms
- 使用预分配的错误对象避免频繁内存分配
- 日志记录异步执行Zap 已支持)
- 避免复杂的字符串处理(如正则匹配)
### 安全考虑
- 所有 5xx 错误返回通用消息
- 日志访问受限(仅运维团队)
- 堆栈跟踪仅记录到日志,不返回给客户端
- 敏感字段(密码、密钥)不记录到日志
---
**研究完成**: ✅ 所有技术不确定性已解决,可以进入设计阶段