diff --git a/README.md b/README.md index 93c70c5..f3e120d 100644 --- a/README.md +++ b/README.md @@ -452,13 +452,13 @@ junhong_cmp_fiber/ │ (访问日志) │ └────────────┬────────────┘ │ - ┌────────────▼────────────┐ - │ 4. KeyAuth 中间件 │ - │ (认证) │ ─── 可选 (config: enable_auth) - └────────────┬────────────┘ - │ - ┌────────────▼────────────┐ - │ 5. RateLimiter 中间件 │ + ┌────────────▼────────────┐ + │ 4. 认证中间件 │ + │ (按路由组配置) │ ─── 模块化路由注册 + └────────────┬────────────┘ + │ + ┌────────────▼────────────┐ + │ 5. RateLimiter 中间件 │ │ (限流) │ ─── 可选 (config: enable_rate_limiter) └────────────┬────────────┘ │ @@ -502,20 +502,22 @@ junhong_cmp_fiber/ - **始终激活**:是 - **日志格式**:包含字段的 JSON:timestamp、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 - - 通过 Redis 验证 token(`auth:token:{token}`) + - 从 `Authorization: Bearer {token}` 请求头提取 token + - 通过 TokenValidator 函数验证 token(支持 JWT 和 Redis Token) - 如果缺失/无效 token 返回 401 - - 如果 Redis 不可用返回 503(fail-closed 策略) - - 成功时将用户 ID 存储在上下文中:`c.Locals(constants.ContextKeyUserID)` -- **配置**:`middleware.enable_auth`(默认:true) -- **跳过路由**:`/health`(健康检查绕过认证) + - 成功时将用户信息存储在上下文中(UserID、UserType、ShopID、EnterpriseID) +- **实现方式**:模块化路由注册(无全局配置) + - `/api/admin/*`:后台认证(SuperAdmin、Platform、Agent) + - `/api/h5/*`:H5 认证(Agent、Enterprise) + - `/api/personal/*`:个人客户认证(JWT) +- **跳过路由**:各路由组可自行配置跳过路径(如 `/api/admin/login`) - **错误码**: - 1001:缺失 token - 1002:无效或过期 token - - 1004:认证服务不可用 + - 1003:权限不足 #### 5. RateLimiter 中间件(internal/middleware/ratelimit.go) - **用途**:通过限制请求速率保护 API 免受滥用 @@ -545,11 +547,8 @@ app.Use(recover.New()) app.Use(addRequestID()) app.Use(loggerMiddleware()) -// 可选:认证中间件 -if config.GetConfig().Middleware.EnableAuth { - tokenValidator := validator.NewTokenValidator(rdb, logger.GetAppLogger()) - app.Use(middleware.KeyAuth(tokenValidator, logger.GetAppLogger())) -} +// 模块化路由注册(认证中间件按路由组配置) +routes.RegisterRoutes(app, handlers, middlewares) // 可选:限流中间件 if config.GetConfig().Middleware.EnableRateLimiter { @@ -557,16 +556,13 @@ if config.GetConfig().Middleware.EnableRateLimiter { if config.GetConfig().Middleware.RateLimiter.Storage == "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.Expiration, storage, )) } - -// 路由 -app.Get("/health", healthHandler) -app.Get("/api/v1/users", listUsersHandler) ``` ### 请求流程示例 diff --git a/cmd/api/main.go b/cmd/api/main.go index aa181b9..dc8a2fe 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -226,12 +226,6 @@ func initRoutes(app *fiber.App, cfg *config.Config, result *bootstrap.BootstrapR // API v1 路由组(用于受保护的端点) v1 := app.Group("/api/v1") - // 可选:启用认证中间件 - if cfg.Middleware.EnableAuth { - // TODO: 配置 TokenValidator - appLogger.Info("认证中间件已启用") - } - // 可选:启用限流器 if cfg.Middleware.EnableRateLimiter { initRateLimiter(v1, cfg, appLogger) diff --git a/configs/config.dev.yaml b/configs/config.dev.yaml index 77c42d3..889ac61 100644 --- a/configs/config.dev.yaml +++ b/configs/config.dev.yaml @@ -53,7 +53,6 @@ logging: compress: false middleware: - enable_auth: true # 开发环境可选禁用认证 enable_rate_limiter: true rate_limiter: max: 1000 diff --git a/configs/config.prod.yaml b/configs/config.prod.yaml index 9b3de76..3468f7a 100644 --- a/configs/config.prod.yaml +++ b/configs/config.prod.yaml @@ -52,9 +52,6 @@ logging: compress: true middleware: - # 生产环境必须启用认证 - enable_auth: true - # 生产环境启用限流,保护服务免受滥用 enable_rate_limiter: true diff --git a/configs/config.staging.yaml b/configs/config.staging.yaml index 5e6a6e5..9591920 100644 --- a/configs/config.staging.yaml +++ b/configs/config.staging.yaml @@ -52,9 +52,6 @@ logging: compress: true middleware: - # 预发布环境启用认证 - enable_auth: true - # 预发布环境启用限流,测试生产配置 enable_rate_limiter: true diff --git a/configs/config.yaml b/configs/config.yaml index 0675ab9..2b14bb8 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -53,7 +53,6 @@ logging: compress: false middleware: - enable_auth: true # 开发环境可选禁用认证 enable_rate_limiter: true rate_limiter: max: 1000 diff --git a/docs/PROJECT-COMPLETION-SUMMARY.md b/docs/PROJECT-COMPLETION-SUMMARY.md index 65ec94e..8a64c51 100644 --- a/docs/PROJECT-COMPLETION-SUMMARY.md +++ b/docs/PROJECT-COMPLETION-SUMMARY.md @@ -481,7 +481,6 @@ redis: pool_size: 50 # 连接池大小 middleware: - enable_auth: true # 启用认证 enable_rate_limiter: true # 启用限流 rate_limiter: max: 5000 # 每分钟最大请求数 diff --git a/docs/deployment/deployment-guide.md b/docs/deployment/deployment-guide.md index 274768e..1c3e0b3 100644 --- a/docs/deployment/deployment-guide.md +++ b/docs/deployment/deployment-guide.md @@ -152,7 +152,6 @@ logging: compress: true middleware: - enable_auth: true enable_rate_limiter: false EOF ``` diff --git a/docs/rate-limiting.md b/docs/rate-limiting.md index 68fc42f..57b582d 100644 --- a/docs/rate-limiting.md +++ b/docs/rate-limiting.md @@ -166,7 +166,6 @@ rate_limiter: ```yaml middleware: - enable_auth: false # Optional: disable auth for easier testing enable_rate_limiter: false # Disabled by default rate_limiter: @@ -181,7 +180,6 @@ middleware: ```yaml middleware: - enable_auth: true enable_rate_limiter: true # Enabled to test production behavior rate_limiter: @@ -196,7 +194,6 @@ middleware: ```yaml middleware: - enable_auth: true enable_rate_limiter: true # Always enabled in production rate_limiter: diff --git a/internal/handler/app/personal_customer.go b/internal/handler/app/personal_customer.go index 465c7bc..9c7233a 100644 --- a/internal/handler/app/personal_customer.go +++ b/internal/handler/app/personal_customer.go @@ -43,7 +43,7 @@ func (h *PersonalCustomerHandler) SendCode(c *fiber.Ctx) error { zap.String("phone", req.Phone), zap.Error(err), ) - return errors.Wrap(errors.CodeInternalError, "发送验证码失败", err) + return errors.Wrap(errors.CodeInternalError, err, "发送验证码失败") } return response.Success(c, fiber.Map{ @@ -88,7 +88,7 @@ func (h *PersonalCustomerHandler) Login(c *fiber.Ctx) error { zap.String("phone", req.Phone), 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.Error(err), ) - return errors.Wrap(errors.CodeInternalError, "更新个人资料失败", err) + return errors.Wrap(errors.CodeInternalError, err, "更新个人资料失败") } return response.Success(c, fiber.Map{ @@ -181,7 +181,7 @@ func (h *PersonalCustomerHandler) GetProfile(c *fiber.Ctx) error { zap.Uint("customer_id", customerID), zap.Error(err), ) - return errors.Wrap(errors.CodeInternalError, "获取个人资料失败", err) + return errors.Wrap(errors.CodeInternalError, err, "获取个人资料失败") } // 构造响应 diff --git a/internal/middleware/recover.go b/internal/middleware/recover.go index 77d0ec0..0125c3f 100644 --- a/internal/middleware/recover.go +++ b/internal/middleware/recover.go @@ -40,8 +40,8 @@ func Recover(logger *zap.Logger) fiber.Handler { // 但由于我们在 defer 中,需要通过 c.Next() 返回错误 panicErr := errors.Wrap( errors.CodeInternalError, - fmt.Sprintf("服务发生异常: %v", r), fmt.Errorf("panic: %v", r), + fmt.Sprintf("服务发生异常: %v", r), ) // 直接调用 ErrorHandler(通过返回错误) diff --git a/openspec/changes/archive/2026-01-22-unify-error-message-source/.openspec.yaml b/openspec/changes/archive/2026-01-22-unify-error-message-source/.openspec.yaml new file mode 100644 index 0000000..ec9a990 --- /dev/null +++ b/openspec/changes/archive/2026-01-22-unify-error-message-source/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-01-22 diff --git a/openspec/changes/archive/2026-01-22-unify-error-message-source/design.md b/openspec/changes/archive/2026-01-22-unify-error-message-source/design.md new file mode 100644 index 0000000..3ebaf7b --- /dev/null +++ b/openspec/changes/archive/2026-01-22-unify-error-message-source/design.md @@ -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)` | ✅ 强制单一数据源
❌ 丢失业务上下文 | +| B. message 改为可选 | `New(code int, msg ...string)` | ✅ 默认用映射表
✅ 允许覆盖
✅ 向后兼容 | +| C. 新增 NewWithMsg | `New(code)` + `NewWithMsg(code, msg)` | ✅ 清晰区分
❌ 需改所有调用点 | + +**决策**:选项 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 | 程序启动 | ✅ 立即发现
❌ 启动失败 | +| B. init() 日志警告 | 程序启动 | ✅ 不阻塞启动
❌ 可能被忽略 | +| C. 仅测试校验 | CI 运行 | ✅ 不影响生产
❌ 本地开发可能遗漏 | + +**决策**:选项 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` 扫描生成 | ✅ 自动化
❌ 增加构建复杂度 | +| D. 基于映射表反向校验 | 遍历 errorMessages 的 key | ✅ 简单
❌ 无法检测缺失 | +| E. 定义错误码注册表 | 显式注册每个错误码 | ✅ 清晰
✅ 编译时检查 | + +**决策**:选项 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 处 | ✅ 彻底
❌ 风险高,改动大 | +| B. 仅清理冗余 | 消息与映射表一致的 | ✅ 安全
✅ 保留业务上下文 | +| C. 不清理 | 保持现状 | ✅ 零风险
❌ 技术债未还 | + +**决策**:选项 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 与映射表重复的情况 + - 暂不实施,后续根据需要添加 diff --git a/openspec/changes/archive/2026-01-22-unify-error-message-source/proposal.md b/openspec/changes/archive/2026-01-22-unify-error-message-source/proposal.md new file mode 100644 index 0000000..64191c7 --- /dev/null +++ b/openspec/changes/archive/2026-01-22-unify-error-message-source/proposal.md @@ -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 | 中 | 分阶段实施:先加校验,再清理代码 | diff --git a/openspec/changes/archive/2026-01-22-unify-error-message-source/specs/error-code-validation/spec.md b/openspec/changes/archive/2026-01-22-unify-error-message-source/specs/error-code-validation/spec.md new file mode 100644 index 0000000..c7aec26 --- /dev/null +++ b/openspec/changes/archive/2026-01-22-unify-error-message-source/specs/error-code-validation/spec.md @@ -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** 测试失败,输出孤立的错误码列表(可选警告) diff --git a/openspec/changes/archive/2026-01-22-unify-error-message-source/tasks.md b/openspec/changes/archive/2026-01-22-unify-error-message-source/tasks.md new file mode 100644 index 0000000..b94f620 --- /dev/null +++ b/openspec/changes/archive/2026-01-22-unify-error-message-source/tasks.md @@ -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 ./...` - 成功 diff --git a/openspec/specs/error-code-validation/spec.md b/openspec/specs/error-code-validation/spec.md new file mode 100644 index 0000000..6aac43c --- /dev/null +++ b/openspec/specs/error-code-validation/spec.md @@ -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** 测试失败,输出孤立的错误码列表(可选警告) + diff --git a/pkg/config/config.go b/pkg/config/config.go index 1e6c82c..19f613c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -85,7 +85,6 @@ type LogRotationConfig struct { // MiddlewareConfig 中间件配置 type MiddlewareConfig struct { - EnableAuth bool `mapstructure:"enable_auth"` // 启用 keyauth 中间件 EnableRateLimiter bool `mapstructure:"enable_rate_limiter"` // 启用限流器(默认:false) RateLimiter RateLimiterConfig `mapstructure:"rate_limiter"` // 限流器配置 } diff --git a/pkg/config/config_bench_test.go b/pkg/config/config_bench_test.go index 739ee60..7750357 100644 --- a/pkg/config/config_bench_test.go +++ b/pkg/config/config_bench_test.go @@ -54,7 +54,7 @@ func BenchmarkGet(b *testing.B) { _ = cfg.Server.Address _ = cfg.Redis.Address _ = cfg.Logging.Level - _ = cfg.Middleware.EnableAuth + _ = cfg.Middleware.EnableRateLimiter } }) } diff --git a/pkg/config/loader_test.go b/pkg/config/loader_test.go index 8609135..4dc7271 100644 --- a/pkg/config/loader_test.go +++ b/pkg/config/loader_test.go @@ -104,9 +104,6 @@ middleware: if cfg.Logging.Level != "info" { 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 middleware: - enable_auth: false enable_rate_limiter: false rate_limiter: max: 50 @@ -194,9 +190,6 @@ middleware: if cfg.Logging.Level != "debug" { 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 { 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 { t.Errorf("expected updated enable_rate_limiter true, got %v", newCfg.Middleware.EnableRateLimiter) } diff --git a/pkg/errors/codes.go b/pkg/errors/codes.go index 8772ae6..db4407e 100644 --- a/pkg/errors/codes.go +++ b/pkg/errors/codes.go @@ -1,5 +1,7 @@ package errors +import "fmt" + // 错误码定义 const ( // 成功 @@ -66,6 +68,68 @@ const ( 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 错误消息映射表(中文) var errorMessages = map[int]string{ CodeSuccess: "成功", diff --git a/pkg/errors/codes_test.go b/pkg/errors/codes_test.go index 3e4ca99..6b571d4 100644 --- a/pkg/errors/codes_test.go +++ b/pkg/errors/codes_test.go @@ -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 状态码映射性能 func BenchmarkGetHTTPStatus(b *testing.B) { codes := []int{ diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go index 7df2c6f..35d9156 100644 --- a/pkg/errors/errors.go +++ b/pkg/errors/errors.go @@ -32,10 +32,16 @@ func (e *AppError) Unwrap() error { } // New 创建新的 AppError -func New(code int, message string) *AppError { - // 如果消息为空,使用默认消息 - if message == "" { - message = GetMessage(code, "zh-CN") +// 优先使用 errorMessages 映射表中的消息,允许通过可选参数覆盖 +// 用法: +// - errors.New(errors.CodeNotFound) // 使用映射表默认消息 +// - 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{ Code: code, @@ -44,10 +50,14 @@ func New(code int, message string) *AppError { } // Wrap 用错误码和消息包装现有错误 -func Wrap(code int, message string, err error) *AppError { - // 如果消息为空,使用默认消息 - if message == "" { - message = GetMessage(code, "zh-CN") +// 优先使用 errorMessages 映射表中的消息,允许通过可选参数覆盖 +// 用法: +// - errors.Wrap(errors.CodeDatabaseError, originalErr) // 使用映射表默认消息 +// - 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{ Code: code, diff --git a/pkg/errors/handler_test.go b/pkg/errors/handler_test.go index a1da393..fbc996f 100644 --- a/pkg/errors/handler_test.go +++ b/pkg/errors/handler_test.go @@ -124,7 +124,7 @@ func TestAppErrorMethods(t *testing.T) { // TestAppErrorUnwrap 测试错误链支持 func TestAppErrorUnwrap(t *testing.T) { originalErr := errors.New("database connection failed") - appErr := Wrap(CodeDatabaseError, "", originalErr) + appErr := Wrap(CodeDatabaseError, originalErr) // 测试 Unwrap unwrapped := appErr.Unwrap() @@ -239,7 +239,12 @@ func TestWrapError(t *testing.T) { for _, tt := range tests { 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 { t.Errorf("Wrap().Error() = %q, expected %q", err.Error(), tt.expectedMessage) diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index bde72c6..cc0f9ed 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -163,7 +163,7 @@ func Auth(config AuthConfig) fiber.Handler { return appErr } // 否则包装为 AppError - return errors.Wrap(errors.CodeInvalidToken, "认证令牌无效", err) + return errors.Wrap(errors.CodeInvalidToken, err, "认证令牌无效") } // 将用户信息设置到 context diff --git a/pkg/middleware/permission.go b/pkg/middleware/permission.go index 279dc1d..b79fdba 100644 --- a/pkg/middleware/permission.go +++ b/pkg/middleware/permission.go @@ -70,7 +70,7 @@ func RequirePermission(permCode string, config PermissionConfig) fiber.Handler { return appErr } // 否则包装为 AppError - return errors.Wrap(errors.CodeInternalError, "权限检查失败", err) + return errors.Wrap(errors.CodeInternalError, err, "权限检查失败") } if !hasPermission { @@ -119,7 +119,7 @@ func RequireAnyPermission(permCodes []string, config PermissionConfig) fiber.Han return appErr } // 否则包装为 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 } // 否则包装为 AppError - return errors.Wrap(errors.CodeInternalError, "权限检查失败", err) + return errors.Wrap(errors.CodeInternalError, err, "权限检查失败") } // 如果缺少任意一个权限,则拒绝访问 diff --git a/specs/001-fiber-middleware-integration/data-model.md b/specs/001-fiber-middleware-integration/data-model.md index 3925909..396da2e 100644 --- a/specs/001-fiber-middleware-integration/data-model.md +++ b/specs/001-fiber-middleware-integration/data-model.md @@ -65,7 +65,6 @@ type LogRotationConfig struct { // MiddlewareConfig contains middleware settings type MiddlewareConfig struct { - EnableAuth bool `mapstructure:"enable_auth"` // Enable keyauth middleware EnableRateLimiter bool `mapstructure:"enable_rate_limiter"` // Enable limiter (default: false) RateLimiter RateLimiterConfig `mapstructure:"rate_limiter"` // Rate limiter settings } @@ -141,7 +140,6 @@ logging: compress: true middleware: - enable_auth: true enable_rate_limiter: false # Disabled by default rate_limiter: max: 100 # requests @@ -771,7 +769,6 @@ Example: | 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)" | #### Rate Limiter Validation diff --git a/specs/001-fiber-middleware-integration/quickstart.md b/specs/001-fiber-middleware-integration/quickstart.md index 531ac8f..2b305a8 100644 --- a/specs/001-fiber-middleware-integration/quickstart.md +++ b/specs/001-fiber-middleware-integration/quickstart.md @@ -99,7 +99,6 @@ logging: compress: true middleware: - enable_auth: true enable_rate_limiter: false # Disabled by default rate_limiter: max: 100 # requests @@ -356,7 +355,6 @@ Edit `configs/config.yaml`: ```yaml middleware: - enable_auth: true enable_rate_limiter: true # 设置为 true 启用限流 rate_limiter: max: 5 # 每个窗口最大请求数(测试用低值) @@ -799,7 +797,7 @@ logging: development: true # Pretty-printed logs (non-JSON) middleware: - enable_auth: false # Optional: disable auth for easier testing + enable_rate_limiter: false # Optional: disable rate limiter for easier testing ``` **Usage**: