Files
huang fb83c9a706 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
2025-11-15 12:17:44 +08:00

14 KiB
Raw Permalink Blame History

使用指南Fiber 错误处理集成

功能编号: 003-error-handling
版本: 1.0.0
更新日期: 2025-11-15

目录

  1. 快速开始
  2. 错误码参考
  3. Handler 中使用错误
  4. 客户端错误处理
  5. 错误日志查询
  6. 最佳实践
  7. 常见问题

快速开始

1. 在 Handler 中返回错误

package handler

import (
    "github.com/break/junhong_cmp_fiber/pkg/errors"
    "github.com/break/junhong_cmp_fiber/pkg/response"
    "github.com/gofiber/fiber/v2"
)

func (h *UserHandler) GetUser(c *fiber.Ctx) error {
    userID := c.Params("id")
    
    // 参数验证失败
    if userID == "" {
        return errors.New(errors.CodeInvalidParam, "用户 ID 不能为空")
    }
    
    // 调用服务层
    user, err := h.service.GetByID(c.Context(), userID)
    if err != nil {
        // 包装底层错误
        return errors.Wrap(errors.CodeDatabaseError, "查询用户失败", err)
    }
    
    // 资源未找到
    if user == nil {
        return errors.New(errors.CodeNotFound, "用户不存在")
    }
    
    return response.Success(c, user)
}

2. 错误响应格式

所有错误自动转换为统一格式:

{
  "code": 1001,
  "data": null,
  "msg": "参数验证失败",
  "timestamp": "2025-11-15T10:00:00+08:00"
}

HTTP Header 中包含 Request ID

X-Request-ID: 550e8400-e29b-41d4-a716-446655440000

错误码参考

成功

错误码 名称 HTTP 状态 消息
0 CodeSuccess 200 操作成功

客户端错误 (1000-1999)

错误码 名称 HTTP 状态 消息 使用场景
1001 CodeInvalidParam 400 参数验证失败 请求参数格式错误、必填字段缺失
1002 CodeMissingToken 401 缺少认证令牌 未提供 Token
1003 CodeInvalidToken 401 无效的认证令牌 Token 格式错误
1004 CodeInvalidCredentials 401 认证凭证无效 Token 过期、验证失败
1005 CodeForbidden 403 禁止访问 无权限访问资源
1006 CodeNotFound 404 资源未找到 用户、订单等资源不存在
1007 CodeConflict 409 资源冲突 唯一性约束冲突
1008 CodeTooManyRequests 429 请求过多 触发限流
1009 CodeRequestEntityTooLarge 413 请求体过大 文件上传超限

服务端错误 (2000-2999)

错误码 名称 HTTP 状态 消息 使用场景
2001 CodeInternalError 500 内部服务器错误 未分类的内部错误
2002 CodeDatabaseError 500 数据库错误 数据库连接失败、查询错误
2003 CodeCacheError 500 缓存服务错误 Redis 连接失败
2004 CodeServiceUnavailable 503 服务暂时不可用 外部服务不可用
2005 CodeTimeout 504 请求超时 上游服务超时
2006 CodeQueueError 500 任务队列错误 Asynq 任务投递失败

Handler 中使用错误

1. 参数验证错误

func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
    var req CreateUserRequest
    if err := c.BodyParser(&req); err != nil {
        return errors.New(errors.CodeInvalidParam, "请求参数格式错误")
    }
    
    // 业务验证
    if len(req.Username) < 3 || len(req.Username) > 20 {
        return errors.New(errors.CodeInvalidParam, "用户名长度必须在 3-20 个字符之间")
    }
    
    if !isValidEmail(req.Email) {
        return errors.New(errors.CodeInvalidParam, "邮箱格式不正确")
    }
    
    // 继续处理...
}

2. 认证/授权错误

func (h *OrderHandler) GetOrder(c *fiber.Ctx) error {
    // 检查用户是否登录
    userID := c.Locals("user_id")
    if userID == nil {
        return errors.New(errors.CodeMissingToken, "请先登录")
    }
    
    order, err := h.service.GetByID(c.Params("id"))
    if err != nil {
        return errors.Wrap(errors.CodeDatabaseError, "查询订单失败", err)
    }
    
    // 检查权限
    if order.UserID != userID.(string) {
        return errors.New(errors.CodeForbidden, "无权访问此订单")
    }
    
    return response.Success(c, order)
}

