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"` FailReason string `json:"failReason,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 title == "" { title = that.swaggerTitle } if version == "" { version = that.swaggerVersion } if outputDir == "" { outputDir = that.swaggerOutputDir() } if title == "" { title = "API" } if version == "" { version = "1.0.0" } that.swaggerEnabled = true that.swaggerTitle = title that.swaggerVersion = version if that.swaggerDir == "" { that.swaggerDir = outputDir } 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 } visitedFlows := map[string]bool{} for k, v := range that.collector.VisitedFlows { visitedFlows[k] = v } that.collector.mu.Unlock() pathRecords := map[string][]TestRecord{} flowRecords := map[string][]TestRecord{} for _, r := range records { if r.FlowName != "" { flowRecords[r.FlowName] = append(flowRecords[r.FlowName], r) continue } pathRecords[r.Path] = append(pathRecords[r.Path], r) } fullComplete := that.isSwaggerFullComplete(visited, visitedFlows) activeModules := map[string]bool{} for projName, projDef := range that.projs { activeModules[projName] = true moduleDir := filepath.Join(swaggerRoot, projName) if err := os.MkdirAll(moduleDir, os.ModePerm); err != nil { return fmt.Errorf("创建模块目录 %s 失败: %w", projName, err) } specPath := filepath.Join(moduleDir, "api-spec.json") existingEndpoints := loadExistingEndpoints(specPath) existingFlows := loadExistingFlows(specPath) endpoints := []map[string]interface{}{} for ctrName, ctr := range projDef.Proj { for methodName := range ctr { apiPath := "/" + projName + "/" + ctrName + "/" + methodName recs := pathRecords[apiPath] existingEp := existingEndpoints[apiPath] // 无本轮用例记录:一律保留旧 swagger,禁止空 cases 覆盖已测内容 if len(recs) == 0 && existingEp != nil { endpoints = append(endpoints, existingEp) continue } ctrTest, hasCtr := projDef.Tests[ctrName] hasTest := false var apiTest ApiTestDef if hasCtr { apiTest, hasTest = ctrTest[methodName] } summary := methodName if hasTest { summary = apiTest.Desc } endpoints = append(endpoints, that.buildEndpointMap(projName, ctrName, apiPath, summary, hasTest, recs, existingEp)) } } // 未全量时:保留旧 spec 中已不在当前 Proj 的 endpoint if !fullComplete { projPaths := map[string]bool{} for ctrName, ctr := range projDef.Proj { for methodName := range ctr { projPaths["/"+projName+"/"+ctrName+"/"+methodName] = true } } have := map[string]bool{} for _, ep := range endpoints { if p, ok := ep["path"].(string); ok { have[p] = true } } for path, ep := range existingEndpoints { if !projPaths[path] && !have[path] { endpoints = append(endpoints, ep) } } } sort.Slice(endpoints, func(i, j int) bool { return endpoints[i]["path"].(string) < endpoints[j]["path"].(string) }) flows := that.mergeFlows(existingFlows, flowRecords, visitedFlows, fullComplete) if err := that.writeModuleSpec(moduleDir, title, version, endpoints, flows); err != nil { return err } fmt.Printf("Swagger 文档已生成: %s\n", moduleDir) } if fullComplete { if err := that.pruneOrphanSwaggerModules(swaggerRoot, activeModules); err != nil { return err } } if err := generateSwaggerPortal(swaggerRoot); err != nil { return fmt.Errorf("生成导航页失败: %w", err) } return nil } func (that *TestApp) isSwaggerFullComplete(visited, visitedFlows map[string]bool) bool { totalPaths := 0 visitedPaths := 0 for projName, projDef := range that.projs { for ctrName, ctr := range projDef.Proj { for methodName := range ctr { totalPaths++ if visited["/"+projName+"/"+ctrName+"/"+methodName] { visitedPaths++ } } } } if totalPaths == 0 || visitedPaths < totalPaths { return false } for name := range that.flows { if !visitedFlows[name] { return false } } return true } func (that *TestApp) buildEndpointMap(projName, ctrName, apiPath, summary string, hasTest bool, recs []TestRecord, existing map[string]interface{}) map[string]interface{} { httpMethod := "POST" if len(recs) > 0 && recs[0].Method != "" { httpMethod = strings.ToUpper(recs[0].Method) } else if existing != nil { if m, ok := existing["method"].(string); ok && m != "" { httpMethod = m } } cases := make([]swaggerTestCase, 0, len(recs)) for _, r := range recs { cases = append(cases, recordToSwaggerCase(r)) } return map[string]interface{}{ "path": apiPath, "project": projName, "ctr": ctrName, "method": httpMethod, "summary": summary, "tested": hasTest, "cases": cases, "params": inferParamsFromCases(cases), } } func recordToSwaggerCase(r TestRecord) swaggerTestCase { tc := swaggerTestCase{ Name: r.CaseName, Note: r.Note, Method: r.Method, Passed: r.Passed, Response: r.ResponseBody, Expect: r.ExpectResult, VerifyError: r.VerifyError, FailReason: r.FailReason, } 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 } return tc } func (that *TestApp) mergeFlows(existing map[string]map[string]interface{}, flowRecords map[string][]TestRecord, visitedFlows map[string]bool, fullComplete bool) []map[string]interface{} { out := []map[string]interface{}{} seen := map[string]bool{} for name, recs := range flowRecords { out = append(out, that.buildFlowMap(name, recs)) seen[name] = true } for name, flowDef := range that.flows { if seen[name] { continue } if visitedFlows[name] { continue } if existingFlow, ok := existing[name]; ok { out = append(out, existingFlow) seen[name] = true continue } _ = flowDef } if !fullComplete { for name, ep := range existing { if !seen[name] { // 未全量:保留旧 flow(含已从注册删除的) out = append(out, ep) seen[name] = true } } } else { // 全量:只保留仍注册或本轮跑过的 registered := map[string]bool{} for name := range that.flows { registered[name] = true } filtered := out[:0] for _, f := range out { name, _ := f["name"].(string) if registered[name] || visitedFlows[name] { filtered = append(filtered, f) } } out = filtered } sort.Slice(out, func(i, j int) bool { return out[i]["name"].(string) < out[j]["name"].(string) }) return out } func (that *TestApp) buildFlowMap(name string, recs []TestRecord) map[string]interface{} { desc := name group := "" prep := "" passed := true steps := []map[string]interface{}{} sort.Slice(recs, func(i, j int) bool { if recs[i].FlowStepIndex != recs[j].FlowStepIndex { return recs[i].FlowStepIndex < recs[j].FlowStepIndex } return recs[i].CaseName < recs[j].CaseName }) for _, r := range recs { if r.FlowDesc != "" { desc = r.FlowDesc } if r.FlowGroup != "" { group = r.FlowGroup } if r.FlowPrep != "" { prep = r.FlowPrep } if !r.Passed { passed = false } stepName := r.FlowStep if stepName == "" { stepName = r.CaseName } tc := recordToSwaggerCase(r) step := map[string]interface{}{ "index": r.FlowStepIndex, "name": stepName, "path": r.Path, "method": tc.Method, "passed": tc.Passed, "note": tc.Note, "query": tc.Query, "json": tc.Json, "form": tc.Form, "response": tc.Response, "expect": tc.Expect, "failReason": tc.FailReason, "verifyError": tc.VerifyError, "caseName": tc.Name, } if r.BindCase != "" { step["bindCase"] = r.BindCase } steps = append(steps, step) } if group == "" { if def, ok := that.flows[name]; ok && def.Group != "" { group = def.Group } } if prep == "" { if def, ok := that.flows[name]; ok && def.Prep != "" { prep = def.Prep } } if desc == name { if def, ok := that.flows[name]; ok && def.Desc != "" { desc = def.Desc } } m := map[string]interface{}{ "name": name, "desc": desc, "passed": passed, "steps": steps, } if group != "" { m["group"] = group } if prep != "" { m["prep"] = prep } return m } // caseTemplate 单接口验收用例的请求模板(供 FromCase 加载) type caseTemplate struct { Query map[string]interface{} Json interface{} Form map[string]interface{} Note string } func (that *TestApp) findCaseTemplate(apiPath, caseName string) *caseTemplate { that.collector.mu.Lock() for _, r := range that.collector.Records { if r.FlowName == "" && r.Path == apiPath && r.CaseName == caseName { tmpl := &caseTemplate{Note: r.Note} if r.Query != nil { tmpl.Query = r.Query } if r.JsonBody != nil { tmpl.Json = r.JsonBody } if r.FormBody != nil { tmpl.Form = r.FormBody } that.collector.mu.Unlock() return tmpl } } that.collector.mu.Unlock() dir := that.swaggerOutputDir() parts := strings.Split(strings.Trim(apiPath, "/"), "/") if len(parts) == 0 { return nil } specPath := filepath.Join(dir, "swagger", parts[0], "api-spec.json") existing := loadExistingEndpoints(specPath) ep, ok := existing[apiPath] if !ok { return nil } cases, ok := ep["cases"].([]interface{}) if !ok { // json 反序列化可能是 []swaggerTestCase 经 map 再读 raw, _ := json.Marshal(ep["cases"]) var parsed []swaggerTestCase if json.Unmarshal(raw, &parsed) != nil { return nil } for _, c := range parsed { if c.Name == caseName { return &caseTemplate{Query: c.Query, Json: c.Json, Form: c.Form, Note: c.Note} } } return nil } for _, item := range cases { cm, ok := item.(map[string]interface{}) if !ok { continue } name, _ := cm["name"].(string) if name != caseName { continue } tmpl := &caseTemplate{} if n, ok := cm["note"].(string); ok { tmpl.Note = n } if q, ok := cm["query"].(map[string]interface{}); ok { tmpl.Query = q } if j, ok := cm["json"]; ok { tmpl.Json = j } if f, ok := cm["form"].(map[string]interface{}); ok { tmpl.Form = f } return tmpl } return nil } // syncBoundCaseToEndpoint 流程步骤跑通后,把响应回写到绑定的单接口验收用例 func (that *TestApp) syncBoundCaseToEndpoint(record TestRecord) { if record.BindCase == "" || record.Path == "" { return } updated := false that.collector.mu.Lock() for i := range that.collector.Records { r := &that.collector.Records[i] if r.FlowName != "" || r.Path != record.Path || r.CaseName != record.BindCase { continue } r.ResponseBody = record.ResponseBody r.Passed = record.Passed r.FailReason = record.FailReason r.VerifyError = record.VerifyError r.ExpectResult = record.ExpectResult if record.Query != nil { r.Query = record.Query } if record.JsonBody != nil { r.JsonBody = record.JsonBody } if record.FormBody != nil { r.FormBody = record.FormBody } updated = true break } that.collector.mu.Unlock() if !updated || !that.swaggerEnabled { return } parts := strings.Split(strings.Trim(record.Path, "/"), "/") if len(parts) == 0 { return } that.flushEndpointSwagger(parts[0], record.Path) } func (that *TestApp) writeModuleSpec(moduleDir, title, version string, endpoints, flows []map[string]interface{}) error { that.swaggerMu.Lock() defer that.swaggerMu.Unlock() if endpoints == nil { endpoints = []map[string]interface{}{} } if flows == nil { flows = []map[string]interface{}{} } spec := map[string]interface{}{ "title": title, "version": version, "endpoints": endpoints, "flows": flows, } 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("写入 api-spec.json 失败: %w", err) } if err := os.WriteFile(filepath.Join(moduleDir, "index.html"), []byte(apiConsoleHTML()), os.ModePerm); err != nil { return fmt.Errorf("写入 index.html 失败: %w", err) } return nil } func (that *TestApp) pruneOrphanSwaggerModules(swaggerRoot string, active map[string]bool) error { entries, err := os.ReadDir(swaggerRoot) if err != nil { return err } for _, e := range entries { if !e.IsDir() { continue } if active[e.Name()] { continue } _ = os.RemoveAll(filepath.Join(swaggerRoot, e.Name())) } return nil } // flushEndpointSwagger 单个接口跑完后按 path 替换写入;无用例记录则不写盘 func (that *TestApp) flushEndpointSwagger(projName, apiPath string) { that.ensureInit() that.collector.mu.Lock() var recs []TestRecord for _, r := range that.collector.Records { if r.FlowName == "" && r.Path == apiPath { recs = append(recs, r) } } that.collector.mu.Unlock() if len(recs) == 0 { return } dir := that.swaggerOutputDir() moduleDir := filepath.Join(dir, "swagger", projName) _ = os.MkdirAll(moduleDir, os.ModePerm) parts := strings.Split(strings.Trim(apiPath, "/"), "/") ctrName := "" if len(parts) >= 2 { ctrName = parts[1] } summary := parts[len(parts)-1] hasTest := false if projDef, ok := that.projs[projName]; ok { if ctrTest, ok := projDef.Tests[ctrName]; ok { methodName := parts[len(parts)-1] if apiTest, ok := ctrTest[methodName]; ok { hasTest = true summary = apiTest.Desc } } } specPath := filepath.Join(moduleDir, "api-spec.json") existing := loadExistingEndpoints(specPath) existingFlows := loadExistingFlows(specPath) ep := that.buildEndpointMap(projName, ctrName, apiPath, summary, hasTest, recs, existing[apiPath]) existing[apiPath] = ep endpoints := make([]map[string]interface{}, 0, len(existing)) for _, v := range existing { endpoints = append(endpoints, v) } sort.Slice(endpoints, func(i, j int) bool { return endpoints[i]["path"].(string) < endpoints[j]["path"].(string) }) flows := make([]map[string]interface{}, 0, len(existingFlows)) for _, f := range existingFlows { flows = append(flows, f) } sort.Slice(flows, func(i, j int) bool { return flows[i]["name"].(string) < flows[j]["name"].(string) }) title := that.swaggerTitle version := that.swaggerVersion if title == "" { title = "API" } if version == "" { version = "1.0.0" } _ = that.writeModuleSpec(moduleDir, title, version, endpoints, flows) _ = generateSwaggerPortal(filepath.Join(dir, "swagger")) } // flushFlowSwagger 单个流程跑完后替换写入;无步骤记录则不写盘 func (that *TestApp) flushFlowSwagger(flowName string) { that.ensureInit() that.collector.mu.Lock() var recs []TestRecord for _, r := range that.collector.Records { if r.FlowName == flowName { recs = append(recs, r) } } that.collector.mu.Unlock() if len(recs) == 0 { return } dir := that.swaggerOutputDir() flowMap := that.buildFlowMap(flowName, recs) title := that.swaggerTitle version := that.swaggerVersion if title == "" { title = "API" } if version == "" { version = "1.0.0" } for projName := range that.projs { moduleDir := filepath.Join(dir, "swagger", projName) _ = os.MkdirAll(moduleDir, os.ModePerm) specPath := filepath.Join(moduleDir, "api-spec.json") existingEndpoints := loadExistingEndpoints(specPath) existingFlows := loadExistingFlows(specPath) existingFlows[flowName] = flowMap endpoints := make([]map[string]interface{}, 0, len(existingEndpoints)) for _, v := range existingEndpoints { endpoints = append(endpoints, v) } sort.Slice(endpoints, func(i, j int) bool { return endpoints[i]["path"].(string) < endpoints[j]["path"].(string) }) flows := make([]map[string]interface{}, 0, len(existingFlows)) for _, f := range existingFlows { flows = append(flows, f) } sort.Slice(flows, func(i, j int) bool { return flows[i]["name"].(string) < flows[j]["name"].(string) }) _ = that.writeModuleSpec(moduleDir, title, version, endpoints, flows) } _ = generateSwaggerPortal(filepath.Join(dir, "swagger")) } // loadExistingEndpoints 读取已有的 api-spec.json,返回 path → endpoint 映射。 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 loadExistingFlows(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 } flows, ok := spec["flows"].([]interface{}) if !ok { return result } for _, raw := range flows { f, ok := raw.(map[string]interface{}) if !ok { continue } if name, ok := f["name"].(string); ok { result[name] = f } } 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"} } }