package hotime import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" . "code.hoteas.com/golang/hotime/common" ) type swaggerTestCase struct { Name string `json:"name"` Note string `json:"note,omitempty"` Method string `json:"method"` Passed bool `json:"passed"` Query map[string]interface{} `json:"query,omitempty"` Json interface{} `json:"json,omitempty"` Form map[string]interface{} `json:"form,omitempty"` HasFile bool `json:"hasFile,omitempty"` FileField string `json:"fileField,omitempty"` Response map[string]interface{} `json:"response,omitempty"` Expect interface{} `json:"expect,omitempty"` VerifyError string `json:"verifyError,omitempty"` } type paramSpec struct { Name string `json:"name"` Required bool `json:"required"` Type string `json:"type,omitempty"` Example interface{} `json:"example,omitempty"` In string `json:"in"` } // inferParamsFromCases 从测试用例自动推断参数必填性、类型和示例值。 // 必填判定:参数显式存在但值为空字符串,且该用例 response.status==3。 func inferParamsFromCases(cases []swaggerTestCase) []paramSpec { type paramInfo struct { in string required bool typ string example interface{} } params := map[string]*paramInfo{} getStatus := func(resp map[string]interface{}) int { if resp == nil { return -1 } if s, ok := resp["status"]; ok { if f, ok := s.(float64); ok { return int(f) } } return -1 } inferType := func(v interface{}) string { switch v.(type) { case float64: return "number" case bool: return "boolean" case []interface{}: return "array" case map[string]interface{}: return "object" default: return "string" } } sv := func(v interface{}) string { if v == nil { return "" } return fmt.Sprintf("%v", v) } // toStrMap 兼容 Map 类型别名和 map[string]interface{} 两种情况 toStrMap := func(v interface{}) map[string]interface{} { if v == nil { return nil } switch m := v.(type) { case map[string]interface{}: return m case Map: return map[string]interface{}(m) } return nil } // 第一轮:收集所有参数 key、类型和示例值 for _, c := range cases { st := getStatus(c.Response) for k, v := range c.Query { if _, exists := params[k]; !exists { params[k] = ¶mInfo{in: "query"} } p := params[k] if p.typ == "" && sv(v) != "" { p.typ = inferType(v) } if p.example == nil && st == 0 && sv(v) != "" { p.example = v } } for k, v := range c.Form { if _, exists := params[k]; !exists { params[k] = ¶mInfo{in: "form"} } p := params[k] if p.typ == "" && sv(v) != "" { p.typ = inferType(v) } if p.example == nil && st == 0 && sv(v) != "" { p.example = v } } if jmap := toStrMap(c.Json); jmap != nil { for k, v := range jmap { if _, exists := params[k]; !exists { params[k] = ¶mInfo{in: "json"} } p := params[k] if p.typ == "" && sv(v) != "" { p.typ = inferType(v) } if p.example == nil && st == 0 && sv(v) != "" { p.example = v } } } } // 第二轮:标记必填 — 参数存在但值为空 且 status==3 for _, c := range cases { if getStatus(c.Response) != 3 { continue } for k, v := range c.Query { if p, ok := params[k]; ok && p.in == "query" && sv(v) == "" { p.required = true } } for k, v := range c.Form { if p, ok := params[k]; ok && p.in == "form" && sv(v) == "" { p.required = true } } if jmap := toStrMap(c.Json); jmap != nil { for k, v := range jmap { if p, ok := params[k]; ok && p.in == "json" && sv(v) == "" { p.required = true } } } } result := make([]paramSpec, 0, len(params)) for name, p := range params { result = append(result, paramSpec{ Name: name, Required: p.required, Type: p.typ, Example: p.example, In: p.in, }) } sort.Slice(result, func(i, j int) bool { if result[i].Required != result[j].Required { return result[i].Required } return result[i].Name < result[j].Name }) return result } func (that *TestApp) GenerateSwagger(title, version, outputDir string) error { if outputDir == "" { outputDir = "tpt" } swaggerRoot := filepath.Join(outputDir, "swagger") if err := os.MkdirAll(swaggerRoot, os.ModePerm); err != nil { return fmt.Errorf("创建 swagger 目录失败: %w", err) } that.collector.mu.Lock() records := make([]TestRecord, len(that.collector.Records)) copy(records, that.collector.Records) that.collector.mu.Unlock() pathRecords := map[string][]TestRecord{} for _, r := range records { pathRecords[r.Path] = append(pathRecords[r.Path], r) } for projName, projDef := range that.projs { moduleDir := filepath.Join(swaggerRoot, projName) if err := os.MkdirAll(moduleDir, os.ModePerm); err != nil { return fmt.Errorf("创建模块目录 %s 失败: %w", projName, err) } endpoints := []map[string]interface{}{} for ctrName, ctr := range projDef.Proj { for methodName := range ctr { apiPath := "/" + projName + "/" + ctrName + "/" + methodName ctrTest, hasCtr := projDef.Tests[ctrName] hasTest := false var apiTest ApiTestDef if hasCtr { apiTest, hasTest = ctrTest[methodName] } summary := methodName if hasTest { summary = apiTest.Desc } recs := pathRecords[apiPath] httpMethod := "POST" if len(recs) > 0 && recs[0].Method != "" { httpMethod = strings.ToUpper(recs[0].Method) } var cases []swaggerTestCase for _, r := range recs { tc := swaggerTestCase{ Name: r.CaseName, Note: r.Note, Method: r.Method, Passed: r.Passed, Response: r.ResponseBody, Expect: r.ExpectResult, VerifyError: r.VerifyError, } if r.Query != nil { tc.Query = r.Query } if r.JsonBody != nil { tc.Json = r.JsonBody } if r.FormBody != nil { tc.Form = r.FormBody } if r.HasFile { tc.HasFile = true tc.FileField = r.FileField } cases = append(cases, tc) } endpoints = append(endpoints, map[string]interface{}{ "path": apiPath, "project": projName, "ctr": ctrName, "method": httpMethod, "summary": summary, "tested": hasTest, "cases": cases, "params": inferParamsFromCases(cases), }) } } sort.Slice(endpoints, func(i, j int) bool { return endpoints[i]["path"].(string) < endpoints[j]["path"].(string) }) spec := map[string]interface{}{ "title": title, "version": version, "endpoints": endpoints, } specJSON, err := json.MarshalIndent(spec, "", " ") if err != nil { return fmt.Errorf("序列化 JSON 失败: %w", err) } if err := os.WriteFile(filepath.Join(moduleDir, "api-spec.json"), specJSON, os.ModePerm); err != nil { return fmt.Errorf("写入 %s/api-spec.json 失败: %w", projName, err) } if err := os.WriteFile(filepath.Join(moduleDir, "index.html"), []byte(apiConsoleHTML()), os.ModePerm); err != nil { return fmt.Errorf("写入 %s/index.html 失败: %w", projName, err) } fmt.Printf("Swagger 文档已生成: %s\n", moduleDir) } if err := generateSwaggerPortal(swaggerRoot); err != nil { return fmt.Errorf("生成导航页失败: %w", err) } return nil } func generateSwaggerPortal(swaggerRoot string) error { entries, err := os.ReadDir(swaggerRoot) if err != nil { return err } var modules []struct{ Name, Title string } for _, e := range entries { if !e.IsDir() { continue } specPath := filepath.Join(swaggerRoot, e.Name(), "api-spec.json") title := e.Name() if data, err := os.ReadFile(specPath); err == nil { var spec map[string]interface{} if json.Unmarshal(data, &spec) == nil { if t, ok := spec["title"].(string); ok && t != "" { title = t } } } modules = append(modules, struct{ Name, Title string }{e.Name(), title}) } sort.Slice(modules, func(i, j int) bool { return modules[i].Name < modules[j].Name }) html := swaggerPortalHTML(modules) return os.WriteFile(filepath.Join(swaggerRoot, "index.html"), []byte(html), os.ModePerm) } func swaggerPortalHTML(modules []struct{ Name, Title string }) string { var cards string for _, m := range modules { cards += `
` + strings.ToUpper(m.Name[:1]) + m.Name[1:] + `
` + m.Title + `
` + m.Name + `/
` } return ` API 文档中心

API 文档中心

选择一个模块查看接口文档与调试控制台
` + cards + `
` } func apiConsoleHTML() string { return ` API 调试控制台

← 选择一个接口开始
` } func inferSchema(v interface{}) map[string]interface{} { if v == nil { return map[string]interface{}{"type": "string"} } switch v.(type) { case int, int64, int32, float64, float32: return map[string]interface{}{"type": "number"} case bool: return map[string]interface{}{"type": "boolean"} case map[string]interface{}, Map: props := map[string]interface{}{} var m map[string]interface{} switch val := v.(type) { case Map: m = map[string]interface{}(val) case map[string]interface{}: m = val } for k, val := range m { props[k] = inferSchema(val) } return map[string]interface{}{"type": "object", "properties": props} default: return map[string]interface{}{"type": "string"} } }