3. 资源未找到

func (h *UserHandler) GetUser(c *fiber.Ctx) error {
    user, err := h.service.GetByID(c.Params("id"))
    if err != nil {
        return errors.Wrap(errors.CodeDatabaseError, "查询用户失败", err)
    }
    
    if user == nil {
        return errors.New(errors.CodeNotFound, fmt.Sprintf("用户 ID %s 不存在", c.Params("id")))
    }
    
    return response.Success(c, user)
}

4. 资源冲突

func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
    var req CreateUserRequest
    if err := c.BodyParser(&req); err != nil {
        return errors.New(errors.CodeInvalidParam, "请求参数错误")
    }
    
    // 检查用户名是否已存在
    exists, err := h.service.ExistsByUsername(req.Username)
    if err != nil {
        return errors.Wrap(errors.CodeDatabaseError, "检查用户名失败", err)
    }
    
    if exists {
        return errors.New(errors.CodeConflict, "用户名已被使用")
    }
    
    // 创建用户...
}

5. 外部服务错误

func (h *NotificationHandler) 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)
}

6. 自定义 HTTP 状态码(高级用法)

func (h *Handler) SpecialCase(c *fiber.Ctx) error {
    // 默认 CodeInvalidParam 映射为 400
    // 但某些场景需要返回 422
    appErr := errors.New(errors.CodeInvalidParam, "数据验证失败")
    appErr = appErr.WithHTTPStatus(422)
    return appErr
}

客户端错误处理

JavaScript/TypeScript

async function fetchUser(userId: string) {
    try {
        const response = await fetch(`/api/v1/users/${userId}`);
        const data = await response.json();
        
        // 检查业务错误码
        if (data.code !== 0) {
            const requestId = response.headers.get('X-Request-ID');
            
            switch (data.code) {
                // 认证错误 - 跳转登录
                case 1002:
                case 1003:
                case 1004:
                    redirectToLogin();
                    break;
                
                // 权限错误 - 显示无权限提示
                case 1005:
                    showError('您没有权限访问此资源');
                    break;
                
                // 资源未找到 - 显示 404 页面
                case 1006:
                    showNotFoundPage();
                    break;
                
                // 服务端错误 - 显示错误并提供 Request ID
                case 2001:
                case 2002:
                case 2003:
                case 2004:
                    showError(`服务器错误请联系管理员。Request ID: ${requestId}`);
                    break;
                
                // 限流错误 - 提示稍后重试
                case 1008:
                    showError('请求过于频繁,请稍后再试');
                    break;
                
                // 其他错误 - 显示错误消息
                default:
                    showError(data.msg);
            }
            
            return null;
        }
        
        return data.data;
    } catch (err) {
        // 网络错误
        showError('网络连接失败,请检查您的网络');
        return null;
    }
}

Axios 拦截器

import axios from 'axios';

const api = axios.create({
    baseURL: '/api/v1',
});

// 响应拦截器
api.interceptors.response.use(
    (response) => {
        const { code, data, msg } = response.data;
        
        if (code !== 0) {
            const requestId = response.headers['x-request-id'];
            
            // 根据错误码处理
            if ([1002, 1003, 1004].includes(code)) {
                // 认证失败,跳转登录
                redirectToLogin();
                return Promise.reject(new Error(msg));
            }
            
            if (code === 1005) {
                // 权限不足
                showError('您没有权限执行此操作');
                return Promise.reject(new Error(msg));
            }
            
            if (code >= 2000) {
                // 服务端错误
                console.error(`Server error: ${msg}, Request ID: ${requestId}`);
                showError(`服务器错误Request ID: ${requestId}`);
                return Promise.reject(new Error(msg));
            }
            
            // 其他业务错误
            showError(msg);
            return Promise.reject(new Error(msg));
        }
        
        return data;
    },
    (error) => {
        // 网络错误
        showError('网络连接失败');
        return Promise.reject(error);
    }
);

错误日志查询

1. 通过 Request ID 查询

# 查询特定请求的所有日志
grep "550e8400-e29b-41d4-a716-446655440000" logs/app.log

# 使用 jq 格式化 JSON 日志
grep "550e8400-e29b-41d4-a716-446655440000" logs/app.log | jq .

2. 查询特定错误码

# 查询所有参数验证失败的错误
grep '"error_code":1001' logs/app.log | jq .

# 查询所有数据库错误
grep '"error_code":2002' logs/app.log | jq .

