Files
junhong_cmp_fiber/openspec/specs/error-handling/spec.md
huang 409a68d60b
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m45s
feat: OpenAPI 契约对齐与框架优化
主要变更:
1. OpenAPI 文档契约对齐
   - 统一错误响应字段名为 msg(非 message)
   - 规范 envelope 响应结构(code, msg, data, timestamp)
   - 个人客户路由纳入文档体系(使用 Register 机制)
   - 新增 BuildDocHandlers() 统一管理 handler 构造
   - 确保文档生成的幂等性

2. Service 层错误处理统一
   - 全面替换 fmt.Errorf 为 errors.New/Wrap
   - 统一错误码使用规范
   - Handler 层参数校验不泄露底层细节
   - 新增错误码验证集成测试

3. 代码质量提升
   - 删除未使用的 Task handler 和路由
   - 新增代码规范检查脚本(check-service-errors.sh)
   - 新增注释路径一致性检查(check-comment-paths.sh)
   - 更新 API 文档生成指南

4. OpenSpec 归档
   - 归档 openapi-contract-alignment 变更(63 tasks)
   - 归档 service-error-unify-core 变更
   - 归档 service-error-unify-support 变更
   - 归档 code-cleanup-docs-update 变更
   - 归档 handler-validation-security 变更
   - 同步 delta specs 到主规范文件

