Files
junhong_cmp_fiber/openspec/changes/archive/2026-01-30-openapi-contract-alignment/design.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

528 lines
15 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.
# 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 生成的模型结构错误
#### 问题 3handlers 清单不完整
**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`
### 决策 2envelope 包裹实现方式
**选择**:在生成 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"},
},
}
}
```
### 决策 3handlers 清单管理
**选择**:创建公共函数 `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
### 风险 1Breaking Changes
**风险**OpenAPI 文档结构变化,已生成的 SDK 需要重新生成
**影响范围**
- 使用 OpenAPI 生成 SDK 的客户端(前端、移动端)
- 直接解析 OpenAPI 文档的工具
**缓解措施**
- 在变更日志中明确说明CHANGELOG.md
- 通知前端团队重新生成 SDK
- 提供文档对比(旧版 vs 新版)
### 风险 2envelope 包裹可能遗漏某些接口
**风险**:某些特殊接口可能不适用 envelope 包裹
**示例场景**
- 文件下载接口(返回二进制流)
- 健康检查接口(可能只返回简单字符串)
**缓解措施**
-`RouteSpec` 中添加 `SkipEnvelope` 标志(如需要)
- 当前项目中所有 JSON API 都使用 envelope暂不处理
### 风险 3个人客户路由改造可能影响现有功能
**风险**:修改 `RegisterPersonalRoutes` 可能影响已部署的服务
**缓解措施**
- 保持路径和 Handler 不变(只改注册方式)
- 集成测试验证所有个人客户 API
- 对比改造前后的响应格式
### 权衡 1文档生成时机
**选择**:保持现有机制(服务启动时生成 + 独立工具生成)
**权衡**
- ✅ 优势:文档始终与代码同步
- ❌ 劣势:每次启动都重新生成(轻微性能影响)
**决定**:维持现状,性能影响可忽略
### 权衡 2handlers 构造函数位置
**选择**:放在 `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 }
```
#### 验证 4envelope 检查
```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. 生成文档验证字段名
### 阶段 2handlers 清单补齐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
- 需要验证:文件上传接口是否返回统一格式