refactor: 统一错误消息数据源,优化错误码与映射表管理
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 4m36s

主要改动:
- 改造 errors.New() 和 Wrap() 函数签名为可变参数,优先使用 errorMessages 映射表
- 添加 allErrorCodes 注册表和 init() 启动时校验,确保错误码与映射表一致
- 添加 TestAllCodesHaveMessages 和 TestNoOrphanMessages 测试防止映射表腐化
- 清理 109 处与映射表一致的冗余硬编码(service 层)
- 保留业务特定消息覆盖能力

新增 API 用法:
- errors.New(errors.CodeUnauthorized) // 使用映射表默认消息
- errors.New(errors.CodeNotFound, "提现申请不存在") // 覆盖为自定义消息
This commit is contained in:
2026-01-22 18:27:42 +08:00
parent b68e7ec013
commit 6821e5abcf
28 changed files with 665 additions and 81 deletions

View File

@@ -452,13 +452,13 @@ junhong_cmp_fiber/
│ (访问日志) │ │ (访问日志) │
└────────────┬────────────┘ └────────────┬────────────┘
┌────────────▼────────────┐ ┌────────────▼────────────┐
│ 4. KeyAuth 中间件 │ 4. 认证中间件
│ (认证) │ ─── 可选 (config: enable_auth) │ (按路由组配置) │ ─── 模块化路由注册
└────────────┬────────────┘ └────────────┬────────────┘
┌────────────▼────────────┐ ┌────────────▼────────────┐
│ 5. RateLimiter 中间件 │ │ 5. RateLimiter 中间件 │
│ (限流) │ ─── 可选 (config: enable_rate_limiter) │ (限流) │ ─── 可选 (config: enable_rate_limiter)
└────────────┬────────────┘ └────────────┬────────────┘
@@ -502,20 +502,22 @@ junhong_cmp_fiber/
- **始终激活**:是 - **始终激活**:是
- **日志格式**:包含字段的 JSONtimestamp、level、method、path、status、duration_ms、request_id、ip、user_agent、user_id - **日志格式**:包含字段的 JSONtimestamp、level、method、path、status、duration_ms、request_id、ip、user_agent、user_id
#### 4. KeyAuth 中间件(internal/middleware/auth.go #### 4. 认证中间件pkg/middleware/auth.go 和 internal/middleware/
- **用途**:使用 Token 验证对请求进行认证 - **用途**:使用 Token 验证对请求进行认证
- **行为** - **行为**
-`token` 请求头提取 token -`Authorization: Bearer {token}` 请求头提取 token
- 通过 Redis 验证 token`auth:token:{token}` - 通过 TokenValidator 函数验证 token支持 JWT 和 Redis Token
- 如果缺失/无效 token 返回 401 - 如果缺失/无效 token 返回 401
- 如果 Redis 不可用返回 503fail-closed 策略 - 成功时将用户信息存储在上下文中UserID、UserType、ShopID、EnterpriseID
- 成功时将用户 ID 存储在上下文中:`c.Locals(constants.ContextKeyUserID)` - **实现方式**:模块化路由注册(无全局配置)
- **配置**`middleware.enable_auth`默认true - `/api/admin/*`后台认证SuperAdmin、Platform、Agent
- **跳过路由**`/health`(健康检查绕过认证 - `/api/h5/*`H5 认证Agent、Enterprise
- `/api/personal/*`个人客户认证JWT
- **跳过路由**:各路由组可自行配置跳过路径(如 `/api/admin/login`
- **错误码** - **错误码**
- 1001缺失 token - 1001缺失 token
- 1002无效或过期 token - 1002无效或过期 token
- 1004认证服务不可用 - 1003权限不足
#### 5. RateLimiter 中间件internal/middleware/ratelimit.go #### 5. RateLimiter 中间件internal/middleware/ratelimit.go
- **用途**:通过限制请求速率保护 API 免受滥用 - **用途**:通过限制请求速率保护 API 免受滥用
@@ -545,11 +547,8 @@ app.Use(recover.New())
app.Use(addRequestID()) app.Use(addRequestID())
app.Use(loggerMiddleware()) app.Use(loggerMiddleware())
// 可选:认证中间件 // 模块化路由注册(认证中间件按路由组配置)
if config.GetConfig().Middleware.EnableAuth { routes.RegisterRoutes(app, handlers, middlewares)
tokenValidator := validator.NewTokenValidator(rdb, logger.GetAppLogger())
app.Use(middleware.KeyAuth(tokenValidator, logger.GetAppLogger()))
}
// 可选:限流中间件 // 可选:限流中间件
if config.GetConfig().Middleware.EnableRateLimiter { if config.GetConfig().Middleware.EnableRateLimiter {
@@ -557,16 +556,13 @@ if config.GetConfig().Middleware.EnableRateLimiter {
if config.GetConfig().Middleware.RateLimiter.Storage == "redis" { if config.GetConfig().Middleware.RateLimiter.Storage == "redis" {
storage = redisStorage // 使用 Redis 存储 storage = redisStorage // 使用 Redis 存储
} }
app.Use(middleware.RateLimiter( v1 := app.Group("/api/v1")
v1.Use(middleware.RateLimiter(
config.GetConfig().Middleware.RateLimiter.Max, config.GetConfig().Middleware.RateLimiter.Max,
config.GetConfig().Middleware.RateLimiter.Expiration, config.GetConfig().Middleware.RateLimiter.Expiration,
storage, storage,
)) ))
} }
// 路由
app.Get("/health", healthHandler)
app.Get("/api/v1/users", listUsersHandler)
``` ```
### 请求流程示例 ### 请求流程示例

View File

@@ -226,12 +226,6 @@ func initRoutes(app *fiber.App, cfg *config.Config, result *bootstrap.BootstrapR
// API v1 路由组(用于受保护的端点) // API v1 路由组(用于受保护的端点)
v1 := app.Group("/api/v1") v1 := app.Group("/api/v1")
// 可选:启用认证中间件
if cfg.Middleware.EnableAuth {
// TODO: 配置 TokenValidator
appLogger.Info("认证中间件已启用")
}
// 可选:启用限流器 // 可选:启用限流器
if cfg.Middleware.EnableRateLimiter { if cfg.Middleware.EnableRateLimiter {
initRateLimiter(v1, cfg, appLogger) initRateLimiter(v1, cfg, appLogger)

View File

@@ -53,7 +53,6 @@ logging:
compress: false compress: false
middleware: middleware:
enable_auth: true # 开发环境可选禁用认证
enable_rate_limiter: true enable_rate_limiter: true
rate_limiter: rate_limiter:
max: 1000 max: 1000

View File

@@ -52,9 +52,6 @@ logging:
compress: true compress: true
middleware: middleware:
# 生产环境必须启用认证
enable_auth: true
# 生产环境启用限流,保护服务免受滥用 # 生产环境启用限流,保护服务免受滥用
enable_rate_limiter: true enable_rate_limiter: true

View File

@@ -52,9 +52,6 @@ logging:
compress: true compress: true
middleware: middleware:
# 预发布环境启用认证
enable_auth: true
# 预发布环境启用限流,测试生产配置 # 预发布环境启用限流,测试生产配置
enable_rate_limiter: true enable_rate_limiter: true

View File

@@ -53,7 +53,6 @@ logging:
compress: false compress: false
middleware: middleware:
enable_auth: true # 开发环境可选禁用认证
enable_rate_limiter: true enable_rate_limiter: true
rate_limiter: rate_limiter:
max: 1000 max: 1000

View File

@@ -481,7 +481,6 @@ redis:
pool_size: 50 # 连接池大小 pool_size: 50 # 连接池大小
middleware: middleware:
enable_auth: true # 启用认证
enable_rate_limiter: true # 启用限流 enable_rate_limiter: true # 启用限流
rate_limiter: rate_limiter:
max: 5000 # 每分钟最大请求数 max: 5000 # 每分钟最大请求数

View File

@@ -152,7 +152,6 @@ logging:
compress: true compress: true
middleware: middleware:
enable_auth: true
enable_rate_limiter: false enable_rate_limiter: false
EOF EOF
``` ```

View File

@@ -166,7 +166,6 @@ rate_limiter:
```yaml ```yaml
middleware: middleware:
enable_auth: false # Optional: disable auth for easier testing
enable_rate_limiter: false # Disabled by default enable_rate_limiter: false # Disabled by default
rate_limiter: rate_limiter:
@@ -181,7 +180,6 @@ middleware:
```yaml ```yaml
middleware: middleware:
enable_auth: true
enable_rate_limiter: true # Enabled to test production behavior enable_rate_limiter: true # Enabled to test production behavior
rate_limiter: rate_limiter:
@@ -196,7 +194,6 @@ middleware:
```yaml ```yaml
middleware: middleware:
enable_auth: true
enable_rate_limiter: true # Always enabled in production enable_rate_limiter: true # Always enabled in production
rate_limiter: rate_limiter:

View File

@@ -43,7 +43,7 @@ func (h *PersonalCustomerHandler) SendCode(c *fiber.Ctx) error {
zap.String("phone", req.Phone), zap.String("phone", req.Phone),
zap.Error(err), zap.Error(err),
) )
return errors.Wrap(errors.CodeInternalError, "发送验证码失败", err) return errors.Wrap(errors.CodeInternalError, err, "发送验证码失败")
} }
return response.Success(c, fiber.Map{ return response.Success(c, fiber.Map{
@@ -88,7 +88,7 @@ func (h *PersonalCustomerHandler) Login(c *fiber.Ctx) error {
zap.String("phone", req.Phone), zap.String("phone", req.Phone),
zap.Error(err), zap.Error(err),
) )
return errors.Wrap(errors.CodeInternalError, "登录失败", err) return errors.Wrap(errors.CodeInternalError, err, "登录失败")
} }
// 构造响应 // 构造响应
@@ -157,7 +157,7 @@ func (h *PersonalCustomerHandler) UpdateProfile(c *fiber.Ctx) error {
zap.Uint("customer_id", customerID), zap.Uint("customer_id", customerID),
zap.Error(err), zap.Error(err),
) )
return errors.Wrap(errors.CodeInternalError, "更新个人资料失败", err) return errors.Wrap(errors.CodeInternalError, err, "更新个人资料失败")
} }
return response.Success(c, fiber.Map{ return response.Success(c, fiber.Map{
@@ -181,7 +181,7 @@ func (h *PersonalCustomerHandler) GetProfile(c *fiber.Ctx) error {
zap.Uint("customer_id", customerID), zap.Uint("customer_id", customerID),
zap.Error(err), zap.Error(err),
) )
return errors.Wrap(errors.CodeInternalError, "获取个人资料失败", err) return errors.Wrap(errors.CodeInternalError, err, "获取个人资料失败")
} }
// 构造响应 // 构造响应

View File

@@ -40,8 +40,8 @@ func Recover(logger *zap.Logger) fiber.Handler {
// 但由于我们在 defer 中,需要通过 c.Next() 返回错误 // 但由于我们在 defer 中,需要通过 c.Next() 返回错误
panicErr := errors.Wrap( panicErr := errors.Wrap(
errors.CodeInternalError, errors.CodeInternalError,
fmt.Sprintf("服务发生异常: %v", r),
fmt.Errorf("panic: %v", r), fmt.Errorf("panic: %v", r),
fmt.Sprintf("服务发生异常: %v", r),
) )
// 直接调用 ErrorHandler通过返回错误 // 直接调用 ErrorHandler通过返回错误

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-22

View File

@@ -0,0 +1,228 @@
## Context
### 背景
项目中错误处理使用 `pkg/errors/` 包,核心组件:
| 组件 | 职责 |
|------|------|
| `codes.go` | 定义错误码常量 `Code*` 和消息映射表 `errorMessages` |
| `errors.go` | 定义 `AppError` 结构和 `New()`/`Wrap()` 构造函数 |
| `handler.go` | Fiber 全局 ErrorHandler处理错误响应 |
### 当前问题
```go
// errors.go 的设计意图:当 message 为空时自动使用映射表
func New(code int, message string) *AppError {
if message == "" {
message = GetMessage(code, "zh-CN") // 理论上的自动填充
}
return &AppError{Code: code, Message: message}
}
// 实际业务代码100% 传入硬编码消息,映射表从未被使用
errors.New(errors.CodeNotFound, "提现申请不存在") // 不是空字符串
```
**量化数据**(通过 grep 统计):
- `errors.New(code, "硬编码")` 调用291 处
- `errors.New(code, "")` 调用0 处
- 映射表使用率0%
### 约束
- 必须向后兼容,不能破坏现有 API 响应格式
- 业务特定消息(如 "提现申请不存在")优于通用消息("资源未找到"
- 不能引入运行时性能损耗
## Goals / Non-Goals
### Goals
1. **单一数据源原则**:错误码与消息映射表作为默认消息来源
2. **编译时/启动时校验**:缺失映射条目会立即暴露,而非运行时默默失败
3. **向后兼容**:保留业务特定消息覆盖能力
4. **开发体验优化**:简化 API减少样板代码
### Non-Goals
1. ❌ 多语言支持(保留 `lang` 参数但不实现)
2. ❌ 修改 API 响应格式
3. ❌ 强制所有错误使用映射表消息(保留覆盖能力)
## Decisions
### Decision 1: 改造 `errors.New()` 函数签名
**选项**
| 选项 | 实现 | 优缺点 |
|------|------|--------|
| A. 删除 message 参数 | `New(code int)` | ✅ 强制单一数据源<br>❌ 丢失业务上下文 |
| B. message 改为可选 | `New(code int, msg ...string)` | ✅ 默认用映射表<br>✅ 允许覆盖<br>✅ 向后兼容 |
| C. 新增 NewWithMsg | `New(code)` + `NewWithMsg(code, msg)` | ✅ 清晰区分<br>❌ 需改所有调用点 |
**决策**:选项 B - 使用可变参数
```go
// 新签名
func New(code int, customMsg ...string) *AppError {
msg := GetMessage(code, "zh-CN") // 默认从映射表取
if len(customMsg) > 0 && customMsg[0] != "" {
msg = customMsg[0] // 允许覆盖
}
return &AppError{Code: code, Message: msg}
}
// 使用方式
errors.New(errors.CodeNotFound) // 使用映射表: "资源未找到"
errors.New(errors.CodeNotFound, "提现申请不存在") // 覆盖: "提现申请不存在"
```
**理由**
- 向后兼容:现有代码无需修改即可编译
- 渐进式迁移:可逐步清理冗余硬编码
- 保留灵活性:业务特定消息仍可使用
### Decision 2: 启动时校验机制
**选项**
| 选项 | 时机 | 优缺点 |
|------|------|--------|
| A. init() panic | 程序启动 | ✅ 立即发现<br>❌ 启动失败 |
| B. init() 日志警告 | 程序启动 | ✅ 不阻塞启动<br>❌ 可能被忽略 |
| C. 仅测试校验 | CI 运行 | ✅ 不影响生产<br>❌ 本地开发可能遗漏 |
**决策**:选项 A + C 组合
1. **init() 严格校验**:缺失映射立即 panic阻止服务启动
2. **CI 测试兜底**:测试覆盖所有错误码,防止遗漏
```go
func init() {
for code := range allCodes() { // 遍历所有 Code* 常量
if _, ok := errorMessages[code]; !ok {
panic(fmt.Sprintf("错误码 %d 缺少映射消息", code))
}
}
}
```
**理由**
- 快速失败:问题在部署前暴露,而非运行时
- 强制一致性:不允许"遗漏"状态存在
### Decision 3: 错误码常量收集方式
**选项**
| 选项 | 实现 | 优缺点 |
|------|------|--------|
| A. 手动维护列表 | `allCodes = []int{CodeSuccess, CodeNotFound, ...}` | ❌ 容易忘记更新 |
| B. 反射扫描 | 运行时反射遍历 const | ❌ Go 不支持反射 const |
| C. 代码生成 | `go generate` 扫描生成 | ✅ 自动化<br>❌ 增加构建复杂度 |
| D. 基于映射表反向校验 | 遍历 errorMessages 的 key | ✅ 简单<br>❌ 无法检测缺失 |
| E. 定义错误码注册表 | 显式注册每个错误码 | ✅ 清晰<br>✅ 编译时检查 |
**决策**:选项 E - 定义完整的错误码切片
```go
// 所有错误码必须在此列表中注册
var allErrorCodes = []int{
CodeSuccess,
CodeInvalidParam,
CodeMissingToken,
// ... 所有错误码
}
func init() {
for _, code := range allErrorCodes {
if _, ok := errorMessages[code]; !ok {
panic(fmt.Sprintf("错误码 %d 缺少映射消息", code))
}
}
}
```
**理由**
- 显式优于隐式:所有错误码一目了然
- 新增错误码时必须同时更新两处(常量 + 列表),测试会强制检查映射表
### Decision 4: 业务代码清理策略
**选项**
| 选项 | 范围 | 优缺点 |
|------|------|--------|
| A. 全量清理 | 所有 291 处 | ✅ 彻底<br>❌ 风险高,改动大 |
| B. 仅清理冗余 | 消息与映射表一致的 | ✅ 安全<br>✅ 保留业务上下文 |
| C. 不清理 | 保持现状 | ✅ 零风险<br>❌ 技术债未还 |
**决策**:选项 B - 仅清理冗余硬编码
清理规则:
```go
// 清理前:消息与映射表完全一致
errors.New(errors.CodeUnauthorized, "未授权访问")
// 清理后:使用映射表默认值
errors.New(errors.CodeUnauthorized)
// 保留:业务特定消息,比映射表更精确
errors.New(errors.CodeNotFound, "提现申请不存在") // 保留,比 "资源未找到" 更清晰
```
**理由**
- 最小改动原则:只改必要的
- 保留业务价值:精确消息优于通用消息
## Risks / Trade-offs
### 风险矩阵
| 风险 | 等级 | 缓解措施 |
|------|------|----------|
| 启动时 panic 影响部署 | 中 | 本地开发和 CI 会先发现;错误消息清晰指明缺失的错误码 |
| 消息文案变化影响前端 | 低 | 仅清理与映射表一致的;业务特定消息保留 |
| 大规模代码改动引入 bug | 中 | 分阶段实施:先加校验和测试,再清理代码 |
| 遗漏错误码导致 panic | 低 | allErrorCodes 列表 + 测试双重保障 |
### Trade-offs
1. **启动时 panic vs 运行时容错**
- 选择 panic快速失败问题在部署前暴露
- 代价:如果遗漏会阻止服务启动
2. **强制映射表 vs 允许覆盖**
- 选择允许覆盖:保留业务灵活性
- 代价:无法 100% 保证消息一致性
## Migration Plan
### Phase 1: 基础设施(本次实施)
1. 改造 `errors.New()` 函数签名(向后兼容)
2. 添加 `allErrorCodes` 注册表
3. 添加 `init()` 启动校验
4. 添加 `TestAllCodesHaveMessages` 测试
5. 补充可能缺失的 errorMessages 条目
### Phase 2: 代码清理(本次实施)
1. 清理与映射表一致的冗余硬编码
2. 保留业务特定消息
### Rollback Strategy
如出现问题,回滚方式:
1. `errors.New()` 签名改动是向后兼容的,无需回滚
2. 如果 init() panic 导致问题,临时注释校验代码
3. Git revert 整个变更
## Open Questions
1. **是否需要 linter 规则?**
- 可考虑添加 golangci-lint 自定义规则,检测 `errors.New(code, msg)` 中 msg 与映射表重复的情况
- 暂不实施,后续根据需要添加

View File

@@ -0,0 +1,54 @@
## Why
当前项目中错误消息存在**双数据源问题**
1. `pkg/errors/codes.go` 中定义了 `errorMessages` 映射表
2. 业务代码中 `errors.New(code, "硬编码消息")` 全部传入硬编码消息
结果是:
- 映射表的"自动填充"功能(当 message 为空时使用映射表)**从未被使用**(死代码)
- 新增错误码时可能忘记更新映射表,导致映射表逐渐腐化
- 长期开发后新人不知道该用哪种方式,造成使用混乱
- 同一错误码在不同地方可能返回不同消息,影响一致性
## What Changes
- **改造 `errors.New()` 函数签名**:优先使用映射表消息,允许可选覆盖
- **添加 `init()` 启动时校验**:确保所有错误码常量都有对应的映射表条目
- **添加 CI 测试**:防止映射表腐化,确保错误码与消息映射完整
- **清理业务代码中的冗余硬编码**:将与映射表一致的消息改为使用默认值
- **保留业务特定消息覆盖能力**:如 "提现申请不存在" 比 "资源未找到" 更清晰的场景
## Capabilities
### New Capabilities
- `error-code-validation`: 错误码与消息映射的编译时/运行时校验机制,确保所有 Code* 常量都有对应的 errorMessages 条目
### Modified Capabilities
无。此变更不改变现有 spec 的行为要求,仅加固内部实现。
## Impact
### 代码影响
| 文件/目录 | 影响 |
|-----------|------|
| `pkg/errors/errors.go` | 改造 `New()` 函数签名,添加 `init()` 校验 |
| `pkg/errors/codes.go` | 可能需要补充缺失的映射条目 |
| `pkg/errors/codes_test.go` | 添加完整性校验测试 |
| `internal/service/**/*.go` | 清理冗余硬编码(约 150+ 处) |
| `internal/handler/**/*.go` | 清理冗余硬编码(约 80+ 处) |
| `pkg/middleware/*.go` | 清理冗余硬编码(约 15 处) |
### API 影响
无。错误响应格式不变,仅内部消息来源统一。
### 风险评估
| 风险 | 等级 | 缓解措施 |
|------|------|----------|
| 消息文案变化影响前端展示 | 低 | 保留业务特定消息覆盖能力 |
| 遗漏错误码导致启动失败 | 中 | init() 校验会在启动时立即暴露问题 |
| 大规模代码变更引入 bug | 中 | 分阶段实施:先加校验,再清理代码 |

View File

@@ -0,0 +1,91 @@
# error-code-validation
错误码与消息映射的校验机制,确保所有错误码常量都有对应的消息映射。
## ADDED Requirements
### Requirement: 错误码消息映射完整性校验
系统 SHALL 在启动时校验所有已注册的错误码都有对应的 `errorMessages` 映射条目。
如果发现缺失映射,系统 MUST 立即 panic 并输出清晰的错误信息,指明缺失的错误码。
#### Scenario: 所有错误码都有映射时正常启动
- **WHEN** 所有 `allErrorCodes` 中的错误码都在 `errorMessages` 映射表中存在
- **THEN** 系统正常启动,无错误日志
#### Scenario: 存在缺失映射时启动失败
- **WHEN** 某个错误码(如 `CodeNewFeature = 1099`)在 `allErrorCodes` 中注册但 `errorMessages` 中缺失
- **THEN** 系统 panic错误信息包含 "错误码 1099 缺少映射消息"
### Requirement: 错误码注册表维护
系统 SHALL 维护一个 `allErrorCodes` 切片,包含所有已定义的错误码常量。
新增错误码时,开发者 MUST 同时:
1.`codes.go` 中定义常量
2.`allErrorCodes` 中注册
3.`errorMessages` 中添加映射
#### Scenario: 新增错误码完整注册
- **WHEN** 开发者新增错误码 `CodeXxx = 1100`
- **THEN** 必须同时在 `allErrorCodes``errorMessages` 中添加对应条目
- **THEN** 否则启动时 panic 或测试失败
### Requirement: errors.New 默认使用映射表消息
`errors.New()` 函数 SHALL 优先使用 `errorMessages` 映射表中的消息作为默认值。
当调用者提供自定义消息时,系统 MUST 允许覆盖默认消息。
#### Scenario: 不传消息参数时使用映射表
- **WHEN** 调用 `errors.New(errors.CodeNotFound)`
- **THEN** 返回的 `AppError.Message` 为 "资源未找到"(映射表中的值)
#### Scenario: 传空字符串时使用映射表
- **WHEN** 调用 `errors.New(errors.CodeNotFound, "")`
- **THEN** 返回的 `AppError.Message` 为 "资源未找到"(映射表中的值)
#### Scenario: 传自定义消息时覆盖映射表
- **WHEN** 调用 `errors.New(errors.CodeNotFound, "提现申请不存在")`
- **THEN** 返回的 `AppError.Message` 为 "提现申请不存在"(自定义值)
### Requirement: errors.Wrap 默认使用映射表消息
`errors.Wrap()` 函数 SHALL 与 `errors.New()` 保持一致的消息处理逻辑。
#### Scenario: Wrap 不传消息时使用映射表
- **WHEN** 调用 `errors.Wrap(errors.CodeDatabaseError, originalErr)`
- **THEN** 返回的 `AppError.Message` 为 "数据库错误"(映射表中的值)
- **THEN** 返回的 `AppError.Err``originalErr`
#### Scenario: Wrap 传自定义消息时覆盖
- **WHEN** 调用 `errors.Wrap(errors.CodeDatabaseError, "查询用户失败", originalErr)`
- **THEN** 返回的 `AppError.Message` 为 "查询用户失败"
- **THEN** 返回的 `AppError.Err``originalErr`
### Requirement: CI 测试覆盖映射完整性
系统 SHALL 提供单元测试 `TestAllCodesHaveMessages`,验证所有注册的错误码都有对应的映射。
此测试 MUST 在 CI 流程中运行,防止映射表腐化。
#### Scenario: 测试检测到缺失映射
- **WHEN** 运行 `go test ./pkg/errors/...`
- **WHEN** 存在错误码在 `allErrorCodes` 但不在 `errorMessages`
- **THEN** 测试失败,输出缺失的错误码列表
#### Scenario: 测试检测到孤立映射
- **WHEN** 运行 `go test ./pkg/errors/...`
- **WHEN** 存在映射条目的错误码不在 `allErrorCodes`
- **THEN** 测试失败,输出孤立的错误码列表(可选警告)

View File

@@ -0,0 +1,49 @@
# 统一错误消息数据源 - 任务清单
## 1. 基础设施改造
- [x] 1.1 在 `pkg/errors/codes.go` 中添加 `allErrorCodes` 错误码注册表
- [x] 1.2 改造 `pkg/errors/errors.go` 中的 `New()` 函数签名为可变参数
- [x] 1.3 改造 `pkg/errors/errors.go` 中的 `Wrap()` 函数签名为可变参数
- [x] 1.4 在 `pkg/errors/codes.go` 中添加 `init()` 启动时校验函数
## 2. 测试保障
- [x] 2.1 在 `pkg/errors/codes_test.go` 中添加 `TestAllCodesHaveMessages` 测试
- [x] 2.2 在 `pkg/errors/codes_test.go` 中添加 `TestNoOrphanMessages` 测试(检测孤立映射)
- [x] 2.3 更新 `pkg/errors/handler_test.go` 测试覆盖新的函数签名
## 3. 业务代码清理 - Service 层
- [x] 3.1 清理 `internal/service/commission_withdrawal/service.go` 冗余硬编码
- [x] 3.2 清理 `internal/service/shop/service.go` 冗余硬编码
- [x] 3.3 清理 `internal/service/auth/service.go` 冗余硬编码
- [x] 3.4 清理 `internal/service/shop_account/service.go` 冗余硬编码
- [x] 3.5 清理 `internal/service/enterprise/service.go` 冗余硬编码
- [x] 3.6 清理 `internal/service/customer/service.go` 冗余硬编码
- [x] 3.7 清理 `internal/service/customer_account/service.go` 冗余硬编码
- [x] 3.8 清理 `internal/service/role/service.go` 冗余硬编码
- [x] 3.9 清理 `internal/service/permission/service.go` 冗余硬编码
- [x] 3.10 清理 `internal/service/account/service.go` 冗余硬编码
- [x] 3.11 清理 `internal/service/enterprise_card/service.go` 冗余硬编码
- [x] 3.12 清理 `internal/service/my_commission/service.go` 冗余硬编码
- [x] 3.13 清理 `internal/service/shop_commission/service.go` 冗余硬编码
- [x] 3.14 清理 `internal/service/commission_withdrawal_setting/service.go` 冗余硬编码
## 4. 业务代码清理 - Handler 层
- [x] 4.1 清理 `internal/handler/admin/*.go` 冗余硬编码(无需清理,都是业务特定消息)
- [x] 4.2 清理 `internal/handler/h5/*.go` 冗余硬编码(无需清理,都是业务特定消息)
- [x] 4.3 清理 `internal/handler/app/*.go` 冗余硬编码(无需清理,都是业务特定消息)
## 5. 业务代码清理 - Middleware 和其他
- [x] 5.1 清理 `pkg/middleware/*.go` 冗余硬编码(无需清理,都是业务特定消息)
- [x] 5.2 清理 `internal/middleware/*.go` 冗余硬编码(无需清理,都是业务特定消息)
- [x] 5.3 清理 `internal/bootstrap/*.go` 冗余硬编码(无需清理,都是业务特定消息)
## 6. 验证和收尾
- [x] 6.1 运行完整测试套件 `go test ./pkg/...` - 全部通过
- [x] 6.2 运行 lsp_diagnostics 检查类型错误 - 无错误
- [x] 6.3 编译验证 `go build ./...` - 成功

View File

@@ -0,0 +1,91 @@
# error-code-validation Specification
## Purpose
TBD - created by archiving change unify-error-message-source. Update Purpose after archive.
## Requirements
### Requirement: 错误码消息映射完整性校验
系统 SHALL 在启动时校验所有已注册的错误码都有对应的 `errorMessages` 映射条目。
如果发现缺失映射,系统 MUST 立即 panic 并输出清晰的错误信息,指明缺失的错误码。
#### Scenario: 所有错误码都有映射时正常启动
- **WHEN** 所有 `allErrorCodes` 中的错误码都在 `errorMessages` 映射表中存在
- **THEN** 系统正常启动,无错误日志
#### Scenario: 存在缺失映射时启动失败
- **WHEN** 某个错误码(如 `CodeNewFeature = 1099`)在 `allErrorCodes` 中注册但 `errorMessages` 中缺失
- **THEN** 系统 panic错误信息包含 "错误码 1099 缺少映射消息"
### Requirement: 错误码注册表维护
系统 SHALL 维护一个 `allErrorCodes` 切片,包含所有已定义的错误码常量。
新增错误码时,开发者 MUST 同时:
1.`codes.go` 中定义常量
2.`allErrorCodes` 中注册
3.`errorMessages` 中添加映射
#### Scenario: 新增错误码完整注册
- **WHEN** 开发者新增错误码 `CodeXxx = 1100`
- **THEN** 必须同时在 `allErrorCodes``errorMessages` 中添加对应条目
- **THEN** 否则启动时 panic 或测试失败
### Requirement: errors.New 默认使用映射表消息
`errors.New()` 函数 SHALL 优先使用 `errorMessages` 映射表中的消息作为默认值。
当调用者提供自定义消息时,系统 MUST 允许覆盖默认消息。
#### Scenario: 不传消息参数时使用映射表
- **WHEN** 调用 `errors.New(errors.CodeNotFound)`
- **THEN** 返回的 `AppError.Message` 为 "资源未找到"(映射表中的值)
#### Scenario: 传空字符串时使用映射表
- **WHEN** 调用 `errors.New(errors.CodeNotFound, "")`
- **THEN** 返回的 `AppError.Message` 为 "资源未找到"(映射表中的值)
#### Scenario: 传自定义消息时覆盖映射表
- **WHEN** 调用 `errors.New(errors.CodeNotFound, "提现申请不存在")`
- **THEN** 返回的 `AppError.Message` 为 "提现申请不存在"(自定义值)
### Requirement: errors.Wrap 默认使用映射表消息
`errors.Wrap()` 函数 SHALL 与 `errors.New()` 保持一致的消息处理逻辑。
#### Scenario: Wrap 不传消息时使用映射表
- **WHEN** 调用 `errors.Wrap(errors.CodeDatabaseError, originalErr)`
- **THEN** 返回的 `AppError.Message` 为 "数据库错误"(映射表中的值)
- **THEN** 返回的 `AppError.Err``originalErr`
#### Scenario: Wrap 传自定义消息时覆盖
- **WHEN** 调用 `errors.Wrap(errors.CodeDatabaseError, "查询用户失败", originalErr)`
- **THEN** 返回的 `AppError.Message` 为 "查询用户失败"
- **THEN** 返回的 `AppError.Err``originalErr`
### Requirement: CI 测试覆盖映射完整性
系统 SHALL 提供单元测试 `TestAllCodesHaveMessages`,验证所有注册的错误码都有对应的映射。
此测试 MUST 在 CI 流程中运行,防止映射表腐化。
#### Scenario: 测试检测到缺失映射
- **WHEN** 运行 `go test ./pkg/errors/...`
- **WHEN** 存在错误码在 `allErrorCodes` 但不在 `errorMessages`
- **THEN** 测试失败,输出缺失的错误码列表
#### Scenario: 测试检测到孤立映射
- **WHEN** 运行 `go test ./pkg/errors/...`
- **WHEN** 存在映射条目的错误码不在 `allErrorCodes`
- **THEN** 测试失败,输出孤立的错误码列表(可选警告)

View File

@@ -85,7 +85,6 @@ type LogRotationConfig struct {
// MiddlewareConfig 中间件配置 // MiddlewareConfig 中间件配置
type MiddlewareConfig struct { type MiddlewareConfig struct {
EnableAuth bool `mapstructure:"enable_auth"` // 启用 keyauth 中间件
EnableRateLimiter bool `mapstructure:"enable_rate_limiter"` // 启用限流器默认false EnableRateLimiter bool `mapstructure:"enable_rate_limiter"` // 启用限流器默认false
RateLimiter RateLimiterConfig `mapstructure:"rate_limiter"` // 限流器配置 RateLimiter RateLimiterConfig `mapstructure:"rate_limiter"` // 限流器配置
} }

View File

@@ -54,7 +54,7 @@ func BenchmarkGet(b *testing.B) {
_ = cfg.Server.Address _ = cfg.Server.Address
_ = cfg.Redis.Address _ = cfg.Redis.Address
_ = cfg.Logging.Level _ = cfg.Logging.Level
_ = cfg.Middleware.EnableAuth _ = cfg.Middleware.EnableRateLimiter
} }
}) })
} }

