This is page 8 of 9. Use http://codebase.md/higress-group/himarket?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .cursor │ └── rules │ ├── api-style.mdc │ └── project-architecture.mdc ├── .gitignore ├── build.sh ├── deploy │ ├── docker │ │ ├── docker-compose.yml │ │ └── Docker部署说明.md │ └── helm │ ├── Chart.yaml │ ├── Helm部署说明.md │ ├── templates │ │ ├── _helpers.tpl │ │ ├── himarket-admin-cm.yaml │ │ ├── himarket-admin-deployment.yaml │ │ ├── himarket-admin-service.yaml │ │ ├── himarket-frontend-cm.yaml │ │ ├── himarket-frontend-deployment.yaml │ │ ├── himarket-frontend-service.yaml │ │ ├── himarket-server-cm.yaml │ │ ├── himarket-server-deployment.yaml │ │ ├── himarket-server-service.yaml │ │ ├── mysql.yaml │ │ └── serviceaccount.yaml │ └── values.yaml ├── LICENSE ├── NOTICE ├── pom.xml ├── portal-bootstrap │ ├── Dockerfile │ ├── pom.xml │ └── src │ ├── main │ │ ├── java │ │ │ └── com │ │ │ └── alibaba │ │ │ └── apiopenplatform │ │ │ ├── config │ │ │ │ ├── AsyncConfig.java │ │ │ │ ├── FilterConfig.java │ │ │ │ ├── PageConfig.java │ │ │ │ ├── RestTemplateConfig.java │ │ │ │ ├── SecurityConfig.java │ │ │ │ └── SwaggerConfig.java │ │ │ ├── filter │ │ │ │ └── PortalResolvingFilter.java │ │ │ └── PortalApplication.java │ │ └── resources │ │ └── application.yaml │ └── test │ └── java │ └── com │ └── alibaba │ └── apiopenplatform │ └── integration │ └── AdministratorAuthIntegrationTest.java ├── portal-dal │ ├── pom.xml │ └── src │ └── main │ └── java │ └── com │ └── alibaba │ └── apiopenplatform │ ├── converter │ │ ├── AdpAIGatewayConfigConverter.java │ │ ├── APIGConfigConverter.java │ │ ├── APIGRefConfigConverter.java │ │ ├── ApiKeyConfigConverter.java │ │ ├── ConsumerAuthConfigConverter.java │ │ ├── GatewayConfigConverter.java │ │ ├── HigressConfigConverter.java │ │ ├── HigressRefConfigConverter.java │ │ ├── HmacConfigConverter.java │ │ ├── JsonConverter.java │ │ ├── JwtConfigConverter.java │ │ ├── NacosRefConfigConverter.java │ │ ├── PortalSettingConfigConverter.java │ │ ├── PortalUiConfigConverter.java │ │ └── ProductIconConverter.java │ ├── entity │ │ ├── Administrator.java │ │ ├── BaseEntity.java │ │ ├── Consumer.java │ │ ├── ConsumerCredential.java │ │ ├── ConsumerRef.java │ │ ├── Developer.java │ │ ├── DeveloperExternalIdentity.java │ │ ├── Gateway.java │ │ ├── NacosInstance.java │ │ ├── Portal.java │ │ ├── PortalDomain.java │ │ ├── Product.java │ │ ├── ProductPublication.java │ │ ├── ProductRef.java │ │ └── ProductSubscription.java │ ├── repository │ │ ├── AdministratorRepository.java │ │ ├── BaseRepository.java │ │ ├── ConsumerCredentialRepository.java │ │ ├── ConsumerRefRepository.java │ │ ├── ConsumerRepository.java │ │ ├── DeveloperExternalIdentityRepository.java │ │ ├── DeveloperRepository.java │ │ ├── GatewayRepository.java │ │ ├── NacosInstanceRepository.java │ │ ├── PortalDomainRepository.java │ │ ├── PortalRepository.java │ │ ├── ProductPublicationRepository.java │ │ ├── ProductRefRepository.java │ │ ├── ProductRepository.java │ │ └── SubscriptionRepository.java │ └── support │ ├── common │ │ ├── Encrypted.java │ │ ├── Encryptor.java │ │ └── User.java │ ├── consumer │ │ ├── AdpAIAuthConfig.java │ │ ├── APIGAuthConfig.java │ │ ├── ApiKeyConfig.java │ │ ├── ConsumerAuthConfig.java │ │ ├── HigressAuthConfig.java │ │ ├── HmacConfig.java │ │ └── JwtConfig.java │ ├── enums │ │ ├── APIGAPIType.java │ │ ├── ConsumerAuthType.java │ │ ├── ConsumerStatus.java │ │ ├── CredentialMode.java │ │ ├── DeveloperAuthType.java │ │ ├── DeveloperStatus.java │ │ ├── DomainType.java │ │ ├── GatewayType.java │ │ ├── GrantType.java │ │ ├── HigressAPIType.java │ │ ├── JwtAlgorithm.java │ │ ├── ProductIconType.java │ │ ├── ProductStatus.java │ │ ├── ProductType.java │ │ ├── ProtocolType.java │ │ ├── PublicKeyFormat.java │ │ ├── SourceType.java │ │ ├── SubscriptionStatus.java │ │ └── UserType.java │ ├── gateway │ │ ├── AdpAIGatewayConfig.java │ │ ├── APIGConfig.java │ │ ├── GatewayConfig.java │ │ └── HigressConfig.java │ ├── portal │ │ ├── AuthCodeConfig.java │ │ ├── IdentityMapping.java │ │ ├── JwtBearerConfig.java │ │ ├── OAuth2Config.java │ │ ├── OidcConfig.java │ │ ├── PortalSettingConfig.java │ │ ├── PortalUiConfig.java │ │ └── PublicKeyConfig.java │ └── product │ ├── APIGRefConfig.java │ ├── HigressRefConfig.java │ ├── NacosRefConfig.java │ └── ProductIcon.java ├── portal-server │ ├── pom.xml │ └── src │ └── main │ └── java │ └── com │ └── alibaba │ └── apiopenplatform │ ├── controller │ │ ├── AdministratorController.java │ │ ├── ConsumerController.java │ │ ├── DeveloperController.java │ │ ├── GatewayController.java │ │ ├── NacosController.java │ │ ├── OAuth2Controller.java │ │ ├── OidcController.java │ │ ├── PortalController.java │ │ └── ProductController.java │ ├── core │ │ ├── advice │ │ │ ├── ExceptionAdvice.java │ │ │ └── ResponseAdvice.java │ │ ├── annotation │ │ │ ├── AdminAuth.java │ │ │ ├── AdminOrDeveloperAuth.java │ │ │ └── DeveloperAuth.java │ │ ├── constant │ │ │ ├── CommonConstants.java │ │ │ ├── IdpConstants.java │ │ │ ├── JwtConstants.java │ │ │ └── Resources.java │ │ ├── event │ │ │ ├── DeveloperDeletingEvent.java │ │ │ ├── PortalDeletingEvent.java │ │ │ └── ProductDeletingEvent.java │ │ ├── exception │ │ │ ├── BusinessException.java │ │ │ └── ErrorCode.java │ │ ├── response │ │ │ └── Response.java │ │ ├── security │ │ │ ├── ContextHolder.java │ │ │ ├── DeveloperAuthenticationProvider.java │ │ │ └── JwtAuthenticationFilter.java │ │ └── utils │ │ ├── IdGenerator.java │ │ ├── PasswordHasher.java │ │ └── TokenUtil.java │ ├── dto │ │ ├── converter │ │ │ ├── InputConverter.java │ │ │ ├── NacosToGatewayToolsConverter.java │ │ │ └── OutputConverter.java │ │ ├── params │ │ │ ├── admin │ │ │ │ ├── AdminCreateParam.java │ │ │ │ ├── AdminLoginParam.java │ │ │ │ └── ResetPasswordParam.java │ │ │ ├── consumer │ │ │ │ ├── CreateConsumerParam.java │ │ │ │ ├── CreateCredentialParam.java │ │ │ │ ├── CreateSubscriptionParam.java │ │ │ │ ├── QueryConsumerParam.java │ │ │ │ ├── QuerySubscriptionParam.java │ │ │ │ └── UpdateCredentialParam.java │ │ │ ├── developer │ │ │ │ ├── CreateDeveloperParam.java │ │ │ │ ├── CreateExternalDeveloperParam.java │ │ │ │ ├── DeveloperLoginParam.java │ │ │ │ ├── QueryDeveloperParam.java │ │ │ │ ├── UnbindExternalIdentityParam.java │ │ │ │ ├── UpdateDeveloperParam.java │ │ │ │ └── UpdateDeveloperStatusParam.java │ │ │ ├── gateway │ │ │ │ ├── ImportGatewayParam.java │ │ │ │ ├── QueryAdpAIGatewayParam.java │ │ │ │ ├── QueryAPIGParam.java │ │ │ │ └── QueryGatewayParam.java │ │ │ ├── nacos │ │ │ │ ├── CreateNacosParam.java │ │ │ │ ├── QueryNacosNamespaceParam.java │ │ │ │ ├── QueryNacosParam.java │ │ │ │ └── UpdateNacosParam.java │ │ │ ├── portal │ │ │ │ ├── BindDomainParam.java │ │ │ │ ├── CreatePortalParam.java │ │ │ │ └── UpdatePortalParam.java │ │ │ └── product │ │ │ ├── CreateProductParam.java │ │ │ ├── CreateProductRefParam.java │ │ │ ├── PublishProductParam.java │ │ │ ├── QueryProductParam.java │ │ │ ├── QueryProductSubscriptionParam.java │ │ │ ├── UnPublishProductParam.java │ │ │ └── UpdateProductParam.java │ │ └── result │ │ ├── AdminResult.java │ │ ├── AdpGatewayInstanceResult.java │ │ ├── AdpMcpServerListResult.java │ │ ├── AdpMCPServerResult.java │ │ ├── APIConfigResult.java │ │ ├── APIGMCPServerResult.java │ │ ├── APIResult.java │ │ ├── AuthResult.java │ │ ├── ConsumerCredentialResult.java │ │ ├── ConsumerResult.java │ │ ├── DeveloperResult.java │ │ ├── GatewayMCPServerResult.java │ │ ├── GatewayResult.java │ │ ├── HigressMCPServerResult.java │ │ ├── IdpResult.java │ │ ├── IdpState.java │ │ ├── IdpTokenResult.java │ │ ├── MCPConfigResult.java │ │ ├── MCPServerResult.java │ │ ├── MseNacosResult.java │ │ ├── NacosMCPServerResult.java │ │ ├── NacosNamespaceResult.java │ │ ├── NacosResult.java │ │ ├── PageResult.java │ │ ├── PortalResult.java │ │ ├── ProductPublicationResult.java │ │ ├── ProductRefResult.java │ │ ├── ProductResult.java │ │ └── SubscriptionResult.java │ └── service │ ├── AdministratorService.java │ ├── AdpAIGatewayService.java │ ├── ConsumerService.java │ ├── DeveloperService.java │ ├── gateway │ │ ├── AdpAIGatewayOperator.java │ │ ├── AIGatewayOperator.java │ │ ├── APIGOperator.java │ │ ├── client │ │ │ ├── AdpAIGatewayClient.java │ │ │ ├── APIGClient.java │ │ │ ├── GatewayClient.java │ │ │ ├── HigressClient.java │ │ │ ├── PopGatewayClient.java │ │ │ └── SLSClient.java │ │ ├── factory │ │ │ └── HTTPClientFactory.java │ │ ├── GatewayOperator.java │ │ └── HigressOperator.java │ ├── GatewayService.java │ ├── IdpService.java │ ├── impl │ │ ├── AdministratorServiceImpl.java │ │ ├── ConsumerServiceImpl.java │ │ ├── DeveloperServiceImpl.java │ │ ├── GatewayServiceImpl.java │ │ ├── IdpServiceImpl.java │ │ ├── NacosServiceImpl.java │ │ ├── OAuth2ServiceImpl.java │ │ ├── OidcServiceImpl.java │ │ ├── PortalServiceImpl.java │ │ └── ProductServiceImpl.java │ ├── NacosService.java │ ├── OAuth2Service.java │ ├── OidcService.java │ ├── PortalService.java │ └── ProductService.java ├── portal-web │ ├── api-portal-admin │ │ ├── .env │ │ ├── .gitignore │ │ ├── bin │ │ │ ├── replace_var.py │ │ │ └── start.sh │ │ ├── Dockerfile │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── nginx.conf │ │ ├── package.json │ │ ├── postcss.config.js │ │ ├── proxy.conf │ │ ├── public │ │ │ ├── logo.png │ │ │ └── vite.svg │ │ ├── README.md │ │ ├── src │ │ │ ├── aliyunThemeToken.ts │ │ │ ├── App.css │ │ │ ├── App.tsx │ │ │ ├── assets │ │ │ │ └── react.svg │ │ │ ├── components │ │ │ │ ├── api-product │ │ │ │ │ ├── ApiProductApiDocs.tsx │ │ │ │ │ ├── ApiProductDashboard.tsx │ │ │ │ │ ├── ApiProductFormModal.tsx │ │ │ │ │ ├── ApiProductLinkApi.tsx │ │ │ │ │ ├── ApiProductOverview.tsx │ │ │ │ │ ├── ApiProductPolicy.tsx │ │ │ │ │ ├── ApiProductPortal.tsx │ │ │ │ │ ├── ApiProductUsageGuide.tsx │ │ │ │ │ ├── SwaggerUIWrapper.css │ │ │ │ │ └── SwaggerUIWrapper.tsx │ │ │ │ ├── common │ │ │ │ │ ├── AdvancedSearch.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── console │ │ │ │ │ ├── GatewayTypeSelector.tsx │ │ │ │ │ ├── ImportGatewayModal.tsx │ │ │ │ │ ├── ImportHigressModal.tsx │ │ │ │ │ ├── ImportMseNacosModal.tsx │ │ │ │ │ └── NacosTypeSelector.tsx │ │ │ │ ├── icons │ │ │ │ │ └── McpServerIcon.tsx │ │ │ │ ├── Layout.tsx │ │ │ │ ├── LayoutWrapper.tsx │ │ │ │ ├── portal │ │ │ │ │ ├── PortalConsumers.tsx │ │ │ │ │ ├── PortalDashboard.tsx │ │ │ │ │ ├── PortalDevelopers.tsx │ │ │ │ │ ├── PortalDomain.tsx │ │ │ │ │ ├── PortalFormModal.tsx │ │ │ │ │ ├── PortalOverview.tsx │ │ │ │ │ ├── PortalPublishedApis.tsx │ │ │ │ │ ├── PortalSecurity.tsx │ │ │ │ │ ├── PortalSettings.tsx │ │ │ │ │ ├── PublicKeyManager.tsx │ │ │ │ │ └── ThirdPartyAuthManager.tsx │ │ │ │ └── subscription │ │ │ │ └── SubscriptionListModal.tsx │ │ │ ├── contexts │ │ │ │ └── LoadingContext.tsx │ │ │ ├── index.css │ │ │ ├── lib │ │ │ │ ├── api.ts │ │ │ │ ├── constant.ts │ │ │ │ └── utils.ts │ │ │ ├── main.tsx │ │ │ ├── pages │ │ │ │ ├── ApiProductDetail.tsx │ │ │ │ ├── ApiProducts.tsx │ │ │ │ ├── Dashboard.tsx │ │ │ │ ├── GatewayConsoles.tsx │ │ │ │ ├── Login.tsx │ │ │ │ ├── NacosConsoles.tsx │ │ │ │ ├── PortalDetail.tsx │ │ │ │ ├── Portals.tsx │ │ │ │ └── Register.tsx │ │ │ ├── routes │ │ │ │ └── index.tsx │ │ │ ├── types │ │ │ │ ├── api-product.ts │ │ │ │ ├── consumer.ts │ │ │ │ ├── gateway.ts │ │ │ │ ├── index.ts │ │ │ │ ├── portal.ts │ │ │ │ ├── shims-js-yaml.d.ts │ │ │ │ └── subscription.ts │ │ │ └── vite-env.d.ts │ │ ├── tailwind.config.js │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ └── api-portal-frontend │ ├── .env │ ├── .gitignore │ ├── .husky │ │ └── pre-commit │ ├── bin │ │ ├── replace_var.py │ │ └── start.sh │ ├── Dockerfile │ ├── eslint.config.js │ ├── index.html │ ├── nginx.conf │ ├── package.json │ ├── postcss.config.js │ ├── proxy.conf │ ├── public │ │ ├── favicon.ico │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── MCP.png │ │ ├── MCP.svg │ │ └── vite.svg │ ├── README.md │ ├── src │ │ ├── aliyunThemeToken.ts │ │ ├── App.css │ │ ├── App.tsx │ │ ├── assets │ │ │ ├── aliyun.png │ │ │ ├── github.png │ │ │ ├── google.png │ │ │ └── react.svg │ │ ├── components │ │ │ ├── consumer │ │ │ │ ├── ConsumerBasicInfo.tsx │ │ │ │ ├── CredentialManager.tsx │ │ │ │ ├── index.ts │ │ │ │ └── SubscriptionManager.tsx │ │ │ ├── Layout.tsx │ │ │ ├── Navigation.tsx │ │ │ ├── ProductHeader.tsx │ │ │ ├── SwaggerUIWrapper.css │ │ │ ├── SwaggerUIWrapper.tsx │ │ │ └── UserInfo.tsx │ │ ├── index.css │ │ ├── lib │ │ │ ├── api.ts │ │ │ ├── statusUtils.ts │ │ │ └── utils.ts │ │ ├── main.tsx │ │ ├── pages │ │ │ ├── ApiDetail.tsx │ │ │ ├── Apis.tsx │ │ │ ├── Callback.tsx │ │ │ ├── ConsumerDetail.tsx │ │ │ ├── Consumers.tsx │ │ │ ├── GettingStarted.tsx │ │ │ ├── Home.tsx │ │ │ ├── Login.tsx │ │ │ ├── Mcp.tsx │ │ │ ├── McpDetail.tsx │ │ │ ├── OidcCallback.tsx │ │ │ ├── Profile.tsx │ │ │ ├── Register.tsx │ │ │ └── Test.css │ │ ├── router.tsx │ │ ├── types │ │ │ ├── consumer.ts │ │ │ └── index.ts │ │ └── vite-env.d.ts │ ├── tailwind.config.js │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts └── README.md ``` # Files -------------------------------------------------------------------------------- /portal-web/api-portal-frontend/src/pages/McpDetail.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { useEffect, useState, useCallback } from "react"; 2 | import { useParams } from "react-router-dom"; 3 | import api from "../lib/api"; 4 | import { Layout } from "../components/Layout"; 5 | import { ProductHeader } from "../components/ProductHeader"; 6 | import { 7 | Card, 8 | Alert, 9 | Button, 10 | message, 11 | Tabs, 12 | Row, 13 | Col, 14 | Collapse, 15 | } from "antd"; 16 | import { CopyOutlined } from "@ant-design/icons"; 17 | import ReactMarkdown from "react-markdown"; 18 | import { ProductType } from "../types"; 19 | import type { 20 | Product, 21 | McpConfig, 22 | McpServerProduct, 23 | ApiResponse, 24 | } from "../types"; 25 | import * as yaml from "js-yaml"; 26 | import remarkGfm from 'remark-gfm'; 27 | import 'react-markdown-editor-lite/lib/index.css' 28 | 29 | function McpDetail() { 30 | const { mcpName } = useParams(); 31 | const [loading, setLoading] = useState(true); 32 | const [error, setError] = useState(""); 33 | const [data, setData] = useState<Product | null>(null); 34 | const [mcpConfig, setMcpConfig] = useState<McpConfig | null>(null); 35 | const [parsedTools, setParsedTools] = useState< 36 | Array<{ 37 | name: string; 38 | description: string; 39 | args?: Array<{ 40 | name: string; 41 | description: string; 42 | type: string; 43 | required: boolean; 44 | position: string; 45 | default?: string; 46 | enum?: string[]; 47 | }>; 48 | }> 49 | >([]); 50 | const [httpJson, setHttpJson] = useState(""); 51 | const [sseJson, setSseJson] = useState(""); 52 | const [localJson, setLocalJson] = useState(""); 53 | 54 | // 解析YAML配置的函数 55 | const parseYamlConfig = ( 56 | yamlString: string 57 | ): { 58 | tools?: Array<{ 59 | name: string; 60 | description: string; 61 | args?: Array<{ 62 | name: string; 63 | description: string; 64 | type: string; 65 | required: boolean; 66 | position: string; 67 | default?: string; 68 | enum?: string[]; 69 | }>; 70 | }>; 71 | } | null => { 72 | try { 73 | const parsed = yaml.load(yamlString) as { 74 | tools?: Array<{ 75 | name: string; 76 | description: string; 77 | args?: Array<{ 78 | name: string; 79 | description: string; 80 | type: string; 81 | required: boolean; 82 | position: string; 83 | default?: string; 84 | enum?: string[]; 85 | }>; 86 | }>; 87 | }; 88 | return parsed; 89 | } catch (error) { 90 | console.warn("解析YAML配置失败:", error); 91 | return null; 92 | } 93 | }; 94 | 95 | // 生成连接配置的函数 96 | const generateConnectionConfig = useCallback(( 97 | domains: Array<{ domain: string; protocol: string }> | null | undefined, 98 | path: string | null | undefined, 99 | serverName: string, 100 | localConfig?: unknown, 101 | protocolType?: string 102 | ) => { 103 | // 互斥:优先判断本地模式 104 | if (localConfig) { 105 | const localConfigJson = JSON.stringify(localConfig, null, 2); 106 | setLocalJson(localConfigJson); 107 | setHttpJson(""); 108 | setSseJson(""); 109 | return; 110 | } 111 | 112 | // HTTP/SSE 模式 113 | if (domains && domains.length > 0 && path) { 114 | const domain = domains[0]; 115 | const baseUrl = `${domain.protocol}://${domain.domain}`; 116 | let endpoint = `${baseUrl}${path}`; 117 | 118 | if (mcpConfig?.meta?.source === 'ADP_AI_GATEWAY') { 119 | endpoint = `${baseUrl}/mcp-servers${path}`; 120 | } 121 | 122 | if (protocolType === 'SSE') { 123 | // 仅生成SSE配置,不追加/sse 124 | const sseConfig = `{ 125 | "mcpServers": { 126 | "${serverName}": { 127 | "type": "sse", 128 | "url": "${endpoint}" 129 | } 130 | } 131 | }`; 132 | setSseJson(sseConfig); 133 | setHttpJson(""); 134 | setLocalJson(""); 135 | return; 136 | } else if (protocolType === 'StreamableHTTP') { 137 | // 仅生成HTTP配置 138 | const httpConfig = `{ 139 | "mcpServers": { 140 | "${serverName}": { 141 | "url": "${endpoint}" 142 | } 143 | } 144 | }`; 145 | setHttpJson(httpConfig); 146 | setSseJson(""); 147 | setLocalJson(""); 148 | return; 149 | } else { 150 | // protocol为null或其他值:生成两种配置 151 | const httpConfig = `{ 152 | "mcpServers": { 153 | "${serverName}": { 154 | "url": "${endpoint}" 155 | } 156 | } 157 | }`; 158 | 159 | const sseConfig = `{ 160 | "mcpServers": { 161 | "${serverName}": { 162 | "type": "sse", 163 | "url": "${endpoint}/sse" 164 | } 165 | } 166 | }`; 167 | 168 | setHttpJson(httpConfig); 169 | setSseJson(sseConfig); 170 | setLocalJson(""); 171 | return; 172 | } 173 | } 174 | 175 | // 无有效配置 176 | setHttpJson(""); 177 | setSseJson(""); 178 | setLocalJson(""); 179 | }, [mcpConfig]); 180 | 181 | useEffect(() => { 182 | const fetchDetail = async () => { 183 | if (!mcpName) { 184 | return; 185 | } 186 | setLoading(true); 187 | setError(""); 188 | try { 189 | const response: ApiResponse<Product> = await api.get(`/products/${mcpName}`); 190 | if (response.code === "SUCCESS" && response.data) { 191 | setData(response.data); 192 | 193 | // 处理MCP配置(统一使用新结构 mcpConfig) 194 | if (response.data.type === ProductType.MCP_SERVER) { 195 | const mcpProduct = response.data as McpServerProduct; 196 | 197 | if (mcpProduct.mcpConfig) { 198 | setMcpConfig(mcpProduct.mcpConfig); 199 | 200 | // 解析tools配置 201 | if (mcpProduct.mcpConfig.tools) { 202 | const parsedConfig = parseYamlConfig( 203 | mcpProduct.mcpConfig.tools 204 | ); 205 | if (parsedConfig && parsedConfig.tools) { 206 | setParsedTools(parsedConfig.tools); 207 | } 208 | } 209 | } 210 | } 211 | } else { 212 | setError(response.message || "数据加载失败"); 213 | } 214 | } catch (error) { 215 | console.error("API请求失败:", error); 216 | setError("加载失败,请稍后重试"); 217 | } finally { 218 | setLoading(false); 219 | } 220 | }; 221 | fetchDetail(); 222 | }, [mcpName]); 223 | 224 | // 监听 mcpConfig 变化,重新生成连接配置 225 | useEffect(() => { 226 | if (mcpConfig) { 227 | generateConnectionConfig( 228 | mcpConfig.mcpServerConfig.domains, 229 | mcpConfig.mcpServerConfig.path, 230 | mcpConfig.mcpServerName, 231 | mcpConfig.mcpServerConfig.rawConfig, 232 | (mcpConfig.meta as any)?.protocol 233 | ); 234 | } 235 | }, [mcpConfig, generateConnectionConfig]); 236 | 237 | const handleCopy = async (text: string) => { 238 | try { 239 | if (navigator.clipboard && window.isSecureContext) { 240 | await navigator.clipboard.writeText(text); 241 | } else { 242 | // 非安全上下文降级处理 243 | const textarea = document.createElement("textarea"); 244 | textarea.value = text; 245 | textarea.style.position = "fixed"; 246 | document.body.appendChild(textarea); 247 | textarea.focus(); 248 | textarea.select(); 249 | document.execCommand("copy"); 250 | document.body.removeChild(textarea); 251 | } 252 | message.success("已复制到剪贴板", 1); 253 | } catch { 254 | message.error("复制失败,请手动复制"); 255 | } 256 | }; 257 | 258 | 259 | if (error) { 260 | return ( 261 | <Layout loading={loading}> 262 | <Alert message={error} type="error" showIcon className="my-8" /> 263 | </Layout> 264 | ); 265 | } 266 | if (!data) { 267 | return ( 268 | <Layout loading={loading}> 269 | <Alert 270 | message="未找到相关数据" 271 | type="warning" 272 | showIcon 273 | className="my-8" 274 | /> 275 | </Layout> 276 | ); 277 | } 278 | 279 | const { name, description } = data; 280 | const hasLocalConfig = Boolean(mcpConfig?.mcpServerConfig.rawConfig); 281 | 282 | 283 | 284 | return ( 285 | <Layout loading={loading}> 286 | <div className="mb-6"> 287 | <ProductHeader 288 | name={name} 289 | description={description} 290 | icon={data.icon} 291 | defaultIcon="/MCP.svg" 292 | mcpConfig={mcpConfig} 293 | updatedAt={data.updatedAt} 294 | productType="MCP_SERVER" 295 | /> 296 | <hr className="border-gray-200 mt-4" /> 297 | </div> 298 | 299 | {/* 主要内容区域 - 左右布局 */} 300 | <Row gutter={24}> 301 | {/* 左侧内容 */} 302 | <Col span={15}> 303 | <Card className="mb-6 rounded-lg border-gray-200"> 304 | <Tabs 305 | defaultActiveKey="overview" 306 | items={[ 307 | { 308 | key: "overview", 309 | label: "Overview", 310 | children: data.document ? ( 311 | <div className="min-h-[400px]"> 312 | <div 313 | className="prose prose-lg max-w-none" 314 | style={{ 315 | lineHeight: '1.7', 316 | color: '#374151', 317 | fontSize: '16px', 318 | fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif' 319 | }} 320 | > 321 | <style>{` 322 | .prose h1 { 323 | color: #111827; 324 | font-weight: 700; 325 | font-size: 2.25rem; 326 | line-height: 1.2; 327 | margin-top: 0; 328 | margin-bottom: 1.5rem; 329 | border-bottom: 2px solid #e5e7eb; 330 | padding-bottom: 0.5rem; 331 | } 332 | .prose h2 { 333 | color: #1f2937; 334 | font-weight: 600; 335 | font-size: 1.875rem; 336 | line-height: 1.3; 337 | margin-top: 2rem; 338 | margin-bottom: 1rem; 339 | border-bottom: 1px solid #e5e7eb; 340 | padding-bottom: 0.25rem; 341 | } 342 | .prose h3 { 343 | color: #374151; 344 | font-weight: 600; 345 | font-size: 1.5rem; 346 | margin-top: 1.5rem; 347 | margin-bottom: 0.75rem; 348 | } 349 | .prose p { 350 | margin-bottom: 1.25rem; 351 | color: #4b5563; 352 | line-height: 1.7; 353 | font-size: 16px; 354 | } 355 | .prose code { 356 | background-color: #f3f4f6; 357 | border: 1px solid #e5e7eb; 358 | border-radius: 0.375rem; 359 | padding: 0.125rem 0.375rem; 360 | font-size: 0.875rem; 361 | color: #374151; 362 | font-weight: 500; 363 | } 364 | .prose pre { 365 | background-color: #1f2937; 366 | border-radius: 0.5rem; 367 | padding: 1.25rem; 368 | overflow-x: auto; 369 | margin: 1.5rem 0; 370 | border: 1px solid #374151; 371 | } 372 | .prose pre code { 373 | background-color: transparent; 374 | border: none; 375 | color: #f9fafb; 376 | padding: 0; 377 | font-size: 0.875rem; 378 | font-weight: normal; 379 | } 380 | .prose blockquote { 381 | border-left: 4px solid #3b82f6; 382 | padding-left: 1rem; 383 | margin: 1.5rem 0; 384 | color: #6b7280; 385 | font-style: italic; 386 | background-color: #f8fafc; 387 | padding: 1rem; 388 | border-radius: 0.375rem; 389 | font-size: 16px; 390 | } 391 | .prose ul, .prose ol { 392 | margin: 1.25rem 0; 393 | padding-left: 1.5rem; 394 | } 395 | .prose ol { 396 | list-style-type: decimal; 397 | list-style-position: outside; 398 | } 399 | .prose ul { 400 | list-style-type: disc; 401 | list-style-position: outside; 402 | } 403 | .prose li { 404 | margin: 0.5rem 0; 405 | color: #4b5563; 406 | display: list-item; 407 | font-size: 16px; 408 | } 409 | .prose ol li { 410 | padding-left: 0.25rem; 411 | } 412 | .prose ul li { 413 | padding-left: 0.25rem; 414 | } 415 | .prose table { 416 | width: 100%; 417 | border-collapse: collapse; 418 | margin: 1.5rem 0; 419 | font-size: 16px; 420 | } 421 | .prose th, .prose td { 422 | border: 1px solid #d1d5db; 423 | padding: 0.75rem; 424 | text-align: left; 425 | } 426 | .prose th { 427 | background-color: #f9fafb; 428 | font-weight: 600; 429 | color: #374151; 430 | font-size: 16px; 431 | } 432 | .prose td { 433 | color: #4b5563; 434 | font-size: 16px; 435 | } 436 | .prose a { 437 | color: #3b82f6; 438 | text-decoration: underline; 439 | font-weight: 500; 440 | transition: color 0.2s; 441 | font-size: inherit; 442 | } 443 | .prose a:hover { 444 | color: #1d4ed8; 445 | } 446 | .prose strong { 447 | color: #111827; 448 | font-weight: 600; 449 | font-size: inherit; 450 | } 451 | .prose em { 452 | color: #6b7280; 453 | font-style: italic; 454 | font-size: inherit; 455 | } 456 | .prose hr { 457 | border: none; 458 | height: 1px; 459 | background-color: #e5e7eb; 460 | margin: 2rem 0; 461 | } 462 | `}</style> 463 | <ReactMarkdown remarkPlugins={[remarkGfm]}>{data.document}</ReactMarkdown> 464 | </div> 465 | </div> 466 | ) : ( 467 | <div className="text-gray-500 text-center py-8"> 468 | No overview available 469 | </div> 470 | ), 471 | }, 472 | { 473 | key: "tools", 474 | label: `Tools (${parsedTools.length})`, 475 | children: parsedTools.length > 0 ? ( 476 | <div className="border border-gray-200 rounded-lg bg-gray-50"> 477 | {parsedTools.map((tool, idx) => ( 478 | <div key={idx} className={idx < parsedTools.length - 1 ? "border-b border-gray-200" : ""}> 479 | <Collapse 480 | ghost 481 | expandIconPosition="end" 482 | items={[{ 483 | key: idx.toString(), 484 | label: tool.name, 485 | children: ( 486 | <div className="px-4 pb-2"> 487 | <div className="text-gray-600 mb-4">{tool.description}</div> 488 | 489 | {tool.args && tool.args.length > 0 && ( 490 | <div> 491 | <p className="font-medium text-gray-700 mb-3">输入参数:</p> 492 | {tool.args.map((arg, argIdx) => ( 493 | <div key={argIdx} className="mb-3"> 494 | <div className="flex items-center mb-2"> 495 | <span className="font-medium text-gray-800 mr-2">{arg.name}</span> 496 | <span className="text-xs bg-gray-200 text-gray-600 px-2 py-1 rounded mr-2"> 497 | {arg.type} 498 | </span> 499 | {arg.required && ( 500 | <span className="text-red-500 text-xs mr-2">*</span> 501 | )} 502 | {arg.description && ( 503 | <span className="text-xs text-gray-500"> 504 | {arg.description} 505 | </span> 506 | )} 507 | </div> 508 | <input 509 | type="text" 510 | placeholder={arg.description || `请输入${arg.name}`} 511 | className="w-full px-3 py-2 bg-gray-100 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" 512 | /> 513 | </div> 514 | ))} 515 | </div> 516 | )} 517 | 518 | {(!tool.args || tool.args.length === 0) && ( 519 | <div className="text-gray-500 text-sm">No parameters required</div> 520 | )} 521 | </div> 522 | ), 523 | }]} 524 | /> 525 | </div> 526 | ))} 527 | </div> 528 | ) : ( 529 | <div className="text-gray-500 text-center py-8"> 530 | No tools available 531 | </div> 532 | ), 533 | }, 534 | ]} 535 | /> 536 | </Card> 537 | </Col> 538 | 539 | {/* 右侧连接指导 */} 540 | <Col span={9}> 541 | {mcpConfig && ( 542 | <Card className="mb-6 rounded-lg border-gray-200"> 543 | <div className="mb-4"> 544 | <h3 className="text-sm font-semibold mb-3">连接点配置</h3> 545 | <Tabs 546 | size="small" 547 | defaultActiveKey={hasLocalConfig ? "local" : (sseJson ? "sse" : "http")} 548 | items={(() => { 549 | const tabs = []; 550 | 551 | if (hasLocalConfig) { 552 | tabs.push({ 553 | key: "local", 554 | label: "Stdio", 555 | children: ( 556 | <div className="relative bg-gray-50 border border-gray-200 rounded-md p-3"> 557 | <Button 558 | type="text" 559 | size="small" 560 | icon={<CopyOutlined />} 561 | className="absolute top-2 right-2 z-10" 562 | onClick={() => handleCopy(localJson)} 563 | /> 564 | <div className="text-gray-800 font-mono text-xs overflow-x-auto"> 565 | <pre className="whitespace-pre-wrap">{localJson}</pre> 566 | </div> 567 | </div> 568 | ), 569 | }); 570 | } else { 571 | if (sseJson) { 572 | tabs.push({ 573 | key: "sse", 574 | label: "SSE", 575 | children: ( 576 | <div className="relative bg-gray-50 border border-gray-200 rounded-md p-3"> 577 | <Button 578 | type="text" 579 | size="small" 580 | icon={<CopyOutlined />} 581 | className="absolute top-2 right-2 z-10" 582 | onClick={() => handleCopy(sseJson)} 583 | /> 584 | <div className="text-gray-800 font-mono text-xs overflow-x-auto"> 585 | <pre className="whitespace-pre-wrap">{sseJson}</pre> 586 | </div> 587 | </div> 588 | ), 589 | }); 590 | } 591 | 592 | if (httpJson) { 593 | tabs.push({ 594 | key: "http", 595 | label: "Streaming HTTP", 596 | children: ( 597 | <div className="relative bg-gray-50 border border-gray-200 rounded-md p-3"> 598 | <Button 599 | type="text" 600 | size="small" 601 | icon={<CopyOutlined />} 602 | className="absolute top-2 right-2 z-10" 603 | onClick={() => handleCopy(httpJson)} 604 | /> 605 | <div className="text-gray-800 font-mono text-xs overflow-x-auto"> 606 | <pre className="whitespace-pre-wrap">{httpJson}</pre> 607 | </div> 608 | </div> 609 | ), 610 | }); 611 | } 612 | } 613 | 614 | return tabs; 615 | })()} 616 | /> 617 | </div> 618 | </Card> 619 | )} 620 | </Col> 621 | </Row> 622 | </Layout> 623 | ); 624 | } 625 | 626 | export default McpDetail; 627 | ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/service/impl/NacosServiceImpl.java: -------------------------------------------------------------------------------- ```java 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one 3 | * or more contributor license agreements. See the NOTICE file 4 | * distributed with this work for additional information 5 | * regarding copyright ownership. The ASF licenses this file 6 | * to you under the Apache License, Version 2.0 (the 7 | * "License"); you may not use this file except in compliance 8 | * with the License. You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | package com.alibaba.apiopenplatform.service.impl; 21 | 22 | import cn.hutool.core.util.StrUtil; 23 | import com.alibaba.apiopenplatform.core.constant.Resources; 24 | import com.alibaba.apiopenplatform.core.exception.BusinessException; 25 | import com.alibaba.apiopenplatform.core.exception.ErrorCode; 26 | import com.alibaba.apiopenplatform.core.security.ContextHolder; 27 | import com.alibaba.apiopenplatform.core.utils.IdGenerator; 28 | import com.alibaba.apiopenplatform.dto.params.nacos.CreateNacosParam; 29 | import com.alibaba.apiopenplatform.dto.params.nacos.QueryNacosParam; 30 | import com.alibaba.apiopenplatform.dto.params.nacos.UpdateNacosParam; 31 | import com.alibaba.apiopenplatform.dto.result.NacosMCPServerResult; 32 | import com.alibaba.apiopenplatform.dto.result.NacosNamespaceResult; 33 | import com.alibaba.apiopenplatform.dto.result.NacosResult; 34 | import com.alibaba.apiopenplatform.dto.result.PageResult; 35 | import com.alibaba.apiopenplatform.dto.result.MCPConfigResult; 36 | import com.alibaba.apiopenplatform.dto.result.MseNacosResult; 37 | import com.alibaba.apiopenplatform.entity.NacosInstance; 38 | import com.alibaba.apiopenplatform.repository.NacosInstanceRepository; 39 | import com.alibaba.apiopenplatform.service.NacosService; 40 | import com.alibaba.apiopenplatform.support.enums.SourceType; 41 | import com.alibaba.apiopenplatform.support.product.NacosRefConfig; 42 | import com.alibaba.apiopenplatform.dto.converter.NacosToGatewayToolsConverter; 43 | import cn.hutool.json.JSONUtil; 44 | import com.alibaba.nacos.api.PropertyKeyConst; 45 | import com.alibaba.nacos.api.ai.model.mcp.McpServerBasicInfo; 46 | import lombok.RequiredArgsConstructor; 47 | import lombok.extern.slf4j.Slf4j; 48 | import org.springframework.data.domain.Page; 49 | import org.springframework.data.domain.Pageable; 50 | import org.springframework.stereotype.Service; 51 | import com.alibaba.nacos.maintainer.client.ai.AiMaintainerFactory; 52 | import com.alibaba.nacos.maintainer.client.ai.McpMaintainerService; 53 | import com.alibaba.nacos.maintainer.client.naming.NamingMaintainerFactory; 54 | import com.alibaba.nacos.maintainer.client.naming.NamingMaintainerService; 55 | import com.alibaba.nacos.api.exception.NacosException; 56 | import com.aliyun.mse20190531.Client; 57 | import com.aliyun.mse20190531.models.ListClustersRequest; 58 | import com.aliyun.mse20190531.models.ListClustersResponse; 59 | import com.aliyun.mse20190531.models.ListClustersResponseBody; 60 | import com.aliyun.teautil.models.RuntimeOptions; 61 | import com.alibaba.nacos.api.ai.model.mcp.McpServerDetailInfo; 62 | 63 | import java.util.HashMap; 64 | import java.util.List; 65 | import java.util.Map; 66 | import java.util.Objects; 67 | import java.util.Optional; 68 | import java.util.Properties; 69 | import java.util.stream.Collectors; 70 | 71 | @Service 72 | @Slf4j 73 | @RequiredArgsConstructor 74 | public class NacosServiceImpl implements NacosService { 75 | 76 | private static final String DEFAULT_CONTEXT_PATH = "nacos"; 77 | 78 | private final NacosInstanceRepository nacosInstanceRepository; 79 | 80 | private final ContextHolder contextHolder; 81 | 82 | @Override 83 | public PageResult<NacosResult> listNacosInstances(Pageable pageable) { 84 | Page<NacosInstance> nacosInstances = nacosInstanceRepository.findAll(pageable); 85 | return new PageResult<NacosResult>().convertFrom(nacosInstances, nacosInstance -> new NacosResult().convertFrom(nacosInstance)); 86 | } 87 | 88 | @Override 89 | public NacosResult getNacosInstance(String nacosId) { 90 | NacosInstance nacosInstance = findNacosInstance(nacosId); 91 | return new NacosResult().convertFrom(nacosInstance); 92 | } 93 | 94 | @Override 95 | public void createNacosInstance(CreateNacosParam param) { 96 | nacosInstanceRepository.findByNacosName(param.getNacosName()) 97 | .ifPresent(nacos -> { 98 | throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.NACOS_INSTANCE, param.getNacosName())); 99 | }); 100 | 101 | NacosInstance nacosInstance = param.convertTo(); 102 | 103 | // If client provided nacosId use it after checking uniqueness, otherwise generate one 104 | String providedId = param.getNacosId(); 105 | if (providedId != null && !providedId.trim().isEmpty()) { 106 | // ensure not already exist 107 | boolean exists = nacosInstanceRepository.findByNacosId(providedId).isPresent(); 108 | if (exists) { 109 | throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.NACOS_INSTANCE, providedId)); 110 | } 111 | nacosInstance.setNacosId(providedId); 112 | } else { 113 | nacosInstance.setNacosId(IdGenerator.genNacosId()); 114 | } 115 | 116 | nacosInstance.setAdminId(contextHolder.getUser()); 117 | 118 | nacosInstanceRepository.save(nacosInstance); 119 | } 120 | 121 | @Override 122 | public void updateNacosInstance(String nacosId, UpdateNacosParam param) { 123 | NacosInstance instance = findNacosInstance(nacosId); 124 | 125 | Optional.ofNullable(param.getNacosName()) 126 | .filter(name -> !name.equals(instance.getNacosName())) 127 | .flatMap(nacosInstanceRepository::findByNacosName) 128 | .ifPresent(nacos -> { 129 | throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.NACOS_INSTANCE, param.getNacosName())); 130 | }); 131 | 132 | param.update(instance); 133 | nacosInstanceRepository.saveAndFlush(instance); 134 | } 135 | 136 | @Override 137 | public void deleteNacosInstance(String nacosId) { 138 | NacosInstance nacosInstance = findNacosInstance(nacosId); 139 | nacosInstanceRepository.delete(nacosInstance); 140 | } 141 | 142 | @Override 143 | public PageResult<MseNacosResult> fetchNacos(QueryNacosParam param, Pageable pageable) { 144 | try { 145 | // 创建MSE客户端 146 | Client client = new Client(param.toClientConfig()); 147 | 148 | // 构建请求 149 | ListClustersRequest request = new ListClustersRequest() 150 | .setRegionId(param.getRegionId()) 151 | .setPageNum(pageable.getPageNumber() + 1) 152 | .setPageSize(pageable.getPageSize()); 153 | 154 | RuntimeOptions runtime = new RuntimeOptions(); 155 | 156 | // 调用MSE API获取集群列表 157 | ListClustersResponse response = 158 | client.listClustersWithOptions(request, runtime); 159 | 160 | // 转换响应结果,并过滤掉 clusterType 为 "Nacos-Ans" 的实例 161 | Optional<List<MseNacosResult>> nacosResults = Optional.ofNullable(response.getBody()) 162 | .map(ListClustersResponseBody::getData) 163 | .map(clusters -> clusters.stream() 164 | .filter(cluster -> { 165 | String type = cluster.getClusterType(); 166 | return (type == null || "Nacos-Ans".equalsIgnoreCase(type)) 167 | && cluster.getVersionCode().startsWith("NACOS_3"); 168 | }) 169 | .map(MseNacosResult::fromListClustersResponseBodyData) 170 | .collect(Collectors.toList()) 171 | ); 172 | 173 | if (nacosResults.isPresent()) { 174 | // 返回分页结果 175 | int total = response.getBody() != null && response.getBody().getTotalCount() != null ? 176 | response.getBody().getTotalCount().intValue() : 0; 177 | return PageResult.of(nacosResults.get(), pageable.getPageNumber(), pageable.getPageSize(), total); 178 | } 179 | return PageResult.empty(pageable.getPageNumber(), pageable.getPageSize()); 180 | } catch (Exception e) { 181 | log.error("Error fetching Nacos clusters from MSE", e); 182 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Failed to fetch Nacos clusters from MSE: " + e.getMessage()); 183 | } 184 | } 185 | 186 | @Override 187 | public PageResult<NacosMCPServerResult> fetchMcpServers(String nacosId, String namespaceId, Pageable pageable) throws Exception { 188 | NacosInstance nacosInstance = findNacosInstance(nacosId); 189 | McpMaintainerService service = buildDynamicMcpService(nacosInstance); 190 | String ns = namespaceId == null ? "" : namespaceId; 191 | com.alibaba.nacos.api.model.Page<McpServerBasicInfo> page = service.listMcpServer(ns, "", 1, Integer.MAX_VALUE); 192 | if (page == null || page.getPageItems() == null) { 193 | return PageResult.empty(pageable.getPageNumber(), pageable.getPageSize()); 194 | } 195 | return page.getPageItems().stream() 196 | .map(basicInfo -> new NacosMCPServerResult().convertFrom(basicInfo)) 197 | .skip(pageable.getOffset()) 198 | .limit(pageable.getPageSize()) 199 | .collect(Collectors.collectingAndThen( 200 | Collectors.toList(), 201 | list -> PageResult.of(list, pageable.getPageNumber(), pageable.getPageSize(), page.getPageItems().size()) 202 | )); 203 | } 204 | 205 | @Override 206 | public PageResult<NacosNamespaceResult> fetchNamespaces(String nacosId, Pageable pageable) throws Exception { 207 | NacosInstance nacosInstance = findNacosInstance(nacosId); 208 | // 使用空 namespace 构建 (列出全部命名空间) 209 | NamingMaintainerService namingService = buildDynamicNamingService(nacosInstance, ""); 210 | List<?> namespaces; 211 | try { 212 | namespaces = namingService.getNamespaceList(); 213 | } catch (NacosException e) { 214 | log.error("Error fetching namespaces from Nacos by nacosId {}", nacosId, e); 215 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Failed to fetch namespaces: " + e.getErrMsg()); 216 | } 217 | 218 | if (namespaces == null || namespaces.isEmpty()) { 219 | return PageResult.empty(pageable.getPageNumber(), pageable.getPageSize()); 220 | } 221 | 222 | List<NacosNamespaceResult> list = namespaces.stream() 223 | .map(o -> new NacosNamespaceResult().convertFrom(o)) 224 | .skip(pageable.getOffset()) 225 | .limit(pageable.getPageSize()) 226 | .collect(Collectors.toList()); 227 | 228 | return PageResult.of(list, pageable.getPageNumber(), pageable.getPageSize(), namespaces.size()); 229 | } 230 | 231 | @Override 232 | public String fetchMcpConfig(String nacosId, NacosRefConfig nacosRefConfig) { 233 | NacosInstance nacosInstance = findNacosInstance(nacosId); 234 | 235 | McpMaintainerService service = buildDynamicMcpService(nacosInstance); 236 | try { 237 | McpServerDetailInfo detail = service.getMcpServerDetail(nacosRefConfig.getNamespaceId(), 238 | nacosRefConfig.getMcpServerName(), null); 239 | if (detail == null) { 240 | return null; 241 | } 242 | 243 | MCPConfigResult mcpConfig = buildMCPConfigResult(detail); 244 | return JSONUtil.toJsonStr(mcpConfig); 245 | } catch (Exception e) { 246 | log.error("Error fetching Nacos MCP servers", e); 247 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Failed to fetch Nacos MCP config"); 248 | } 249 | } 250 | 251 | private MCPConfigResult buildMCPConfigResult(McpServerDetailInfo detail) { 252 | MCPConfigResult mcpConfig = new MCPConfigResult(); 253 | mcpConfig.setMcpServerName(detail.getName()); 254 | 255 | MCPConfigResult.MCPServerConfig serverConfig = new MCPConfigResult.MCPServerConfig(); 256 | 257 | if (detail.getLocalServerConfig() != null) { 258 | serverConfig.setRawConfig(detail.getLocalServerConfig()); 259 | serverConfig.setTransportMode(MCPConfigResult.MCPTransportMode.LOCAL.getMode()); 260 | } else if (detail.getRemoteServerConfig() != null || (detail.getBackendEndpoints() != null && !detail.getBackendEndpoints().isEmpty())) { 261 | Object remoteConfig = buildRemoteConnectionConfig(detail); 262 | serverConfig.setRawConfig(remoteConfig); 263 | } else { 264 | Map<String, Object> defaultConfig = new HashMap<>(); 265 | defaultConfig.put("type", "unknown"); 266 | defaultConfig.put("name", detail.getName()); 267 | serverConfig.setRawConfig(defaultConfig); 268 | } 269 | 270 | mcpConfig.setMcpServerConfig(serverConfig); 271 | 272 | if (detail.getToolSpec() != null) { 273 | try { 274 | NacosToGatewayToolsConverter converter = new NacosToGatewayToolsConverter(); 275 | converter.convertFromNacos(detail); 276 | String gatewayFormatYaml = converter.toYaml(); 277 | mcpConfig.setTools(gatewayFormatYaml); 278 | } catch (Exception e) { 279 | log.error("Error converting tools to gateway format", e); 280 | mcpConfig.setTools(null); 281 | } 282 | } else { 283 | mcpConfig.setTools(null); 284 | } 285 | 286 | MCPConfigResult.McpMetadata meta = new MCPConfigResult.McpMetadata(); 287 | meta.setSource(SourceType.NACOS.name()); 288 | mcpConfig.setMeta(meta); 289 | 290 | return mcpConfig; 291 | } 292 | 293 | private Object buildRemoteConnectionConfig(McpServerDetailInfo detail) { 294 | List<?> backendEndpoints = detail.getBackendEndpoints(); 295 | 296 | if (backendEndpoints != null && !backendEndpoints.isEmpty()) { 297 | Object firstEndpoint = backendEndpoints.get(0); 298 | 299 | Map<String, Object> connectionConfig = new HashMap<>(); 300 | Map<String, Object> mcpServers = new HashMap<>(); 301 | Map<String, Object> serverConfig = new HashMap<>(); 302 | 303 | String endpointUrl = extractEndpointUrl(firstEndpoint); 304 | if (endpointUrl != null) { 305 | serverConfig.put("url", endpointUrl); 306 | } 307 | 308 | mcpServers.put(detail.getName(), serverConfig); 309 | connectionConfig.put("mcpServers", mcpServers); 310 | 311 | return connectionConfig; 312 | } 313 | 314 | Map<String, Object> basicConfig = new HashMap<>(); 315 | basicConfig.put("type", "remote"); 316 | basicConfig.put("name", detail.getName()); 317 | basicConfig.put("protocol", "http"); 318 | return basicConfig; 319 | } 320 | 321 | private String extractEndpointUrl(Object endpoint) { 322 | if (endpoint == null) { 323 | return null; 324 | } 325 | 326 | if (endpoint instanceof String) { 327 | return (String) endpoint; 328 | } 329 | 330 | if (endpoint instanceof Map) { 331 | Map<?, ?> endpointMap = (Map<?, ?>) endpoint; 332 | 333 | String url = getStringValue(endpointMap, "url"); 334 | if (url != null) return url; 335 | 336 | String endpointUrl = getStringValue(endpointMap, "endpointUrl"); 337 | if (endpointUrl != null) return endpointUrl; 338 | 339 | String host = getStringValue(endpointMap, "host"); 340 | String port = getStringValue(endpointMap, "port"); 341 | String path = getStringValue(endpointMap, "path"); 342 | 343 | if (host != null) { 344 | StringBuilder urlBuilder = new StringBuilder(); 345 | String protocol = getStringValue(endpointMap, "protocol"); 346 | urlBuilder.append(protocol != null ? protocol : "http").append("://"); 347 | urlBuilder.append(host); 348 | 349 | if (port != null && !port.isEmpty()) { 350 | urlBuilder.append(":").append(port); 351 | } 352 | 353 | if (path != null && !path.isEmpty()) { 354 | if (!path.startsWith("/")) { 355 | urlBuilder.append("/"); 356 | } 357 | urlBuilder.append(path); 358 | } 359 | 360 | return urlBuilder.toString(); 361 | } 362 | } 363 | 364 | if (endpoint.getClass().getName().contains("McpEndpointInfo")) { 365 | return extractUrlFromMcpEndpointInfo(endpoint); 366 | } 367 | 368 | return endpoint.toString(); 369 | } 370 | 371 | private String getStringValue(Map<?, ?> map, String key) { 372 | Object value = map.get(key); 373 | return value != null ? value.toString() : null; 374 | } 375 | 376 | private String extractUrlFromMcpEndpointInfo(Object endpoint) { 377 | String[] possibleFieldNames = {"url", "endpointUrl", "address", "host", "endpoint"}; 378 | 379 | for (String fieldName : possibleFieldNames) { 380 | try { 381 | java.lang.reflect.Field field = endpoint.getClass().getDeclaredField(fieldName); 382 | field.setAccessible(true); 383 | Object value = field.get(endpoint); 384 | if (value != null && !value.toString().trim().isEmpty()) { 385 | if (value.toString().contains("://") || value.toString().contains(":")) { 386 | return value.toString(); 387 | } 388 | } 389 | } catch (Exception e) { 390 | continue; 391 | } 392 | } 393 | 394 | java.lang.reflect.Field[] fields = endpoint.getClass().getDeclaredFields(); 395 | 396 | String host = null; 397 | String port = null; 398 | String path = null; 399 | String protocol = null; 400 | 401 | for (java.lang.reflect.Field field : fields) { 402 | try { 403 | field.setAccessible(true); 404 | Object value = field.get(endpoint); 405 | if (value != null && !value.toString().trim().isEmpty()) { 406 | String fieldName = field.getName().toLowerCase(); 407 | 408 | if (fieldName.contains("host") || fieldName.contains("ip") || fieldName.contains("address")) { 409 | host = value.toString(); 410 | } else if (fieldName.contains("port")) { 411 | port = value.toString(); 412 | } else if (fieldName.contains("path") || fieldName.contains("endpoint") || fieldName.contains("uri")) { 413 | path = value.toString(); 414 | } else if (fieldName.contains("protocol") || fieldName.contains("scheme")) { 415 | protocol = value.toString(); 416 | } 417 | } 418 | } catch (Exception e) { 419 | continue; 420 | } 421 | } 422 | 423 | if (host != null) { 424 | StringBuilder urlBuilder = new StringBuilder(); 425 | urlBuilder.append(protocol != null ? protocol : "http").append("://"); 426 | urlBuilder.append(host); 427 | 428 | if (port != null && !port.isEmpty()) { 429 | urlBuilder.append(":").append(port); 430 | } 431 | 432 | if (path != null && !path.isEmpty()) { 433 | if (!path.startsWith("/")) { 434 | urlBuilder.append("/"); 435 | } 436 | urlBuilder.append(path); 437 | } 438 | 439 | return urlBuilder.toString(); 440 | } 441 | 442 | return endpoint.toString(); 443 | } 444 | 445 | private NacosInstance findNacosInstance(String nacosId) { 446 | return nacosInstanceRepository.findByNacosId(nacosId) 447 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.NACOS_INSTANCE, nacosId)); 448 | } 449 | 450 | private McpMaintainerService buildDynamicMcpService(NacosInstance nacosInstance) { 451 | Properties properties = new Properties(); 452 | properties.setProperty(PropertyKeyConst.SERVER_ADDR, nacosInstance.getServerUrl()); 453 | if (Objects.nonNull(nacosInstance.getUsername())) { 454 | properties.setProperty(PropertyKeyConst.USERNAME, nacosInstance.getUsername()); 455 | } 456 | 457 | if (Objects.nonNull(nacosInstance.getPassword())) { 458 | properties.setProperty(PropertyKeyConst.PASSWORD, nacosInstance.getPassword()); 459 | } 460 | properties.setProperty(PropertyKeyConst.CONTEXT_PATH, DEFAULT_CONTEXT_PATH); 461 | // instance no longer stores namespace; leave namespace empty to let requests use default/public 462 | // if consumers need a specific namespace, they should call an overload that accepts it 463 | if (Objects.nonNull(nacosInstance.getAccessKey())) { 464 | properties.setProperty(PropertyKeyConst.ACCESS_KEY, nacosInstance.getAccessKey()); 465 | } 466 | 467 | if (Objects.nonNull(nacosInstance.getSecretKey())) { 468 | properties.setProperty(PropertyKeyConst.SECRET_KEY, nacosInstance.getSecretKey()); 469 | } 470 | 471 | try { 472 | return AiMaintainerFactory.createAiMaintainerService(properties); 473 | } catch (Exception e) { 474 | log.error("Error init Nacos AiMaintainerService", e); 475 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error init Nacos AiMaintainerService"); 476 | } 477 | } 478 | 479 | // removed unused no-namespace overload; use the runtime-namespace overload instead 480 | 481 | // overload to build NamingMaintainerService with a runtime namespace value 482 | private NamingMaintainerService buildDynamicNamingService(NacosInstance nacosInstance, String runtimeNamespace) { 483 | Properties properties = new Properties(); 484 | properties.setProperty(PropertyKeyConst.SERVER_ADDR, nacosInstance.getServerUrl()); 485 | if (Objects.nonNull(nacosInstance.getUsername())) { 486 | properties.setProperty(PropertyKeyConst.USERNAME, nacosInstance.getUsername()); 487 | } 488 | 489 | if (Objects.nonNull(nacosInstance.getPassword())) { 490 | properties.setProperty(PropertyKeyConst.PASSWORD, nacosInstance.getPassword()); 491 | } 492 | properties.setProperty(PropertyKeyConst.CONTEXT_PATH, DEFAULT_CONTEXT_PATH); 493 | properties.setProperty(PropertyKeyConst.NAMESPACE, runtimeNamespace == null ? "" : runtimeNamespace); 494 | 495 | if (Objects.nonNull(nacosInstance.getAccessKey())) { 496 | properties.setProperty(PropertyKeyConst.ACCESS_KEY, nacosInstance.getAccessKey()); 497 | } 498 | 499 | if (Objects.nonNull(nacosInstance.getSecretKey())) { 500 | properties.setProperty(PropertyKeyConst.SECRET_KEY, nacosInstance.getSecretKey()); 501 | } 502 | 503 | try { 504 | return NamingMaintainerFactory.createNamingMaintainerService(properties); 505 | } catch (Exception e) { 506 | log.error("Error init Nacos NamingMaintainerService", e); 507 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error init Nacos NamingMaintainerService"); 508 | } 509 | } 510 | } ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/service/impl/ConsumerServiceImpl.java: -------------------------------------------------------------------------------- ```java 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one 3 | * or more contributor license agreements. See the NOTICE file 4 | * distributed with this work for additional information 5 | * regarding copyright ownership. The ASF licenses this file 6 | * to you under the Apache License, Version 2.0 (the 7 | * "License"); you may not use this file except in compliance 8 | * with the License. You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | package com.alibaba.apiopenplatform.service.impl; 21 | 22 | import cn.hutool.core.util.StrUtil; 23 | import cn.hutool.json.JSONUtil; 24 | 25 | import com.alibaba.apiopenplatform.core.constant.Resources; 26 | import com.alibaba.apiopenplatform.core.event.DeveloperDeletingEvent; 27 | import com.alibaba.apiopenplatform.core.event.ProductDeletingEvent; 28 | import com.alibaba.apiopenplatform.core.exception.BusinessException; 29 | import com.alibaba.apiopenplatform.core.exception.ErrorCode; 30 | import com.alibaba.apiopenplatform.core.security.ContextHolder; 31 | import com.alibaba.apiopenplatform.core.utils.IdGenerator; 32 | import com.alibaba.apiopenplatform.dto.params.consumer.QueryConsumerParam; 33 | import com.alibaba.apiopenplatform.dto.params.consumer.CreateConsumerParam; 34 | import com.alibaba.apiopenplatform.dto.params.consumer.CreateCredentialParam; 35 | import com.alibaba.apiopenplatform.dto.params.consumer.UpdateCredentialParam; 36 | import com.alibaba.apiopenplatform.dto.result.*; 37 | import com.alibaba.apiopenplatform.dto.params.consumer.CreateSubscriptionParam; 38 | import com.alibaba.apiopenplatform.dto.params.consumer.QuerySubscriptionParam; 39 | import com.alibaba.apiopenplatform.entity.*; 40 | import com.alibaba.apiopenplatform.repository.ConsumerRepository; 41 | import com.alibaba.apiopenplatform.repository.ConsumerCredentialRepository; 42 | import com.alibaba.apiopenplatform.repository.SubscriptionRepository; 43 | import com.alibaba.apiopenplatform.service.ConsumerService; 44 | import com.alibaba.apiopenplatform.service.GatewayService; 45 | import com.alibaba.apiopenplatform.service.PortalService; 46 | import com.alibaba.apiopenplatform.service.ProductService; 47 | import com.alibaba.apiopenplatform.support.consumer.ApiKeyConfig; 48 | import com.alibaba.apiopenplatform.support.consumer.ConsumerAuthConfig; 49 | import com.alibaba.apiopenplatform.support.consumer.HmacConfig; 50 | import com.alibaba.apiopenplatform.support.enums.CredentialMode; 51 | import com.alibaba.apiopenplatform.support.enums.SourceType; 52 | import com.alibaba.apiopenplatform.support.gateway.GatewayConfig; 53 | import cn.hutool.core.util.BooleanUtil; 54 | import lombok.RequiredArgsConstructor; 55 | import lombok.extern.slf4j.Slf4j; 56 | import org.springframework.context.event.EventListener; 57 | import org.springframework.data.domain.Page; 58 | import org.springframework.data.domain.Pageable; 59 | import org.springframework.data.jpa.domain.Specification; 60 | 61 | import javax.persistence.criteria.Predicate; 62 | 63 | import org.springframework.scheduling.annotation.Async; 64 | import org.springframework.stereotype.Service; 65 | 66 | import javax.persistence.criteria.Root; 67 | import javax.persistence.criteria.Subquery; 68 | import javax.transaction.Transactional; 69 | import java.util.*; 70 | import java.util.stream.Collectors; 71 | 72 | import com.alibaba.apiopenplatform.support.enums.SubscriptionStatus; 73 | import com.alibaba.apiopenplatform.repository.ConsumerRefRepository; 74 | 75 | @Service 76 | @RequiredArgsConstructor 77 | @Transactional 78 | @Slf4j 79 | public class ConsumerServiceImpl implements ConsumerService { 80 | 81 | private final PortalService portalService; 82 | 83 | private final ConsumerRepository consumerRepository; 84 | 85 | private final GatewayService gatewayService; 86 | 87 | private final ContextHolder contextHolder; 88 | 89 | private final ConsumerCredentialRepository credentialRepository; 90 | 91 | private final SubscriptionRepository subscriptionRepository; 92 | 93 | private final ProductService productService; 94 | 95 | private final ConsumerRefRepository consumerRefRepository; 96 | 97 | @Override 98 | public ConsumerResult createConsumer(CreateConsumerParam param) { 99 | PortalResult portal = portalService.getPortal(contextHolder.getPortal()); 100 | 101 | String consumerId = IdGenerator.genConsumerId(); 102 | Consumer consumer = param.convertTo(); 103 | consumer.setConsumerId(consumerId); 104 | consumer.setDeveloperId(contextHolder.getUser()); 105 | consumer.setPortalId(portal.getPortalId()); 106 | 107 | consumerRepository.save(consumer); 108 | 109 | // 初始化Credential 110 | ConsumerCredential credential = initCredential(consumerId); 111 | credentialRepository.save(credential); 112 | 113 | return getConsumer(consumerId); 114 | } 115 | 116 | @Override 117 | public PageResult<ConsumerResult> listConsumers(QueryConsumerParam param, Pageable pageable) { 118 | Page<Consumer> consumers = consumerRepository.findAll(buildConsumerSpec(param), pageable); 119 | 120 | return new PageResult<ConsumerResult>().convertFrom(consumers, consumer -> new ConsumerResult().convertFrom(consumer)); 121 | } 122 | 123 | @Override 124 | public ConsumerResult getConsumer(String consumerId) { 125 | Consumer consumer = contextHolder.isDeveloper() ? findDevConsumer(consumerId) : findConsumer(consumerId); 126 | 127 | return new ConsumerResult().convertFrom(consumer); 128 | } 129 | 130 | @Override 131 | public void deleteConsumer(String consumerId) { 132 | Consumer consumer = contextHolder.isDeveloper() ? findDevConsumer(consumerId) : findConsumer(consumerId); 133 | // 订阅 134 | subscriptionRepository.deleteAllByConsumerId(consumerId); 135 | 136 | // 凭证 137 | credentialRepository.deleteAllByConsumerId(consumerId); 138 | 139 | // 删除网关上的Consumer 140 | List<ConsumerRef> consumerRefs = consumerRefRepository.findAllByConsumerId(consumerId); 141 | for (ConsumerRef consumerRef : consumerRefs) { 142 | try { 143 | gatewayService.deleteConsumer(consumerRef.getGwConsumerId(), consumerRef.getGatewayConfig()); 144 | } catch (Exception e) { 145 | log.error("deleteConsumer gatewayConsumer error, gwConsumerId: {}", consumerRef.getGwConsumerId(), e); 146 | } 147 | } 148 | 149 | consumerRepository.delete(consumer); 150 | } 151 | 152 | @Override 153 | public void createCredential(String consumerId, CreateCredentialParam param) { 154 | existsConsumer(consumerId); 155 | // Consumer仅一份Credential 156 | credentialRepository.findByConsumerId(consumerId) 157 | .ifPresent(c -> { 158 | throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在凭证", Resources.CONSUMER, consumerId)); 159 | }); 160 | ConsumerCredential credential = param.convertTo(); 161 | credential.setConsumerId(consumerId); 162 | complementCredentials(credential); 163 | credentialRepository.save(credential); 164 | } 165 | 166 | private ConsumerCredential initCredential(String consumerId) { 167 | ConsumerCredential credential = new ConsumerCredential(); 168 | credential.setConsumerId(consumerId); 169 | 170 | ApiKeyConfig.ApiKeyCredential apiKeyCredential = new ApiKeyConfig.ApiKeyCredential(); 171 | ApiKeyConfig apiKeyConfig = new ApiKeyConfig(); 172 | apiKeyConfig.setCredentials(Collections.singletonList(apiKeyCredential)); 173 | 174 | credential.setApiKeyConfig(apiKeyConfig); 175 | complementCredentials(credential); 176 | 177 | return credential; 178 | } 179 | 180 | @Override 181 | public ConsumerCredentialResult getCredential(String consumerId) { 182 | existsConsumer(consumerId); 183 | 184 | return credentialRepository.findByConsumerId(consumerId) 185 | .map(credential -> new ConsumerCredentialResult().convertFrom(credential)) 186 | .orElse(new ConsumerCredentialResult()); 187 | } 188 | 189 | @Override 190 | public void updateCredential(String consumerId, UpdateCredentialParam param) { 191 | ConsumerCredential credential = credentialRepository.findByConsumerId(consumerId) 192 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.CONSUMER_CREDENTIAL, consumerId)); 193 | 194 | param.update(credential); 195 | 196 | List<ConsumerRef> consumerRefs = consumerRefRepository.findAllByConsumerId(consumerId); 197 | for (ConsumerRef consumerRef : consumerRefs) { 198 | try { 199 | gatewayService.updateConsumer(consumerRef.getGwConsumerId(), credential, consumerRef.getGatewayConfig()); 200 | } catch (Exception e) { 201 | log.error("update gatewayConsumer error, gwConsumerId: {}", consumerRef.getGwConsumerId(), e); 202 | } 203 | } 204 | 205 | credentialRepository.saveAndFlush(credential); 206 | } 207 | 208 | @Override 209 | public void deleteCredential(String consumerId) { 210 | existsConsumer(consumerId); 211 | credentialRepository.deleteAllByConsumerId(consumerId); 212 | } 213 | 214 | @Override 215 | public SubscriptionResult subscribeProduct(String consumerId, CreateSubscriptionParam param) { 216 | 217 | Consumer consumer = contextHolder.isDeveloper() ? 218 | findDevConsumer(consumerId) : findConsumer(consumerId); 219 | // 勿重复订阅 220 | if (subscriptionRepository.findByConsumerIdAndProductId(consumerId, param.getProductId()).isPresent()) { 221 | throw new BusinessException(ErrorCode.INVALID_REQUEST, "重复订阅"); 222 | } 223 | 224 | ProductResult product = productService.getProduct(param.getProductId()); 225 | ProductRefResult productRef = productService.getProductRef(param.getProductId()); 226 | if (productRef == null) { 227 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "API产品未关联API"); 228 | } 229 | 230 | // 非网关型不支持订阅 231 | if (productRef.getSourceType() != SourceType.GATEWAY) { 232 | throw new BusinessException(ErrorCode.INVALID_REQUEST, "API产品不支持订阅"); 233 | } 234 | 235 | ConsumerCredential credential = credentialRepository.findByConsumerId(consumerId) 236 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.CONSUMER_CREDENTIAL, consumerId)); 237 | 238 | ProductSubscription subscription = param.convertTo(); 239 | subscription.setConsumerId(consumerId); 240 | 241 | // 检查产品级别的自动审批设置 242 | boolean autoApprove = false; 243 | 244 | // 优先检查产品级别的autoApprove配置 245 | if (product.getAutoApprove() != null) { 246 | // 如果产品配置了autoApprove,直接使用产品级别的配置 247 | autoApprove = product.getAutoApprove(); 248 | log.info("使用产品级别自动审批配置: productId={}, autoApprove={}", param.getProductId(), autoApprove); 249 | } else { 250 | // 如果产品未配置autoApprove,则使用平台级别的配置 251 | PortalResult portal = portalService.getPortal(consumer.getPortalId()); 252 | log.info("portal: {}", JSONUtil.toJsonStr(portal)); 253 | autoApprove = portal.getPortalSettingConfig() != null 254 | && BooleanUtil.isTrue(portal.getPortalSettingConfig().getAutoApproveSubscriptions()); 255 | log.info("使用平台级别自动审批配置: portalId={}, autoApprove={}", consumer.getPortalId(), autoApprove); 256 | } 257 | 258 | if (autoApprove) { 259 | // 如果autoApprove为true,立即授权并设置为APPROVED状态 260 | ConsumerAuthConfig consumerAuthConfig = authorizeConsumer(consumer, credential, productRef); 261 | subscription.setConsumerAuthConfig(consumerAuthConfig); 262 | subscription.setStatus(SubscriptionStatus.APPROVED); 263 | } else { 264 | // 如果autoApprove为false,暂时不授权,设置为PENDING状态 265 | subscription.setStatus(SubscriptionStatus.PENDING); 266 | } 267 | 268 | subscriptionRepository.save(subscription); 269 | 270 | SubscriptionResult r = new SubscriptionResult().convertFrom(subscription); 271 | r.setProductName(product.getName()); 272 | r.setProductType(product.getType()); 273 | 274 | return r; 275 | } 276 | 277 | @Override 278 | public void unsubscribeProduct(String consumerId, String productId) { 279 | existsConsumer(consumerId); 280 | 281 | ProductSubscription subscription = subscriptionRepository 282 | .findByConsumerIdAndProductId(consumerId, productId) 283 | .orElse(null); 284 | if (subscription == null) { 285 | return; 286 | } 287 | 288 | if (subscription.getConsumerAuthConfig() != null) { 289 | ProductRefResult productRef = productService.getProductRef(productId); 290 | GatewayConfig gatewayConfig = gatewayService.getGatewayConfig(productRef.getGatewayId()); 291 | 292 | // 取消网关上的Consumer授权 293 | Optional.ofNullable(matchConsumerRef(consumerId, gatewayConfig)) 294 | .ifPresent(consumerRef -> 295 | gatewayService.revokeConsumerAuthorization(productRef.getGatewayId(), consumerRef.getGwConsumerId(), subscription.getConsumerAuthConfig()) 296 | ); 297 | } 298 | 299 | subscriptionRepository.deleteByConsumerIdAndProductId(consumerId, productId); 300 | } 301 | 302 | @Override 303 | public PageResult<SubscriptionResult> listSubscriptions(String consumerId, QuerySubscriptionParam param, Pageable pageable) { 304 | existsConsumer(consumerId); 305 | 306 | Page<ProductSubscription> subscriptions = subscriptionRepository.findAll(buildCredentialSpec(consumerId, param), pageable); 307 | 308 | List<String> productIds = subscriptions.getContent().stream() 309 | .map(ProductSubscription::getProductId) 310 | .collect(Collectors.toList()); 311 | Map<String, ProductResult> products = productService.getProducts(productIds); 312 | return new PageResult<SubscriptionResult>().convertFrom(subscriptions, s -> { 313 | SubscriptionResult r = new SubscriptionResult().convertFrom(s); 314 | ProductResult product = products.get(r.getProductId()); 315 | if (product != null) { 316 | r.setProductType(product.getType()); 317 | r.setProductName(product.getName()); 318 | } 319 | return r; 320 | }); 321 | } 322 | 323 | @Override 324 | public void deleteSubscription(String consumerId, String productId) { 325 | existsConsumer(consumerId); 326 | 327 | subscriptionRepository.findByConsumerIdAndProductId(consumerId, productId) 328 | .ifPresent(subscriptionRepository::delete); 329 | } 330 | 331 | @Override 332 | public SubscriptionResult approveSubscription(String consumerId, String productId) { 333 | existsConsumer(consumerId); 334 | 335 | ProductSubscription subscription = subscriptionRepository.findByConsumerIdAndProductId(consumerId, productId) 336 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.SUBSCRIPTION, StrUtil.format("{}:{}", productId, consumerId))); 337 | 338 | // 检查订阅状态,只有PENDING状态的订阅才能被审批 339 | if (subscription.getStatus() != SubscriptionStatus.PENDING) { 340 | throw new BusinessException(ErrorCode.INVALID_REQUEST, "订阅已审批"); 341 | } 342 | 343 | // 获取消费者和凭证信息 344 | Consumer consumer = contextHolder.isDeveloper() ? 345 | findDevConsumer(consumerId) : findConsumer(consumerId); 346 | ConsumerCredential credential = credentialRepository.findByConsumerId(consumerId) 347 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.CONSUMER_CREDENTIAL, consumerId)); 348 | 349 | // 获取产品引用信息 350 | ProductRefResult productRef = productService.getProductRef(productId); 351 | if (productRef == null) { 352 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "API产品未关联API"); 353 | } 354 | 355 | // 执行授权操作 356 | ConsumerAuthConfig consumerAuthConfig = authorizeConsumer(consumer, credential, productRef); 357 | 358 | // 更新订阅状态和授权配置 359 | subscription.setConsumerAuthConfig(consumerAuthConfig); 360 | subscription.setStatus(SubscriptionStatus.APPROVED); 361 | subscriptionRepository.saveAndFlush(subscription); 362 | 363 | ProductResult product = productService.getProduct(productId); 364 | SubscriptionResult result = new SubscriptionResult().convertFrom(subscription); 365 | if (product != null) { 366 | result.setProductName(product.getName()); 367 | result.setProductType(product.getType()); 368 | } 369 | return result; 370 | } 371 | 372 | private Consumer findConsumer(String consumerId) { 373 | return consumerRepository.findByConsumerId(consumerId) 374 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.CONSUMER, consumerId)); 375 | } 376 | 377 | private Consumer findDevConsumer(String consumerId) { 378 | return consumerRepository.findByDeveloperIdAndConsumerId(contextHolder.getUser(), consumerId) 379 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.CONSUMER, consumerId)); 380 | } 381 | 382 | private void existsConsumer(String consumerId) { 383 | (contextHolder.isDeveloper() ? 384 | consumerRepository.findByDeveloperIdAndConsumerId(contextHolder.getUser(), consumerId) : 385 | consumerRepository.findByConsumerId(consumerId)) 386 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.CONSUMER, consumerId)); 387 | } 388 | 389 | private Specification<Consumer> buildConsumerSpec(QueryConsumerParam param) { 390 | return (root, query, cb) -> { 391 | List<Predicate> predicates = new ArrayList<>(); 392 | 393 | if (contextHolder.isDeveloper()) { 394 | param.setDeveloperId(contextHolder.getUser()); 395 | } 396 | 397 | if (StrUtil.isNotBlank(param.getDeveloperId())) { 398 | predicates.add(cb.equal(root.get("developerId"), param.getDeveloperId())); 399 | } 400 | 401 | if (StrUtil.isNotBlank(param.getPortalId())) { 402 | predicates.add(cb.equal(root.get("portalId"), param.getPortalId())); 403 | } 404 | 405 | if (StrUtil.isNotBlank(param.getName())) { 406 | String likePattern = "%" + param.getName() + "%"; 407 | predicates.add(cb.like(cb.lower(root.get("name")), likePattern)); 408 | } 409 | 410 | return cb.and(predicates.toArray(new Predicate[0])); 411 | }; 412 | } 413 | 414 | private Specification<ProductSubscription> buildCredentialSpec(String consumerId, QuerySubscriptionParam param) { 415 | return (root, query, cb) -> { 416 | List<Predicate> predicates = new ArrayList<>(); 417 | predicates.add(cb.equal(root.get("consumerId"), consumerId)); 418 | if (param.getStatus() != null) { 419 | predicates.add(cb.equal(root.get("status"), param.getStatus())); 420 | } 421 | if (StrUtil.isNotBlank(param.getProductName())) { 422 | // 使用子查询 423 | Subquery<String> productSubquery = query.subquery(String.class); 424 | Root<Product> productRoot = productSubquery.from(Product.class); 425 | 426 | productSubquery.select(productRoot.get("productId")) 427 | .where(cb.like( 428 | cb.lower(productRoot.get("name")), 429 | "%" + param.getProductName().toLowerCase() + "%" 430 | )); 431 | 432 | predicates.add(root.get("productId").in(productSubquery)); 433 | } 434 | return cb.and(predicates.toArray(new Predicate[0])); 435 | }; 436 | } 437 | 438 | /** 439 | * 补充Credentials 440 | * 441 | * @param credential 442 | */ 443 | private void complementCredentials(ConsumerCredential credential) { 444 | if (credential == null) { 445 | return; 446 | } 447 | 448 | // ApiKey 449 | if (credential.getApiKeyConfig() != null) { 450 | List<ApiKeyConfig.ApiKeyCredential> apiKeyCredentials = credential.getApiKeyConfig().getCredentials(); 451 | if (apiKeyCredentials != null) { 452 | for (ApiKeyConfig.ApiKeyCredential cred : apiKeyCredentials) { 453 | if (cred.getMode() == CredentialMode.SYSTEM && StrUtil.isBlank(cred.getApiKey())) { 454 | cred.setApiKey(IdGenerator.genIdWithPrefix("apikey-")); 455 | } 456 | } 457 | } 458 | } 459 | 460 | // HMAC 461 | if (credential.getHmacConfig() != null) { 462 | List<HmacConfig.HmacCredential> hmacCredentials = credential.getHmacConfig().getCredentials(); 463 | if (hmacCredentials != null) { 464 | for (HmacConfig.HmacCredential cred : hmacCredentials) { 465 | if (cred.getMode() == CredentialMode.SYSTEM && 466 | (StrUtil.isBlank(cred.getAk()) || StrUtil.isBlank(cred.getSk()))) { 467 | cred.setAk(IdGenerator.genIdWithPrefix("ak-")); 468 | cred.setSk(IdGenerator.genIdWithPrefix("sk-")); 469 | } 470 | } 471 | } 472 | } 473 | } 474 | 475 | private ConsumerAuthConfig authorizeConsumer(Consumer consumer, ConsumerCredential credential, ProductRefResult productRef) { 476 | GatewayConfig gatewayConfig = gatewayService.getGatewayConfig(productRef.getGatewayId()); 477 | 478 | // 检查是否在网关上有对应的Consumer 479 | ConsumerRef existingConsumerRef = matchConsumerRef(consumer.getConsumerId(), gatewayConfig); 480 | String gwConsumerId; 481 | 482 | if (existingConsumerRef != null) { 483 | // 如果存在ConsumerRef记录,需要检查实际网关中是否还存在该消费者 484 | gwConsumerId = existingConsumerRef.getGwConsumerId(); 485 | 486 | // 检查实际网关中是否还存在该消费者 487 | if (!isConsumerExistsInGateway(gwConsumerId, gatewayConfig)) { 488 | log.warn("网关中的消费者已被删除,需要重新创建: gwConsumerId={}, gatewayType={}", 489 | gwConsumerId, gatewayConfig.getGatewayType()); 490 | 491 | // 删除过期的ConsumerRef记录 492 | consumerRefRepository.delete(existingConsumerRef); 493 | 494 | // 重新创建消费者 495 | gwConsumerId = gatewayService.createConsumer(consumer, credential, gatewayConfig); 496 | consumerRefRepository.save(ConsumerRef.builder() 497 | .consumerId(consumer.getConsumerId()) 498 | .gwConsumerId(gwConsumerId) 499 | .gatewayType(gatewayConfig.getGatewayType()) 500 | .gatewayConfig(gatewayConfig) 501 | .build()); 502 | } 503 | } else { 504 | // 如果不存在ConsumerRef记录,直接创建新的消费者 505 | gwConsumerId = gatewayService.createConsumer(consumer, credential, gatewayConfig); 506 | consumerRefRepository.save(ConsumerRef.builder() 507 | .consumerId(consumer.getConsumerId()) 508 | .gwConsumerId(gwConsumerId) 509 | .gatewayType(gatewayConfig.getGatewayType()) 510 | .gatewayConfig(gatewayConfig) 511 | .build()); 512 | } 513 | 514 | // 授权 515 | return gatewayService.authorizeConsumer(productRef.getGatewayId(), gwConsumerId, productRef); 516 | } 517 | 518 | /** 519 | * 检查消费者是否在实际网关中存在 520 | */ 521 | private boolean isConsumerExistsInGateway(String gwConsumerId, GatewayConfig gatewayConfig) { 522 | try { 523 | return gatewayService.isConsumerExists(gwConsumerId, gatewayConfig); 524 | } catch (Exception e) { 525 | log.warn("检查网关消费者存在性失败: gwConsumerId={}, gatewayType={}", 526 | gwConsumerId, gatewayConfig.getGatewayType(), e); 527 | // 如果检查失败,默认认为存在,避免无谓的重新创建 528 | return true; 529 | } 530 | } 531 | 532 | @EventListener 533 | @Async("taskExecutor") 534 | public void handleDeveloperDeletion(DeveloperDeletingEvent event) { 535 | String developerId = event.getDeveloperId(); 536 | log.info("Cleaning consumers for developer {}", developerId); 537 | 538 | List<Consumer> consumers = consumerRepository.findAllByDeveloperId(developerId); 539 | consumers.forEach(consumer -> { 540 | try { 541 | deleteConsumer(consumer.getConsumerId()); 542 | } catch (Exception e) { 543 | log.error("Failed to delete consumer {}", consumer.getConsumerId(), e); 544 | } 545 | }); 546 | } 547 | 548 | @EventListener 549 | @Async("taskExecutor") 550 | public void handleProductDeletion(ProductDeletingEvent event) { 551 | String productId = event.getProductId(); 552 | log.info("Cleaning subscriptions for product {}", productId); 553 | 554 | subscriptionRepository.deleteAllByProductId(productId); 555 | 556 | List<ProductSubscription> subscriptions = subscriptionRepository.findAllByProductId(productId); 557 | 558 | subscriptions.forEach(subscription -> { 559 | try { 560 | unsubscribeProduct(subscription.getConsumerId(), subscription.getProductId()); 561 | } catch (Exception e) { 562 | log.error("Failed to unsubscribe product {} for consumer {}", productId, subscription.getConsumerId(), e); 563 | } 564 | }); 565 | } 566 | 567 | private ConsumerRef matchConsumerRef(String consumerId, GatewayConfig gatewayConfig) { 568 | List<ConsumerRef> consumeRefs = consumerRefRepository.findAllByConsumerIdAndGatewayType(consumerId, gatewayConfig.getGatewayType()); 569 | if (consumeRefs.isEmpty()) { 570 | return null; 571 | } 572 | 573 | for (ConsumerRef ref : consumeRefs) { 574 | // 网关配置相同 575 | if (StrUtil.equals(JSONUtil.toJsonStr(ref.getGatewayConfig()), JSONUtil.toJsonStr(gatewayConfig))) { 576 | return ref; 577 | } 578 | } 579 | return null; 580 | } 581 | } 582 | ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/service/gateway/APIGOperator.java: -------------------------------------------------------------------------------- ```java 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one 3 | * or more contributor license agreements. See the NOTICE file 4 | * distributed with this work for additional information 5 | * regarding copyright ownership. The ASF licenses this file 6 | * to you under the Apache License, Version 2.0 (the 7 | * "License"); you may not use this file except in compliance 8 | * with the License. You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | package com.alibaba.apiopenplatform.service.gateway; 21 | 22 | import cn.hutool.core.codec.Base64; 23 | import cn.hutool.core.collection.CollUtil; 24 | import cn.hutool.core.util.StrUtil; 25 | import cn.hutool.json.JSONUtil; 26 | import com.alibaba.apiopenplatform.dto.params.gateway.QueryAPIGParam; 27 | import com.alibaba.apiopenplatform.dto.result.*; 28 | import com.alibaba.apiopenplatform.support.consumer.APIGAuthConfig; 29 | import com.alibaba.apiopenplatform.support.consumer.ApiKeyConfig; 30 | import com.alibaba.apiopenplatform.support.consumer.ConsumerAuthConfig; 31 | import com.alibaba.apiopenplatform.support.consumer.HmacConfig; 32 | import com.alibaba.apiopenplatform.support.enums.APIGAPIType; 33 | import com.alibaba.apiopenplatform.core.exception.BusinessException; 34 | import com.alibaba.apiopenplatform.core.exception.ErrorCode; 35 | import com.alibaba.apiopenplatform.entity.Gateway; 36 | import com.alibaba.apiopenplatform.entity.Consumer; 37 | import com.alibaba.apiopenplatform.entity.ConsumerCredential; 38 | import com.alibaba.apiopenplatform.service.gateway.client.APIGClient; 39 | import com.alibaba.apiopenplatform.service.gateway.client.SLSClient; 40 | import com.alibaba.apiopenplatform.support.enums.GatewayType; 41 | import com.alibaba.apiopenplatform.support.gateway.GatewayConfig; 42 | import com.alibaba.apiopenplatform.support.product.APIGRefConfig; 43 | import com.aliyun.sdk.gateway.pop.exception.PopClientException; 44 | import com.aliyun.sdk.service.apig20240327.models.*; 45 | import com.aliyun.sdk.service.apig20240327.models.CreateConsumerAuthorizationRulesRequest.AuthorizationRules; 46 | import com.aliyun.sdk.service.apig20240327.models.CreateConsumerAuthorizationRulesRequest.ResourceIdentifier; 47 | import com.aliyun.sdk.service.sls20201230.models.*; 48 | import lombok.RequiredArgsConstructor; 49 | import lombok.extern.slf4j.Slf4j; 50 | import org.springframework.context.annotation.Primary; 51 | import org.springframework.stereotype.Service; 52 | 53 | import java.util.ArrayList; 54 | import java.util.List; 55 | import java.util.concurrent.CompletableFuture; 56 | import java.util.concurrent.ExecutionException; 57 | import java.util.stream.Collectors; 58 | 59 | @RequiredArgsConstructor 60 | @Service 61 | @Slf4j 62 | @Primary 63 | public class APIGOperator extends GatewayOperator<APIGClient> { 64 | 65 | @Override 66 | public PageResult<APIResult> fetchHTTPAPIs(Gateway gateway, int page, int size) { 67 | return fetchAPIs(gateway, APIGAPIType.HTTP, page, size); 68 | } 69 | 70 | public PageResult<APIResult> fetchRESTAPIs(Gateway gateway, int page, int size) { 71 | return fetchAPIs(gateway, APIGAPIType.REST, page, size); 72 | } 73 | 74 | @Override 75 | public PageResult<? extends GatewayMCPServerResult> fetchMcpServers(Gateway gateway, int page, int size) { 76 | throw new UnsupportedOperationException("APIG does not support MCP Servers"); 77 | } 78 | 79 | @Override 80 | public String fetchAPIConfig(Gateway gateway, Object config) { 81 | APIGClient client = getClient(gateway); 82 | 83 | try { 84 | APIGRefConfig apigRefConfig = (APIGRefConfig) config; 85 | CompletableFuture<ExportHttpApiResponse> f = client.execute(c -> { 86 | ExportHttpApiRequest request = ExportHttpApiRequest.builder() 87 | .httpApiId(apigRefConfig.getApiId()) 88 | .build(); 89 | return c.exportHttpApi(request); 90 | }); 91 | 92 | ExportHttpApiResponse response = f.join(); 93 | if (response.getStatusCode() != 200) { 94 | throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); 95 | } 96 | 97 | String contentBase64 = response.getBody().getData().getSpecContentBase64(); 98 | 99 | APIConfigResult configResult = new APIConfigResult(); 100 | // spec 101 | String apiSpec = Base64.decodeStr(contentBase64); 102 | configResult.setSpec(apiSpec); 103 | 104 | // meta 105 | APIConfigResult.APIMetadata meta = new APIConfigResult.APIMetadata(); 106 | meta.setSource(GatewayType.APIG_API.name()); 107 | meta.setType("REST"); 108 | configResult.setMeta(meta); 109 | 110 | return JSONUtil.toJsonStr(configResult); 111 | } catch (Exception e) { 112 | log.error("Error fetching API Spec", e); 113 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching API Spec,Cause:" + e.getMessage()); 114 | } 115 | } 116 | 117 | @Override 118 | public String fetchMcpConfig(Gateway gateway, Object conf) { 119 | throw new UnsupportedOperationException("APIG does not support MCP Servers"); 120 | } 121 | 122 | @Override 123 | public PageResult<GatewayResult> fetchGateways(Object param, int page, int size) { 124 | return fetchGateways((QueryAPIGParam) param, page, size); 125 | } 126 | 127 | public PageResult<GatewayResult> fetchGateways(QueryAPIGParam param, int page, int size) { 128 | APIGClient client = new APIGClient(param.convertTo()); 129 | 130 | List<GatewayResult> gateways = new ArrayList<>(); 131 | try { 132 | CompletableFuture<ListGatewaysResponse> f = client.execute(c -> { 133 | ListGatewaysRequest request = ListGatewaysRequest.builder() 134 | .gatewayType(param.getGatewayType().getType()) 135 | .pageNumber(page) 136 | .pageSize(size) 137 | .build(); 138 | 139 | return c.listGateways(request); 140 | }); 141 | 142 | ListGatewaysResponse response = f.join(); 143 | if (response.getStatusCode() != 200) { 144 | throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); 145 | } 146 | 147 | for (ListGatewaysResponseBody.Items item : response.getBody().getData().getItems()) { 148 | gateways.add(GatewayResult.builder() 149 | .gatewayName(item.getName()) 150 | .gatewayId(item.getGatewayId()) 151 | .gatewayType(param.getGatewayType()) 152 | .build()); 153 | } 154 | 155 | int total = Math.toIntExact(response.getBody().getData().getTotalSize()); 156 | return PageResult.of(gateways, page, size, total); 157 | } catch (Exception e) { 158 | log.error("Error fetching Gateways", e); 159 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching Gateways,Cause:" + e.getMessage()); 160 | } 161 | } 162 | 163 | protected String fetchGatewayEnv(Gateway gateway) { 164 | APIGClient client = getClient(gateway); 165 | try { 166 | CompletableFuture<GetGatewayResponse> f = client.execute(c -> { 167 | GetGatewayRequest request = GetGatewayRequest.builder() 168 | .gatewayId(gateway.getGatewayId()) 169 | .build(); 170 | 171 | return c.getGateway(request); 172 | 173 | }); 174 | 175 | GetGatewayResponse response = f.join(); 176 | if (response.getStatusCode() != 200) { 177 | throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); 178 | } 179 | 180 | List<GetGatewayResponseBody.Environments> environments = response.getBody().getData().getEnvironments(); 181 | if (CollUtil.isEmpty(environments)) { 182 | return null; 183 | } 184 | 185 | return environments.get(0).getEnvironmentId(); 186 | } catch (Exception e) { 187 | log.error("Error fetching Gateway", e); 188 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching Gateway,Cause:" + e.getMessage()); 189 | } 190 | } 191 | 192 | @Override 193 | public String createConsumer(Consumer consumer, ConsumerCredential credential, GatewayConfig config) { 194 | APIGClient client = new APIGClient(config.getApigConfig()); 195 | 196 | String mark = consumer.getConsumerId().substring(Math.max(0, consumer.getConsumerId().length() - 8)); 197 | String gwConsumerName = StrUtil.format("{}-{}", consumer.getName(), mark); 198 | try { 199 | // ApiKey 200 | ApiKeyIdentityConfig apikeyIdentityConfig = convertToApiKeyIdentityConfig(credential.getApiKeyConfig()); 201 | 202 | // Hmac 203 | List<AkSkIdentityConfig> akSkIdentityConfigs = convertToAkSkIdentityConfigs(credential.getHmacConfig()); 204 | 205 | CreateConsumerRequest.Builder builder = CreateConsumerRequest.builder() 206 | .name(gwConsumerName) 207 | .description("Created by HiMarket") 208 | .gatewayType(config.getGatewayType().getType()) 209 | .enable(true); 210 | if (apikeyIdentityConfig != null) { 211 | builder.apikeyIdentityConfig(apikeyIdentityConfig); 212 | } 213 | if (akSkIdentityConfigs != null) { 214 | builder.akSkIdentityConfigs(akSkIdentityConfigs); 215 | } 216 | 217 | CompletableFuture<CreateConsumerResponse> f = client.execute(c -> c.createConsumer(builder.build())); 218 | 219 | CreateConsumerResponse response = f.join(); 220 | if (response.getStatusCode() != 200) { 221 | throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); 222 | } 223 | 224 | return response.getBody().getData().getConsumerId(); 225 | } catch (Exception e) { 226 | Throwable cause = e.getCause(); 227 | // Consumer已经存在 228 | if (cause instanceof PopClientException && "Conflict.ConsumerNameDuplicate".equals(((PopClientException) cause).getErrCode())) { 229 | return retrievalConsumer(gwConsumerName, config); 230 | } 231 | log.error("Error creating Consumer", e); 232 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error creating Consumer,Cause:" + e.getMessage()); 233 | } 234 | } 235 | 236 | private String retrievalConsumer(String name, GatewayConfig gatewayConfig) { 237 | APIGClient client = new APIGClient(gatewayConfig.getApigConfig()); 238 | 239 | try { 240 | CompletableFuture<ListConsumersResponse> f = client.execute(c -> { 241 | ListConsumersRequest request = ListConsumersRequest.builder() 242 | .gatewayType(gatewayConfig.getGatewayType().getType()) 243 | .nameLike(name) 244 | .pageNumber(1) 245 | .pageSize(10) 246 | .build(); 247 | 248 | return c.listConsumers(request); 249 | }); 250 | ListConsumersResponse response = f.join(); 251 | if (response.getStatusCode() != 200) { 252 | throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); 253 | } 254 | 255 | for (ListConsumersResponseBody.Items item : response.getBody().getData().getItems()) { 256 | if (StrUtil.equals(item.getName(), name)) { 257 | return item.getConsumerId(); 258 | } 259 | } 260 | } catch (Exception e) { 261 | log.error("Error fetching Consumer", e); 262 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching Consumer,Cause:" + e.getMessage()); 263 | } 264 | return null; 265 | } 266 | 267 | @Override 268 | public void updateConsumer(String consumerId, ConsumerCredential credential, GatewayConfig config) { 269 | APIGClient client = new APIGClient(config.getApigConfig()); 270 | try { 271 | // ApiKey 272 | ApiKeyIdentityConfig apikeyIdentityConfig = convertToApiKeyIdentityConfig(credential.getApiKeyConfig()); 273 | 274 | // Hmac 275 | List<AkSkIdentityConfig> akSkIdentityConfigs = convertToAkSkIdentityConfigs(credential.getHmacConfig()); 276 | 277 | UpdateConsumerRequest.Builder builder = UpdateConsumerRequest.builder() 278 | .enable(true) 279 | .consumerId(consumerId); 280 | 281 | if (apikeyIdentityConfig != null) { 282 | builder.apikeyIdentityConfig(apikeyIdentityConfig); 283 | } 284 | 285 | if (akSkIdentityConfigs != null) { 286 | builder.akSkIdentityConfigs(akSkIdentityConfigs); 287 | } 288 | 289 | CompletableFuture<UpdateConsumerResponse> f = client.execute(c -> c.updateConsumer(builder.build())); 290 | 291 | UpdateConsumerResponse response = f.join(); 292 | if (response.getStatusCode() != 200) { 293 | throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); 294 | } 295 | } catch (Exception e) { 296 | log.error("Error creating Consumer", e); 297 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error creating Consumer,Cause:" + e.getMessage()); 298 | } 299 | } 300 | 301 | @Override 302 | public void deleteConsumer(String consumerId, GatewayConfig config) { 303 | APIGClient client = new APIGClient(config.getApigConfig()); 304 | try { 305 | DeleteConsumerRequest request = DeleteConsumerRequest.builder() 306 | .consumerId(consumerId) 307 | .build(); 308 | client.execute(c -> { 309 | c.deleteConsumer(request); 310 | return null; 311 | }); 312 | } catch (Exception e) { 313 | log.error("Error deleting Consumer", e); 314 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error deleting Consumer,Cause:" + e.getMessage()); 315 | } 316 | } 317 | 318 | @Override 319 | public boolean isConsumerExists(String consumerId, GatewayConfig config) { 320 | // TODO: 实现APIG网关消费者存在性检查 321 | return true; 322 | } 323 | 324 | @Override 325 | public ConsumerAuthConfig authorizeConsumer(Gateway gateway, String consumerId, Object refConfig) { 326 | APIGClient client = getClient(gateway); 327 | 328 | APIGRefConfig config = (APIGRefConfig) refConfig; 329 | // REST API 授权 330 | String apiId = config.getApiId(); 331 | 332 | try { 333 | List<HttpApiOperationInfo> operations = fetchRESTOperations(gateway, apiId); 334 | if (CollUtil.isEmpty(operations)) { 335 | return null; 336 | } 337 | 338 | // 确认Gateway的EnvId 339 | String envId = fetchGatewayEnv(gateway); 340 | 341 | List<AuthorizationRules> rules = new ArrayList<>(); 342 | for (HttpApiOperationInfo operation : operations) { 343 | AuthorizationRules rule = AuthorizationRules.builder() 344 | .consumerId(consumerId) 345 | .expireMode("LongTerm") 346 | .resourceType("RestApiOperation") 347 | .resourceIdentifier(ResourceIdentifier.builder() 348 | .resourceId(operation.getOperationId()) 349 | .environmentId(envId).build()) 350 | .build(); 351 | rules.add(rule); 352 | } 353 | 354 | CompletableFuture<CreateConsumerAuthorizationRulesResponse> f = client.execute(c -> { 355 | CreateConsumerAuthorizationRulesRequest request = CreateConsumerAuthorizationRulesRequest.builder() 356 | .authorizationRules(rules) 357 | .build(); 358 | return c.createConsumerAuthorizationRules(request); 359 | }); 360 | 361 | CreateConsumerAuthorizationRulesResponse response = f.join(); 362 | if (200 != response.getStatusCode()) { 363 | throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); 364 | } 365 | 366 | APIGAuthConfig apigAuthConfig = APIGAuthConfig.builder() 367 | .authorizationRuleIds(response.getBody().getData().getConsumerAuthorizationRuleIds()) 368 | .build(); 369 | 370 | return ConsumerAuthConfig.builder() 371 | .apigAuthConfig(apigAuthConfig) 372 | .build(); 373 | } catch (Exception e) { 374 | log.error("Error authorizing consumer {} to apiId {} in APIG gateway {}", consumerId, apiId, gateway.getGatewayId(), e); 375 | throw new BusinessException(ErrorCode.GATEWAY_ERROR, "Failed to authorize consumer to apiId in APIG gateway: " + e.getMessage()); 376 | } 377 | } 378 | 379 | @Override 380 | public void revokeConsumerAuthorization(Gateway gateway, String consumerId, ConsumerAuthConfig authConfig) { 381 | APIGAuthConfig apigAuthConfig = authConfig.getApigAuthConfig(); 382 | if (apigAuthConfig == null) { 383 | return; 384 | } 385 | 386 | APIGClient client = getClient(gateway); 387 | 388 | try { 389 | BatchDeleteConsumerAuthorizationRuleRequest request = BatchDeleteConsumerAuthorizationRuleRequest.builder() 390 | .consumerAuthorizationRuleIds(StrUtil.join(",", apigAuthConfig.getAuthorizationRuleIds())) 391 | .build(); 392 | 393 | CompletableFuture<BatchDeleteConsumerAuthorizationRuleResponse> f = client.execute(c -> c.batchDeleteConsumerAuthorizationRule(request)); 394 | 395 | BatchDeleteConsumerAuthorizationRuleResponse response = f.join(); 396 | if (response.getStatusCode() != 200) { 397 | throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); 398 | } 399 | } catch (Exception e) { 400 | Throwable cause = e.getCause(); 401 | if (cause instanceof PopClientException 402 | && "DatabaseError.RecordNotFound".equals(((PopClientException) cause).getErrCode())) { 403 | log.warn("Consumer authorization rules[{}] not found, ignore", apigAuthConfig.getAuthorizationRuleIds()); 404 | return; 405 | } 406 | 407 | log.error("Error deleting Consumer Authorization", e); 408 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error deleting Consumer Authorization,Cause:" + e.getMessage()); 409 | } 410 | } 411 | 412 | @Override 413 | public GatewayType getGatewayType() { 414 | return GatewayType.APIG_API; 415 | } 416 | 417 | @Override 418 | public String getDashboard(Gateway gateway, String type) { 419 | SLSClient ticketClient = new SLSClient(gateway.getApigConfig(), true); 420 | String ticket = null; 421 | try { 422 | CreateTicketResponse response = ticketClient.execute(c -> { 423 | CreateTicketRequest request = CreateTicketRequest.builder().build(); 424 | try { 425 | return c.createTicket(request).get(); 426 | } catch (InterruptedException | ExecutionException e) { 427 | throw new RuntimeException(e); 428 | } 429 | }); 430 | ticket = response.getBody().getTicket(); 431 | } catch (Exception e) { 432 | log.error("Error fetching API", e); 433 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching createTicker API,Cause:" + e.getMessage()); 434 | } 435 | SLSClient client = new SLSClient(gateway.getApigConfig(), false); 436 | String projectName = null; 437 | try { 438 | ListProjectResponse response = client.execute(c -> { 439 | ListProjectRequest request = ListProjectRequest.builder().projectName("product").build(); 440 | try { 441 | return c.listProject(request).get(); 442 | } catch (InterruptedException | ExecutionException e) { 443 | throw new RuntimeException(e); 444 | } 445 | }); 446 | projectName = response.getBody().getProjects().get(0).getProjectName(); 447 | } catch (Exception e) { 448 | log.error("Error fetching Project", e); 449 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching Project,Cause:" + e.getMessage()); 450 | } 451 | String region = gateway.getApigConfig().getRegion(); 452 | String gatewayId = gateway.getGatewayId(); 453 | String dashboardId = ""; 454 | if (type.equals("Portal")) { 455 | dashboardId = "dashboard-1758009692051-393998"; 456 | } else if (type.equals("MCP")) { 457 | dashboardId = "dashboard-1757483808537-433375"; 458 | } else if (type.equals("API")) { 459 | dashboardId = "dashboard-1756276497392-966932"; 460 | } 461 | String dashboardUrl = String.format("https://sls.console.aliyun.com/lognext/project/%s/dashboard/%s?filters=cluster_id%%253A%%2520%s&slsRegion=%s&sls_ticket=%s&isShare=true&hideTopbar=true&hideSidebar=true&ignoreTabLocalStorage=true", projectName, dashboardId, gatewayId, region, ticket); 462 | log.info("Dashboard URL: {}", dashboardUrl); 463 | return dashboardUrl; 464 | } 465 | 466 | public APIResult fetchAPI(Gateway gateway, String apiId) { 467 | APIGClient client = getClient(gateway); 468 | try { 469 | CompletableFuture<GetHttpApiResponse> f = client.execute(c -> { 470 | GetHttpApiRequest request = GetHttpApiRequest.builder() 471 | .httpApiId(apiId) 472 | .build(); 473 | 474 | return c.getHttpApi(request); 475 | 476 | }); 477 | 478 | GetHttpApiResponse response = f.join(); 479 | if (response.getStatusCode() != 200) { 480 | throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); 481 | } 482 | 483 | HttpApiApiInfo apiInfo = response.getBody().getData(); 484 | return new APIResult().convertFrom(apiInfo); 485 | } catch (Exception e) { 486 | log.error("Error fetching API", e); 487 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching API,Cause:" + e.getMessage()); 488 | } 489 | } 490 | 491 | protected HttpRoute fetchHTTPRoute(Gateway gateway, String apiId, String routeId) { 492 | APIGClient client = getClient(gateway); 493 | 494 | try { 495 | CompletableFuture<GetHttpApiRouteResponse> f = client.execute(c -> { 496 | GetHttpApiRouteRequest request = GetHttpApiRouteRequest.builder() 497 | .httpApiId(apiId) 498 | .routeId(routeId) 499 | .build(); 500 | 501 | return c.getHttpApiRoute(request); 502 | 503 | }); 504 | 505 | GetHttpApiRouteResponse response = f.join(); 506 | if (response.getStatusCode() != 200) { 507 | throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); 508 | } 509 | 510 | return response.getBody().getData(); 511 | 512 | } catch (Exception e) { 513 | log.error("Error fetching HTTP Route", e); 514 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching HTTP Route,Cause:" + e.getMessage()); 515 | } 516 | } 517 | 518 | protected PageResult<APIResult> fetchAPIs(Gateway gateway, APIGAPIType type, int page, int size) { 519 | APIGClient client = getClient(gateway); 520 | try { 521 | List<APIResult> apis = new ArrayList<>(); 522 | CompletableFuture<ListHttpApisResponse> f = client.execute(c -> { 523 | ListHttpApisRequest request = ListHttpApisRequest.builder() 524 | .gatewayId(gateway.getGatewayId()) 525 | .gatewayType(gateway.getGatewayType().getType()) 526 | .types(type.getType()) 527 | .pageNumber(page) 528 | .pageSize(size) 529 | .build(); 530 | 531 | return c.listHttpApis(request); 532 | }); 533 | 534 | ListHttpApisResponse response = f.join(); 535 | if (response.getStatusCode() != 200) { 536 | throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); 537 | } 538 | 539 | for (HttpApiInfoByName item : response.getBody().getData().getItems()) { 540 | for (HttpApiApiInfo apiInfo : item.getVersionedHttpApis()) { 541 | APIResult apiResult = new APIResult().convertFrom(apiInfo); 542 | apis.add(apiResult); 543 | break; 544 | } 545 | } 546 | 547 | int total = response.getBody().getData().getTotalSize(); 548 | return PageResult.of(apis, page, size, total); 549 | } catch (Exception e) { 550 | log.error("Error fetching APIs", e); 551 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching APIs,Cause:" + e.getMessage()); 552 | } 553 | } 554 | 555 | public PageResult<HttpRoute> fetchHttpRoutes(Gateway gateway, String apiId, int page, int size) { 556 | APIGClient client = getClient(gateway); 557 | try { 558 | CompletableFuture<ListHttpApiRoutesResponse> f = client.execute(c -> { 559 | ListHttpApiRoutesRequest request = ListHttpApiRoutesRequest.builder() 560 | .gatewayId(gateway.getGatewayId()) 561 | .httpApiId(apiId) 562 | .pageNumber(page) 563 | .pageSize(size) 564 | .build(); 565 | 566 | return c.listHttpApiRoutes(request); 567 | 568 | }); 569 | 570 | ListHttpApiRoutesResponse response = f.join(); 571 | if (response.getStatusCode() != 200) { 572 | throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); 573 | } 574 | List<HttpRoute> httpRoutes = response.getBody().getData().getItems(); 575 | int total = response.getBody().getData().getTotalSize(); 576 | return PageResult.of(httpRoutes, page, size, total); 577 | } catch (Exception e) { 578 | log.error("Error fetching HTTP Roues", e); 579 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching HTTP Roues,Cause:" + e.getMessage()); 580 | } 581 | } 582 | 583 | public List<HttpApiOperationInfo> fetchRESTOperations(Gateway gateway, String apiId) { 584 | APIGClient client = getClient(gateway); 585 | 586 | try { 587 | CompletableFuture<ListHttpApiOperationsResponse> f = client.execute(c -> { 588 | ListHttpApiOperationsRequest request = ListHttpApiOperationsRequest.builder() 589 | .gatewayId(gateway.getGatewayId()) 590 | .httpApiId(apiId) 591 | .pageNumber(1) 592 | .pageSize(500) 593 | .build(); 594 | 595 | return c.listHttpApiOperations(request); 596 | 597 | }); 598 | 599 | ListHttpApiOperationsResponse response = f.join(); 600 | if (response.getStatusCode() != 200) { 601 | throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); 602 | } 603 | 604 | return response.getBody().getData().getItems(); 605 | } catch (Exception e) { 606 | log.error("Error fetching REST operations", e); 607 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching REST operations,Cause:" + e.getMessage()); 608 | } 609 | } 610 | 611 | protected ApiKeyIdentityConfig convertToApiKeyIdentityConfig(ApiKeyConfig config) { 612 | if (config == null) { 613 | return null; 614 | } 615 | 616 | // ApikeySource 617 | ApiKeyIdentityConfig.ApikeySource apikeySource = ApiKeyIdentityConfig.ApikeySource.builder() 618 | .source(config.getSource()) 619 | .value(config.getKey()) 620 | .build(); 621 | 622 | // credentials 623 | List<ApiKeyIdentityConfig.Credentials> credentials = config.getCredentials().stream() 624 | .map(cred -> ApiKeyIdentityConfig.Credentials.builder() 625 | .apikey(cred.getApiKey()) 626 | .generateMode("Custom") 627 | .build()) 628 | .collect(Collectors.toList()); 629 | 630 | return ApiKeyIdentityConfig.builder() 631 | .apikeySource(apikeySource) 632 | .credentials(credentials) 633 | .type("Apikey") 634 | .build(); 635 | } 636 | 637 | protected List<AkSkIdentityConfig> convertToAkSkIdentityConfigs(HmacConfig hmacConfig) { 638 | if (hmacConfig == null || hmacConfig.getCredentials() == null) { 639 | return null; 640 | } 641 | 642 | return hmacConfig.getCredentials().stream() 643 | .map(cred -> AkSkIdentityConfig.builder() 644 | .ak(cred.getAk()) 645 | .sk(cred.getSk()) 646 | .generateMode("Custom") 647 | .type("AkSk") 648 | .build()) 649 | .collect(Collectors.toList()); 650 | } 651 | } 652 | 653 | ``` -------------------------------------------------------------------------------- /portal-web/api-portal-frontend/src/components/consumer/CredentialManager.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import {useState, useEffect} from "react"; 2 | import { 3 | Card, 4 | Button, 5 | message, 6 | Tabs, 7 | Modal, 8 | Radio, 9 | Input, 10 | Table, 11 | Popconfirm, 12 | Select, 13 | Form, 14 | } from "antd"; 15 | import { 16 | PlusOutlined, 17 | InfoCircleOutlined, 18 | CopyOutlined, 19 | DeleteOutlined, 20 | EditOutlined 21 | } from "@ant-design/icons"; 22 | import api from "../../lib/api"; 23 | import type { 24 | ConsumerCredentialResult, 25 | CreateCredentialParam, 26 | ConsumerCredential, 27 | HMACCredential, 28 | APIKeyCredential 29 | } from "../../types/consumer"; 30 | import type {ApiResponse} from "../../types"; 31 | 32 | interface CredentialManagerProps { 33 | consumerId: string; 34 | } 35 | 36 | export function CredentialManager({consumerId}: CredentialManagerProps) { 37 | const [credentialType, setCredentialType] = useState<'API_KEY' | 'HMAC'>('API_KEY'); 38 | const [credentialModalVisible, setCredentialModalVisible] = useState(false); 39 | const [credentialLoading, setCredentialLoading] = useState(false); 40 | 41 | const [sourceModalVisible, setSourceModalVisible] = useState(false); 42 | const [editingSource, setEditingSource] = useState<string>('Default'); 43 | const [editingKey, setEditingKey] = useState<string>('Authorization'); 44 | // 已保存(展示用)与编辑中的两套状态,取消时回滚到已保存值 45 | const [currentSource, setCurrentSource] = useState<string>('Default'); 46 | const [currentKey, setCurrentKey] = useState<string>('Authorization'); 47 | // 表单(编辑凭证来源) 48 | const [sourceForm] = Form.useForm(); 49 | // 表单(创建凭证) 50 | const [credentialForm] = Form.useForm(); 51 | // 当前完整配置(驱动表格数据源) 52 | const [currentConfig, setCurrentConfig] = useState<ConsumerCredentialResult | null>(null); 53 | 54 | // 初始化时获取当前配置 55 | const fetchCurrentConfig = async () => { 56 | try { 57 | const response: ApiResponse<ConsumerCredentialResult> = await api.get(`/consumers/${consumerId}/credentials`); 58 | if (response.code === "SUCCESS" && response.data) { 59 | const config = response.data; 60 | setCurrentConfig(config); 61 | if (config.apiKeyConfig) { 62 | setCurrentSource(config.apiKeyConfig.source || 'Default'); 63 | setCurrentKey(config.apiKeyConfig.key || 'Authorization'); 64 | } 65 | } 66 | } catch (error) { 67 | console.error('获取当前配置失败:', error); 68 | } 69 | }; 70 | 71 | // 组件挂载时获取配置 72 | useEffect(() => { 73 | fetchCurrentConfig(); 74 | }, [consumerId]); 75 | 76 | const handleCreateCredential = async () => { 77 | try { 78 | const values = await credentialForm.validateFields(); 79 | setCredentialLoading(true); 80 | 81 | // 先获取当前的凭证配置 82 | const currentResponse: ApiResponse<ConsumerCredentialResult> = await api.get(`/consumers/${consumerId}/credentials`); 83 | let currentConfig: ConsumerCredentialResult = {}; 84 | 85 | if (currentResponse.code === "SUCCESS" && currentResponse.data) { 86 | currentConfig = currentResponse.data; 87 | } 88 | 89 | // 构建新的凭证配置 90 | const param: CreateCredentialParam = { 91 | ...currentConfig, 92 | }; 93 | 94 | if (credentialType === 'API_KEY') { 95 | const newCredential: ConsumerCredential = { 96 | apiKey: values.generationMethod === 'CUSTOM' ? values.customApiKey : generateRandomCredential('apiKey'), 97 | mode: values.generationMethod 98 | }; 99 | param.apiKeyConfig = { 100 | ...currentConfig.apiKeyConfig, 101 | credentials: [...(currentConfig.apiKeyConfig?.credentials || []), newCredential] 102 | }; 103 | } else if (credentialType === 'HMAC') { 104 | const newCredential: ConsumerCredential = { 105 | ak: values.generationMethod === 'CUSTOM' ? values.customAccessKey : generateRandomCredential('accessKey'), 106 | sk: values.generationMethod === 'CUSTOM' ? values.customSecretKey : generateRandomCredential('secretKey'), 107 | mode: values.generationMethod 108 | }; 109 | param.hmacConfig = { 110 | ...currentConfig.hmacConfig, 111 | credentials: [...(currentConfig.hmacConfig?.credentials || []), newCredential] 112 | }; 113 | } 114 | 115 | const response: ApiResponse<ConsumerCredentialResult> = await api.put(`/consumers/${consumerId}/credentials`, param); 116 | if (response?.code === "SUCCESS") { 117 | message.success('凭证添加成功'); 118 | setCredentialModalVisible(false); 119 | resetCredentialForm(); 120 | // 刷新当前配置以驱动表格 121 | await fetchCurrentConfig(); 122 | } 123 | } catch (error) { 124 | console.error('创建凭证失败:', error); 125 | // message.error('创建凭证失败'); 126 | } finally { 127 | setCredentialLoading(false); 128 | } 129 | }; 130 | 131 | const handleDeleteCredential = async (credentialType: string, credential: ConsumerCredential) => { 132 | try { 133 | // 先获取当前的凭证配置 134 | const currentResponse: ApiResponse<ConsumerCredentialResult> = await api.get(`/consumers/${consumerId}/credentials`); 135 | let currentConfig: ConsumerCredentialResult = {}; 136 | 137 | if (currentResponse.code === "SUCCESS" && currentResponse.data) { 138 | currentConfig = currentResponse.data; 139 | } 140 | 141 | // 构建删除后的凭证配置,清空对应类型的凭证 142 | const param: CreateCredentialParam = { 143 | ...currentConfig, 144 | }; 145 | 146 | if (credentialType === 'API_KEY') { 147 | param.apiKeyConfig = { 148 | credentials: currentConfig.apiKeyConfig?.credentials?.filter(cred => cred.apiKey !== (credential as APIKeyCredential).apiKey), 149 | source: currentConfig.apiKeyConfig?.source || 'Default', 150 | key: currentConfig.apiKeyConfig?.key || 'Authorization' 151 | }; 152 | } else if (credentialType === 'HMAC') { 153 | param.hmacConfig = { 154 | credentials: currentConfig.hmacConfig?.credentials?.filter(cred => cred.ak !== (credential as HMACCredential).ak), 155 | }; 156 | } 157 | 158 | const response: ApiResponse<ConsumerCredentialResult> = await api.put(`/consumers/${consumerId}/credentials`, param); 159 | if (response?.code === "SUCCESS") { 160 | message.success('凭证删除成功'); 161 | await fetchCurrentConfig(); 162 | } 163 | } catch (error) { 164 | console.error('删除凭证失败:', error); 165 | // message.error('删除凭证失败'); 166 | } 167 | }; 168 | const handleCopyCredential = (text: string) => { 169 | const textArea = document.createElement('textarea'); 170 | textArea.value = text; 171 | textArea.style.position = 'fixed'; 172 | textArea.style.left = '-9999px'; // 避免影响页面布局 173 | document.body.appendChild(textArea); 174 | textArea.focus(); 175 | textArea.select(); 176 | 177 | try { 178 | const success = document.execCommand('copy'); 179 | if (success) { 180 | message.success('已复制到剪贴板'); 181 | } else { 182 | // message.error('复制失败,请手动复制内容'); 183 | } 184 | } catch (err) { 185 | // message.error('复制失败,请手动复制内容'); 186 | } finally { 187 | document.body.removeChild(textArea); // 清理 DOM 188 | } 189 | }; 190 | 191 | 192 | const resetCredentialForm = () => { 193 | credentialForm.resetFields(); 194 | }; 195 | 196 | const handleEditSource = async (source: string, key: string) => { 197 | try { 198 | // 先获取当前的凭证配置 199 | const currentResponse: ApiResponse<ConsumerCredentialResult> = await api.get(`/consumers/${consumerId}/credentials`); 200 | let currentConfig: ConsumerCredentialResult = {}; 201 | 202 | if (currentResponse.code === "SUCCESS" && currentResponse.data) { 203 | currentConfig = currentResponse.data as ConsumerCredentialResult; 204 | } 205 | 206 | // 构建新的凭证配置 207 | const param: CreateCredentialParam = {}; 208 | 209 | // 更新API Key配置的source和key 210 | if (currentConfig.apiKeyConfig) { 211 | param.apiKeyConfig = { 212 | source: source, 213 | key: source === 'Default' ? 'Authorization' : key, 214 | credentials: currentConfig.apiKeyConfig.credentials 215 | }; 216 | } else { 217 | param.apiKeyConfig = { 218 | source: source, 219 | key: source === 'Default' ? 'Authorization' : key, 220 | credentials: [] 221 | }; 222 | } 223 | 224 | 225 | // 提交配置到后端 226 | const response: ApiResponse<ConsumerCredentialResult> = await api.put(`/consumers/${consumerId}/credentials`, param); 227 | if (response?.code === "SUCCESS") { 228 | message.success('凭证来源更新成功'); 229 | 230 | // 重新查询接口获取最新配置,确保数据落盘 231 | const updatedResponse: ApiResponse<ConsumerCredentialResult> = await api.get(`/consumers/${consumerId}/credentials`); 232 | if (updatedResponse.code === "SUCCESS" && updatedResponse.data) { 233 | const updatedConfig = updatedResponse.data; 234 | if (updatedConfig.apiKeyConfig) { 235 | setCurrentSource(updatedConfig.apiKeyConfig.source || 'Default'); 236 | setCurrentKey(updatedConfig.apiKeyConfig.key || 'Authorization'); 237 | } 238 | } 239 | 240 | setSourceModalVisible(false); 241 | await fetchCurrentConfig(); 242 | } 243 | } catch (error) { 244 | console.error('更新凭证来源失败:', error); 245 | // message.error('更新凭证来源失败'); 246 | } 247 | }; 248 | 249 | const openSourceModal = () => { 250 | // 打开弹窗前将已保存值拷贝到编辑态和表单 251 | const initSource = currentSource; 252 | const initKey = initSource === 'Default' ? 'Authorization' : currentKey; 253 | setEditingSource(initSource); 254 | setEditingKey(initKey); 255 | sourceForm.setFieldsValue({source: initSource, key: initKey}); 256 | setSourceModalVisible(true); 257 | }; 258 | 259 | const openCredentialModal = () => { 260 | // 打开弹窗前重置表单并设置初始值 261 | credentialForm.resetFields(); 262 | credentialForm.setFieldsValue({ 263 | generationMethod: 'SYSTEM', 264 | customApiKey: '', 265 | customAccessKey: '', 266 | customSecretKey: '' 267 | }); 268 | setCredentialModalVisible(true); 269 | }; 270 | 271 | // 生成随机凭证 272 | const generateRandomCredential = (type: 'apiKey' | 'accessKey' | 'secretKey'): string => { 273 | const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-'; 274 | 275 | if (type === 'apiKey') { 276 | // 生成32位API Key 277 | const apiKey = Array.from({length: 32}, () => chars.charAt(Math.floor(Math.random() * chars.length))).join(''); 278 | 279 | // 确保表单字段已经渲染并设置值 280 | const setValue = () => { 281 | try { 282 | credentialForm.setFieldsValue({customApiKey: apiKey}); 283 | } catch (error) { 284 | console.error('设置API Key失败:', error); 285 | } 286 | }; 287 | 288 | // 如果表单已经渲染,立即设置;否则延迟设置 289 | if (credentialForm.getFieldValue('customApiKey') !== undefined) { 290 | setValue(); 291 | } else { 292 | setTimeout(setValue, 100); 293 | } 294 | 295 | return apiKey; 296 | } else { 297 | // 生成32位Access Key和64位Secret Key 298 | const ak = Array.from({length: 32}, () => chars.charAt(Math.floor(Math.random() * chars.length))).join(''); 299 | const sk = Array.from({length: 64}, () => chars.charAt(Math.floor(Math.random() * chars.length))).join(''); 300 | 301 | // 确保表单字段已经渲染并设置值 302 | const setValue = () => { 303 | try { 304 | credentialForm.setFieldsValue({ 305 | customAccessKey: ak, 306 | customSecretKey: sk 307 | }); 308 | } catch (error) { 309 | console.error('设置AK/SK失败:', error); 310 | } 311 | }; 312 | 313 | // 如果表单已经渲染,立即设置;否则延迟设置 314 | if (credentialForm.getFieldValue('customAccessKey') !== undefined) { 315 | setValue(); 316 | } else { 317 | setTimeout(setValue, 100); 318 | } 319 | 320 | // 根据类型返回对应的值 321 | return type === 'accessKey' ? ak : sk; 322 | } 323 | }; 324 | 325 | // API Key 列 326 | const apiKeyColumns = [ 327 | { 328 | title: 'API Key', 329 | dataIndex: 'apiKey', 330 | key: 'apiKey', 331 | render: (apiKey: string) => ( 332 | <div className="flex items-center space-x-2"> 333 | <code className="text-sm bg-gray-100 px-2 py-1 rounded">{apiKey}</code> 334 | <Button type="text" size="small" icon={<CopyOutlined/>} 335 | onClick={() => handleCopyCredential(apiKey)}/> 336 | </div> 337 | ), 338 | }, 339 | { 340 | title: '操作', 341 | key: 'action', 342 | render: (record: ConsumerCredential) => ( 343 | <Popconfirm title="确定要删除该API Key凭证吗?" 344 | onConfirm={() => handleDeleteCredential('API_KEY', record)}> 345 | <Button type="link" danger size="small" icon={<DeleteOutlined/>}>删除</Button> 346 | </Popconfirm> 347 | ), 348 | }, 349 | ]; 350 | 351 | // 脱敏函数 352 | const maskSecretKey = (secretKey: string): string => { 353 | if (!secretKey || secretKey.length < 8) return secretKey; 354 | return secretKey.substring(0, 4) + '*'.repeat(secretKey.length - 8) + secretKey.substring(secretKey.length - 4); 355 | }; 356 | 357 | // HMAC 列 358 | const hmacColumns = [ 359 | { 360 | title: 'Access Key', 361 | dataIndex: 'ak', 362 | key: 'ak', 363 | render: (ak: string) => ( 364 | <div className="flex items-center space-x-2"> 365 | <code className="text-sm bg-gray-100 px-2 py-1 rounded">{ak}</code> 366 | <Button type="text" size="small" icon={<CopyOutlined/>} onClick={() => handleCopyCredential(ak)}/> 367 | </div> 368 | ), 369 | }, 370 | { 371 | title: 'Secret Key', 372 | dataIndex: 'sk', 373 | key: 'sk', 374 | render: (sk: string) => ( 375 | <div className="flex items-center space-x-2"> 376 | <code className="text-sm bg-gray-100 px-2 py-1 rounded">{maskSecretKey(sk)}</code> 377 | <Button type="text" size="small" icon={<CopyOutlined/>} onClick={() => handleCopyCredential(sk)}/> 378 | </div> 379 | ), 380 | }, 381 | { 382 | title: '操作', 383 | key: 'action', 384 | render: (record: ConsumerCredential) => ( 385 | <Popconfirm title="确定要删除该AK/SK凭证吗?" onConfirm={() => handleDeleteCredential('HMAC', record)}> 386 | <Button type="link" danger size="small" icon={<DeleteOutlined/>}>删除</Button> 387 | </Popconfirm> 388 | ), 389 | }, 390 | ]; 391 | 392 | return ( 393 | <> 394 | <Card title="认证方式"> 395 | <Tabs defaultActiveKey="API_KEY"> 396 | <Tabs.TabPane tab="API Key" key="API_KEY"> 397 | <div className="mb-4"> 398 | <div className="flex items-start space-x-2 mb-4"> 399 | <InfoCircleOutlined className="text-blue-500 mt-1"/> 400 | <div className="text-sm text-gray-600"> 401 | API Key是一种简单的认证方式,客户端需要在请求中添加凭证,网关会验证API Key的合法性和权限。 402 | API Key常用于简单场景,不涉及敏感操作,安全性相对较低,请注意凭证的管理与保护。 403 | </div> 404 | </div> 405 | 406 | {/* 凭证来源配置(展示已保存值)*/} 407 | <div className="mb-4 p-3 bg-gray-50 rounded border"> 408 | <div className="flex items-center gap-2 mb-2"> 409 | <span className="text-sm font-medium text-gray-700">凭证来源</span> 410 | <Button type="link" size="small" icon={<EditOutlined/>} onClick={openSourceModal}> 411 | 编辑 412 | </Button> 413 | </div> 414 | {/* <div className="text-sm text-gray-600"> 415 | {currentSource === 'Default' ? '' : `${currentSource}`} 416 | </div> */} 417 | <div className="text-sm text-gray-600"> 418 | {currentSource === 'Default' ? 'Authorization: Bearer <token>' : `${currentSource}:${currentKey}`} 419 | </div> 420 | </div> 421 | 422 | <Button 423 | type="primary" 424 | icon={<PlusOutlined/>} 425 | onClick={() => { 426 | setCredentialType('API_KEY'); 427 | openCredentialModal(); 428 | }} 429 | > 430 | 添加凭证 431 | </Button> 432 | </div> 433 | <Table 434 | columns={apiKeyColumns} 435 | dataSource={currentConfig?.apiKeyConfig?.credentials || []} 436 | rowKey={(record) => record.apiKey || Math.random().toString()} 437 | pagination={false} 438 | size="small" 439 | locale={{emptyText: '暂无API Key凭证,请点击上方按钮创建'}} 440 | /> 441 | </Tabs.TabPane> 442 | 443 | <Tabs.TabPane tab="HMAC" key="HMAC"> 444 | <div className="mb-4"> 445 | <div className="flex items-start space-x-2 mb-4"> 446 | <InfoCircleOutlined className="text-blue-500 mt-1"/> 447 | <div className="text-sm text-gray-600"> 448 | 一种基于HMAC算法的AK/SK签名认证方式。客户端在调用API时,需要使用签名密钥对请求内容进行签名计算, 449 | 并将签名同步传输给服务器端进行签名验证。 450 | </div> 451 | </div> 452 | <Button 453 | type="primary" 454 | icon={<PlusOutlined/>} 455 | onClick={() => { 456 | setCredentialType('HMAC'); 457 | openCredentialModal(); 458 | }} 459 | > 460 | 添加AK/SK 461 | </Button> 462 | </div> 463 | <Table 464 | columns={hmacColumns} 465 | dataSource={currentConfig?.hmacConfig?.credentials || []} 466 | rowKey={(record) => record.ak || record.sk || Math.random().toString()} 467 | pagination={false} 468 | size="small" 469 | locale={{emptyText: '暂无AK/SK凭证,请点击上方按钮创建'}} 470 | /> 471 | </Tabs.TabPane> 472 | 473 | <Tabs.TabPane tab="JWT" key="JWT" disabled> 474 | <div className="text-center py-8 text-gray-500"> 475 | JWT功能暂未开放 476 | </div> 477 | </Tabs.TabPane> 478 | </Tabs> 479 | </Card> 480 | 481 | {/* 创建凭证模态框 */} 482 | <Modal 483 | title={`添加 ${credentialType === 'API_KEY' ? 'API Key' : 'AK/SK'}`} 484 | open={credentialModalVisible} 485 | onCancel={() => { 486 | setCredentialModalVisible(false); 487 | resetCredentialForm(); 488 | }} 489 | onOk={handleCreateCredential} 490 | confirmLoading={credentialLoading} 491 | okText="添加" 492 | cancelText="取消" 493 | > 494 | <Form form={credentialForm} initialValues={{ 495 | generationMethod: 'SYSTEM', 496 | customApiKey: '', 497 | customAccessKey: '', 498 | customSecretKey: '' 499 | }}> 500 | <div className="mb-4"> 501 | <div className="mb-2"> 502 | <span className="text-red-500 mr-1">*</span> 503 | <span>生成方式</span> 504 | </div> 505 | <Form.Item 506 | name="generationMethod" 507 | rules={[{required: true, message: '请选择生成方式'}]} 508 | className="mb-0" 509 | > 510 | <Radio.Group> 511 | <Radio value="SYSTEM">系统生成</Radio> 512 | <Radio value="CUSTOM">自定义</Radio> 513 | </Radio.Group> 514 | </Form.Item> 515 | </div> 516 | 517 | <Form.Item noStyle shouldUpdate={(prev, curr) => prev.generationMethod !== curr.generationMethod}> 518 | {({getFieldValue}) => { 519 | const method = getFieldValue('generationMethod'); 520 | if (method === 'CUSTOM') { 521 | return ( 522 | <> 523 | {credentialType === 'API_KEY' && ( 524 | <div className="mb-4"> 525 | <div className="mb-2"> 526 | <span className="text-red-500 mr-1">*</span> 527 | <span>凭证</span> 528 | </div> 529 | <Form.Item 530 | name="customApiKey" 531 | rules={[ 532 | {required: true, message: '请输入自定义API Key'}, 533 | { 534 | pattern: /^[A-Za-z0-9_-]+$/, 535 | message: '支持英文、数字、下划线(_)和短横线(-)' 536 | }, 537 | {min: 8, message: 'API Key长度至少8个字符'}, 538 | {max: 128, message: 'API Key长度不能超过128个字符'} 539 | ]} 540 | className="mb-2" 541 | > 542 | <Input placeholder="请输入凭证" maxLength={128}/> 543 | </Form.Item> 544 | <div className="text-xs text-gray-500"> 545 | 长度为8-128个字符,可包含英文、数字、下划线(_)和短横线(-) 546 | </div> 547 | </div> 548 | )} 549 | {credentialType === 'HMAC' && ( 550 | <> 551 | <div className="mb-4"> 552 | <div className="mb-2"> 553 | <span className="text-red-500 mr-1">*</span> 554 | <span>Access Key</span> 555 | </div> 556 | <Form.Item 557 | name="customAccessKey" 558 | rules={[ 559 | {required: true, message: '请输入自定义Access Key'}, 560 | { 561 | pattern: /^[A-Za-z0-9_-]+$/, 562 | message: '支持英文、数字、下划线(_)和短横线(-)' 563 | }, 564 | {min: 8, message: 'Access Key长度至少8个字符'}, 565 | {max: 128, message: 'Access Key长度不能超过128个字符'} 566 | ]} 567 | className="mb-2" 568 | > 569 | <Input placeholder="请输入Access Key" maxLength={128}/> 570 | </Form.Item> 571 | <div className="text-xs text-gray-500"> 572 | 长度为8-128个字符,可包含英文、数字、下划线(_)和短横线(-) 573 | </div> 574 | </div> 575 | <div className="mb-4"> 576 | <div className="mb-2"> 577 | <span className="text-red-500 mr-1">*</span> 578 | <span>Secret Key</span> 579 | </div> 580 | <Form.Item 581 | name="customSecretKey" 582 | rules={[ 583 | {required: true, message: '请输入自定义Secret Key'}, 584 | { 585 | pattern: /^[A-Za-z0-9_-]+$/, 586 | message: '支持英文、数字、下划线(_)和短横线(-)' 587 | }, 588 | {min: 8, message: 'Secret Key长度至少8个字符'}, 589 | {max: 128, message: 'Secret Key长度不能超过128个字符'} 590 | ]} 591 | className="mb-2" 592 | > 593 | <Input placeholder="请输入 Secret Key" maxLength={128}/> 594 | </Form.Item> 595 | <div className="text-xs text-gray-500"> 596 | 长度为8-128个字符,可包含英文、数字、下划线(_)和短横线(-) 597 | </div> 598 | </div> 599 | </> 600 | )} 601 | </> 602 | ); 603 | } else if (method === 'SYSTEM') { 604 | return ( 605 | <div> 606 | <div className="flex items-center gap-2 text-sm text-gray-500"> 607 | <InfoCircleOutlined/> 608 | <span>系统将自动生成符合规范的凭证</span> 609 | </div> 610 | </div> 611 | ); 612 | } 613 | return null; 614 | }} 615 | </Form.Item> 616 | </Form> 617 | </Modal> 618 | 619 | {/* 编辑凭证来源模态框 */} 620 | <Modal 621 | title="编辑凭证来源" 622 | open={sourceModalVisible} 623 | onCancel={() => { 624 | // 取消不落盘,回退到已保存值并重置表单 625 | const initSource = currentSource; 626 | const initKey = initSource === 'Default' ? 'Authorization' : currentKey; 627 | setEditingSource(initSource); 628 | setEditingKey(initKey); 629 | sourceForm.resetFields(); 630 | setSourceModalVisible(false); 631 | }} 632 | onOk={async () => { 633 | try { 634 | const values = await sourceForm.validateFields(); 635 | setEditingSource(values.source); 636 | setEditingKey(values.key); 637 | await handleEditSource(values.source, values.key); 638 | } catch { 639 | // 校验失败,不提交 640 | } 641 | }} 642 | okText="保存" 643 | cancelText="取消" 644 | > 645 | <Form form={sourceForm} layout="vertical" initialValues={{source: editingSource, key: editingKey}}> 646 | <Form.Item 647 | label="凭证来源" 648 | name="source" 649 | rules={[{required: true, message: '请选择凭证来源'}]} 650 | > 651 | <Select 652 | onChange={(value) => { 653 | const nextKey = value === 'Default' ? 'Authorization' : ''; 654 | sourceForm.setFieldsValue({key: nextKey}); 655 | }} 656 | style={{width: '100%'}} 657 | > 658 | <Select.Option value="Header">Header</Select.Option> 659 | <Select.Option value="QueryString">QueryString</Select.Option> 660 | <Select.Option value="Default">默认</Select.Option> 661 | </Select> 662 | </Form.Item> 663 | 664 | <Form.Item noStyle shouldUpdate={(prev, curr) => prev.source !== curr.source}> 665 | {({getFieldValue}) => 666 | getFieldValue('source') !== 'Default' ? ( 667 | <Form.Item 668 | label="键名" 669 | name="key" 670 | rules={[ 671 | { 672 | required: true, 673 | message: '请输入键名', 674 | }, 675 | { 676 | pattern: /^[A-Za-z0-9-_]+$/, 677 | message: '仅支持字母/数字/-/_', 678 | }, 679 | ]} 680 | > 681 | <Input placeholder="请输入键名"/> 682 | </Form.Item> 683 | ) : null 684 | } 685 | </Form.Item> 686 | {/* 687 | <div className="text-sm text-gray-500"> 688 | <div>说明:</div> 689 | <div>• Header: 凭证放在HTTP请求头中</div> 690 | <div>• QueryString: 凭证放在URL查询参数中</div> 691 | <div>• Default: 使用标准的Authorization头</div> 692 | </div> */} 693 | </Form> 694 | </Modal> 695 | </> 696 | ); 697 | } ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/api-product/ApiProductLinkApi.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { Card, Button, Modal, Form, Select, message, Collapse, Tabs, Row, Col } from 'antd' 2 | import { PlusOutlined, DeleteOutlined, ExclamationCircleOutlined, CopyOutlined } from '@ant-design/icons' 3 | import { useState, useEffect } from 'react' 4 | import type { ApiProduct, LinkedService, RestAPIItem, HigressMCPItem, NacosMCPItem, APIGAIMCPItem, ApiItem } from '@/types/api-product' 5 | import type { Gateway, NacosInstance } from '@/types/gateway' 6 | import { apiProductApi, gatewayApi, nacosApi } from '@/lib/api' 7 | import { getGatewayTypeLabel } from '@/lib/constant' 8 | import { copyToClipboard } from '@/lib/utils' 9 | import * as yaml from 'js-yaml' 10 | import { SwaggerUIWrapper } from './SwaggerUIWrapper' 11 | 12 | interface ApiProductLinkApiProps { 13 | apiProduct: ApiProduct 14 | linkedService: LinkedService | null 15 | onLinkedServiceUpdate: (linkedService: LinkedService | null) => void 16 | handleRefresh: () => void 17 | } 18 | 19 | export function ApiProductLinkApi({ apiProduct, linkedService, onLinkedServiceUpdate, handleRefresh }: ApiProductLinkApiProps) { 20 | // 移除了内部的 linkedService 状态,现在从 props 接收 21 | const [isModalVisible, setIsModalVisible] = useState(false) 22 | const [form] = Form.useForm() 23 | const [gateways, setGateways] = useState<Gateway[]>([]) 24 | const [nacosInstances, setNacosInstances] = useState<NacosInstance[]>([]) 25 | const [gatewayLoading, setGatewayLoading] = useState(false) 26 | const [nacosLoading, setNacosLoading] = useState(false) 27 | const [selectedGateway, setSelectedGateway] = useState<Gateway | null>(null) 28 | const [selectedNacos, setSelectedNacos] = useState<NacosInstance | null>(null) 29 | const [nacosNamespaces, setNacosNamespaces] = useState<any[]>([]) 30 | const [selectedNamespace, setSelectedNamespace] = useState<string | null>(null) 31 | const [apiList, setApiList] = useState<ApiItem[] | NacosMCPItem[]>([]) 32 | const [apiLoading, setApiLoading] = useState(false) 33 | const [sourceType, setSourceType] = useState<'GATEWAY' | 'NACOS'>('GATEWAY') 34 | const [parsedTools, setParsedTools] = useState<Array<{ 35 | name: string; 36 | description: string; 37 | args?: Array<{ 38 | name: string; 39 | description: string; 40 | type: string; 41 | required: boolean; 42 | position: string; 43 | default?: string; 44 | enum?: string[]; 45 | }>; 46 | }>>([]) 47 | const [httpJson, setHttpJson] = useState('') 48 | const [sseJson, setSseJson] = useState('') 49 | const [localJson, setLocalJson] = useState('') 50 | 51 | useEffect(() => { 52 | fetchGateways() 53 | fetchNacosInstances() 54 | }, []) 55 | 56 | // 解析MCP tools配置 57 | useEffect(() => { 58 | if (apiProduct.type === 'MCP_SERVER' && apiProduct.mcpConfig?.tools) { 59 | const parsedConfig = parseYamlConfig(apiProduct.mcpConfig.tools) 60 | if (parsedConfig && parsedConfig.tools && Array.isArray(parsedConfig.tools)) { 61 | setParsedTools(parsedConfig.tools) 62 | } else { 63 | // 如果tools字段存在但是空数组,也设置为空数组 64 | setParsedTools([]) 65 | } 66 | } else { 67 | setParsedTools([]) 68 | } 69 | }, [apiProduct]) 70 | 71 | // 生成连接配置 72 | useEffect(() => { 73 | if (apiProduct.type === 'MCP_SERVER' && apiProduct.mcpConfig) { 74 | generateConnectionConfig( 75 | apiProduct.mcpConfig.mcpServerConfig.domains, 76 | apiProduct.mcpConfig.mcpServerConfig.path, 77 | apiProduct.mcpConfig.mcpServerName, 78 | apiProduct.mcpConfig.mcpServerConfig.rawConfig, 79 | apiProduct.mcpConfig.meta?.protocol 80 | ) 81 | } 82 | }, [apiProduct]) 83 | 84 | // 解析YAML配置的函数 85 | const parseYamlConfig = (yamlString: string): { 86 | tools?: Array<{ 87 | name: string; 88 | description: string; 89 | args?: Array<{ 90 | name: string; 91 | description: string; 92 | type: string; 93 | required: boolean; 94 | position: string; 95 | default?: string; 96 | enum?: string[]; 97 | }>; 98 | }>; 99 | } | null => { 100 | try { 101 | const parsed = yaml.load(yamlString) as { 102 | tools?: Array<{ 103 | name: string; 104 | description: string; 105 | args?: Array<{ 106 | name: string; 107 | description: string; 108 | type: string; 109 | required: boolean; 110 | position: string; 111 | default?: string; 112 | enum?: string[]; 113 | }>; 114 | }>; 115 | }; 116 | return parsed; 117 | } catch (error) { 118 | console.error('YAML解析失败:', error) 119 | return null 120 | } 121 | } 122 | 123 | // 生成连接配置 124 | const generateConnectionConfig = ( 125 | domains: Array<{ domain: string; protocol: string }> | null | undefined, 126 | path: string | null | undefined, 127 | serverName: string, 128 | localConfig?: unknown, 129 | protocolType?: string 130 | ) => { 131 | // 互斥:优先判断本地模式 132 | if (localConfig) { 133 | const localConfigJson = JSON.stringify(localConfig, null, 2); 134 | setLocalJson(localConfigJson); 135 | setHttpJson(""); 136 | setSseJson(""); 137 | return; 138 | } 139 | 140 | // HTTP/SSE 模式 141 | if (domains && domains.length > 0 && path) { 142 | const domain = domains[0] 143 | const fullUrl = `${domain.protocol}://${domain.domain}${path || '/'}` 144 | 145 | if (protocolType === 'SSE') { 146 | // 仅生成SSE配置,不追加/sse 147 | const sseConfig = { 148 | mcpServers: { 149 | [serverName]: { 150 | type: "sse", 151 | url: fullUrl 152 | } 153 | } 154 | } 155 | setSseJson(JSON.stringify(sseConfig, null, 2)) 156 | setHttpJson("") 157 | setLocalJson("") 158 | return; 159 | } else if (protocolType === 'StreamableHTTP') { 160 | // 仅生成HTTP配置 161 | const httpConfig = { 162 | mcpServers: { 163 | [serverName]: { 164 | url: fullUrl 165 | } 166 | } 167 | } 168 | setHttpJson(JSON.stringify(httpConfig, null, 2)) 169 | setSseJson("") 170 | setLocalJson("") 171 | return; 172 | } else { 173 | // protocol为null或其他值:生成两种配置 174 | const sseConfig = { 175 | mcpServers: { 176 | [serverName]: { 177 | type: "sse", 178 | url: `${fullUrl}/sse` 179 | } 180 | } 181 | } 182 | 183 | const httpConfig = { 184 | mcpServers: { 185 | [serverName]: { 186 | url: fullUrl 187 | } 188 | } 189 | } 190 | 191 | setSseJson(JSON.stringify(sseConfig, null, 2)) 192 | setHttpJson(JSON.stringify(httpConfig, null, 2)) 193 | setLocalJson("") 194 | return; 195 | } 196 | } 197 | 198 | // 无有效配置 199 | setHttpJson(""); 200 | setSseJson(""); 201 | setLocalJson(""); 202 | } 203 | 204 | const handleCopy = async (text: string) => { 205 | try { 206 | await copyToClipboard(text); 207 | message.success("已复制到剪贴板"); 208 | } catch { 209 | message.error("复制失败,请手动复制"); 210 | } 211 | } 212 | 213 | const fetchGateways = async () => { 214 | setGatewayLoading(true) 215 | try { 216 | const res = await gatewayApi.getGateways() 217 | const result = apiProduct.type === 'REST_API' ? 218 | res.data?.content?.filter?.((item: Gateway) => item.gatewayType === 'APIG_API') : 219 | res.data?.content?.filter?.((item: Gateway) => item.gatewayType === 'HIGRESS' || item.gatewayType === 'APIG_AI' || item.gatewayType === 'ADP_AI_GATEWAY') 220 | setGateways(result || []) 221 | } catch (error) { 222 | console.error('获取网关列表失败:', error) 223 | } finally { 224 | setGatewayLoading(false) 225 | } 226 | } 227 | 228 | const fetchNacosInstances = async () => { 229 | setNacosLoading(true) 230 | try { 231 | const res = await nacosApi.getNacos({ 232 | page: 1, 233 | size: 1000 // 获取所有 Nacos 实例 234 | }) 235 | setNacosInstances(res.data.content || []) 236 | } catch (error) { 237 | console.error('获取Nacos实例列表失败:', error) 238 | } finally { 239 | setNacosLoading(false) 240 | } 241 | } 242 | 243 | const handleSourceTypeChange = (value: 'GATEWAY' | 'NACOS') => { 244 | setSourceType(value) 245 | setSelectedGateway(null) 246 | setSelectedNacos(null) 247 | setSelectedNamespace(null) 248 | setNacosNamespaces([]) 249 | setApiList([]) 250 | form.setFieldsValue({ 251 | gatewayId: undefined, 252 | nacosId: undefined, 253 | apiId: undefined 254 | }) 255 | } 256 | 257 | const handleGatewayChange = async (gatewayId: string) => { 258 | const gateway = gateways.find(g => g.gatewayId === gatewayId) 259 | setSelectedGateway(gateway || null) 260 | 261 | if (!gateway) return 262 | 263 | setApiLoading(true) 264 | try { 265 | if (gateway.gatewayType === 'APIG_API') { 266 | // APIG_API类型:获取REST API列表 267 | const restRes = await gatewayApi.getGatewayRestApis(gatewayId, {}) 268 | const restApis = (restRes.data?.content || []).map((api: any) => ({ 269 | apiId: api.apiId, 270 | apiName: api.apiName, 271 | type: 'REST API' 272 | })) 273 | setApiList(restApis) 274 | } else if (gateway.gatewayType === 'HIGRESS') { 275 | // HIGRESS类型:获取MCP Server列表 276 | const res = await gatewayApi.getGatewayMcpServers(gatewayId, { 277 | page: 1, 278 | size: 1000 // 获取所有MCP Server 279 | }) 280 | const mcpServers = (res.data?.content || []).map((api: any) => ({ 281 | mcpServerName: api.mcpServerName, 282 | fromGatewayType: 'HIGRESS' as const, 283 | type: 'MCP Server' 284 | })) 285 | setApiList(mcpServers) 286 | } else if (gateway.gatewayType === 'APIG_AI') { 287 | // APIG_AI类型:获取MCP Server列表 288 | const res = await gatewayApi.getGatewayMcpServers(gatewayId, { 289 | page: 1, 290 | size: 500 // 获取所有MCP Server 291 | }) 292 | const mcpServers = (res.data?.content || []).map((api: any) => ({ 293 | mcpServerName: api.mcpServerName, 294 | fromGatewayType: 'APIG_AI' as const, 295 | mcpRouteId: api.mcpRouteId, 296 | apiId: api.apiId, 297 | mcpServerId: api.mcpServerId, 298 | type: 'MCP Server' 299 | })) 300 | setApiList(mcpServers) 301 | } else if (gateway.gatewayType === 'ADP_AI_GATEWAY') { 302 | // ADP_AI_GATEWAY类型:获取MCP Server列表 303 | const res = await gatewayApi.getGatewayMcpServers(gatewayId, { 304 | page: 1, 305 | size: 500 // 获取所有MCP Server 306 | }) 307 | const mcpServers = (res.data?.content || []).map((api: any) => ({ 308 | mcpServerName: api.mcpServerName || api.name, 309 | fromGatewayType: 'ADP_AI_GATEWAY' as const, 310 | mcpRouteId: api.mcpRouteId, 311 | mcpServerId: api.mcpServerId, 312 | type: 'MCP Server' 313 | })) 314 | setApiList(mcpServers) 315 | } 316 | } catch (error) { 317 | } finally { 318 | setApiLoading(false) 319 | } 320 | } 321 | 322 | const handleNacosChange = async (nacosId: string) => { 323 | const nacos = nacosInstances.find(n => n.nacosId === nacosId) 324 | setSelectedNacos(nacos || null) 325 | setSelectedNamespace(null) 326 | setApiList([]) 327 | setNacosNamespaces([]) 328 | if (!nacos) return 329 | 330 | // 获取命名空间列表 331 | try { 332 | const nsRes = await nacosApi.getNamespaces(nacosId, { page: 1, size: 1000 }) 333 | const namespaces = (nsRes.data?.content || []).map((ns: any) => ({ 334 | namespaceId: ns.namespaceId, 335 | namespaceName: ns.namespaceName || ns.namespaceId, 336 | namespaceDesc: ns.namespaceDesc 337 | })) 338 | setNacosNamespaces(namespaces) 339 | } catch (e) { 340 | console.error('获取命名空间失败', e) 341 | } 342 | } 343 | 344 | const handleNamespaceChange = async (namespaceId: string) => { 345 | setSelectedNamespace(namespaceId) 346 | setApiLoading(true) 347 | try { 348 | if (!selectedNacos) return 349 | const res = await nacosApi.getNacosMcpServers(selectedNacos.nacosId, { 350 | page: 1, 351 | size: 1000, 352 | namespaceId 353 | }) 354 | const mcpServers = (res.data?.content || []).map((api: any) => ({ 355 | mcpServerName: api.mcpServerName, 356 | fromGatewayType: 'NACOS' as const, 357 | type: `MCP Server (${namespaceId})` 358 | })) 359 | setApiList(mcpServers) 360 | } catch (e) { 361 | console.error('获取Nacos MCP Server列表失败:', e) 362 | } finally { 363 | setApiLoading(false) 364 | } 365 | } 366 | 367 | 368 | const handleModalOk = () => { 369 | form.validateFields().then((values) => { 370 | const { sourceType, gatewayId, nacosId, apiId } = values 371 | const selectedApi = apiList.find(item => { 372 | if ('apiId' in item) { 373 | // mcp server 会返回apiId和mcpRouteId,此时mcpRouteId为唯一值,apiId不是 374 | if ('mcpRouteId' in item) { 375 | return item.mcpRouteId === apiId 376 | } else { 377 | return item.apiId === apiId 378 | } 379 | } else if ('mcpServerName' in item) { 380 | return item.mcpServerName === apiId 381 | } 382 | return false 383 | }) 384 | const newService: LinkedService = { 385 | gatewayId: sourceType === 'GATEWAY' ? gatewayId : undefined, // 对于 Nacos,使用 nacosId 作为 gatewayId 386 | nacosId: sourceType === 'NACOS' ? nacosId : undefined, 387 | sourceType, 388 | productId: apiProduct.productId, 389 | apigRefConfig: selectedApi && 'apiId' in selectedApi ? selectedApi as RestAPIItem | APIGAIMCPItem : undefined, 390 | higressRefConfig: selectedApi && 'mcpServerName' in selectedApi && 'fromGatewayType' in selectedApi && selectedApi.fromGatewayType === 'HIGRESS' ? selectedApi as HigressMCPItem : undefined, 391 | nacosRefConfig: sourceType === 'NACOS' && selectedApi && 'fromGatewayType' in selectedApi && selectedApi.fromGatewayType === 'NACOS' ? { 392 | ...selectedApi, 393 | namespaceId: selectedNamespace || 'public' 394 | } : undefined, 395 | adpAIGatewayRefConfig: selectedApi && 'fromGatewayType' in selectedApi && selectedApi.fromGatewayType === 'ADP_AI_GATEWAY' ? selectedApi as APIGAIMCPItem : undefined, 396 | } 397 | apiProductApi.createApiProductRef(apiProduct.productId, newService).then(async () => { 398 | message.success('关联成功') 399 | setIsModalVisible(false) 400 | 401 | // 重新获取关联信息并更新 402 | try { 403 | const res = await apiProductApi.getApiProductRef(apiProduct.productId) 404 | onLinkedServiceUpdate(res.data || null) 405 | } catch (error) { 406 | console.error('获取关联API失败:', error) 407 | onLinkedServiceUpdate(null) 408 | } 409 | 410 | // 重新获取产品详情(特别重要,因为关联API后apiProduct.apiConfig可能会更新) 411 | handleRefresh() 412 | 413 | form.resetFields() 414 | setSelectedGateway(null) 415 | setSelectedNacos(null) 416 | setApiList([]) 417 | setSourceType('GATEWAY') 418 | }).catch(() => { 419 | message.error('关联失败') 420 | }) 421 | }) 422 | } 423 | 424 | const handleModalCancel = () => { 425 | setIsModalVisible(false) 426 | form.resetFields() 427 | setSelectedGateway(null) 428 | setSelectedNacos(null) 429 | setApiList([]) 430 | setSourceType('GATEWAY') 431 | } 432 | 433 | 434 | const handleDelete = () => { 435 | if (!linkedService) return 436 | 437 | Modal.confirm({ 438 | title: '确认解除关联', 439 | content: '确定要解除与当前API的关联吗?', 440 | icon: <ExclamationCircleOutlined />, 441 | onOk() { 442 | return apiProductApi.deleteApiProductRef(apiProduct.productId).then(() => { 443 | message.success('解除关联成功') 444 | onLinkedServiceUpdate(null) 445 | // 重新获取产品详情(解除关联后apiProduct.apiConfig可能会更新) 446 | handleRefresh() 447 | }).catch(() => { 448 | message.error('解除关联失败') 449 | }) 450 | } 451 | }) 452 | } 453 | 454 | const getServiceInfo = () => { 455 | if (!linkedService) return null 456 | 457 | let apiName = '' 458 | let apiType = '' 459 | let sourceInfo = '' 460 | let gatewayInfo = '' 461 | 462 | // 首先根据 Product 的 type 确定基本类型 463 | if (apiProduct.type === 'REST_API') { 464 | // REST API 类型产品 - 只能关联 API 网关上的 REST API 465 | if (linkedService.sourceType === 'GATEWAY' && linkedService.apigRefConfig && 'apiName' in linkedService.apigRefConfig) { 466 | apiName = linkedService.apigRefConfig.apiName || '未命名' 467 | apiType = 'REST API' 468 | sourceInfo = 'API网关' 469 | gatewayInfo = linkedService.gatewayId || '未知' 470 | } 471 | } else if (apiProduct.type === 'MCP_SERVER') { 472 | // MCP Server 类型产品 - 可以关联多种平台上的 MCP Server 473 | apiType = 'MCP Server' 474 | 475 | if (linkedService.sourceType === 'GATEWAY' && linkedService.apigRefConfig && 'mcpServerName' in linkedService.apigRefConfig) { 476 | // AI网关上的MCP Server 477 | apiName = linkedService.apigRefConfig.mcpServerName || '未命名' 478 | sourceInfo = 'AI网关' 479 | gatewayInfo = linkedService.gatewayId || '未知' 480 | } else if (linkedService.sourceType === 'GATEWAY' && linkedService.higressRefConfig) { 481 | // Higress网关上的MCP Server 482 | apiName = linkedService.higressRefConfig.mcpServerName || '未命名' 483 | sourceInfo = 'Higress网关' 484 | gatewayInfo = linkedService.gatewayId || '未知' 485 | } else if (linkedService.sourceType === 'GATEWAY' && linkedService.adpAIGatewayRefConfig) { 486 | // 专有云AI网关上的MCP Server 487 | apiName = linkedService.adpAIGatewayRefConfig.mcpServerName || '未命名' 488 | sourceInfo = '专有云AI网关' 489 | gatewayInfo = linkedService.gatewayId || '未知' 490 | } else if (linkedService.sourceType === 'NACOS' && linkedService.nacosRefConfig) { 491 | // Nacos上的MCP Server 492 | apiName = linkedService.nacosRefConfig.mcpServerName || '未命名' 493 | sourceInfo = 'Nacos服务发现' 494 | gatewayInfo = linkedService.nacosId || '未知' 495 | } 496 | } 497 | 498 | return { 499 | apiName, 500 | apiType, 501 | sourceInfo, 502 | gatewayInfo 503 | } 504 | } 505 | 506 | const renderLinkInfo = () => { 507 | const serviceInfo = getServiceInfo() 508 | 509 | // 没有关联任何API 510 | if (!linkedService || !serviceInfo) { 511 | return ( 512 | <Card className="mb-6"> 513 | <div className="text-center py-8"> 514 | <div className="text-gray-500 mb-4">暂未关联任何API</div> 515 | <Button type="primary" icon={<PlusOutlined />} onClick={() => setIsModalVisible(true)}> 516 | 关联API 517 | </Button> 518 | </div> 519 | </Card> 520 | ) 521 | } 522 | 523 | return ( 524 | <Card 525 | className="mb-6" 526 | title="关联详情" 527 | extra={ 528 | <Button type="primary" danger icon={<DeleteOutlined />} onClick={handleDelete}> 529 | 解除关联 530 | </Button> 531 | } 532 | > 533 | <div> 534 | {/* 第一行:名称 + 类型 */} 535 | <div className="grid grid-cols-6 gap-8 items-center pt-2 pb-2"> 536 | <span className="text-xs text-gray-600">名称:</span> 537 | <span className="col-span-2 text-xs text-gray-900">{serviceInfo.apiName || '未命名'}</span> 538 | <span className="text-xs text-gray-600">类型:</span> 539 | <span className="col-span-2 text-xs text-gray-900">{serviceInfo.apiType}</span> 540 | </div> 541 | 542 | {/* 第二行:来源 + ID */} 543 | <div className="grid grid-cols-6 gap-8 items-center pt-2 pb-2"> 544 | <span className="text-xs text-gray-600">来源:</span> 545 | <span className="col-span-2 text-xs text-gray-900">{serviceInfo.sourceInfo}</span> 546 | <span className="text-xs text-gray-600"> 547 | {linkedService?.sourceType === 'NACOS' ? 'Nacos ID:' : '网关ID:'} 548 | </span> 549 | <span className="col-span-2 text-xs text-gray-700">{serviceInfo.gatewayInfo}</span> 550 | </div> 551 | </div> 552 | </Card> 553 | ) 554 | } 555 | 556 | const renderApiConfig = () => { 557 | const isMcp = apiProduct.type === 'MCP_SERVER' 558 | const isOpenApi = apiProduct.type === 'REST_API' 559 | 560 | // MCP Server类型:无论是否有linkedService都显示tools和连接点配置 561 | if (isMcp && apiProduct.mcpConfig) { 562 | return ( 563 | <Card title="配置详情"> 564 | <Row gutter={24}> 565 | {/* 左侧:工具列表 */} 566 | <Col span={15}> 567 | <Card> 568 | <Tabs 569 | defaultActiveKey="tools" 570 | items={[ 571 | { 572 | key: "tools", 573 | label: `Tools (${parsedTools.length})`, 574 | children: parsedTools.length > 0 ? ( 575 | <div className="border border-gray-200 rounded-lg bg-gray-50"> 576 | {parsedTools.map((tool, idx) => ( 577 | <div key={idx} className={idx < parsedTools.length - 1 ? "border-b border-gray-200" : ""}> 578 | <Collapse 579 | ghost 580 | expandIconPosition="end" 581 | items={[{ 582 | key: idx.toString(), 583 | label: tool.name, 584 | children: ( 585 | <div className="px-4 pb-2"> 586 | <div className="text-gray-600 mb-4">{tool.description}</div> 587 | 588 | {tool.args && tool.args.length > 0 && ( 589 | <div> 590 | <p className="font-medium text-gray-700 mb-3">输入参数:</p> 591 | {tool.args.map((arg, argIdx) => ( 592 | <div key={argIdx} className="mb-3"> 593 | <div className="flex items-center mb-2"> 594 | <span className="font-medium text-gray-800 mr-2">{arg.name}</span> 595 | <span className="text-xs bg-gray-200 text-gray-600 px-2 py-1 rounded mr-2"> 596 | {arg.type} 597 | </span> 598 | {arg.required && ( 599 | <span className="text-red-500 text-xs mr-2">*</span> 600 | )} 601 | {arg.description && ( 602 | <span className="text-xs text-gray-500"> 603 | {arg.description} 604 | </span> 605 | )} 606 | </div> 607 | <input 608 | type="text" 609 | placeholder={arg.description || `请输入${arg.name}`} 610 | className="w-full px-3 py-2 bg-gray-100 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent mb-2" 611 | defaultValue={arg.default !== undefined ? JSON.stringify(arg.default) : ''} 612 | /> 613 | {arg.enum && ( 614 | <div className="text-xs text-gray-500"> 615 | 可选值: {arg.enum.map(value => <code key={value} className="mr-1">{value}</code>)} 616 | </div> 617 | )} 618 | </div> 619 | ))} 620 | </div> 621 | )} 622 | </div> 623 | ) 624 | }]} 625 | /> 626 | </div> 627 | ))} 628 | </div> 629 | ) : ( 630 | <div className="text-gray-500 text-center py-8"> 631 | No tools available 632 | </div> 633 | ), 634 | }, 635 | ]} 636 | /> 637 | </Card> 638 | </Col> 639 | 640 | {/* 右侧:连接点配置 */} 641 | <Col span={9}> 642 | <Card> 643 | <div className="mb-4"> 644 | <h3 className="text-sm font-semibold mb-3">连接点配置</h3> 645 | <Tabs 646 | size="small" 647 | defaultActiveKey={localJson ? "local" : (sseJson ? "sse" : "http")} 648 | items={(() => { 649 | const tabs = []; 650 | 651 | if (localJson) { 652 | tabs.push({ 653 | key: "local", 654 | label: "Stdio", 655 | children: ( 656 | <div className="relative bg-gray-50 border border-gray-200 rounded-md p-3"> 657 | <Button 658 | size="small" 659 | icon={<CopyOutlined />} 660 | className="absolute top-2 right-2 z-10" 661 | onClick={() => handleCopy(localJson)} 662 | > 663 | </Button> 664 | <div className="text-gray-800 font-mono text-xs overflow-x-auto"> 665 | <pre className="whitespace-pre-wrap">{localJson}</pre> 666 | </div> 667 | </div> 668 | ), 669 | }); 670 | } else { 671 | if (sseJson) { 672 | tabs.push({ 673 | key: "sse", 674 | label: "SSE", 675 | children: ( 676 | <div className="relative bg-gray-50 border border-gray-200 rounded-md p-3"> 677 | <Button 678 | size="small" 679 | icon={<CopyOutlined />} 680 | className="absolute top-2 right-2 z-10" 681 | onClick={() => handleCopy(sseJson)} 682 | > 683 | </Button> 684 | <div className="text-gray-800 font-mono text-xs overflow-x-auto"> 685 | <pre className="whitespace-pre-wrap">{sseJson}</pre> 686 | </div> 687 | </div> 688 | ), 689 | }); 690 | } 691 | 692 | if (httpJson) { 693 | tabs.push({ 694 | key: "http", 695 | label: "Streaming HTTP", 696 | children: ( 697 | <div className="relative bg-gray-50 border border-gray-200 rounded-md p-3"> 698 | <Button 699 | size="small" 700 | icon={<CopyOutlined />} 701 | className="absolute top-2 right-2 z-10" 702 | onClick={() => handleCopy(httpJson)} 703 | > 704 | </Button> 705 | <div className="text-gray-800 font-mono text-xs overflow-x-auto"> 706 | <pre className="whitespace-pre-wrap">{httpJson}</pre> 707 | </div> 708 | </div> 709 | ), 710 | }); 711 | } 712 | } 713 | 714 | return tabs; 715 | })()} 716 | /> 717 | </div> 718 | </Card> 719 | </Col> 720 | </Row> 721 | </Card> 722 | ) 723 | } 724 | 725 | // REST API类型:需要linkedService才显示 726 | if (!linkedService) { 727 | return null 728 | } 729 | 730 | return ( 731 | <Card title="配置详情"> 732 | 733 | {isOpenApi && apiProduct.apiConfig && apiProduct.apiConfig.spec && ( 734 | <div> 735 | <h4 className="text-base font-medium mb-4">REST API接口文档</h4> 736 | <SwaggerUIWrapper apiSpec={apiProduct.apiConfig.spec} /> 737 | </div> 738 | )} 739 | </Card> 740 | ) 741 | } 742 | 743 | return ( 744 | <div className="p-6 space-y-6"> 745 | <div className="mb-6"> 746 | <h1 className="text-2xl font-bold mb-2">API关联</h1> 747 | <p className="text-gray-600">管理Product关联的API</p> 748 | </div> 749 | 750 | {renderLinkInfo()} 751 | {renderApiConfig()} 752 | 753 | <Modal 754 | title={linkedService ? '重新关联API' : '关联新API'} 755 | open={isModalVisible} 756 | onOk={handleModalOk} 757 | onCancel={handleModalCancel} 758 | okText="关联" 759 | cancelText="取消" 760 | width={600} 761 | > 762 | <Form form={form} layout="vertical"> 763 | <Form.Item 764 | name="sourceType" 765 | label="来源类型" 766 | initialValue="GATEWAY" 767 | rules={[{ required: true, message: '请选择来源类型' }]} 768 | > 769 | <Select placeholder="请选择来源类型" onChange={handleSourceTypeChange}> 770 | <Select.Option value="GATEWAY">网关</Select.Option> 771 | <Select.Option value="NACOS" disabled={apiProduct.type === 'REST_API'}>Nacos</Select.Option> 772 | </Select> 773 | </Form.Item> 774 | 775 | {sourceType === 'GATEWAY' && ( 776 | <Form.Item 777 | name="gatewayId" 778 | label="网关实例" 779 | rules={[{ required: true, message: '请选择网关' }]} 780 | > 781 | <Select 782 | placeholder="请选择网关实例" 783 | loading={gatewayLoading} 784 | showSearch 785 | filterOption={(input, option) => 786 | (option?.value as unknown as string)?.toLowerCase().includes(input.toLowerCase()) 787 | } 788 | onChange={handleGatewayChange} 789 | optionLabelProp="label" 790 | > 791 | {gateways.map(gateway => ( 792 | <Select.Option 793 | key={gateway.gatewayId} 794 | value={gateway.gatewayId} 795 | label={gateway.gatewayName} 796 | > 797 | <div> 798 | <div className="font-medium">{gateway.gatewayName}</div> 799 | <div className="text-sm text-gray-500"> 800 | {gateway.gatewayId} - {getGatewayTypeLabel(gateway.gatewayType as any)} 801 | </div> 802 | </div> 803 | </Select.Option> 804 | ))} 805 | </Select> 806 | </Form.Item> 807 | )} 808 | 809 | {sourceType === 'NACOS' && ( 810 | <Form.Item 811 | name="nacosId" 812 | label="Nacos实例" 813 | rules={[{ required: true, message: '请选择Nacos实例' }]} 814 | > 815 | <Select 816 | placeholder="请选择Nacos实例" 817 | loading={nacosLoading} 818 | showSearch 819 | filterOption={(input, option) => 820 | (option?.value as unknown as string)?.toLowerCase().includes(input.toLowerCase()) 821 | } 822 | onChange={handleNacosChange} 823 | optionLabelProp="label" 824 | > 825 | {nacosInstances.map(nacos => ( 826 | <Select.Option 827 | key={nacos.nacosId} 828 | value={nacos.nacosId} 829 | label={nacos.nacosName} 830 | > 831 | <div> 832 | <div className="font-medium">{nacos.nacosName}</div> 833 | <div className="text-sm text-gray-500"> 834 | {nacos.serverUrl} 835 | </div> 836 | </div> 837 | </Select.Option> 838 | ))} 839 | </Select> 840 | </Form.Item> 841 | )} 842 | 843 | {sourceType === 'NACOS' && selectedNacos && ( 844 | <Form.Item 845 | name="namespaceId" 846 | label="命名空间" 847 | rules={[{ required: true, message: '请选择命名空间' }]} 848 | > 849 | <Select 850 | placeholder="请选择命名空间" 851 | loading={apiLoading && nacosNamespaces.length === 0} 852 | onChange={handleNamespaceChange} 853 | showSearch 854 | filterOption={(input, option) => (option?.children as unknown as string)?.toLowerCase().includes(input.toLowerCase())} 855 | optionLabelProp="label" 856 | > 857 | {nacosNamespaces.map(ns => ( 858 | <Select.Option key={ns.namespaceId} value={ns.namespaceId} label={ns.namespaceName}> 859 | <div> 860 | <div className="font-medium">{ns.namespaceName}</div> 861 | <div className="text-sm text-gray-500">{ns.namespaceId}</div> 862 | </div> 863 | </Select.Option> 864 | ))} 865 | </Select> 866 | </Form.Item> 867 | )} 868 | 869 | {(selectedGateway || (selectedNacos && selectedNamespace)) && ( 870 | <Form.Item 871 | name="apiId" 872 | label={apiProduct.type === 'REST_API' ? '选择REST API' : '选择MCP Server'} 873 | rules={[{ required: true, message: apiProduct.type === 'REST_API' ? '请选择REST API' : '请选择MCP Server' }]} 874 | > 875 | <Select 876 | placeholder={apiProduct.type === 'REST_API' ? '请选择REST API' : '请选择MCP Server'} 877 | loading={apiLoading} 878 | showSearch 879 | filterOption={(input, option) => 880 | (option?.value as unknown as string)?.toLowerCase().includes(input.toLowerCase()) 881 | } 882 | optionLabelProp="label" 883 | > 884 | {apiList.map((api: any) => ( 885 | <Select.Option 886 | key={apiProduct.type === 'REST_API' ? api.apiId : (api.mcpRouteId || api.mcpServerName || api.name)} 887 | value={apiProduct.type === 'REST_API' ? api.apiId : (api.mcpRouteId || api.mcpServerName || api.name)} 888 | label={api.apiName || api.mcpServerName || api.name} 889 | > 890 | <div> 891 | <div className="font-medium">{api.apiName || api.mcpServerName || api.name}</div> 892 | <div className="text-sm text-gray-500"> 893 | {api.type} - {apiProduct.type === 'REST_API' ? api.apiId : (api.mcpRouteId || api.mcpServerName || api.name)} 894 | </div> 895 | </div> 896 | </Select.Option> 897 | ))} 898 | </Select> 899 | </Form.Item> 900 | )} 901 | </Form> 902 | </Modal> 903 | </div> 904 | ) 905 | } ```