Files
junhong_cmp_fiber/pkg/openapi/generator.go
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

500 lines
12 KiB
Go
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.
package openapi
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"github.com/swaggest/openapi-go/openapi3"
"gopkg.in/yaml.v3"
)
// Generator OpenAPI 文档生成器
type Generator struct {
Reflector *openapi3.Reflector
}
// NewGenerator 创建一个新的生成器实例
func NewGenerator(title, version string) *Generator {
reflector := openapi3.Reflector{}
reflector.Spec = &openapi3.Spec{
Openapi: "3.0.3",
Info: openapi3.Info{
Title: title,
Version: version,
},
}
g := &Generator{Reflector: &reflector}
g.addBearerAuth()
return g
}
// addBearerAuth 添加 Bearer Token 认证定义
func (g *Generator) addBearerAuth() {
bearerFormat := "JWT"
g.Reflector.Spec.ComponentsEns().SecuritySchemesEns().WithMapOfSecuritySchemeOrRefValuesItem(
"BearerAuth",
openapi3.SecuritySchemeOrRef{
SecurityScheme: &openapi3.SecurityScheme{
HTTPSecurityScheme: &openapi3.HTTPSecurityScheme{
Scheme: "bearer",
BearerFormat: &bearerFormat,
},
},
},
)
g.addErrorResponseSchema()
}
// addErrorResponseSchema 添加错误响应 Schema 定义
func (g *Generator) addErrorResponseSchema() {
objectType := openapi3.SchemaType("object")
integerType := openapi3.SchemaType("integer")
stringType := openapi3.SchemaType("string")
dateTimeFormat := "date-time"
var errorExample interface{} = "参数验证失败"
var codeExample interface{} = 1001
errorSchema := openapi3.SchemaOrRef{
Schema: &openapi3.Schema{
Type: &objectType,
Properties: map[string]openapi3.SchemaOrRef{
"code": {
Schema: &openapi3.Schema{
Type: &integerType,
Description: ptrString("错误码"),
Example: &codeExample,
},
},
"msg": {
Schema: &openapi3.Schema{
Type: &stringType,
Description: ptrString("错误消息"),
Example: &errorExample,
},
},
"data": {
Schema: &openapi3.Schema{
Type: &objectType,
Description: ptrString("错误详情(可选)"),
},
},
"timestamp": {
Schema: &openapi3.Schema{
Type: &stringType,
Format: &dateTimeFormat,
Description: ptrString("时间戳"),
},
},
},
Required: []string{"code", "msg", "timestamp"},
},
}
g.Reflector.Spec.ComponentsEns().SchemasEns().WithMapOfSchemaOrRefValuesItem("ErrorResponse", errorSchema)
}
func ptrString(s string) *string {
return &s
}
// FileUploadField 定义文件上传字段
type FileUploadField struct {
Name string
Description string
Required bool
}
// AddOperation 向 OpenAPI 规范中添加一个操作
// 参数:
// - method: HTTP 方法GET, POST, PUT, DELETE 等)
// - path: API 路径
// - summary: 操作摘要
// - description: 详细说明,支持 Markdown 语法(可为空)
// - input: 请求参数结构体(可为 nil
// - output: 响应结构体(可为 nil
// - tags: 标签列表
// - requiresAuth: 是否需要认证
func (g *Generator) AddOperation(method, path, summary, description string, input interface{}, output interface{}, requiresAuth bool, tags ...string) {
op := openapi3.Operation{
Summary: &summary,
Tags: tags,
}
if description != "" {
op.Description = &description
}
// 反射输入 (请求参数/Body)
if input != nil {
// SetRequest 根据结构体标签自动检测 Body、Query 或 Path 参数
if err := g.Reflector.SetRequest(&op, input, method); err != nil {
panic(err) // 生成过程中出错直接 panic以便快速发现问题
}
}
// 反射输出 (响应 Body)
if output != nil {
// 将输出包裹在 envelope 中
g.setEnvelopeResponse(&op, output, 200)
}
// 添加认证要求
if requiresAuth {
g.addSecurityRequirement(&op)
}
// 添加标准错误响应
g.addStandardErrorResponses(&op, requiresAuth)
// 将操作添加到规范中
if err := g.Reflector.Spec.AddOperation(method, path, op); err != nil {
panic(err)
}
}
// AddMultipartOperation 添加支持文件上传的 multipart/form-data 操作
func (g *Generator) AddMultipartOperation(method, path, summary, description string, input interface{}, output interface{}, requiresAuth bool, fileFields []FileUploadField, tags ...string) {
op := openapi3.Operation{
Summary: &summary,
Tags: tags,
}
if description != "" {
op.Description = &description
}
objectType := openapi3.SchemaType("object")
stringType := openapi3.SchemaType("string")
integerType := openapi3.SchemaType("integer")
binaryFormat := "binary"
properties := make(map[string]openapi3.SchemaOrRef)
var requiredFields []string
for _, f := range fileFields {
properties[f.Name] = openapi3.SchemaOrRef{
Schema: &openapi3.Schema{
Type: &stringType,
Format: &binaryFormat,
Description: ptrString(f.Description),
},
}
if f.Required {
requiredFields = append(requiredFields, f.Name)
}
}
if input != nil {
formFields := parseFormFields(input)
for _, field := range formFields {
var schemaType *openapi3.SchemaType
switch field.Type {
case "integer":
schemaType = &integerType
default:
schemaType = &stringType
}
schema := &openapi3.Schema{
Type: schemaType,
Description: ptrString(field.Description),
}
if field.Min != nil {
schema.Minimum = field.Min
}
if field.MaxLength != nil {
schema.MaxLength = field.MaxLength
}
properties[field.Name] = openapi3.SchemaOrRef{Schema: schema}
if field.Required {
requiredFields = append(requiredFields, field.Name)
}
}
}
op.RequestBody = &openapi3.RequestBodyOrRef{
RequestBody: &openapi3.RequestBody{
Required: ptrBool(true),
Content: map[string]openapi3.MediaType{
"multipart/form-data": {
Schema: &openapi3.SchemaOrRef{
Schema: &openapi3.Schema{
Type: &objectType,
Properties: properties,
Required: requiredFields,
},
},
},
},
},
}
if output != nil {
// 将输出包裹在 envelope 中
g.setEnvelopeResponse(&op, output, 200)
}
if requiresAuth {
g.addSecurityRequirement(&op)
}
g.addStandardErrorResponses(&op, requiresAuth)
if err := g.Reflector.Spec.AddOperation(method, path, op); err != nil {
panic(err)
}
}
func ptrBool(b bool) *bool {
return &b
}
type formFieldInfo struct {
Name string
Type string
Description string
Required bool
Min *float64
MaxLength *int64
}
func parseFormFields(input interface{}) []formFieldInfo {
var fields []formFieldInfo
t := reflect.TypeOf(input)
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return fields
}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
formTag := field.Tag.Get("form")
if formTag == "" || formTag == "-" {
continue
}
info := formFieldInfo{
Name: formTag,
Description: field.Tag.Get("description"),
}
switch field.Type.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
info.Type = "integer"
default:
info.Type = "string"
}
validateTag := field.Tag.Get("validate")
if strings.Contains(validateTag, "required") {
info.Required = true
}
if minStr := field.Tag.Get("minimum"); minStr != "" {
if min, err := strconv.ParseFloat(minStr, 64); err == nil {
info.Min = &min
}
}
if maxLenStr := field.Tag.Get("maxLength"); maxLenStr != "" {
if maxLen, err := strconv.ParseInt(maxLenStr, 10, 64); err == nil {
info.MaxLength = &maxLen
}
}
fields = append(fields, info)
}
return fields
}
// setEnvelopeResponse 设置包裹在 envelope 中的响应
func (g *Generator) setEnvelopeResponse(op *openapi3.Operation, output interface{}, statusCode int) {
// 首先调用 SetJSONResponse 让 Reflector 处理 DTO schema
tempOp := openapi3.Operation{}
if err := g.Reflector.SetJSONResponse(&tempOp, output, statusCode); err != nil {
panic(err)
}
// 获取生成的 DTO schema
dtoSchemaOrRef := tempOp.Responses.MapOfResponseOrRefValues[strconv.Itoa(statusCode)].Response.Content["application/json"].Schema
objectType := openapi3.SchemaType("object")
integerType := openapi3.SchemaType("integer")
stringType := openapi3.SchemaType("string")
dateTimeFormat := "date-time"
var successCodeExample interface{} = 0
var successMsgExample interface{} = "success"
// 构造 envelope schema
envelopeSchema := &openapi3.SchemaOrRef{
Schema: &openapi3.Schema{
Type: &objectType,
Properties: map[string]openapi3.SchemaOrRef{
"code": {
Schema: &openapi3.Schema{
Type: &integerType,
Description: ptrString("响应码"),
Example: &successCodeExample,
},
},
"msg": {
Schema: &openapi3.Schema{
Type: &stringType,
Description: ptrString("响应消息"),
Example: &successMsgExample,
},
},
"data": *dtoSchemaOrRef,
"timestamp": {
Schema: &openapi3.Schema{
Type: &stringType,
Format: &dateTimeFormat,
Description: ptrString("时间戳"),
},
},
},
Required: []string{"code", "msg", "data", "timestamp"},
},
}
// 设置响应
statusStr := strconv.Itoa(statusCode)
description := "成功"
if op.Responses.MapOfResponseOrRefValues == nil {
op.Responses.MapOfResponseOrRefValues = make(map[string]openapi3.ResponseOrRef)
}
op.Responses.MapOfResponseOrRefValues[statusStr] = openapi3.ResponseOrRef{
Response: &openapi3.Response{
Description: description,
Content: map[string]openapi3.MediaType{
"application/json": {
Schema: envelopeSchema,
},
},
},
}
}
// addSecurityRequirement 为操作添加认证要求
func (g *Generator) addSecurityRequirement(op *openapi3.Operation) {
op.Security = []map[string][]string{
{"BearerAuth": {}},
}
}
// addStandardErrorResponses 添加标准错误响应
func (g *Generator) addStandardErrorResponses(op *openapi3.Operation, requiresAuth bool) {
if op.Responses.MapOfResponseOrRefValues == nil {
op.Responses.MapOfResponseOrRefValues = make(map[string]openapi3.ResponseOrRef)
}
// 400 Bad Request - 所有端点都可能返回
desc400 := "请求参数错误"
op.Responses.MapOfResponseOrRefValues["400"] = openapi3.ResponseOrRef{
Response: &openapi3.Response{
Description: desc400,
Content: map[string]openapi3.MediaType{
"application/json": {
Schema: &openapi3.SchemaOrRef{
SchemaReference: &openapi3.SchemaReference{
Ref: "#/components/schemas/ErrorResponse",
},
},
},
},
},
}
// 401 Unauthorized - 仅认证端点返回
if requiresAuth {
desc401 := "未认证或认证已过期"
op.Responses.MapOfResponseOrRefValues["401"] = openapi3.ResponseOrRef{
Response: &openapi3.Response{
Description: desc401,
Content: map[string]openapi3.MediaType{
"application/json": {
Schema: &openapi3.SchemaOrRef{
SchemaReference: &openapi3.SchemaReference{
Ref: "#/components/schemas/ErrorResponse",
},
},
},
},
},
}
// 403 Forbidden - 仅认证端点返回
desc403 := "无权访问"
op.Responses.MapOfResponseOrRefValues["403"] = openapi3.ResponseOrRef{
Response: &openapi3.Response{
Description: desc403,
Content: map[string]openapi3.MediaType{
"application/json": {
Schema: &openapi3.SchemaOrRef{
SchemaReference: &openapi3.SchemaReference{
Ref: "#/components/schemas/ErrorResponse",
},
},
},
},
},
}
}
// 500 Internal Server Error - 所有端点都可能返回
desc500 := "服务器内部错误"
op.Responses.MapOfResponseOrRefValues["500"] = openapi3.ResponseOrRef{
Response: &openapi3.Response{
Description: desc500,
Content: map[string]openapi3.MediaType{
"application/json": {
Schema: &openapi3.SchemaOrRef{
SchemaReference: &openapi3.SchemaReference{
Ref: "#/components/schemas/ErrorResponse",
},
},
},
},
},
}
}
// Save 将规范导出为 YAML 文件
func (g *Generator) Save(filename string) error {
// 确保目录存在
dir := filepath.Dir(filename)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
// 安全的方法MarshalJSON -> Unmarshal -> MarshalYAML
// 这确保了我们遵守 openapi3 库中定义的 `json` 标签
jsonBytes, err := g.Reflector.Spec.MarshalJSON()
if err != nil {
return err
}
var obj interface{}
if err := json.Unmarshal(jsonBytes, &obj); err != nil {
return err
}
yamlBytes, err := yaml.Marshal(obj)
if err != nil {
return err
}
return os.WriteFile(filename, yamlBytes, 0644)
}