View File

@@ -104,9 +104,6 @@ middleware:
if cfg.Logging.Level != "info" { if cfg.Logging.Level != "info" {
t.Errorf("expected logging.level info, got %s", cfg.Logging.Level) t.Errorf("expected logging.level info, got %s", cfg.Logging.Level)
} }
if cfg.Middleware.EnableAuth != true {
t.Errorf("expected enable_auth true, got %v", cfg.Middleware.EnableAuth)
}
}, },
}, },
{ {
@@ -165,7 +162,6 @@ logging:
compress: false compress: false
middleware: middleware:
enable_auth: false
enable_rate_limiter: false enable_rate_limiter: false
rate_limiter: rate_limiter:
max: 50 max: 50
@@ -194,9 +190,6 @@ middleware:
if cfg.Logging.Level != "debug" { if cfg.Logging.Level != "debug" {
t.Errorf("expected logging.level debug, got %s", cfg.Logging.Level) t.Errorf("expected logging.level debug, got %s", cfg.Logging.Level)
} }
if cfg.Middleware.EnableAuth != false {
t.Errorf("expected enable_auth false, got %v", cfg.Middleware.EnableAuth)
}
}, },
}, },
{ {
@@ -576,9 +569,6 @@ middleware:
if newCfg.Redis.PoolSize != 20 { if newCfg.Redis.PoolSize != 20 {
t.Errorf("expected updated redis.pool_size 20, got %d", newCfg.Redis.PoolSize) t.Errorf("expected updated redis.pool_size 20, got %d", newCfg.Redis.PoolSize)
} }
if newCfg.Middleware.EnableAuth != false {
t.Errorf("expected updated enable_auth false, got %v", newCfg.Middleware.EnableAuth)
}
if newCfg.Middleware.EnableRateLimiter != true { if newCfg.Middleware.EnableRateLimiter != true {
t.Errorf("expected updated enable_rate_limiter true, got %v", newCfg.Middleware.EnableRateLimiter) t.Errorf("expected updated enable_rate_limiter true, got %v", newCfg.Middleware.EnableRateLimiter)
} }

View File

@@ -1,5 +1,7 @@
package errors package errors
import "fmt"
// 错误码定义 // 错误码定义
const ( const (
// 成功 // 成功
@@ -66,6 +68,68 @@ const (
CodeTaskQueueError = 2006 // 任务队列错误 CodeTaskQueueError = 2006 // 任务队列错误
) )
// allErrorCodes 所有已注册的错误码
// 新增错误码时必须同时在此列表中注册
var allErrorCodes = []int{
CodeSuccess,
CodeInvalidParam,
CodeMissingToken,
CodeInvalidToken,
CodeUnauthorized,
CodeForbidden,
CodeNotFound,
CodeConflict,
CodeTooManyRequests,
CodeRequestTooLarge,
CodeAccountNotFound,
CodeAccountDisabled,
CodeAccountDeleted,
CodeUsernameExists,
CodePhoneExists,
CodeInvalidPassword,
CodePasswordTooWeak,
CodeParentIDRequired,
CodeInvalidParentID,
CodeCannotModifyParent,
CodeCannotModifyUserType,
CodeRoleNotFound,
CodeRoleNameExists,
CodePermissionNotFound,
CodePermCodeExists,
CodeInvalidPermCode,
CodeRoleAlreadyAssigned,
CodePermAlreadyAssigned,
CodeShopNotFound,
CodeShopCodeExists,
CodeShopLevelExceeded,
CodeEnterpriseNotFound,
CodeEnterpriseCodeExists,
CodeCustomerNotFound,
CodeCustomerPhoneExists,
CodeInvalidCredentials,
CodeAccountLocked,
CodePasswordExpired,
CodeInvalidOldPassword,
CodeInvalidStatus,
CodeInsufficientBalance,
CodeWithdrawalNotFound,
CodeWalletNotFound,
CodeInternalError,
CodeDatabaseError,
CodeRedisError,
CodeServiceUnavailable,
CodeTimeout,
CodeTaskQueueError,
}
func init() {
for _, code := range allErrorCodes {
if _, ok := errorMessages[code]; !ok {
panic(fmt.Sprintf("错误码 %d 缺少映射消息,请在 errorMessages 中添加", code))
}
}
}
// errorMessages 错误消息映射表(中文) // errorMessages 错误消息映射表(中文)
var errorMessages = map[int]string{ var errorMessages = map[int]string{
CodeSuccess: "成功", CodeSuccess: "成功",

View File

@@ -135,6 +135,35 @@ func TestGetLogLevel(t *testing.T) {
} }
} }
func TestAllCodesHaveMessages(t *testing.T) {
var missing []int
for _, code := range allErrorCodes {
if _, ok := errorMessages[code]; !ok {
missing = append(missing, code)
}
}
if len(missing) > 0 {
t.Errorf("以下错误码缺少映射消息: %v", missing)
}
}
func TestNoOrphanMessages(t *testing.T) {
codeSet := make(map[int]bool)
for _, code := range allErrorCodes {
codeSet[code] = true
}
var orphan []int
for code := range errorMessages {
if !codeSet[code] {
orphan = append(orphan, code)
}
}
if len(orphan) > 0 {
t.Errorf("以下错误码在 errorMessages 中存在但未在 allErrorCodes 中注册: %v", orphan)
}
}
// BenchmarkGetHTTPStatus 基准测试 HTTP 状态码映射性能 // BenchmarkGetHTTPStatus 基准测试 HTTP 状态码映射性能
func BenchmarkGetHTTPStatus(b *testing.B) { func BenchmarkGetHTTPStatus(b *testing.B) {
codes := []int{ codes := []int{

View File

@@ -32,10 +32,16 @@ func (e *AppError) Unwrap() error {
} }
// New 创建新的 AppError // New 创建新的 AppError
func New(code int, message string) *AppError { // 优先使用 errorMessages 映射表中的消息,允许通过可选参数覆盖
// 如果消息为空,使用默认消息 // 用法:
if message == "" { // - errors.New(errors.CodeNotFound) // 使用映射表默认消息
message = GetMessage(code, "zh-CN") // - errors.New(errors.CodeNotFound, "提现申请不存在") // 覆盖为自定义消息
func New(code int, customMsg ...string) *AppError {
// 默认从映射表获取消息
message := GetMessage(code, "zh-CN")
// 如果提供了自定义消息且非空,则覆盖
if len(customMsg) > 0 && customMsg[0] != "" {
message = customMsg[0]
} }
return &AppError{ return &AppError{
Code: code, Code: code,
@@ -44,10 +50,14 @@ func New(code int, message string) *AppError {
} }
// Wrap 用错误码和消息包装现有错误 // Wrap 用错误码和消息包装现有错误
func Wrap(code int, message string, err error) *AppError { // 优先使用 errorMessages 映射表中的消息,允许通过可选参数覆盖
// 如果消息为空,使用默认消息 // 用法:
if message == "" { // - errors.Wrap(errors.CodeDatabaseError, originalErr) // 使用映射表默认消息
message = GetMessage(code, "zh-CN") // - errors.Wrap(errors.CodeDatabaseError, originalErr, "查询用户失败") // 覆盖为自定义消息
func Wrap(code int, err error, customMsg ...string) *AppError {
message := GetMessage(code, "zh-CN")
if len(customMsg) > 0 && customMsg[0] != "" {
message = customMsg[0]
} }
return &AppError{ return &AppError{
Code: code, Code: code,

View File

@@ -124,7 +124,7 @@ func TestAppErrorMethods(t *testing.T) {
// TestAppErrorUnwrap 测试错误链支持 // TestAppErrorUnwrap 测试错误链支持
func TestAppErrorUnwrap(t *testing.T) { func TestAppErrorUnwrap(t *testing.T) {
originalErr := errors.New("database connection failed") originalErr := errors.New("database connection failed")
appErr := Wrap(CodeDatabaseError, "", originalErr) appErr := Wrap(CodeDatabaseError, originalErr)
// 测试 Unwrap // 测试 Unwrap
unwrapped := appErr.Unwrap() unwrapped := appErr.Unwrap()
@@ -239,7 +239,12 @@ func TestWrapError(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
err := Wrap(tt.code, tt.message, tt.originalErr) var err *AppError
if tt.message == "" {
err = Wrap(tt.code, tt.originalErr)
} else {
err = Wrap(tt.code, tt.originalErr, tt.message)
}
if err.Error() != tt.expectedMessage { if err.Error() != tt.expectedMessage {
t.Errorf("Wrap().Error() = %q, expected %q", err.Error(), tt.expectedMessage) t.Errorf("Wrap().Error() = %q, expected %q", err.Error(), tt.expectedMessage)

View File

@@ -163,7 +163,7 @@ func Auth(config AuthConfig) fiber.Handler {
return appErr return appErr
} }
// 否则包装为 AppError // 否则包装为 AppError
return errors.Wrap(errors.CodeInvalidToken, "认证令牌无效", err) return errors.Wrap(errors.CodeInvalidToken, err, "认证令牌无效")
} }
// 将用户信息设置到 context // 将用户信息设置到 context

View File

@@ -70,7 +70,7 @@ func RequirePermission(permCode string, config PermissionConfig) fiber.Handler {
return appErr return appErr
} }
// 否则包装为 AppError // 否则包装为 AppError
return errors.Wrap(errors.CodeInternalError, "权限检查失败", err) return errors.Wrap(errors.CodeInternalError, err, "权限检查失败")
} }
if !hasPermission { if !hasPermission {
@@ -119,7 +119,7 @@ func RequireAnyPermission(permCodes []string, config PermissionConfig) fiber.Han
return appErr return appErr
} }
// 否则包装为 AppError // 否则包装为 AppError
return errors.Wrap(errors.CodeInternalError, "权限检查失败", err) return errors.Wrap(errors.CodeInternalError, err, "权限检查失败")
} }
// 如果拥有任意一个权限,则放行 // 如果拥有任意一个权限,则放行
@@ -170,7 +170,7 @@ func RequireAllPermissions(permCodes []string, config PermissionConfig) fiber.Ha
return appErr return appErr
} }
// 否则包装为 AppError // 否则包装为 AppError
return errors.Wrap(errors.CodeInternalError, "权限检查失败", err) return errors.Wrap(errors.CodeInternalError, err, "权限检查失败")
} }
// 如果缺少任意一个权限,则拒绝访问 // 如果缺少任意一个权限,则拒绝访问

View File

@@ -65,7 +65,6 @@ type LogRotationConfig struct {
// MiddlewareConfig contains middleware settings // MiddlewareConfig contains middleware settings
type MiddlewareConfig struct { type MiddlewareConfig struct {
EnableAuth bool `mapstructure:"enable_auth"` // Enable keyauth middleware
EnableRateLimiter bool `mapstructure:"enable_rate_limiter"` // Enable limiter (default: false) EnableRateLimiter bool `mapstructure:"enable_rate_limiter"` // Enable limiter (default: false)
RateLimiter RateLimiterConfig `mapstructure:"rate_limiter"` // Rate limiter settings RateLimiter RateLimiterConfig `mapstructure:"rate_limiter"` // Rate limiter settings
} }
@@ -141,7 +140,6 @@ logging:
compress: true compress: true
middleware: middleware:
enable_auth: true
enable_rate_limiter: false # Disabled by default enable_rate_limiter: false # Disabled by default
rate_limiter: rate_limiter:
max: 100 # requests max: 100 # requests
@@ -771,7 +769,6 @@ Example:
| Field | Type | Required | Constraint | Default | Error Message | | Field | Type | Required | Constraint | Default | Error Message |
|-------|------|----------|------------|---------|---------------| |-------|------|----------|------------|---------|---------------|
| `middleware.enable_auth` | bool | No | true or false | `true` | "middleware.enable_auth: must be boolean (true/false)" |
| `middleware.enable_rate_limiter` | bool | No | true or false | `false` | "middleware.enable_rate_limiter: must be boolean (true/false)" | | `middleware.enable_rate_limiter` | bool | No | true or false | `false` | "middleware.enable_rate_limiter: must be boolean (true/false)" |
#### Rate Limiter Validation #### Rate Limiter Validation

View File

@@ -99,7 +99,6 @@ logging:
compress: true compress: true
middleware: middleware:
enable_auth: true
enable_rate_limiter: false # Disabled by default enable_rate_limiter: false # Disabled by default
rate_limiter: rate_limiter:
max: 100 # requests max: 100 # requests
@@ -356,7 +355,6 @@ Edit `configs/config.yaml`:
```yaml ```yaml
middleware: middleware:
enable_auth: true
enable_rate_limiter: true # 设置为 true 启用限流 enable_rate_limiter: true # 设置为 true 启用限流
rate_limiter: rate_limiter:
max: 5 # 每个窗口最大请求数(测试用低值) max: 5 # 每个窗口最大请求数(测试用低值)
@@ -799,7 +797,7 @@ logging:
development: true # Pretty-printed logs (non-JSON) development: true # Pretty-printed logs (non-JSON)
middleware: middleware:
enable_auth: false # Optional: disable auth for easier testing enable_rate_limiter: false # Optional: disable rate limiter for easier testing
``` ```
**Usage**: **Usage**: