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 { switch v := s.(type) { case float64: return int(v) case int64: return int(v) case int: return v } } return -1 } inferType := func(v interface{}) string { switch v.(type) { case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: return "number" case bool: return "boolean" case []interface{}, Slice: return "array" case map[string]interface{}, Map: 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、类型和示例值 // 成功用例(status=0)优先级最高,覆盖 in/typ/example,代表真实请求方式和数据类型 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 st == 0 { p.in = "query" } if p.typ == "" && sv(v) != "" { p.typ = inferType(v) } if st == 0 && sv(v) != "" { p.typ = inferType(v) p.example = v } } for k, v := range c.Form { if _, exists := params[k]; !exists { params[k] = ¶mInfo{in: "form"} } p := params[k] if st == 0 { p.in = "form" } if p.typ == "" && sv(v) != "" { p.typ = inferType(v) } if st == 0 && sv(v) != "" { p.typ = inferType(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 st == 0 { p.in = "json" } if p.typ == "" && sv(v) != "" { p.typ = inferType(v) } if st == 0 && sv(v) != "" { p.typ = inferType(v) p.example = v } } } } // 第二轮:标记必填 — 参数存在但值为空 且 status==3(不限制 in 类型,因为错误用例可能用不同传参方式) for _, c := range cases { if getStatus(c.Response) != 3 { continue } for k, v := range c.Query { if p, ok := params[k]; ok && sv(v) == "" { p.required = true } } for k, v := range c.Form { if p, ok := params[k]; ok && sv(v) == "" { p.required = true } } if jmap := toStrMap(c.Json); jmap != nil { for k, v := range jmap { if p, ok := params[k]; ok && sv(v) == "" { p.required = true } } } } // 第三轮:标记必填 — 字段在 status==3 用例中缺失,但在下一个用例中出现 collectKeys := func(c swaggerTestCase) map[string]bool { keys := map[string]bool{} for k := range c.Query { keys[k] = true } for k := range c.Form { keys[k] = true } if jm := toStrMap(c.Json); jm != nil { for k := range jm { keys[k] = true } } return keys } for i := 0; i < len(cases)-1; i++ { if getStatus(cases[i].Response) != 3 { continue } curKeys := collectKeys(cases[i]) nextKeys := collectKeys(cases[i+1]) for k := range nextKeys { if !curKeys[k] { if p, ok := params[k]; ok { 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) visited := map[string]bool{} for k, v := range that.collector.Visited { visited[k] = v } 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) } // 判断是否为部分运行(-run 过滤):统计本项目下所有端点是否全部被访问 totalPaths := 0 visitedPaths := 0 for ctrName, ctr := range projDef.Proj { for methodName := range ctr { totalPaths++ if visited["/"+projName+"/"+ctrName+"/"+methodName] { visitedPaths++ } } } isPartialRun := visitedPaths > 0 && visitedPaths < totalPaths // #region agent log func() { if f, err := os.OpenFile("debug-e1b6ef.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil { fmt.Fprintf(f, `{"sessionId":"e1b6ef","hypothesisId":"D","message":"partial_run_check","data":{"proj":"%s","totalPaths":%d,"visitedPaths":%d,"isPartialRun":%v}}`+"\n", projName, totalPaths, visitedPaths, isPartialRun) f.Close() } }() // #endregion // 仅部分运行时才加载已有 spec 用于合并,全量运行时清空重建 var existingEndpoints map[string]map[string]interface{} if isPartialRun { existingEndpoints = loadExistingEndpoints(filepath.Join(moduleDir, "api-spec.json")) } 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] // 部分运行且该路径未被访问 → 保留已有 spec 中的数据 if isPartialRun && !visited[apiPath] { if existing, ok := existingEndpoints[apiPath]; ok { // #region agent log if strings.Contains(apiPath, "batch") { func() { if f, err := os.OpenFile("debug-e1b6ef.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil { fmt.Fprintf(f, `{"sessionId":"e1b6ef","hypothesisId":"D","message":"kept_existing","data":{"path":"%s"}}`+"\n", apiPath) f.Close() } }() } // #endregion endpoints = append(endpoints, existing) continue } // #region agent log if strings.Contains(apiPath, "batch") { func() { if f, err := os.OpenFile("debug-e1b6ef.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil { fmt.Fprintf(f, `{"sessionId":"e1b6ef","hypothesisId":"D","message":"NOT_in_existing_file","data":{"path":"%s"}}`+"\n", apiPath) f.Close() } }() } // #endregion } // #region agent log if strings.Contains(apiPath, "batch") && isPartialRun && visited[apiPath] { func() { if f, err := os.OpenFile("debug-e1b6ef.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil { fmt.Fprintf(f, `{"sessionId":"e1b6ef","hypothesisId":"D","message":"regenerated","data":{"path":"%s","numRecs":%d}}`+"\n", apiPath, len(recs)) f.Close() } }() } // #endregion httpMethod := "POST" if len(recs) > 0 && recs[0].Method != "" { httpMethod = strings.ToUpper(recs[0].Method) } else if isPartialRun { if existing, ok := existingEndpoints[apiPath]; ok { if m, ok := existing["method"].(string); ok && m != "" { httpMethod = m } } } 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 } // loadExistingEndpoints 读取已有的 api-spec.json,返回 path → endpoint 映射。 // 用于 -run 部分运行时保留未运行端点的已有数据,避免覆盖清空。 func loadExistingEndpoints(specPath string) map[string]map[string]interface{} { result := map[string]map[string]interface{}{} data, err := os.ReadFile(specPath) if err != nil { return result } var spec map[string]interface{} if err := json.Unmarshal(data, &spec); err != nil { return result } eps, ok := spec["endpoints"].([]interface{}) if !ok { return result } for _, raw := range eps { ep, ok := raw.(map[string]interface{}) if !ok { continue } if path, ok := ep["path"].(string); ok { result[path] = ep } } return result } 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"} } }