package swagger import ( "net/http" "sort" "strconv" "strings" "unicode" "github.com/gin-gonic/gin" ) type Config struct { Title string Version string Description string } func EnabledForMode(mode string) bool { return !strings.EqualFold(strings.TrimSpace(mode), gin.ReleaseMode) } func Register(engine *gin.Engine, cfg Config) { cfg = withDefaults(cfg) engine.GET("/swagger", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/swagger/index.html") }) engine.GET("/swagger/", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/swagger/index.html") }) engine.GET("/swagger/index.html", func(c *gin.Context) { c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(swaggerIndexHTML)) }) engine.GET("/swagger/openapi.json", func(c *gin.Context) { c.JSON(http.StatusOK, BuildOpenAPI(engine.Routes(), cfg)) }) } func BuildOpenAPI(routes gin.RoutesInfo, cfg Config) map[string]any { cfg = withDefaults(cfg) sort.Slice(routes, func(i, j int) bool { if routes[i].Path == routes[j].Path { return routes[i].Method < routes[j].Method } return routes[i].Path < routes[j].Path }) paths := map[string]any{} tagSet := map[string]struct{}{} operationIDs := map[string]int{} for _, route := range routes { if shouldSkipRoute(route.Path) { continue } path := toOpenAPIPath(route.Path) method := strings.ToLower(route.Method) if method == "" { continue } pathItem, ok := paths[path].(map[string]any) if !ok { pathItem = map[string]any{} paths[path] = pathItem } tag := tagForPath(route.Path) tagSet[tag] = struct{}{} operationID := uniqueOperationID(method+"_"+sanitizeIdentifier(path), operationIDs) doc := docFor(route.Method, route.Path) operation := map[string]any{ "tags": []string{tag}, "summary": doc.Summary, "operationId": operationID, "responses": responsesForRoute(route), } if doc.Description != "" { operation["description"] = doc.Description } if params := parametersForRoute(route); len(params) > 0 { operation["parameters"] = params } if body := requestBodyForRoute(route); body != nil { operation["requestBody"] = body } if security := securityForPath(route.Path); len(security) > 0 { operation["security"] = security } pathItem[method] = operation } tags := make([]map[string]string, 0, len(tagSet)) for tag := range tagSet { tags = append(tags, map[string]string{"name": tag}) } sort.Slice(tags, func(i, j int) bool { return tags[i]["name"] < tags[j]["name"] }) return map[string]any{ "openapi": "3.0.3", "info": map[string]any{ "title": cfg.Title, "version": cfg.Version, "description": cfg.Description, }, "servers": []map[string]string{ { "url": "/", "description": "Current host. When opened from admin-web, /api is proxied to tenant-api.", }, }, "tags": tags, "paths": paths, "components": components(), } } func withDefaults(cfg Config) Config { if strings.TrimSpace(cfg.Title) == "" { cfg.Title = "省心推 客户 API" } if strings.TrimSpace(cfg.Version) == "" { cfg.Version = "dev" } if strings.TrimSpace(cfg.Description) == "" { cfg.Description = "Auto-generated from registered Gin routes for admin-web testing. Request and response bodies are intentionally generic where handlers do not expose typed OpenAPI metadata." } return cfg } func shouldSkipRoute(path string) bool { return strings.HasPrefix(path, "/swagger") || strings.HasPrefix(path, "/api/internal/") } func toOpenAPIPath(path string) string { segments := strings.Split(path, "/") for i, segment := range segments { if strings.HasPrefix(segment, ":") && len(segment) > 1 { segments[i] = "{" + segment[1:] + "}" continue } if strings.HasPrefix(segment, "*") && len(segment) > 1 { segments[i] = "{" + segment[1:] + "}" } } return strings.Join(segments, "/") } func tagForPath(path string) string { segments := pathSegments(path) if len(segments) < 2 || segments[0] != "api" { return "General" } switch segments[1] { case "health": return "Health" case "auth": return "Auth" case "public": return "Public" case "desktop": if len(segments) >= 3 { return "Desktop " + titleSegment(segments[2]) } return "Desktop" case "tenant": if len(segments) >= 4 && segments[2] == "kol" { return "Tenant KOL " + titleSegment(segments[3]) } if len(segments) >= 3 { return "Tenant " + titleSegment(segments[2]) } return "Tenant" default: return titleSegment(segments[1]) } } func pathSegments(path string) []string { trimmed := strings.Trim(path, "/") if trimmed == "" { return nil } return strings.Split(trimmed, "/") } func titleSegment(segment string) string { parts := strings.FieldsFunc(segment, func(r rune) bool { return r == '-' || r == '_' || r == ':' }) for i, part := range parts { switch strings.ToLower(part) { case "ai": parts[i] = "AI" case "kol": parts[i] = "KOL" default: parts[i] = strings.ToUpper(part[:1]) + strings.ToLower(part[1:]) } } return strings.Join(parts, " ") } func uniqueOperationID(base string, seen map[string]int) string { if base == "" { base = "operation" } count := seen[base] seen[base] = count + 1 if count == 0 { return base } return base + "_" + strconv.Itoa(count+1) } func sanitizeIdentifier(value string) string { var b strings.Builder lastUnderscore := false for _, r := range value { if unicode.IsLetter(r) || unicode.IsDigit(r) { b.WriteRune(unicode.ToLower(r)) lastUnderscore = false continue } if !lastUnderscore { b.WriteByte('_') lastUnderscore = true } } return strings.Trim(b.String(), "_") } func parametersForRoute(route gin.RouteInfo) []map[string]any { params := make([]map[string]any, 0) params = append(params, pathParameters(route.Path)...) for _, name := range queryParameterNames(route) { params = append(params, queryParameter(name)) } if requiresWorkspaceHeader(route.Path) { params = append(params, map[string]any{ "name": "X-Workspace-ID", "in": "header", "required": false, "description": "Optional workspace override. Defaults to the workspace in the access token.", "schema": map[string]any{ "type": "integer", "format": "int64", }, }) } return params } func pathParameters(path string) []map[string]any { segments := pathSegments(path) params := make([]map[string]any, 0) for _, segment := range segments { if len(segment) < 2 || (segment[0] != ':' && segment[0] != '*') { continue } name := segment[1:] params = append(params, map[string]any{ "name": name, "in": "path", "required": true, "description": "Route parameter `" + name + "`.", "schema": map[string]any{ "type": "string", }, }) } return params } func queryParameterNames(route gin.RouteInfo) []string { if route.Method != http.MethodGet && route.Method != http.MethodDelete && route.Method != http.MethodPost { return nil } path := route.Path names := make([]string, 0) add := func(values ...string) { for _, value := range values { if value == "" || contains(names, value) { continue } names = append(names, value) } } switch { case strings.Contains(path, "/ops/site-domain-mappings"): add("page", "size", "keyword", "is_active") case strings.Contains(path, "/ops/object-storage/objects/detail") || strings.Contains(path, "/ops/object-storage/objects/url") || strings.Contains(path, "/ops/object-storage/objects/download-url") || strings.Contains(path, "/ops/object-storage/objects/download"): add("key") case strings.Contains(path, "/ops/object-storage/objects"): add("prefix", "keyword", "continuation_token", "start_after", "recursive", "size", "key") case strings.Contains(path, "/ops/object-storage/folders"): add("prefix") case strings.Contains(path, "/workspace/ai-point-usage"): add("page", "page_size") case strings.Contains(path, "/tenant/articles"): add("page", "page_size", "generate_status", "publish_status", "source_type", "generation_mode", "template_id", "kol_prompt_id", "prompt_rule_id", "keyword", "created_from", "created_to") case strings.Contains(path, "/tenant/prompt-rules"): add("page", "page_size", "group_id", "ungrouped", "status", "keyword") case strings.Contains(path, "/tenant/schedules"): add("page", "page_size", "prompt_rule_id", "status", "keyword", "created_from", "created_to") case strings.Contains(path, "/tenant/instant-tasks"): add("page", "page_size", "prompt_rule_id", "status", "keyword", "created_from", "created_to") case strings.Contains(path, "/tenant/images"): add("page", "page_size", "folder_id", "q", "force") case strings.Contains(path, "/tenant/knowledge/items"): add("group_id") case strings.Contains(path, "/tenant/brands") && strings.Contains(path, "/questions"): add("keyword_id") case strings.Contains(path, "/tenant/monitoring/dashboard/composite"): add("brand_id", "keyword_id", "question_id", "days", "business_date", "ai_platform_id") case strings.Contains(path, "/tenant/monitoring/citation-summary"): add("days", "brand_id", "keyword_id", "question_id", "business_date", "ai_platform_id") case strings.Contains(path, "/tenant/monitoring/brands/"): add("ai_platform_id", "date_from", "date_to", "question_hash") case strings.Contains(path, "/tenant/kol/marketplace/packages"): add("industry", "keyword", "offset", "limit") case strings.Contains(path, "/tenant/kol/dashboard/trend"): add("period_days") case strings.Contains(path, "/tenant/accounts/"): add("undo") case strings.Contains(path, "/tenant/publish-tasks"): add("page", "page_size", "limit", "title") case strings.Contains(path, "/desktop/accounts"): add("client", "if_sync_version") case strings.Contains(path, "/desktop/publish-tasks"): add("page", "page_size", "limit", "title") case strings.Contains(path, "/templates/") && strings.Contains(path, "_task_result"): add("task_id") case strings.Contains(path, "/public/assets/"): add("format") } return names } func queryParameter(name string) map[string]any { return map[string]any{ "name": name, "in": "query", "required": false, "schema": schemaForQueryParameter(name), } } func schemaForQueryParameter(name string) map[string]any { switch name { case "page", "page_size", "limit", "offset", "days", "period_days", "size": return map[string]any{"type": "integer"} case "brand_id", "keyword_id", "question_id", "template_id", "kol_prompt_id", "prompt_rule_id", "group_id", "folder_id", "if_sync_version": return map[string]any{"type": "integer", "format": "int64"} case "force": return map[string]any{"type": "string", "enum": []string{"1"}} case "ungrouped", "undo", "recursive", "is_active": return map[string]any{"type": "string", "enum": []string{"true", "false"}} case "created_from", "created_to", "date_from", "date_to", "business_date": return map[string]any{"type": "string", "format": "date"} default: return map[string]any{"type": "string"} } } func requestBodyForRoute(route gin.RouteInfo) map[string]any { switch route.Method { case http.MethodPost, http.MethodPut, http.MethodPatch: default: return nil } if isMultipartRoute(route) { return multipartRequestBody(route.Path) } return map[string]any{ "required": false, "content": map[string]any{ "application/json": map[string]any{ "schema": map[string]any{ "type": "object", "additionalProperties": true, }, "example": map[string]any{}, }, }, } } func isMultipartRoute(route gin.RouteInfo) bool { if route.Method != http.MethodPost { return false } path := route.Path return path == "/api/tenant/images" || path == "/api/desktop/bug-reports" || path == "/api/desktop/clients/bug-reports" || path == "/api/ops/site-domain-mappings/import" || path == "/api/ops/object-storage/objects/upload" || path == "/api/ops/desktop-client/releases/upload" || path == "/api/tenant/knowledge/items/file" || path == "/api/tenant/kol/manage/profile/avatar" || path == "/api/tenant/articles/:id/images" } func multipartRequestBody(path string) map[string]any { properties := map[string]any{ "file": map[string]any{ "type": "string", "format": "binary", }, } required := []string{"file"} if path == "/api/desktop/bug-reports" || path == "/api/desktop/clients/bug-reports" { properties = map[string]any{ "upload_file_minidump": map[string]any{ "type": "string", "format": "binary", "description": "Electron crashReporter 默认上传的 minidump 字段;手动反馈也可用 dump 或 file 字段。", }, "report_type": map[string]any{"type": "string", "enum": []string{"crash", "bug"}}, "severity": map[string]any{"type": "string", "enum": []string{"low", "medium", "high", "critical"}}, "title": map[string]any{"type": "string"}, "description": map[string]any{"type": "string"}, "runtime_snapshot": map[string]any{"type": "string"}, "app_log_tail": map[string]any{"type": "string"}, } required = nil } if path == "/api/tenant/images" { properties["folder_id"] = map[string]any{"type": "string"} } if path == "/api/tenant/knowledge/items/file" { properties["group_id"] = map[string]any{"type": "string"} properties["name"] = map[string]any{"type": "string"} required = append(required, "group_id") } return map[string]any{ "required": true, "content": map[string]any{ "multipart/form-data": map[string]any{ "schema": map[string]any{ "type": "object", "properties": properties, "required": required, }, }, }, } } func responsesForRoute(route gin.RouteInfo) map[string]any { successContent := map[string]any{ "application/json": map[string]any{ "schema": map[string]any{ "$ref": "#/components/schemas/ApiEnvelope", }, }, } if strings.HasSuffix(route.Path, "/stream") { successContent = map[string]any{ "text/event-stream": map[string]any{ "schema": map[string]any{"type": "string"}, }, } } responses := map[string]any{ "200": map[string]any{ "description": "OK", "content": successContent, }, "400": errorResponse("Bad Request"), "401": errorResponse("Unauthorized"), "403": errorResponse("Forbidden"), "404": errorResponse("Not Found"), "500": errorResponse("Internal Server Error"), } if route.Method == http.MethodPost { responses["201"] = map[string]any{ "description": "Created", "content": map[string]any{ "application/json": map[string]any{ "schema": map[string]any{ "$ref": "#/components/schemas/ApiEnvelope", }, }, }, } } return responses } func errorResponse(description string) map[string]any { return map[string]any{ "description": description, "content": map[string]any{ "application/json": map[string]any{ "schema": map[string]any{ "$ref": "#/components/schemas/ApiError", }, }, }, } } func securityForPath(path string) []map[string][]string { if path == "/api/auth/password-key" || path == "/api/auth/login" || path == "/api/auth/refresh" || path == "/api/desktop/releases/latest" || path == "/api/desktop/releases/download-url" || path == "/api/desktop/releases/updater/:platform/:arch/:channel/:version" || path == "/api/desktop/releases/updater/:platform/:arch/:channel/:version/:feed" || strings.HasPrefix(path, "/api/health/") || strings.HasPrefix(path, "/api/public/") { return nil } if strings.HasPrefix(path, "/api/") { return []map[string][]string{{"bearerAuth": []string{}}} } return nil } func requiresWorkspaceHeader(path string) bool { return path == "/api/auth/me" || path == "/api/auth/logout" || path == "/api/desktop/clients/register" || strings.HasPrefix(path, "/api/tenant/") } func components() map[string]any { return map[string]any{ "securitySchemes": map[string]any{ "bearerAuth": map[string]any{ "type": "http", "scheme": "bearer", "bearerFormat": "JWT", "description": "Admin-web endpoints use the tenant access token. Desktop client endpoints also use Bearer, with the desktop client token.", }, }, "schemas": map[string]any{ "ApiEnvelope": map[string]any{ "type": "object", "properties": map[string]any{ "code": map[string]any{"type": "integer", "example": 0}, "message": map[string]any{"type": "string", "example": "ok"}, "data": map[string]any{"description": "Response payload."}, "request_id": map[string]any{"type": "string"}, }, }, "ApiError": map[string]any{ "type": "object", "properties": map[string]any{ "code": map[string]any{"type": "integer"}, "message": map[string]any{"type": "string"}, "detail": map[string]any{"type": "string"}, "request_id": map[string]any{"type": "string"}, }, }, }, } } func contains(values []string, expected string) bool { for _, value := range values { if value == expected { return true } } return false } const swaggerIndexHTML = `