3. 查询 Panic 堆栈

# 查询所有 panic 日志
grep "panic recovered" logs/app.log

# 查询包含堆栈的完整 panic 日志
grep -A 20 "panic recovered" logs/app.log

4. 按时间范围查询

# 查询最近 1 小时的错误日志
grep '"level":"error"' logs/app.log | grep "$(date -u -d '1 hour ago' '+%Y-%m-%dT%H')"

最佳实践

1. 错误码选择

正确示例

// 参数验证失败
return errors.New(errors.CodeInvalidParam, "用户名不能为空")

// 资源未找到
return errors.New(errors.CodeNotFound, "订单不存在")

// 数据库错误
return errors.Wrap(errors.CodeDatabaseError, "查询失败", err)

错误示例

// 不要使用错误的错误码
return errors.New(errors.CodeDatabaseError, "用户名不能为空") // 应该用 CodeInvalidParam

// 不要返回空消息
return errors.New(errors.CodeNotFound, "") // 应该提供具体消息

2. 错误消息编写

正确示例

// 清晰、具体的错误消息
errors.New(errors.CodeInvalidParam, "用户名长度必须在 3-20 个字符之间")
errors.New(errors.CodeNotFound, "用户 ID 123 不存在")
errors.New(errors.CodeConflict, "邮箱 test@example.com 已被注册")

错误示例

// 不要使用模糊的消息
errors.New(errors.CodeInvalidParam, "错误")
errors.New(errors.CodeNotFound, "not found")

// 不要暴露敏感信息
errors.New(errors.CodeDatabaseError, "SQL error: SELECT * FROM users WHERE password = '...'")

3. 错误包装

正确示例

// 包装底层错误,保留错误链
user, err := h.repo.GetByID(id)
if err != nil {
    return errors.Wrap(errors.CodeDatabaseError, "查询用户失败", err)
}

错误示例

// 丢失原始错误信息
user, err := h.repo.GetByID(id)
if err != nil {
    return errors.New(errors.CodeDatabaseError, "查询用户失败") // 应该用 Wrap
}

4. 不要过度处理错误

正确示例

func (h *Handler) GetUser(c *fiber.Ctx) error {
    user, err := h.service.GetByID(c.Params("id"))
    if err != nil {
        // 直接返回错误,让 ErrorHandler 统一处理
        return err
    }
    return response.Success(c, user)
}

错误示例

func (h *Handler) GetUser(c *fiber.Ctx) error {
    user, err := h.service.GetByID(c.Params("id"))
    if err != nil {
        // 不要在 Handler 中手动构造错误响应
        return c.Status(500).JSON(fiber.Map{"error": err.Error()})
    }
    return response.Success(c, user)
}

5. Panic 使用建议

正确做法

// 让代码正常返回错误,不要主动 panic
func (s *Service) Process() error {
    if invalidState {
        return errors.New(errors.CodeInternalError, "无效状态")
    }
    return nil
}

避免使用

// 避免在业务代码中主动 panic
func (s *Service) Process() {
    if invalidState {
        panic("invalid state") // 不推荐
    }
}

注意:即使代码中有 panicRecover 中间件也会自动捕获并转换为错误响应,确保服务不崩溃。


常见问题

Q1: 如何自定义错误消息?

A: 使用 errors.New() 的第二个参数:

return errors.New(errors.CodeInvalidParam, "自定义错误消息")

Q2: 如何查看底层错误详情?

A: 底层错误会记录在日志中,通过 Request ID 查询:

grep "<request-id>" logs/app.log | jq .

Q3: 客户端如何获取 Request ID

A: 从响应 Header 中获取:

const requestId = response.headers.get('X-Request-ID');

Q4: 错误码冲突怎么办?

A: 参考 pkg/errors/codes.go 中的定义,避免使用已定义的错误码。如需新增错误码,请在对应范围内添加。

Q5: 如何测试错误处理?

A: 参考 tests/integration/error_handler_test.go 中的示例:

resp, _ := app.Test(httptest.NewRequest("GET", "/api/v1/users/invalid", nil))
assert.Equal(t, 400, resp.StatusCode)

Q6: 如何关闭堆栈跟踪?

A: 堆栈跟踪仅在 panic 时记录,无法关闭。如需调整,修改 internal/middleware/recover.go


更多信息


版本历史:

  • v1.0.0 (2025-11-15): 初始版本