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,541 @@
# Quick Start: Fiber 错误处理集成
**Feature**: 003-error-handling
**Date**: 2025-11-14
**Audience**: 新开发者、集成工程师
## 概述
本文档提供 Fiber 错误处理集成的快速上手指南,帮助开发者快速理解和使用统一的错误处理机制。
## 5 分钟快速开始
### 1. 错误响应格式
**所有 API 错误响应都使用统一格式**:
```json
{
"code": 1001,
"data": null,
"msg": "参数验证失败",
"timestamp": "2025-11-14T16:00:00+08:00"
}
```
- `code`: 错误码 (1000-1999: 客户端错误, 2000-2999: 服务端错误)
- `data`: 错误时始终为 `null`
- `msg`: 用户友好的错误消息 (中文)
- `timestamp`: ISO 8601 格式时间戳
**Request ID** 在响应 Header 中:
```
X-Request-ID: f1d8b767-dfb3-4588-9fa0-8a97e5337184
```
---
### 2. 在 Handler 中返回错误
**方式 1: 使用预定义错误码**
```go
import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
func (h *Handler) CreateUser(c *fiber.Ctx) error {
var req CreateUserRequest
if err := c.BodyParser(&req); err != nil {
// 返回参数验证失败错误
return errors.New(errors.CodeInvalidParam, "参数格式错误")
}
// 业务逻辑...
user, err := h.service.Create(req)
if err != nil {
// 包装错误,添加错误码
return errors.Wrap(errors.CodeDatabaseError, "创建用户失败", err)
}
return response.Success(c, user)
}
```
**方式 2: 直接返回 error (由 ErrorHandler 自动处理)**
```go
func (h *Handler) GetUser(c *fiber.Ctx) error {
id := c.Params("id")
user, err := h.service.GetByID(c.Context(), id)
if err != nil {
// 直接返回错误,ErrorHandler 会自动映射为 500 错误
return err
}
return response.Success(c, user)
}
```
**方式 3: 使用预定义错误常量**
```go
import "github.com/break/junhong_cmp_fiber/pkg/errors"
func (m *Middleware) CheckAuth(c *fiber.Ctx) error {
token := c.Get("Authorization")
if token == "" {
// 使用预定义错误
return errors.ErrMissingToken
}
return c.Next()
}
```
---
### 3. 常用错误码
| 错误码 | HTTP 状态 | 消息 | 使用场景 |
|--------|----------|------|---------|
| 1001 | 400 | 参数验证失败 | 请求参数不符合规则 |
| 1002 | 401 | 缺失认证令牌 | 未提供 Authorization header |
| 1003 | 401 | 无效或过期的令牌 | 令牌验证失败 |
| 1006 | 404 | 资源未找到 | 数据库中找不到资源 |
| 1008 | 429 | 请求过多 | 触发限流 |
| 2001 | 500 | 内部服务器错误 | 未预期的服务器错误 |
| 2002 | 500 | 数据库错误 | 数据库操作失败 |
| 2003 | 500 | 缓存服务错误 | Redis 操作失败 |
**完整列表**: 见 `pkg/errors/codes.go`
---
### 4. 客户端错误处理示例
**JavaScript/TypeScript**:
```typescript
async function getUser(userId: string): Promise<User> {
const response = await fetch(`/api/v1/users/${userId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
const data = await response.json();
if (data.code !== 0) {
// 错误处理
const requestId = response.headers.get('X-Request-ID');
switch (data.code) {
case 1002:
case 1003:
// 认证失败,跳转登录
redirectToLogin();
break;
case 1006:
// 资源未找到
showNotFoundMessage();
break;
case 2001:
case 2002:
// 服务器错误,提示用户联系技术支持
showErrorMessage(`服务器错误,请联系技术支持\nRequest ID: ${requestId}`);
break;
default:
showErrorMessage(data.msg);
}
throw new Error(data.msg);
}
return data.data;
}
```
**Python**:
```python
import requests
def get_user(user_id: str, token: str) -> dict:
response = requests.get(
f'/api/v1/users/{user_id}',
headers={'Authorization': f'Bearer {token}'}
)
data = response.json()
request_id = response.headers.get('X-Request-ID')
if data['code'] != 0:
# 错误处理
if data['code'] in [1002, 1003]:
raise AuthenticationError(data['msg'])
elif data['code'] == 1006:
raise NotFoundError(data['msg'])
elif data['code'] >= 2000:
raise ServerError(f"{data['msg']} (Request ID: {request_id})")
else:
raise APIError(data['msg'])
return data['data']
```
---
## 进阶使用
### 自定义错误消息
```go
// 使用预定义错误码 + 自定义消息
func (h *Handler) UpdateUser(c *fiber.Ctx) error {
id := c.Params("id")
user, err := h.service.GetByID(c.Context(), id)
if err != nil {
// 自定义错误消息
return errors.New(errors.CodeNotFound, fmt.Sprintf("用户 %s 不存在", id))
}
// 更新逻辑...
}
```
### 错误链传递
```go
// Service 层
func (s *Service) CreateOrder(req *CreateOrderRequest) (*Order, error) {
user, err := s.userService.GetByID(req.UserID)
if err != nil {
// 包装错误,保留错误链
return nil, fmt.Errorf("获取用户信息失败: %w", err)
}
// 订单创建逻辑...
}
// Handler 层
func (h *Handler) CreateOrder(c *fiber.Ctx) error {
order, err := h.service.CreateOrder(req)
if err != nil {
// 包装为 AppError,原始错误链会记录到日志
return errors.Wrap(errors.CodeInternalError, "创建订单失败", err)
}
return response.Success(c, order)
}
```
### Panic 自动恢复
**Panic 会被自动捕获并转换为 500 错误**:
```go
func (h *Handler) DangerousOperation(c *fiber.Ctx) error {
// 如果这里发生 panic
result := riskyFunction()
// Recover 中间件会捕获 panic,返回统一错误响应
// 客户端收到: {"code": 2001, "msg": "内部服务器错误"}
// 完整堆栈会记录到日志
return response.Success(c, result)
}
```
**注意**: 不要滥用 panic,业务错误应该使用 error 返回。
---
## 调试技巧
### 1. 使用 Request ID 追踪错误
**客户端获取 Request ID**:
```bash
curl -i http://localhost:8080/api/v1/users/123
```
响应:
```
HTTP/1.1 500 Internal Server Error
X-Request-ID: f1d8b767-dfb3-4588-9fa0-8a97e5337184
Content-Type: application/json
{"code": 2002, "data": null, "msg": "数据库错误", "timestamp": "..."}
```
**在日志中搜索 Request ID**:
```bash
grep "f1d8b767-dfb3-4588-9fa0-8a97e5337184" logs/app.log
```
日志会包含完整的错误详情:
```json
{
"level": "error",
"timestamp": "2025-11-14T16:00:00+08:00",
"request_id": "f1d8b767-dfb3-4588-9fa0-8a97e5337184",
"method": "GET",
"path": "/api/v1/users/123",
"error": "pq: relation 'users' does not exist",
"stack": "..."
}
```
### 2. 本地开发时查看完整错误
**开发环境**: 查看 `logs/app.log` 获取详细错误信息
**生产环境**: 使用 Request ID 联系运维团队查看日志
---
## 常见错误场景
### 场景 1: 参数验证失败
```go
type CreateUserRequest struct {
Username string `json:"username" validate:"required,min=3,max=50"`
Email string `json:"email" validate:"required,email"`
}
func (h *Handler) CreateUser(c *fiber.Ctx) error {
var req CreateUserRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求格式错误")
}
// 使用 validator 验证
if err := validate.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "参数验证失败")
}
// 创建用户...
}
```
### 场景 2: 资源未找到
```go
func (h *Handler) GetUser(c *fiber.Ctx) error {
id := c.Params("id")
user, err := h.service.GetByID(c.Context(), id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(errors.CodeNotFound, "用户不存在")
}
return errors.Wrap(errors.CodeDatabaseError, "查询用户失败", err)
}
return response.Success(c, user)
}
```
### 场景 3: 数据库错误
```go
func (h *Handler) UpdateUser(c *fiber.Ctx) error {
id := c.Params("id")
var req UpdateUserRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求格式错误")
}
user, err := h.service.Update(c.Context(), id, &req)
if err != nil {
// 数据库错误会被包装,原始错误仅记录到日志
return errors.Wrap(errors.CodeDatabaseError, "更新用户失败", err)
}
return response.Success(c, user)
}
```
### 场景 4: 外部服务不可用
```go
func (h *Handler) SendEmail(c *fiber.Ctx) error {
var req SendEmailRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求格式错误")
}
err := h.emailService.Send(req.To, req.Subject, req.Body)
if err != nil {
// 外部服务错误
return errors.Wrap(errors.CodeServiceUnavailable, "邮件服务暂时不可用", err)
}
return response.Success(c, nil)
}
```
---
## 最佳实践
### ✅ 推荐做法
1. **使用预定义错误码**: 保持错误码的一致性
```go
return errors.New(errors.CodeInvalidParam, "用户名不能为空")
```
2. **包装底层错误**: 保留错误链,便于调试
```go
return errors.Wrap(errors.CodeDatabaseError, "查询失败", err)
```
3. **提供友好的错误消息**: 使用中文,面向用户
```go
return errors.New(errors.CodeNotFound, "订单不存在")
```
4. **区分客户端和服务端错误**: 使用正确的错误码范围
- 1000-1999: 客户端问题 (参数错误、权限不足等)
- 2000-2999: 服务端问题 (数据库错误、服务不可用等)
### ❌ 避免做法
1. **不要硬编码错误消息**
```go
// ❌ 错误
return c.Status(400).JSON(fiber.Map{"error": "参数错误"})
// ✅ 正确
return errors.New(errors.CodeInvalidParam, "参数错误")
```
2. **不要暴露敏感信息**
```go
// ❌ 错误: 暴露 SQL 语句
return errors.New(errors.CodeDatabaseError, err.Error())
// ✅ 正确: 使用通用消息
return errors.Wrap(errors.CodeDatabaseError, "查询失败", err)
```
3. **不要滥用 panic**
```go
// ❌ 错误: 业务错误不应该 panic
if user == nil {
panic("user not found")
}
// ✅ 正确: 使用 error 返回
if user == nil {
return errors.New(errors.CodeNotFound, "用户不存在")
}
```
4. **不要忽略错误**
```go
// ❌ 错误: 忽略错误
user, _ := h.service.GetByID(id)
// ✅ 正确: 处理错误
user, err := h.service.GetByID(id)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, "获取用户失败", err)
}
```
---
## 测试错误处理
### 单元测试示例
```go
func TestHandler_CreateUser_InvalidParam(t *testing.T) {
app := fiber.New()
handler := NewHandler(mockService, logger)
app.Post("/users", handler.CreateUser)
// 发送无效请求
req := httptest.NewRequest("POST", "/users", strings.NewReader(`{"username": ""}`))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
// 验证状态码
assert.Equal(t, 400, resp.StatusCode)
// 验证响应格式
var result response.Response
json.NewDecoder(resp.Body).Decode(&result)
assert.Equal(t, errors.CodeInvalidParam, result.Code)
assert.Nil(t, result.Data)
assert.NotEmpty(t, result.Message)
// 验证 Request ID header
requestID := resp.Header.Get("X-Request-ID")
assert.NotEmpty(t, requestID)
}
```
---
## 常见问题 (FAQ)
**Q: 为什么错误响应中没有 request_id 字段?**
A: Request ID 在响应 Header `X-Request-ID` 中传递,不在响应体中。这符合 HTTP 标准实践。
**Q: 如何添加新的错误码?**
A: 在 `pkg/errors/codes.go` 中添加常量定义和错误消息映射:
```go
const (
CodeMyNewError = 1010 // 客户端错误
)
var errorMessages = map[int]string{
CodeMyNewError: "我的新错误",
}
```
**Q: 服务端错误为什么只返回通用消息?**
A: 出于安全考虑,避免泄露数据库结构、文件路径等敏感信息。完整错误详情会记录到日志,运维团队可以通过 Request ID 查看。
**Q: Panic 会导致服务崩溃吗?**
A: 不会。Recover 中间件会捕获所有 panic,转换为 500 错误响应,确保服务继续运行。
**Q: 如何在日志中搜索特定用户的所有错误?**
A: 日志包含 `user_id` 字段 (如果已认证),可以搜索:
```bash
grep '"user_id":"123"' logs/app.log | grep '"level":"error"'
```
---
## 下一步
- 查看完整的错误码列表: `pkg/errors/codes.go`
- 了解错误处理实现细节: `specs/003-error-handling/research.md`
- 查看 API contracts: `specs/003-error-handling/contracts/error-responses.yaml`
- 阅读完整实施计划: `specs/003-error-handling/plan.md`
---
**版本**: 1.0.0
**最后更新**: 2025-11-14