Files
geo/server/internal/shared/swagger/swagger.go
T
root 7d8e82c69f feat(ops): add object storage management and site-mapping CSV import/export
Ship the Ops backstage 对象存储 page (browse / upload / move / delete /
folder ops) backed by a new object-storage service + handler, with the
shared storage client extended with List/Stat/Copy/Usage/DownloadURL so
both MinIO and Aliyun providers cover the new surface. Add
ForcePathStyle to the object-storage config and treat r2/s3/custom_s3
as external providers so Cloudflare R2 and other S3-compatibles can
share the MinIO client path without spinning up the bundled MinIO.

Also add CSV import/export + template download to the site-domain
mappings page, raise gin MaxMultipartMemory to 8 MiB for the new
multipart endpoints, register Ops swagger routes, and extend the
descriptions-parity test to cover the ops router.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 01:19:01 +08:00

616 lines
17 KiB
Go

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/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/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 = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>省心推 API Swagger</title>
<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>`