2026-04-30 23:19:31 +08:00
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 )
2026-05-02 10:06:05 +08:00
doc := docFor ( route . Method , route . Path )
2026-04-30 23:19:31 +08:00
operation := map [ string ] any {
"tags" : [ ] string { tag } ,
2026-05-02 10:06:05 +08:00
"summary" : doc . Summary ,
2026-04-30 23:19:31 +08:00
"operationId" : operationID ,
"responses" : responsesForRoute ( route ) ,
}
2026-05-02 10:06:05 +08:00
if doc . Description != "" {
operation [ "description" ] = doc . Description
}
2026-04-30 23:19:31 +08:00
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 ) == "" {
2026-05-01 11:03:55 +08:00
cfg . Title = "省心推 客户 API"
2026-04-30 23:19:31 +08:00
}
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 {
2026-05-26 01:19:01 +08:00
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" )
2026-04-30 23:19:31 +08:00
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" ) :
2026-05-13 15:59:39 +08:00
add ( "brand_id" , "keyword_id" , "question_id" , "days" , "business_date" , "ai_platform_id" )
2026-04-30 23:19:31 +08:00
case strings . Contains ( path , "/tenant/monitoring/citation-summary" ) :
2026-05-13 15:59:39 +08:00
add ( "days" , "brand_id" , "keyword_id" , "question_id" , "business_date" , "ai_platform_id" )
2026-06-17 12:48:41 +08:00
case strings . Contains ( path , "/tenant/monitoring/marked-articles" ) :
add ( "page" , "page_size" , "domain" , "title" , "created_from" , "created_to" )
2026-04-30 23:19:31 +08:00
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" )
2026-05-07 14:26:14 +08:00
case strings . Contains ( path , "/tenant/publish-tasks" ) :
add ( "page" , "page_size" , "limit" , "title" )
2026-04-30 23:19:31 +08:00
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 {
2026-05-26 01:19:01 +08:00
case "page" , "page_size" , "limit" , "offset" , "days" , "period_days" , "size" :
2026-04-30 23:19:31 +08:00
return map [ string ] any { "type" : "integer" }
2026-05-13 15:59:39 +08:00
case "brand_id" , "keyword_id" , "question_id" , "template_id" , "kol_prompt_id" , "prompt_rule_id" , "group_id" , "folder_id" , "if_sync_version" :
2026-04-30 23:19:31 +08:00
return map [ string ] any { "type" : "integer" , "format" : "int64" }
case "force" :
return map [ string ] any { "type" : "string" , "enum" : [ ] string { "1" } }
2026-05-26 01:19:01 +08:00
case "ungrouped" , "undo" , "recursive" , "is_active" :
2026-04-30 23:19:31 +08:00
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" ||
2026-05-31 21:55:29 +08:00
path == "/api/desktop/bug-reports" ||
path == "/api/desktop/clients/bug-reports" ||
2026-05-26 01:19:01 +08:00
path == "/api/ops/site-domain-mappings/import" ||
path == "/api/ops/object-storage/objects/upload" ||
path == "/api/ops/desktop-client/releases/upload" ||
2026-06-01 01:31:54 +08:00
path == "/api/ops/desktop-client/release-uploads/:upload_id/chunks/:chunk_index" ||
2026-04-30 23:19:31 +08:00
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" }
2026-05-31 21:55:29 +08:00
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
}
2026-04-30 23:19:31 +08:00
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 {
2026-05-14 11:05:20 +08:00
if path == "/api/auth/password-key" ||
path == "/api/auth/login" || path == "/api/auth/refresh" ||
2026-05-25 19:23:49 +08:00
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" ||
2026-04-30 23:19:31 +08:00
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 = ` <!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
2026-05-01 11:03:55 +08:00
<title>省心推 API Swagger</title>
2026-04-30 23:19:31 +08:00
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style>
body {
margin: 0;
background: #f6f7f9;
}
.swagger-ui .topbar {
display: none;
}
.swagger-ui .info {
margin: 32px 0 24px;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.ui = SwaggerUIBundle( {
url: "/swagger/openapi.json",
dom_id: "#swagger-ui",
deepLinking: true,
displayRequestDuration: true,
docExpansion: "none",
persistAuthorization: true,
tryItOutEnabled: true
});
</script>
</body>
</html> `