# OpenAPI 文档契约对齐 - 设计文档 ## Context ### 当前状态 项目使用 `github.com/swaggest/openapi-go/openapi3` 库生成 OpenAPI 3.0.3 规范文档。文档生成通过以下机制实现: 1. **路由注册机制**:`internal/routes/registry.go` 中的 `Register()` 函数 2. **文档生成器**:`pkg/openapi/generator.go` 中的 `Generator` 类 3. **Handler 清单管理**:`cmd/api/docs.go` 和 `cmd/gendocs/main.go` 中构造 handlers ### 问题现状 #### 问题 1:响应字段名不一致 **文档定义**(OpenAPI YAML): ```yaml ErrorResponse: properties: code: { type: integer } message: { type: string } # ❌ 错误字段名 ``` **真实运行时**(`pkg/response/response.go`): ```go type Response struct { Code int `json:"code"` Msg string `json:"msg"` // ✅ 实际字段名 Data interface{} `json:"data"` Timestamp string `json:"timestamp"` } ``` **影响**: - SDK 生成器会生成错误的字段名 - 前端开发者按文档使用 `response.message` 会失败 - 实际需要使用 `response.msg` #### 问题 2:成功响应缺少 envelope **文档定义**(当前): ```yaml /api/admin/users: get: responses: 200: schema: $ref: '#/components/schemas/UserDTO' # ❌ 直接返回 DTO ``` **真实运行时**(Handler 层使用 `response.Success`): ```go return response.Success(c, userDTO) // 实际返回: // { // "code": 0, // "msg": "success", // "data": { ...userDTO... }, // "timestamp": "2026-01-29T10:00:00Z" // } ``` **影响**: - 文档显示直接返回 UserDTO - 实际返回被 envelope 包裹 - SDK 生成的模型结构错误 #### 问题 3:handlers 清单不完整 **cmd/api/docs.go** vs **cmd/gendocs/main.go** 的差异: | Handler | docs.go | gendocs/main.go | |---------|---------|-----------------| | PersonalCustomer | ❌ 缺失 | ❌ 缺失 | | ShopPackageBatchAllocation | ❌ 缺失 | ❌ 缺失 | | ShopPackageBatchPricing | ❌ 缺失 | ❌ 缺失 | **影响**: - 这些 Handler 的接口不出现在 OpenAPI 文档中 - 文档不完整 #### 问题 4:个人客户路由未纳入文档 **当前实现**(`internal/routes/personal.go`): ```go func RegisterPersonalRoutes(app *fiber.App, handlers *bootstrap.Handlers) { api := app.Group("/api/c/v1") api.Get("/cards/:iccid", handlers.PersonalCustomer.GetCard) // ❌ 直接注册到 Fiber,未使用 Register(...) 机制 } ``` **影响**: - `/api/c/v1` 路由不经过文档生成器 - 个人客户 API 不在 OpenAPI 文档中 ### 现有基础设施 **OpenAPI 生成器架构**: ``` internal/routes/registry.go ├── Register(RouteSpec) - 路由注册入口 │ ├── 有 FileUploads → AddMultipartOperation │ └── 无 FileUploads → AddOperation │ pkg/openapi/generator.go ├── AddOperation - 添加普通接口 ├── AddMultipartOperation - 添加文件上传接口 └── Save - 输出 YAML 文件 ``` **RouteSpec 当前字段**: ```go type RouteSpec struct { Method string Path string Handler fiber.Handler Summary string Description string // ✅ 已有(2026-01-24 新增) Tags []string Auth bool Input interface{} Output interface{} FileUploads []FileUploadField } ``` ## Goals / Non-Goals ### Goals 1. **响应字段名对齐**:OpenAPI 文档中的错误响应使用 `msg` 字段 2. **成功响应体现 envelope**:所有成功响应包裹在 `{code, msg, data, timestamp}` 中 3. **补齐 handlers 清单**:补充缺失的 3 个 handlers 4. **个人客户路由纳入文档**:改造 `/api/c/v1` 路由使用 `Register(...)` 机制 5. **统一 handlers 构造**:创建公共函数避免重复 ### Non-Goals - ❌ 不修改 `Response` 结构体(保持 `msg` 字段名) - ❌ 不修改现有 Handler 实现(只改文档生成) - ❌ 不扩展其他 OpenAPI 字段(如 examples、deprecated) - ❌ 不处理 WebSocket 或 SSE 等非 REST 接口 ## Decisions ### 决策 1:字段名对齐策略 **选择**:修改 OpenAPI 生成器,使用 `msg` 而非 `message` **理由**: - 真实运行时的 `Response` 结构体已经使用 `msg` - 修改文档比修改代码影响小 - 保持向后兼容(不破坏现有 API 响应) **备选方案**: - 修改 `Response` 结构体为 `message` - ❌ 破坏性变更,影响所有 API - 同时支持两个字段 - ❌ 增加复杂度,无实际收益 **实现位置**: - `pkg/openapi/generator.go` 中定义 `ErrorResponse` schema 时使用 `msg` ### 决策 2:envelope 包裹实现方式 **选择**:在生成 OpenAPI 时动态包裹 DTO schema **理由**: - 不修改 DTO 定义(保持简洁) - 在文档生成时自动包裹 - 与真实运行时行为一致 **备选方案**: - 为每个 DTO 创建对应的 Response DTO - ❌ 代码重复,维护困难 - 修改 Handler 返回类型 - ❌ 破坏性变更 **实现方式**: ```go // pkg/openapi/generator.go - AddOperation if outputSchema != nil { // 包裹在 envelope 中 responseSchema := map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "code": map[string]interface{}{"type": "integer", "example": 0}, "msg": map[string]interface{}{"type": "string", "example": "success"}, "data": outputSchema, // 原始 DTO "timestamp": map[string]interface{}{"type": "string", "format": "date-time"}, }, } } ``` ### 决策 3:handlers 清单管理 **选择**:创建公共函数 `pkg/openapi/handlers.go` 统一构造 **理由**: - `cmd/api/docs.go` 和 `cmd/gendocs/main.go` 中重复构造 handlers - 容易遗漏新增的 handler - 统一管理便于维护 **备选方案**: - 继续在两个文件中分别构造 - ❌ 容易不一致 - 使用反射自动发现 handlers - ❌ 过度设计,调试困难 **实现方式**: ```go // pkg/openapi/handlers.go package openapi func BuildDocHandlers() *bootstrap.Handlers { // 所有依赖传 nil(文档生成不执行 Handler) return &bootstrap.Handlers{ Account: admin.NewAccountHandler(nil, nil), Shop: admin.NewShopHandler(nil, nil), PersonalCustomer: personal.NewPersonalCustomerHandler(nil), ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(nil), ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(nil), // ... 所有其他 handlers } } ``` ### 决策 4:个人客户路由注册改造 **选择**:修改 `RegisterPersonalRoutes` 函数签名,使用 `Register(...)` **理由**: - 与其他路由注册方式一致(`internal/routes/admin.go`、`internal/routes/h5.go`) - 自动纳入 OpenAPI 文档 - 支持完整的元数据(Summary、Tags、Auth) **备选方案**: - 保持当前方式,单独为个人客户生成文档 - ❌ 分散管理,不统一 - 使用 Fiber 的注释生成文档 - ❌ 项目未采用此方式 **函数签名变更**: ```go // ❌ 修改前 func RegisterPersonalRoutes(app *fiber.App, handlers *bootstrap.Handlers) // ✅ 修改后 func RegisterPersonalRoutes(doc *openapi.Generator, basePath string, handlers *bootstrap.Handlers) ``` ### 决策 5:空 data 字段处理 **选择**:删除操作等无返回数据的接口,data 字段设为 `null` **理由**: - 保持响应格式统一 - 符合 JSON API 规范 - 客户端可以统一解析 **备选方案**: - 不返回 data 字段 - ❌ 响应格式不一致 - data 字段设为空对象 `{}` - ❌ 语义不清晰 **OpenAPI 定义**: ```yaml delete: responses: 200: schema: type: object properties: code: { type: integer, example: 0 } msg: { type: string, example: "success" } data: { type: "null" } # 明确标记为 null timestamp: { type: string, format: date-time } ``` ## Risks / Trade-offs ### 风险 1:Breaking Changes **风险**:OpenAPI 文档结构变化,已生成的 SDK 需要重新生成 **影响范围**: - 使用 OpenAPI 生成 SDK 的客户端(前端、移动端) - 直接解析 OpenAPI 文档的工具 **缓解措施**: - 在变更日志中明确说明(CHANGELOG.md) - 通知前端团队重新生成 SDK - 提供文档对比(旧版 vs 新版) ### 风险 2:envelope 包裹可能遗漏某些接口 **风险**:某些特殊接口可能不适用 envelope 包裹 **示例场景**: - 文件下载接口(返回二进制流) - 健康检查接口(可能只返回简单字符串) **缓解措施**: - 在 `RouteSpec` 中添加 `SkipEnvelope` 标志(如需要) - 当前项目中所有 JSON API 都使用 envelope,暂不处理 ### 风险 3:个人客户路由改造可能影响现有功能 **风险**:修改 `RegisterPersonalRoutes` 可能影响已部署的服务 **缓解措施**: - 保持路径和 Handler 不变(只改注册方式) - 集成测试验证所有个人客户 API - 对比改造前后的响应格式 ### 权衡 1:文档生成时机 **选择**:保持现有机制(服务启动时生成 + 独立工具生成) **权衡**: - ✅ 优势:文档始终与代码同步 - ❌ 劣势:每次启动都重新生成(轻微性能影响) **决定**:维持现状,性能影响可忽略 ### 权衡 2:handlers 构造函数位置 **选择**:放在 `pkg/openapi/handlers.go` **权衡**: - ✅ 优势:与 openapi 包内聚 - ❌ 劣势:依赖 `internal/handler`(跨包依赖) **决定**:可接受,文档生成需要知道所有 handlers ## 实现方案 ### 文件变更清单 | 文件 | 变更类型 | 说明 | |------|---------|------| | `pkg/openapi/generator.go` | 修改 | 字段名对齐 + envelope 包裹 | | `pkg/openapi/handlers.go` | 新建 | 统一 handlers 构造函数 | | `cmd/api/docs.go` | 修改 | 使用 `BuildDocHandlers()` | | `cmd/gendocs/main.go` | 修改 | 使用 `BuildDocHandlers()` | | `internal/routes/personal.go` | 修改 | 改用 `Register(...)` 机制 | | `internal/routes/routes.go` | 修改 | 调整 `RegisterPersonalRoutes` 调用 | ### 代码变更细节 #### 1. pkg/openapi/generator.go ```go // AddOperation 方法修改 func (g *Generator) AddOperation(...) { // ... 现有逻辑 // 修改点 1:包裹 envelope if outputSchema != nil { responseSchema = map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "code": map[string]interface{}{"type": "integer", "example": 0}, "msg": map[string]interface{}{"type": "string", "example": "success"}, "data": outputSchema, "timestamp": map[string]interface{}{"type": "string", "format": "date-time"}, }, } } // 修改点 2:错误响应使用 msg errorResponse := map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "code": map[string]interface{}{"type": "integer"}, "msg": map[string]interface{}{"type": "string"}, // ✅ 改为 msg "data": map[string]interface{}{"type": "object"}, "timestamp": map[string]interface{}{"type": "string", "format": "date-time"}, }, } } ``` #### 2. pkg/openapi/handlers.go(新建) ```go package openapi import ( "github.com/yourusername/junhong_cmp_fiber/internal/bootstrap" "github.com/yourusername/junhong_cmp_fiber/internal/handler/admin" "github.com/yourusername/junhong_cmp_fiber/internal/handler/h5" "github.com/yourusername/junhong_cmp_fiber/internal/handler/personal" ) // BuildDocHandlers 构造文档生成用的 handlers // 所有依赖传 nil,因为文档生成不执行 Handler 逻辑 func BuildDocHandlers() *bootstrap.Handlers { return &bootstrap.Handlers{ // Admin handlers Account: admin.NewAccountHandler(nil, nil), Shop: admin.NewShopHandler(nil, nil), Role: admin.NewRoleHandler(nil, nil), // ... 所有现有 handlers // 补充缺失的 handlers PersonalCustomer: personal.NewPersonalCustomerHandler(nil), ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(nil), ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(nil), } } ``` #### 3. internal/routes/personal.go ```go // ❌ 修改前 func RegisterPersonalRoutes(app *fiber.App, handlers *bootstrap.Handlers) { api := app.Group("/api/c/v1") api.Get("/cards/:iccid", handlers.PersonalCustomer.GetCard) // ... } // ✅ 修改后 func RegisterPersonalRoutes(doc *openapi.Generator, basePath string, handlers *bootstrap.Handlers) { doc.Register(openapi.RouteSpec{ Method: "GET", Path: "/api/c/v1/cards/:iccid", Handler: handlers.PersonalCustomer.GetCard, Summary: "获取个人客户卡详情", Tags: []string{"个人客户"}, Auth: true, Input: nil, Output: &dto.CardDetailResponse{}, }) // ... 其他路由 } ``` ### 验证策略 #### 验证 1:编译检查 ```bash go build -o /tmp/test_gendocs ./cmd/gendocs ``` #### 验证 2:文档生成 ```bash go run cmd/gendocs/main.go ``` #### 验证 3:字段名检查 ```bash grep -A 5 "ErrorResponse" logs/openapi.yaml | grep "msg:" # 应输出:msg: { type: string } ``` #### 验证 4:envelope 检查 ```bash # 检查任意接口的成功响应 grep -A 20 "/api/admin/users:" logs/openapi.yaml | grep -A 5 "200:" # 应包含:code, msg, data, timestamp ``` #### 验证 5:个人客户路由检查 ```bash grep "/api/c/v1" logs/openapi.yaml | wc -l # 应 > 0 ``` #### 验证 6:真实响应对比 ```bash # 启动服务 go run cmd/api/main.go & # 测试接口 curl -X GET http://localhost:8080/api/admin/users/1 \ -H "Authorization: Bearer $TOKEN" | jq . # 应返回: # { # "code": 0, # "msg": "success", # "data": { ... }, # "timestamp": "..." # } ``` ## Migration Plan ### 阶段 1:生成器修改(1-1.5 小时) 1. 修改 `pkg/openapi/generator.go` - 字段名对齐(`msg` vs `message`) - envelope 包裹逻辑 2. 编译验证 3. 生成文档验证字段名 ### 阶段 2:handlers 清单补齐(0.5 小时) 1. 创建 `pkg/openapi/handlers.go` 2. 实现 `BuildDocHandlers()` 3. 更新 `cmd/api/docs.go` 4. 更新 `cmd/gendocs/main.go` 5. 验证文档包含缺失的接口 ### 阶段 3:个人客户路由改造(1 小时) 1. 修改 `internal/routes/personal.go` 2. 使用 `Register(...)` 注册所有路由 3. 更新 `internal/routes/routes.go` 调用 4. 验证 `/api/c/v1` 路由出现在文档中 ### 阶段 4:全量验证和文档更新(0.5-1 小时) 1. 重新生成文档 2. 运行所有验证检查 3. 对比文档差异 4. 更新规范文档 ### 回滚策略 - 每个阶段完成后提交 - 如果某阶段失败,可 revert 到上一阶段 - 保留生成文档的备份(`logs/openapi.yaml.old`) ## Open Questions 1. **是否需要为所有接口添加示例值(examples)?** - 当前决定:不在此次变更中处理 - 可作为后续优化 2. **是否需要支持 SkipEnvelope 标志?** - 当前决定:暂不需要 - 项目中所有 JSON API 都使用 envelope 3. **文件上传接口的 envelope 处理?** - 当前:`AddMultipartOperation` 也应用 envelope - 需要验证:文件上传接口是否返回统一格式