Files
junhong_cmp_fiber/docs/003-error-handling/使用指南.md
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

563 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 使用指南Fiber 错误处理集成
**功能编号**: 003-error-handling
**版本**: 1.0.0
**更新日期**: 2025-11-15
## 目录
1. [快速开始](#快速开始)
2. [错误码参考](#错误码参考)
3. [Handler 中使用错误](#handler-中使用错误)
4. [客户端错误处理](#客户端错误处理)
5. [错误日志查询](#错误日志查询)
6. [最佳实践](#最佳实践)
7. [常见问题](#常见问题)
---
## 快速开始
### 1. 在 Handler 中返回错误
```go
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. 错误响应格式
所有错误自动转换为统一格式:
```json
{
"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. 参数验证错误
```go
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. 认证/授权错误
```go
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. 资源未找到
```go
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. 资源冲突
```go
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. 外部服务错误
```go
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 状态码(高级用法)
```go
func (h *Handler) SpecialCase(c *fiber.Ctx) error {
// 默认 CodeInvalidParam 映射为 400
// 但某些场景需要返回 422
appErr := errors.New(errors.CodeInvalidParam, "数据验证失败")
appErr = appErr.WithHTTPStatus(422)
return appErr
}
```
---
## 客户端错误处理
### JavaScript/TypeScript
```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 拦截器
```typescript
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 查询
```bash
# 查询特定请求的所有日志
grep "550e8400-e29b-41d4-a716-446655440000" logs/app.log
# 使用 jq 格式化 JSON 日志
grep "550e8400-e29b-41d4-a716-446655440000" logs/app.log | jq .
```
### 2. 查询特定错误码
```bash
# 查询所有参数验证失败的错误
grep '"error_code":1001' logs/app.log | jq .
# 查询所有数据库错误
grep '"error_code":2002' logs/app.log | jq .
```
### 3. 查询 Panic 堆栈
```bash
# 查询所有 panic 日志
grep "panic recovered" logs/app.log
# 查询包含堆栈的完整 panic 日志
grep -A 20 "panic recovered" logs/app.log
```
### 4. 按时间范围查询
```bash
# 查询最近 1 小时的错误日志
grep '"level":"error"' logs/app.log | grep "$(date -u -d '1 hour ago' '+%Y-%m-%dT%H')"
```
---
## 最佳实践
### 1. 错误码选择
**正确示例**
```go
// 参数验证失败
return errors.New(errors.CodeInvalidParam, "用户名不能为空")
// 资源未找到
return errors.New(errors.CodeNotFound, "订单不存在")
// 数据库错误
return errors.Wrap(errors.CodeDatabaseError, "查询失败", err)
```
**错误示例**
```go
// 不要使用错误的错误码
return errors.New(errors.CodeDatabaseError, "用户名不能为空") // 应该用 CodeInvalidParam
// 不要返回空消息
return errors.New(errors.CodeNotFound, "") // 应该提供具体消息
```
### 2. 错误消息编写
**正确示例**
```go
// 清晰、具体的错误消息
errors.New(errors.CodeInvalidParam, "用户名长度必须在 3-20 个字符之间")
errors.New(errors.CodeNotFound, "用户 ID 123 不存在")
errors.New(errors.CodeConflict, "邮箱 test@example.com 已被注册")
```
**错误示例**
```go
// 不要使用模糊的消息
errors.New(errors.CodeInvalidParam, "错误")
errors.New(errors.CodeNotFound, "not found")
// 不要暴露敏感信息
errors.New(errors.CodeDatabaseError, "SQL error: SELECT * FROM users WHERE password = '...'")
```
### 3. 错误包装
**正确示例**
```go
// 包装底层错误,保留错误链
user, err := h.repo.GetByID(id)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, "查询用户失败", err)
}
```
**错误示例**
```go
// 丢失原始错误信息
user, err := h.repo.GetByID(id)
if err != nil {
return errors.New(errors.CodeDatabaseError, "查询用户失败") // 应该用 Wrap
}
```
### 4. 不要过度处理错误
**正确示例**
```go
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)
}
```
**错误示例**
```go
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 使用建议
**正确做法**
```go
// 让代码正常返回错误,不要主动 panic
func (s *Service) Process() error {
if invalidState {
return errors.New(errors.CodeInternalError, "无效状态")
}
return nil
}
```
**避免使用**
```go
// 避免在业务代码中主动 panic
func (s *Service) Process() {
if invalidState {
panic("invalid state") // 不推荐
}
}
```
**注意**:即使代码中有 panicRecover 中间件也会自动捕获并转换为错误响应,确保服务不崩溃。
---
## 常见问题
### Q1: 如何自定义错误消息?
A: 使用 `errors.New()` 的第二个参数:
```go
return errors.New(errors.CodeInvalidParam, "自定义错误消息")
```
### Q2: 如何查看底层错误详情?
A: 底层错误会记录在日志中,通过 Request ID 查询:
```bash
grep "<request-id>" logs/app.log | jq .
```
### Q3: 客户端如何获取 Request ID
A: 从响应 Header 中获取:
```javascript
const requestId = response.headers.get('X-Request-ID');
```
### Q4: 错误码冲突怎么办?
A: 参考 `pkg/errors/codes.go` 中的定义,避免使用已定义的错误码。如需新增错误码,请在对应范围内添加。
### Q5: 如何测试错误处理?
A: 参考 `tests/integration/error_handler_test.go` 中的示例:
```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`
---
## 更多信息
- [功能总结](./功能总结.md) - 功能概述和技术要点
- [架构说明](./架构说明.md) - 错误处理架构设计
- [错误码定义](../../pkg/errors/codes.go) - 完整错误码列表
---
**版本历史**:
- v1.0.0 (2025-11-15): 初始版本