影响范围:
- pkg/openapi: 新增 handlers.go,优化 generator.go
- internal/service/*: 48 个 service 文件错误处理统一
- internal/handler/admin: 优化参数校验错误提示
- internal/routes: 个人客户路由改造,删除 task 路由
- scripts: 新增 3 个代码检查脚本
- docs: 更新 OpenAPI 文档(15750+ 行)
- openspec/specs: 同步 3 个主规范文件

破坏性变更:无
向后兼容:是
2026-01-30 11:40:36 +08:00

270 lines
9.8 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.
# error-handling Specification
## Purpose
定义本项目“错误产生、错误传递、错误返回”的统一规范,确保:
- 对外响应结构一致(`{code, data, msg, timestamp}`
- 业务语义一致(可预期业务错误返回 4xx非预期系统错误返回 5xx
- 不泄露内部细节(校验细节、数据库/第三方错误细节仅写日志)
- 分层职责明确Handler 只负责输入/输出Service 负责业务与结构化错误)
## Requirements
### Requirement: Simplified AppError Structure
系统 SHALL 简化 AppError 结构,删除冗余的 HTTPStatus 字段。
#### Scenario: AppError 字段
- **WHEN** 创建 AppError
- **THEN** 结构体只包含 3 个字段:
- Code: 业务错误码
- Message: 错误消息
- Err: 底层错误(可选)
#### Scenario: HTTP 状态码获取
- **WHEN** ErrorHandler 处理 AppError
- **THEN** 通过 GetHTTPStatus(code) 实时获取 HTTP 状态码
- **AND** 不从 AppError 字段中读取
#### Scenario: 禁止手动设置状态码
- **WHEN** 创建 AppError
- **THEN** 不提供 WithHTTPStatus() 方法
- **AND** Code 和 HTTPStatus 始终保持一致
### Requirement: Unified Error Response Format
系统 SHALL 使用统一的 JSON 响应格式(错误和成功均使用相同字段)。
#### Scenario: 响应结构
- **WHEN** 返回任何响应时
- **THEN** JSON 结构仅包含 4 个字段:
- code: 业务错误码0 表示成功)
- msg: 消息(错误消息或 "success"
- data: 响应数据(成功时有数据,错误时为 null
- timestamp: ISO 8601 时间戳
#### Scenario: 不返回 HTTP 状态码字段
- **WHEN** 返回响应时
- **THEN** JSON 不包含 httpstatus 或 http_status 字段
- **AND** HTTP 状态码仅在响应头中体现
#### Scenario: Handler 返回错误
- **WHEN** Handler 函数返回 error
- **THEN** 全局 ErrorHandler 拦截错误
- **AND** 根据错误类型构造统一格式响应
### Requirement: Handler Error Return Convention
所有 Handler 函数 SHALL 通过返回 error 传递错误,由全局 ErrorHandler 统一处理。
#### Scenario: 业务错误
- **WHEN** Handler 遇到业务错误
- **THEN** 返回 errors.New(code, message) 创建的 AppError
- **AND** 不直接调用 response.Error()
#### Scenario: 参数验证错误
- **WHEN** 请求参数验证失败
- **THEN** 返回 errors.New(CodeInvalidParam)
- **AND** 不将 validator 的 err.Error() 直接返回给客户端(避免泄露内部字段和规则)
- **AND** 详细校验错误 SHALL 记录到日志(用于排查)
### Requirement: Service Error Output Convention
Service 层 SHALL 对外输出结构化错误,禁止把普通 error 直接冒泡到 Handler。
#### Scenario: 预期业务错误
- **WHEN** 业务校验失败(例如:验证码错误、资源不存在、状态不允许)
- **THEN** 返回 errors.New(<4xx-code>[, message])
#### Scenario: 非预期系统错误
- **WHEN** 发生数据库/缓存/队列/第三方依赖错误
- **THEN** 返回 errors.Wrap(<5xx-code>, err, "业务动作失败")
- **AND** 客户端 msg 由全局错误映射表提供通用描述
#### Scenario: 禁止 fmt.Errorf 作为对外错误
- **WHEN** Service 需要对外返回错误
- **THEN** 不使用 fmt.Errorf(...) 作为返回值
- **AND** 必须转换为 AppErrorerrors.New/Wrap
#### Scenario: 成功响应
- **WHEN** Handler 执行成功
- **THEN** 调用 response.Success(c, data)
- **AND** 返回 nil
### Requirement: Standardized Error Codes
系统 SHALL 使用标准化的错误码,删除向后兼容的别名。
#### Scenario: 参数验证错误码
- **WHEN** 参数验证失败
- **THEN** 使用 CodeInvalidParam
- **AND** 不使用 CodeBadRequest别名已删除
#### Scenario: 服务不可用错误码
- **WHEN** 服务不可用
- **THEN** 使用 CodeServiceUnavailable
- **AND** 不使用 CodeAuthServiceUnavailable别名已删除
## 错误报错规范(必须遵守)
### Handler 层
-**禁止直接返回/拼接底层错误信息给客户端**
- 例如:`"参数验证失败: " + err.Error()`、直接返回 `err.Error()`
- 原因:泄露内部字段名和校验规则,造成安全风险
-**参数校验失败统一返回** `errors.New(errors.CodeInvalidParam)`
- 详细校验错误写日志,对外返回通用消息
-**详细错误信息记录到日志**,用于排查问题
- 日志级别:参数错误使用 `WARN` 级别(客户端错误)
- 必须包含:`path``method`、完整错误信息
- 使用结构化日志(`zap.String``zap.Error`
### Service 层
-**禁止对外返回** `fmt.Errorf(...)`
- 原因:未结构化的错误消息会泄露实现细节
-**业务错误使用** `errors.New(code[, msg])`
- 适用场景:资源不存在、状态不允许、参数错误等预期错误
-**系统错误使用** `errors.Wrap(code, err[, msg])`
- 适用场景数据库错误、Redis 错误、队列错误等非预期错误
### 示例对比
**Handler 层参数校验**
```go
// ❌ 错误:泄露校验细节
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "参数解析失败: "+err.Error())
}
// ✅ 正确:通用消息 + 结构化日志
if err := c.BodyParser(&req); err != nil {
logger.GetAppLogger().Warn("参数解析失败",
zap.String("path", c.Path()),
zap.String("method", c.Method()),
zap.Error(err),
)
return errors.New(errors.CodeInvalidParam, "请求参数格式错误")
}
// ✅ 参数验证失败示例
if err := h.validator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("参数验证失败",
zap.String("path", c.Path()),
zap.String("method", c.Method()),
zap.Error(err),
)
return errors.New(errors.CodeInvalidParam) // 使用默认消息
}
```
**Service 层错误处理**
```go
// ❌ 错误:使用 fmt.Errorf
if err := s.store.Create(ctx, data); err != nil {
return fmt.Errorf("创建失败: %w", err)
}
// ✅ 正确:使用 errors.Wrap
if err := s.store.Create(ctx, data); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建失败")
}
```
## Service 层错误处理规范
### 错误分类与映射表
| 场景分类 | 错误码 | HTTP 状态码 | 使用方式 |
|---------|-------|-----------|---------|
| 资源不存在 | `CodeNotFound` | 404 | `errors.New(errors.CodeNotFound, "资源不存在")` |
| 状态不允许 | `CodeInvalidStatus` | 400 | `errors.New(errors.CodeInvalidStatus, "状态不允许此操作")` |
| 参数错误 | `CodeInvalidParam` | 400 | `errors.New(errors.CodeInvalidParam)` |
| 重复操作 | `CodeDuplicate` | 409 | `errors.New(errors.CodeDuplicate, "资源已存在")` |
| 余额不足 | `CodeInsufficientBalance` | 400 | `errors.New(errors.CodeInsufficientBalance)` |
| 额度不足 | `CodeInsufficientQuota` | 400 | `errors.New(errors.CodeInsufficientQuota, "分配额度不足")` |
| 超过限制 | `CodeExceedLimit` | 400 | `errors.New(errors.CodeExceedLimit, "超过系统限制")` |
| 资源冲突 | `CodeConflict` | 409 | `errors.New(errors.CodeConflict, "资源冲突")` |
| 数据库错误 | `CodeInternalError` | 500 | `errors.Wrap(errors.CodeInternalError, err, "操作失败")` |
| 队列错误 | `CodeInternalError` | 500 | `errors.Wrap(errors.CodeInternalError, err, "任务提交失败")` |
### 实际案例
#### 案例 1套餐服务package/service.go
**场景:获取套餐**
```go
// ❌ 错误:使用 fmt.Errorf
func (s *Service) Get(ctx context.Context, id uint) (*dto.PackageResponse, error) {
pkg, err := s.packageStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "套餐不存在")
}
return nil, fmt.Errorf("获取套餐失败: %w", err) // ❌ 直接返回系统错误
}
return s.toResponse(ctx, pkg), nil
}
// ✅ 正确:使用 errors.Wrap
func (s *Service) Get(ctx context.Context, id uint) (*dto.PackageResponse, error) {
pkg, err := s.packageStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "套餐不存在")
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取套餐失败") // ✅
}
return s.toResponse(ctx, pkg), nil
}
```
#### 案例 2分佣提现commission_withdrawal/service.go
**场景:余额不足**
```go
// ✅ 业务错误使用 errors.New
if wallet.FrozenBalance < amount {
return nil, errors.New(errors.CodeInsufficientBalance, "钱包冻结余额不足")
}
// ✅ 事务中的数据库错误使用 errors.Wrap
err = s.db.Transaction(func(tx *gorm.DB) error {
if err := s.walletStore.DeductFrozenBalanceWithTx(ctx, tx, wallet.ID, amount); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "扣除冻结余额失败")
}
// ...
})
```
#### 案例 3店铺管理shop/service.go
**场景:层级限制和重复检查**
```go
// ✅ 业务校验
if level > 7 {
return nil, errors.New(errors.CodeInvalidParam, "店铺层级超过限制")
}
// ✅ 重复检查
existing, _ := s.shopStore.GetByCode(ctx, req.ShopCode)
if existing != nil {
return nil, errors.New(errors.CodeDuplicate, "店铺代码已存在")
}
// ✅ 数据库操作
if err := s.shopStore.Create(ctx, shop); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建店铺失败")
}
```
### 统一原则
1. **业务错误4xx**:使用 `errors.New(Code4xx, msg)`
- 资源不存在、状态不允许、参数错误、重复操作等
2. **系统错误5xx**:使用 `errors.Wrap(Code5xx, err, msg)`
- 数据库错误、Redis 错误、队列错误、外部服务错误等
3. **错误消息保持中文**:便于日志排查和问题定位
4. **禁止 fmt.Errorf 对外返回**:避免泄露内部实现细节