This is page 6 of 7. Use http://codebase.md/higress-group/himarket?lines=false&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/components/ProductHeader.tsx: -------------------------------------------------------------------------------- ```typescript import React, { useState, useEffect } from "react"; import { Typography, Button, Modal, Select, message, Popconfirm, Input, Pagination, Spin } from "antd"; import { ApiOutlined, CheckCircleFilled, ClockCircleFilled, ExclamationCircleFilled, PlusOutlined } from "@ant-design/icons"; import { useParams } from "react-router-dom"; import { getConsumers, subscribeProduct, getProductSubscriptionStatus, unsubscribeProduct, getProductSubscriptions } from "../lib/api"; import type { Consumer } from "../types/consumer"; import type { McpConfig, ProductIcon } from "../types"; const { Title, Paragraph } = Typography; const { Search } = Input; interface ProductHeaderProps { name: string; description: string; icon?: ProductIcon | null; defaultIcon?: string; mcpConfig?: McpConfig | null; updatedAt?: string; productType?: 'REST_API' | 'MCP_SERVER'; } // 处理产品图标的函数 const getIconUrl = (icon?: ProductIcon | null, defaultIcon?: string): string => { const fallback = defaultIcon || "/logo.svg"; if (!icon) { return fallback; } switch (icon.type) { case "URL": return icon.value || fallback; case "BASE64": // 如果value已经包含data URL前缀,直接使用;否则添加前缀 return icon.value ? (icon.value.startsWith('data:') ? icon.value : `data:image/png;base64,${icon.value}`) : fallback; default: return fallback; } }; export const ProductHeader: React.FC<ProductHeaderProps> = ({ name, description, icon, defaultIcon = "/default-icon.png", mcpConfig, updatedAt, productType, }) => { const { id, mcpName } = useParams(); const [isManageModalVisible, setIsManageModalVisible] = useState(false); const [isApplyingSubscription, setIsApplyingSubscription] = useState(false); const [selectedConsumerId, setSelectedConsumerId] = useState<string>(''); const [consumers, setConsumers] = useState<Consumer[]>([]); // 分页相关state const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(5); // 每页显示5个订阅 // 分开管理不同的loading状态 const [consumersLoading, setConsumersLoading] = useState(false); const [submitLoading, setSubmitLoading] = useState(false); const [imageLoadFailed, setImageLoadFailed] = useState(false); // 订阅状态相关的state const [subscriptionStatus, setSubscriptionStatus] = useState<{ hasSubscription: boolean; subscribedConsumers: any[]; allConsumers: any[]; fullSubscriptionData?: { content: any[]; totalElements: number; totalPages: number; }; } | null>(null); const [subscriptionLoading, setSubscriptionLoading] = useState(false); // 订阅详情分页数据(用于管理弹窗) const [subscriptionDetails, setSubscriptionDetails] = useState<{ content: any[]; totalElements: number; totalPages: number; }>({ content: [], totalElements: 0, totalPages: 0 }); const [detailsLoading, setDetailsLoading] = useState(false); // 搜索相关state const [searchKeyword, setSearchKeyword] = useState(""); // 判断是否应该显示申请订阅按钮 const shouldShowSubscribeButton = !mcpConfig || mcpConfig.meta.source !== 'NACOS'; // 获取产品ID const productId = id || mcpName || ''; // 查询订阅状态 const fetchSubscriptionStatus = async () => { if (!productId || !shouldShowSubscribeButton) return; setSubscriptionLoading(true); try { const status = await getProductSubscriptionStatus(productId); setSubscriptionStatus(status); } catch (error) { console.error('获取订阅状态失败:', error); } finally { setSubscriptionLoading(false); } }; // 获取订阅详情(用于管理弹窗) const fetchSubscriptionDetails = async (page: number = 1, search: string = ''): Promise<void> => { if (!productId) return Promise.resolve(); setDetailsLoading(true); try { const response = await getProductSubscriptions(productId, { consumerName: search.trim() || undefined, page: page - 1, // 后端使用0基索引 size: pageSize }); setSubscriptionDetails({ content: response.data.content || [], totalElements: response.data.totalElements || 0, totalPages: response.data.totalPages || 0 }); } catch (error) { console.error('获取订阅详情失败:', error); message.error('获取订阅详情失败,请重试'); } finally { setDetailsLoading(false); } }; useEffect(() => { fetchSubscriptionStatus(); }, [productId, shouldShowSubscribeButton]); // 获取消费者列表 const fetchConsumers = async () => { try { setConsumersLoading(true); const response = await getConsumers({}, { page: 1, size: 100 }); if (response.data) { setConsumers(response.data.content || response.data); } } catch (error) { // message.error('获取消费者列表失败'); } finally { setConsumersLoading(false); } }; // 开始申请订阅流程 const startApplyingSubscription = () => { setIsApplyingSubscription(true); setSelectedConsumerId(''); fetchConsumers(); }; // 取消申请订阅 const cancelApplyingSubscription = () => { setIsApplyingSubscription(false); setSelectedConsumerId(''); }; // 提交申请订阅 const handleApplySubscription = async () => { if (!selectedConsumerId) { message.warning('请选择消费者'); return; } try { setSubmitLoading(true); await subscribeProduct(selectedConsumerId, productId); message.success('申请提交成功'); // 重置状态 setIsApplyingSubscription(false); setSelectedConsumerId(''); // 重新获取订阅状态和详情数据 await fetchSubscriptionStatus(); await fetchSubscriptionDetails(currentPage, ''); } catch (error) { console.error('申请订阅失败:', error); message.error('申请提交失败,请重试'); } finally { setSubmitLoading(false); } }; // 显示管理弹窗 const showManageModal = () => { setIsManageModalVisible(true); // 优先使用已缓存的数据,避免重复查询 if (subscriptionStatus?.fullSubscriptionData) { setSubscriptionDetails({ content: subscriptionStatus.fullSubscriptionData.content, totalElements: subscriptionStatus.fullSubscriptionData.totalElements, totalPages: subscriptionStatus.fullSubscriptionData.totalPages }); // 重置分页到第一页 setCurrentPage(1); setSearchKeyword(''); } else { // 如果没有缓存数据,则重新获取 fetchSubscriptionDetails(1, ''); } }; // 处理搜索输入变化 const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => { const value = e.target.value; setSearchKeyword(value); // 只更新状态,不触发搜索 }; // 执行搜索 const handleSearch = (value?: string) => { // 如果传入了value参数,使用该参数;否则使用当前的searchKeyword const keyword = value !== undefined ? value : searchKeyword; const trimmedKeyword = keyword.trim(); setCurrentPage(1); // 总是调用API进行搜索,不使用缓存 fetchSubscriptionDetails(1, trimmedKeyword); }; // 处理回车键搜索 const handleSearchKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => { if (e.key === 'Enter') { handleSearch(); } }; // 隐藏管理弹窗 const handleManageCancel = () => { setIsManageModalVisible(false); // 重置申请订阅状态 setIsApplyingSubscription(false); setSelectedConsumerId(''); // 重置分页和搜索 setCurrentPage(1); setSearchKeyword(''); // 清空订阅详情数据 setSubscriptionDetails({ content: [], totalElements: 0, totalPages: 0 }); }; // 取消订阅 const handleUnsubscribe = async (consumerId: string) => { try { await unsubscribeProduct(consumerId, productId); message.success('取消订阅成功'); // 重新获取订阅状态和详情数据 await fetchSubscriptionStatus(); await fetchSubscriptionDetails(currentPage, ''); } catch (error) { console.error('取消订阅失败:', error); message.error('取消订阅失败,请重试'); } }; return ( <> <div className="mb-2"> {/* 第一行:图标和标题信息 */} <div className="flex items-center gap-4 mb-3"> {(!icon || imageLoadFailed) && productType === 'REST_API' ? ( <div className="w-16 h-16 rounded-xl flex-shrink-0 flex items-center justify-center bg-gray-50 border border-gray-200"> <ApiOutlined className="text-3xl text-black" /> </div> ) : ( <img src={getIconUrl(icon, defaultIcon)} alt="icon" className="w-16 h-16 rounded-xl object-cover border border-gray-200 flex-shrink-0" onError={(e) => { const target = e.target as HTMLImageElement; if (productType === 'REST_API') { setImageLoadFailed(true); } else { // 确保有一个最终的fallback图片,避免无限循环请求 const fallbackIcon = defaultIcon || "/logo.svg"; const currentUrl = new URL(target.src, window.location.href).href; const fallbackUrl = new URL(fallbackIcon, window.location.href).href; if (currentUrl !== fallbackUrl) { target.src = fallbackIcon; } } }} /> )} <div className="flex-1 min-w-0 flex flex-col justify-center"> <Title level={3} className="mb-1 text-xl font-semibold"> {name} </Title> {updatedAt && ( <div className="text-sm text-gray-400"> {new Date(updatedAt).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).replace(/\//g, '.')} updated </div> )} </div> </div> {/* 第二行:描述信息,与左边框对齐 */} <Paragraph className="text-gray-600 mb-3 text-sm leading-relaxed"> {description} </Paragraph> {/* 第三行:徽章式订阅状态 + 管理按钮,与左边框对齐 */} {shouldShowSubscribeButton && ( <div className="flex items-center gap-4"> {subscriptionLoading ? ( <Button loading>加载中...</Button> ) : ( <> {/* 订阅状态徽章 */} <div className="flex items-center"> {subscriptionStatus?.hasSubscription ? ( <> <div className="w-2 h-2 bg-green-500 rounded-full mr-2"></div> <span className="text-sm text-gray-600 font-medium">已订阅</span> </> ) : ( <> <div className="w-2 h-2 bg-gray-400 rounded-full mr-2"></div> <span className="text-sm text-gray-600">未订阅</span> </> )} </div> {/* 管理按钮 */} <Button type="primary" onClick={showManageModal} > 管理订阅 </Button> </> )} </div> )} </div> {/* 订阅管理弹窗 */} <Modal title="订阅管理" open={isManageModalVisible} onCancel={handleManageCancel} footer={null} width={600} styles={{ content: { borderRadius: '8px', padding: 0 }, header: { borderRadius: '8px 8px 0 0', marginBottom: 0, paddingBottom: '8px' }, body: { padding: '0px' } }} > <div className="px-6 py-4"> {/* 产品名称标识 - 容器框样式 */} <div className="mb-4"> <div className="bg-blue-50 border border-blue-200 rounded px-3 py-2"> <span className="text-sm text-gray-600 mr-2">产品名称:</span> <span className="text-sm text-gray-600">{name}</span> </div> </div> {/* 搜索框 */} <div className="mb-4"> <Search placeholder="搜索消费者名称" value={searchKeyword} onChange={handleSearchChange} onSearch={handleSearch} onPressEnter={handleSearchKeyPress} allowClear style={{ width: 250 }} /> </div> {/* 优化的表格式 - 无表头,内嵌分页 */} <div className="border border-gray-200 rounded overflow-hidden"> {detailsLoading ? ( <div className="p-8 text-center"> <Spin size="large" /> </div> ) : subscriptionDetails.content && subscriptionDetails.content.length > 0 ? ( <> {/* 表格内容 */} <div className="divide-y divide-gray-100"> {(searchKeyword.trim() ? subscriptionDetails.content : subscriptionDetails.content.slice((currentPage - 1) * pageSize, currentPage * pageSize) ).map((item) => ( <div key={item.consumerId} className="flex items-center px-4 py-3 hover:bg-gray-50"> {/* 消费者名称 - 40% */} <div className="flex-1 min-w-0 pr-4"> <span className="text-sm text-gray-700 truncate block"> {item.consumerName} </span> </div> {/* 状态 - 30% */} <div className="w-24 flex items-center pr-4"> {item.status === 'APPROVED' ? ( <> <CheckCircleFilled className="text-green-500 mr-1" style={{fontSize: '10px'}} /> <span className="text-xs text-gray-700">已通过</span> </> ) : item.status === 'PENDING' ? ( <> <ClockCircleFilled className="text-blue-500 mr-1" style={{fontSize: '10px'}} /> <span className="text-xs text-gray-700">审核中</span> </> ) : ( <> <ExclamationCircleFilled className="text-red-500 mr-1" style={{fontSize: '10px'}} /> <span className="text-xs text-gray-700">已拒绝</span> </> )} </div> {/* 操作 - 30% */} <div className="w-20"> <Popconfirm title="确定要取消订阅吗?" onConfirm={() => handleUnsubscribe(item.consumerId)} okText="确认" cancelText="取消" > <Button type="link" danger size="small" className="p-0"> 取消订阅 </Button> </Popconfirm> </div> </div> ))} </div> </> ) : ( <div className="p-8 text-center text-gray-500"> {searchKeyword ? '未找到匹配的订阅记录' : '暂无订阅记录'} </div> )} </div> {/* 分页 - 使用Ant Design分页组件,右对齐 */} {subscriptionDetails.totalElements > 0 && ( <div className="mt-3 flex justify-end"> <Pagination current={currentPage} total={subscriptionDetails.totalElements} pageSize={pageSize} size="small" showSizeChanger={true} showQuickJumper={true} onChange={(page, size) => { setCurrentPage(page); if (size !== pageSize) { setPageSize(size); } // 如果有搜索关键词,需要重新查询;否则使用缓存数据 if (searchKeyword.trim()) { fetchSubscriptionDetails(page, searchKeyword); } // 无搜索时不需要重新查询,Ant Design会自动处理前端分页 }} onShowSizeChange={(_current, size) => { setPageSize(size); setCurrentPage(1); // 如果有搜索关键词,需要重新查询;否则使用缓存数据 if (searchKeyword.trim()) { fetchSubscriptionDetails(1, searchKeyword); } // 无搜索时不需要重新查询,页面大小变化会自动重新渲染 }} showTotal={(total) => `共 ${total} 条`} pageSizeOptions={['5', '10', '20']} hideOnSinglePage={false} /> </div> )} {/* 申请订阅区域 - 移回底部 */} <div className={`border-t pt-3 ${subscriptionDetails.totalElements > 0 ? 'mt-4' : 'mt-2'}`}> <div className="flex justify-end"> {!isApplyingSubscription ? ( <Button type="primary" icon={<PlusOutlined />} onClick={startApplyingSubscription} > 订阅 </Button> ) : ( <div className="w-full"> <div className="bg-gray-50 p-4 rounded"> <div className="mb-4"> <label className="block text-sm font-medium text-gray-700 mb-2"> 选择消费者 </label> <Select placeholder="搜索或选择消费者" style={{ width: '100%' }} value={selectedConsumerId} onChange={setSelectedConsumerId} showSearch loading={consumersLoading} filterOption={(input, option) => (option?.children as unknown as string)?.toLowerCase().includes(input.toLowerCase()) } notFoundContent={consumersLoading ? '加载中...' : '暂无消费者数据'} > {consumers .filter(consumer => { // 过滤掉已经订阅的consumer const isAlreadySubscribed = subscriptionStatus?.subscribedConsumers?.some( item => item.consumer.consumerId === consumer.consumerId ); return !isAlreadySubscribed; }) .map(consumer => ( <Select.Option key={consumer.consumerId} value={consumer.consumerId}> {consumer.name} </Select.Option> )) } </Select> </div> <div className="flex justify-end gap-2"> <Button onClick={cancelApplyingSubscription}> 取消 </Button> <Button type="primary" loading={submitLoading} disabled={!selectedConsumerId} onClick={handleApplySubscription} > 确认申请 </Button> </div> </div> </div> )} </div> </div> </div> </Modal> </> ); }; ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/api-product/ApiProductApiDocs.tsx: -------------------------------------------------------------------------------- ```typescript import { Card, Tag, Tabs, Table, Collapse, Descriptions } from "antd"; import { useEffect, useMemo, useState } from "react"; import type { ApiProduct } from "@/types/api-product"; import MonacoEditor from "react-monaco-editor"; import * as yaml from "js-yaml"; import { ProductTypeMap } from "@/lib/utils"; // 来源类型映射 const FromTypeMap: Record<string, string> = { HTTP: "HTTP转MCP", MCP: "MCP直接代理", OPEN_API: "OpenAPI转MCP", DIRECT_ROUTE: "直接路由", DATABASE: "数据库", }; // 来源映射 const SourceMap: Record<string, string> = { APIG_AI: "AI网关", HIGRESS: "Higress", NACOS: "Nacos", APIG_API: "API网关" }; interface ApiProductApiDocsProps { apiProduct: ApiProduct; handleRefresh: () => void; } export function ApiProductApiDocs({ apiProduct }: ApiProductApiDocsProps) { const [content, setContent] = useState(""); // OpenAPI 端点 const [endpoints, setEndpoints] = useState< Array<{ key: string; method: string; path: string; description: string; operationId?: string; }> >([]); // MCP 配置解析结果 const [mcpParsed, setMcpParsed] = useState<{ server?: { name?: string; config?: Record<string, unknown> }; tools?: Array<{ name: string; description?: string; args?: Array<{ name: string; description?: string; type?: string; required?: boolean; position?: string; defaultValue?: string | number | boolean | null; enumValues?: Array<string> | null; }>; }>; allowTools?: Array<string>; }>({}); // MCP 连接配置JSON const [httpJson, setHttpJson] = useState(""); const [sseJson, setSseJson] = useState(""); const [localJson, setLocalJson] = useState(""); // 生成连接配置JSON const generateConnectionConfig = ( domains: Array<{ domain: string; protocol: string }> | null | undefined, path: string | null | undefined, serverName: string, localConfig?: unknown, protocolType?: string ) => { // 互斥:优先判断本地模式 if (localConfig) { const localConfigJson = JSON.stringify(localConfig, null, 2); setLocalJson(localConfigJson); setHttpJson(""); setSseJson(""); return; } // HTTP/SSE 模式 if (domains && domains.length > 0 && path) { const domain = domains[0]; const baseUrl = `${domain.protocol}://${domain.domain}`; const endpoint = `${baseUrl}${path}`; if (protocolType === 'SSE') { // 仅生成SSE配置,不追加/sse const sseConfig = `{ "mcpServers": { "${serverName}": { "type": "sse", "url": "${endpoint}" } } }`; setSseJson(sseConfig); setHttpJson(""); setLocalJson(""); return; } else if (protocolType === 'StreamableHTTP') { // 仅生成HTTP配置 const httpConfig = `{ "mcpServers": { "${serverName}": { "url": "${endpoint}" } } }`; setHttpJson(httpConfig); setSseJson(""); setLocalJson(""); return; } else { // protocol为null或其他值:生成两种配置 const httpConfig = `{ "mcpServers": { "${serverName}": { "url": "${endpoint}" } } }`; const sseConfig = `{ "mcpServers": { "${serverName}": { "type": "sse", "url": "${endpoint}/sse" } } }`; setHttpJson(httpConfig); setSseJson(sseConfig); setLocalJson(""); return; } } // 无有效配置 setHttpJson(""); setSseJson(""); setLocalJson(""); }; useEffect(() => { // 设置源码内容 if (apiProduct.apiConfig?.spec) { setContent(apiProduct.apiConfig.spec); } else if (apiProduct.mcpConfig?.tools) { setContent(apiProduct.mcpConfig.tools); } else { setContent(""); } // 解析 OpenAPI(如有) if (apiProduct.apiConfig?.spec) { const spec = apiProduct.apiConfig.spec; try { const list: Array<{ key: string; method: string; path: string; description: string; operationId?: string; }> = []; const lines = spec.split("\n"); let currentPath = ""; let inPaths = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const trimmedLine = line.trim(); const indentLevel = line.length - line.trimStart().length; if (trimmedLine === "paths:" || trimmedLine.startsWith("paths:")) { inPaths = true; continue; } if (!inPaths) continue; if ( inPaths && indentLevel === 2 && trimmedLine.startsWith("/") && trimmedLine.endsWith(":") ) { currentPath = trimmedLine.slice(0, -1); continue; } if (inPaths && indentLevel === 4) { const httpMethods = [ "get:", "post:", "put:", "delete:", "patch:", "head:", "options:", ]; for (const method of httpMethods) { if (trimmedLine.startsWith(method)) { const methodName = method.replace(":", "").toUpperCase(); const operationId = extractOperationId(lines, i); list.push({ key: `${methodName}-${currentPath}`, method: methodName, path: currentPath, description: operationId || `${methodName} ${currentPath}`, operationId, }); break; } } } } setEndpoints(list.length > 0 ? list : []); } catch { setEndpoints([]); } } else { setEndpoints([]); } // 解析 MCP YAML(如有) if (apiProduct.mcpConfig?.tools) { try { const doc = yaml.load(apiProduct.mcpConfig.tools) as any; const toolsRaw = Array.isArray(doc?.tools) ? doc.tools : []; const tools = toolsRaw.map((t: any) => ({ name: String(t?.name ?? ""), description: t?.description ? String(t.description) : undefined, args: Array.isArray(t?.args) ? t.args.map((a: any) => ({ name: String(a?.name ?? ""), description: a?.description ? String(a.description) : undefined, type: a?.type ? String(a.type) : undefined, required: Boolean(a?.required), position: a?.position ? String(a.position) : undefined, defaultValue: a?.defaultValue ?? a?.default ?? null, enumValues: a?.enumValues ?? a?.enum ?? null, })) : undefined, })); setMcpParsed({ server: doc?.server, tools, allowTools: Array.isArray(doc?.allowTools) ? doc.allowTools : undefined, }); // 生成连接配置JSON test generateConnectionConfig( apiProduct.mcpConfig.mcpServerConfig?.domains, apiProduct.mcpConfig.mcpServerConfig?.path, apiProduct.mcpConfig.mcpServerName, apiProduct.mcpConfig.mcpServerConfig?.rawConfig, apiProduct.mcpConfig.meta?.protocol ); } catch { setMcpParsed({}); } } else { setMcpParsed({}); } }, [apiProduct]); const isOpenApi = useMemo( () => Boolean(apiProduct.apiConfig?.spec), [apiProduct] ); const isMcp = useMemo( () => Boolean(apiProduct.mcpConfig?.tools), [apiProduct] ); const openApiColumns = useMemo( () => [ { title: "方法", dataIndex: "method", key: "method", width: 100, render: (method: string) => ( <span> <Tag color={ method === "GET" ? "green" : method === "POST" ? "blue" : method === "PUT" ? "orange" : method === "DELETE" ? "red" : "default" } > {method} </Tag> </span> ), }, { title: "路径", dataIndex: "path", key: "path", width: 260, render: (path: string) => ( <code className="text-sm bg-gray-100 px-2 py-1 rounded">{path}</code> ), }, ], [] ); function extractOperationId(lines: string[], startIndex: number): string { const currentIndent = lines[startIndex].length - lines[startIndex].trimStart().length; for ( let i = startIndex + 1; i < Math.min(startIndex + 20, lines.length); i++ ) { const line = lines[i]; const trimmedLine = line.trim(); const lineIndent = line.length - line.trimStart().length; if (lineIndent <= currentIndent && trimmedLine !== "") break; if (trimmedLine.startsWith("operationId:")) { return trimmedLine.replace("operationId:", "").trim(); } } return ""; } return ( <div className="p-6 space-y-6"> <div className="flex justify-between items-center"> <div> <h1 className="text-2xl font-bold mb-2">API配置</h1> <p className="text-gray-600">查看API定义和规范</p> </div> </div> <Tabs defaultActiveKey="overview" items={[ { key: "overview", label: "API配置", children: ( <div className="space-y-4"> {isOpenApi && ( <> <Descriptions column={2} bordered size="small" className="mb-4" > {/* 'APIG_API' | 'HIGRESS' | 'APIG_AI' */} <Descriptions.Item label="API来源"> {SourceMap[apiProduct.apiConfig?.meta.source || '']} </Descriptions.Item> <Descriptions.Item label="API类型"> {apiProduct.apiConfig?.meta.type} </Descriptions.Item> </Descriptions> <Table columns={openApiColumns as any} dataSource={endpoints} rowKey="key" pagination={false} size="small" /> </> )} {isMcp && ( <> <Descriptions column={2} bordered size="small" className="mb-4" > <Descriptions.Item label="名称"> {mcpParsed.server?.name || apiProduct.mcpConfig?.meta.mcpServerName || "—"} </Descriptions.Item> <Descriptions.Item label="来源"> {apiProduct.mcpConfig?.meta.source ? SourceMap[apiProduct.mcpConfig.meta.source] || apiProduct.mcpConfig.meta.source : "—"} </Descriptions.Item> <Descriptions.Item label="来源类型"> {apiProduct.mcpConfig?.meta.fromType ? FromTypeMap[apiProduct.mcpConfig.meta.fromType] || apiProduct.mcpConfig.meta.fromType : "—"} </Descriptions.Item> <Descriptions.Item label="API类型"> {apiProduct.mcpConfig?.meta.source ? ProductTypeMap[apiProduct.type] || apiProduct.type : "—"} </Descriptions.Item> </Descriptions> <div className="mb-2"> <span className="font-bold mr-2">工具列表:</span> {/* {Array.isArray(mcpParsed.tools) && mcpParsed.tools.length > 0 ? ( mcpParsed.tools.map((tool, idx) => ( <Tag key={tool.name || idx} color="blue" className="mr-1"> {tool.name} </Tag> )) ) : ( <span className="text-gray-400">—</span> )} */} </div> <Collapse accordion> {(mcpParsed.tools || []).map((tool, idx) => ( <Collapse.Panel header={tool.name} key={idx}> {tool.description && ( <div className="mb-2 text-gray-600"> {tool.description} </div> )} <div className="mb-2 font-bold">输入参数:</div> <div className="space-y-2"> {tool.args && tool.args.length > 0 ? ( tool.args.map((arg, aidx) => ( <div key={aidx} className="flex flex-col mb-2"> <div className="flex items-center mb-1"> <span className="font-medium mr-2"> {arg.name} </span> {arg.type && ( <span className="text-xs text-gray-500 mr-2"> ({arg.type}) </span> )} {arg.required && ( <span className="text-red-500 text-xs"> * </span> )} </div> {arg.description && ( <div className="text-xs text-gray-500 mb-1"> {arg.description} </div> )} <input disabled className="border rounded px-2 py-1 text-sm bg-gray-100 w-full max-w-md" placeholder={ arg.defaultValue !== undefined && arg.defaultValue !== null ? String(arg.defaultValue) : "" } /> {Array.isArray(arg.enumValues) && arg.enumValues.length > 0 && ( <div className="text-xs text-gray-500 mt-1"> 可选值:{arg.enumValues.join(", ")} </div> )} </div> )) ) : ( <span className="text-gray-400">无参数</span> )} </div> </Collapse.Panel> ))} </Collapse> </> )} {!isOpenApi && !isMcp && ( <Card> <div className="text-center py-8 text-gray-500"> <p>暂无配置</p> </div> </Card> )} </div> ), }, ...(!isMcp ? [{ key: "source", label: "OpenAPI 规范", children: ( <div style={{ height: 460 }}> <MonacoEditor language="yaml" theme="vs-light" value={content} options={{ readOnly: true, minimap: { enabled: true }, scrollBeyondLastLine: false, scrollbar: { vertical: "visible", horizontal: "visible" }, wordWrap: "off", lineNumbers: "on", automaticLayout: true, fontSize: 14, copyWithSyntaxHighlighting: true, contextmenu: true, }} height="100%" /> </div> ), }] : []), ...(isMcp ? [{ key: "mcpServerConfig", label: "MCP连接配置", children: ( <div className="space-y-4"> <div className=""> {apiProduct.mcpConfig?.mcpServerConfig?.rawConfig ? ( // Local Mode - 显示本地配置 <div> <h3 className="text-lg font-bold mb-2">Local Config</h3> <MonacoEditor language="json" theme="vs-light" value={localJson} options={{ readOnly: true, minimap: { enabled: true }, scrollBeyondLastLine: false, scrollbar: { vertical: "visible", horizontal: "visible" }, wordWrap: "off", lineNumbers: "on", automaticLayout: true, fontSize: 14, copyWithSyntaxHighlighting: true, contextmenu: true, }} height="150px" /> </div> ) : ( // HTTP/SSE Mode - 根据配置状态动态显示 <> {httpJson && ( <div className="mt-4"> <h3 className="text-lg font-bold mb-2">HTTP Config</h3> <MonacoEditor language="json" theme="vs-light" value={httpJson} options={{ readOnly: true, minimap: { enabled: true }, scrollBeyondLastLine: false, scrollbar: { vertical: "visible", horizontal: "visible" }, wordWrap: "off", lineNumbers: "on", automaticLayout: true, fontSize: 14, copyWithSyntaxHighlighting: true, contextmenu: true, }} height="150px" /> </div> )} {sseJson && ( <div className="mt-4"> <h3 className="text-lg font-bold mb-2">SSE Config</h3> <MonacoEditor language="json" theme="vs-light" value={sseJson} options={{ readOnly: true, minimap: { enabled: true }, scrollBeyondLastLine: false, scrollbar: { vertical: "visible", horizontal: "visible" }, wordWrap: "off", lineNumbers: "on", automaticLayout: true, fontSize: 14, copyWithSyntaxHighlighting: true, contextmenu: true, }} height="150px" /> </div> )} </> )} </div> </div> ), }] : []) ]} /> </div> ); } ``` -------------------------------------------------------------------------------- /portal-web/api-portal-frontend/src/pages/McpDetail.tsx: -------------------------------------------------------------------------------- ```typescript import { useEffect, useState, useCallback } from "react"; import { useParams } from "react-router-dom"; import api from "../lib/api"; import { Layout } from "../components/Layout"; import { ProductHeader } from "../components/ProductHeader"; import { Card, Alert, Button, message, Tabs, Row, Col, Collapse, } from "antd"; import { CopyOutlined } from "@ant-design/icons"; import ReactMarkdown from "react-markdown"; import { ProductType } from "../types"; import type { Product, McpConfig, McpServerProduct, ApiResponse, } from "../types"; import * as yaml from "js-yaml"; import remarkGfm from 'remark-gfm'; import 'react-markdown-editor-lite/lib/index.css' function McpDetail() { const { mcpName } = useParams(); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [data, setData] = useState<Product | null>(null); const [mcpConfig, setMcpConfig] = useState<McpConfig | null>(null); const [parsedTools, setParsedTools] = useState< Array<{ name: string; description: string; args?: Array<{ name: string; description: string; type: string; required: boolean; position: string; default?: string; enum?: string[]; }>; }> >([]); const [httpJson, setHttpJson] = useState(""); const [sseJson, setSseJson] = useState(""); const [localJson, setLocalJson] = useState(""); // 解析YAML配置的函数 const parseYamlConfig = ( yamlString: string ): { tools?: Array<{ name: string; description: string; args?: Array<{ name: string; description: string; type: string; required: boolean; position: string; default?: string; enum?: string[]; }>; }>; } | null => { try { const parsed = yaml.load(yamlString) as { tools?: Array<{ name: string; description: string; args?: Array<{ name: string; description: string; type: string; required: boolean; position: string; default?: string; enum?: string[]; }>; }>; }; return parsed; } catch (error) { console.warn("解析YAML配置失败:", error); return null; } }; // 生成连接配置的函数 const generateConnectionConfig = useCallback(( domains: Array<{ domain: string; protocol: string }> | null | undefined, path: string | null | undefined, serverName: string, localConfig?: unknown, protocolType?: string ) => { // 互斥:优先判断本地模式 if (localConfig) { const localConfigJson = JSON.stringify(localConfig, null, 2); setLocalJson(localConfigJson); setHttpJson(""); setSseJson(""); return; } // HTTP/SSE 模式 if (domains && domains.length > 0 && path) { const domain = domains[0]; const baseUrl = `${domain.protocol}://${domain.domain}`; let endpoint = `${baseUrl}${path}`; if (mcpConfig?.meta?.source === 'ADP_AI_GATEWAY') { endpoint = `${baseUrl}/mcp-servers${path}`; } if (protocolType === 'SSE') { // 仅生成SSE配置,不追加/sse const sseConfig = `{ "mcpServers": { "${serverName}": { "type": "sse", "url": "${endpoint}" } } }`; setSseJson(sseConfig); setHttpJson(""); setLocalJson(""); return; } else if (protocolType === 'StreamableHTTP') { // 仅生成HTTP配置 const httpConfig = `{ "mcpServers": { "${serverName}": { "url": "${endpoint}" } } }`; setHttpJson(httpConfig); setSseJson(""); setLocalJson(""); return; } else { // protocol为null或其他值:生成两种配置 const httpConfig = `{ "mcpServers": { "${serverName}": { "url": "${endpoint}" } } }`; const sseConfig = `{ "mcpServers": { "${serverName}": { "type": "sse", "url": "${endpoint}/sse" } } }`; setHttpJson(httpConfig); setSseJson(sseConfig); setLocalJson(""); return; } } // 无有效配置 setHttpJson(""); setSseJson(""); setLocalJson(""); }, [mcpConfig]); useEffect(() => { const fetchDetail = async () => { if (!mcpName) { return; } setLoading(true); setError(""); try { const response: ApiResponse<Product> = await api.get(`/products/${mcpName}`); if (response.code === "SUCCESS" && response.data) { setData(response.data); // 处理MCP配置(统一使用新结构 mcpConfig) if (response.data.type === ProductType.MCP_SERVER) { const mcpProduct = response.data as McpServerProduct; if (mcpProduct.mcpConfig) { setMcpConfig(mcpProduct.mcpConfig); // 解析tools配置 if (mcpProduct.mcpConfig.tools) { const parsedConfig = parseYamlConfig( mcpProduct.mcpConfig.tools ); if (parsedConfig && parsedConfig.tools) { setParsedTools(parsedConfig.tools); } } } } } else { setError(response.message || "数据加载失败"); } } catch (error) { console.error("API请求失败:", error); setError("加载失败,请稍后重试"); } finally { setLoading(false); } }; fetchDetail(); }, [mcpName]); // 监听 mcpConfig 变化,重新生成连接配置 useEffect(() => { if (mcpConfig) { generateConnectionConfig( mcpConfig.mcpServerConfig.domains, mcpConfig.mcpServerConfig.path, mcpConfig.mcpServerName, mcpConfig.mcpServerConfig.rawConfig, (mcpConfig.meta as any)?.protocol ); } }, [mcpConfig, generateConnectionConfig]); const handleCopy = async (text: string) => { try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); } else { // 非安全上下文降级处理 const textarea = document.createElement("textarea"); textarea.value = text; textarea.style.position = "fixed"; document.body.appendChild(textarea); textarea.focus(); textarea.select(); document.execCommand("copy"); document.body.removeChild(textarea); } message.success("已复制到剪贴板", 1); } catch { message.error("复制失败,请手动复制"); } }; if (error) { return ( <Layout loading={loading}> <Alert message={error} type="error" showIcon className="my-8" /> </Layout> ); } if (!data) { return ( <Layout loading={loading}> <Alert message="未找到相关数据" type="warning" showIcon className="my-8" /> </Layout> ); } const { name, description } = data; const hasLocalConfig = Boolean(mcpConfig?.mcpServerConfig.rawConfig); return ( <Layout loading={loading}> <div className="mb-6"> <ProductHeader name={name} description={description} icon={data.icon} defaultIcon="/MCP.svg" mcpConfig={mcpConfig} updatedAt={data.updatedAt} productType="MCP_SERVER" /> <hr className="border-gray-200 mt-4" /> </div> {/* 主要内容区域 - 左右布局 */} <Row gutter={24}> {/* 左侧内容 */} <Col span={15}> <Card className="mb-6 rounded-lg border-gray-200"> <Tabs defaultActiveKey="overview" items={[ { key: "overview", label: "Overview", children: data.document ? ( <div className="min-h-[400px]"> <div className="prose prose-lg max-w-none" style={{ lineHeight: '1.7', color: '#374151', fontSize: '16px', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif' }} > <style>{` .prose h1 { color: #111827; font-weight: 700; font-size: 2.25rem; line-height: 1.2; margin-top: 0; margin-bottom: 1.5rem; border-bottom: 2px solid #e5e7eb; padding-bottom: 0.5rem; } .prose h2 { color: #1f2937; font-weight: 600; font-size: 1.875rem; line-height: 1.3; margin-top: 2rem; margin-bottom: 1rem; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.25rem; } .prose h3 { color: #374151; font-weight: 600; font-size: 1.5rem; margin-top: 1.5rem; margin-bottom: 0.75rem; } .prose p { margin-bottom: 1.25rem; color: #4b5563; line-height: 1.7; font-size: 16px; } .prose code { background-color: #f3f4f6; border: 1px solid #e5e7eb; border-radius: 0.375rem; padding: 0.125rem 0.375rem; font-size: 0.875rem; color: #374151; font-weight: 500; } .prose pre { background-color: #1f2937; border-radius: 0.5rem; padding: 1.25rem; overflow-x: auto; margin: 1.5rem 0; border: 1px solid #374151; } .prose pre code { background-color: transparent; border: none; color: #f9fafb; padding: 0; font-size: 0.875rem; font-weight: normal; } .prose blockquote { border-left: 4px solid #3b82f6; padding-left: 1rem; margin: 1.5rem 0; color: #6b7280; font-style: italic; background-color: #f8fafc; padding: 1rem; border-radius: 0.375rem; font-size: 16px; } .prose ul, .prose ol { margin: 1.25rem 0; padding-left: 1.5rem; } .prose ol { list-style-type: decimal; list-style-position: outside; } .prose ul { list-style-type: disc; list-style-position: outside; } .prose li { margin: 0.5rem 0; color: #4b5563; display: list-item; font-size: 16px; } .prose ol li { padding-left: 0.25rem; } .prose ul li { padding-left: 0.25rem; } .prose table { width: 100%; border-collapse: collapse; margin: 1.5rem 0; font-size: 16px; } .prose th, .prose td { border: 1px solid #d1d5db; padding: 0.75rem; text-align: left; } .prose th { background-color: #f9fafb; font-weight: 600; color: #374151; font-size: 16px; } .prose td { color: #4b5563; font-size: 16px; } .prose a { color: #3b82f6; text-decoration: underline; font-weight: 500; transition: color 0.2s; font-size: inherit; } .prose a:hover { color: #1d4ed8; } .prose strong { color: #111827; font-weight: 600; font-size: inherit; } .prose em { color: #6b7280; font-style: italic; font-size: inherit; } .prose hr { border: none; height: 1px; background-color: #e5e7eb; margin: 2rem 0; } `}</style> <ReactMarkdown remarkPlugins={[remarkGfm]}>{data.document}</ReactMarkdown> </div> </div> ) : ( <div className="text-gray-500 text-center py-8"> No overview available </div> ), }, { key: "tools", label: `Tools (${parsedTools.length})`, children: parsedTools.length > 0 ? ( <div className="border border-gray-200 rounded-lg bg-gray-50"> {parsedTools.map((tool, idx) => ( <div key={idx} className={idx < parsedTools.length - 1 ? "border-b border-gray-200" : ""}> <Collapse ghost expandIconPosition="end" items={[{ key: idx.toString(), label: tool.name, children: ( <div className="px-4 pb-2"> <div className="text-gray-600 mb-4">{tool.description}</div> {tool.args && tool.args.length > 0 && ( <div> <p className="font-medium text-gray-700 mb-3">输入参数:</p> {tool.args.map((arg, argIdx) => ( <div key={argIdx} className="mb-3"> <div className="flex items-center mb-2"> <span className="font-medium text-gray-800 mr-2">{arg.name}</span> <span className="text-xs bg-gray-200 text-gray-600 px-2 py-1 rounded mr-2"> {arg.type} </span> {arg.required && ( <span className="text-red-500 text-xs mr-2">*</span> )} {arg.description && ( <span className="text-xs text-gray-500"> {arg.description} </span> )} </div> <input type="text" placeholder={arg.description || `请输入${arg.name}`} 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" /> </div> ))} </div> )} {(!tool.args || tool.args.length === 0) && ( <div className="text-gray-500 text-sm">No parameters required</div> )} </div> ), }]} /> </div> ))} </div> ) : ( <div className="text-gray-500 text-center py-8"> No tools available </div> ), }, ]} /> </Card> </Col> {/* 右侧连接指导 */} <Col span={9}> {mcpConfig && ( <Card className="mb-6 rounded-lg border-gray-200"> <div className="mb-4"> <h3 className="text-sm font-semibold mb-3">连接点配置</h3> <Tabs size="small" defaultActiveKey={hasLocalConfig ? "local" : (sseJson ? "sse" : "http")} items={(() => { const tabs = []; if (hasLocalConfig) { tabs.push({ key: "local", label: "Stdio", children: ( <div className="relative bg-gray-50 border border-gray-200 rounded-md p-3"> <Button type="text" size="small" icon={<CopyOutlined />} className="absolute top-2 right-2 z-10" onClick={() => handleCopy(localJson)} /> <div className="text-gray-800 font-mono text-xs overflow-x-auto"> <pre className="whitespace-pre-wrap">{localJson}</pre> </div> </div> ), }); } else { if (sseJson) { tabs.push({ key: "sse", label: "SSE", children: ( <div className="relative bg-gray-50 border border-gray-200 rounded-md p-3"> <Button type="text" size="small" icon={<CopyOutlined />} className="absolute top-2 right-2 z-10" onClick={() => handleCopy(sseJson)} /> <div className="text-gray-800 font-mono text-xs overflow-x-auto"> <pre className="whitespace-pre-wrap">{sseJson}</pre> </div> </div> ), }); } if (httpJson) { tabs.push({ key: "http", label: "Streaming HTTP", children: ( <div className="relative bg-gray-50 border border-gray-200 rounded-md p-3"> <Button type="text" size="small" icon={<CopyOutlined />} className="absolute top-2 right-2 z-10" onClick={() => handleCopy(httpJson)} /> <div className="text-gray-800 font-mono text-xs overflow-x-auto"> <pre className="whitespace-pre-wrap">{httpJson}</pre> </div> </div> ), }); } } return tabs; })()} /> </div> </Card> )} </Col> </Row> </Layout> ); } export default McpDetail; ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/service/impl/NacosServiceImpl.java: -------------------------------------------------------------------------------- ```java /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.alibaba.apiopenplatform.service.impl; import cn.hutool.core.util.StrUtil; import com.alibaba.apiopenplatform.core.constant.Resources; import com.alibaba.apiopenplatform.core.exception.BusinessException; import com.alibaba.apiopenplatform.core.exception.ErrorCode; import com.alibaba.apiopenplatform.core.security.ContextHolder; import com.alibaba.apiopenplatform.core.utils.IdGenerator; import com.alibaba.apiopenplatform.dto.params.nacos.CreateNacosParam; import com.alibaba.apiopenplatform.dto.params.nacos.QueryNacosParam; import com.alibaba.apiopenplatform.dto.params.nacos.UpdateNacosParam; import com.alibaba.apiopenplatform.dto.result.NacosMCPServerResult; import com.alibaba.apiopenplatform.dto.result.NacosNamespaceResult; import com.alibaba.apiopenplatform.dto.result.NacosResult; import com.alibaba.apiopenplatform.dto.result.PageResult; import com.alibaba.apiopenplatform.dto.result.MCPConfigResult; import com.alibaba.apiopenplatform.dto.result.MseNacosResult; import com.alibaba.apiopenplatform.entity.NacosInstance; import com.alibaba.apiopenplatform.repository.NacosInstanceRepository; import com.alibaba.apiopenplatform.service.NacosService; import com.alibaba.apiopenplatform.support.enums.SourceType; import com.alibaba.apiopenplatform.support.product.NacosRefConfig; import com.alibaba.apiopenplatform.dto.converter.NacosToGatewayToolsConverter; import cn.hutool.json.JSONUtil; import com.alibaba.nacos.api.PropertyKeyConst; import com.alibaba.nacos.api.ai.model.mcp.McpServerBasicInfo; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import com.alibaba.nacos.maintainer.client.ai.AiMaintainerFactory; import com.alibaba.nacos.maintainer.client.ai.McpMaintainerService; import com.alibaba.nacos.maintainer.client.naming.NamingMaintainerFactory; import com.alibaba.nacos.maintainer.client.naming.NamingMaintainerService; import com.alibaba.nacos.api.exception.NacosException; import com.aliyun.mse20190531.Client; import com.aliyun.mse20190531.models.ListClustersRequest; import com.aliyun.mse20190531.models.ListClustersResponse; import com.aliyun.mse20190531.models.ListClustersResponseBody; import com.aliyun.teautil.models.RuntimeOptions; import com.alibaba.nacos.api.ai.model.mcp.McpServerDetailInfo; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Properties; import java.util.stream.Collectors; @Service @Slf4j @RequiredArgsConstructor public class NacosServiceImpl implements NacosService { private static final String DEFAULT_CONTEXT_PATH = "nacos"; private final NacosInstanceRepository nacosInstanceRepository; private final ContextHolder contextHolder; @Override public PageResult<NacosResult> listNacosInstances(Pageable pageable) { Page<NacosInstance> nacosInstances = nacosInstanceRepository.findAll(pageable); return new PageResult<NacosResult>().convertFrom(nacosInstances, nacosInstance -> new NacosResult().convertFrom(nacosInstance)); } @Override public NacosResult getNacosInstance(String nacosId) { NacosInstance nacosInstance = findNacosInstance(nacosId); return new NacosResult().convertFrom(nacosInstance); } @Override public void createNacosInstance(CreateNacosParam param) { nacosInstanceRepository.findByNacosName(param.getNacosName()) .ifPresent(nacos -> { throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.NACOS_INSTANCE, param.getNacosName())); }); NacosInstance nacosInstance = param.convertTo(); // If client provided nacosId use it after checking uniqueness, otherwise generate one String providedId = param.getNacosId(); if (providedId != null && !providedId.trim().isEmpty()) { // ensure not already exist boolean exists = nacosInstanceRepository.findByNacosId(providedId).isPresent(); if (exists) { throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.NACOS_INSTANCE, providedId)); } nacosInstance.setNacosId(providedId); } else { nacosInstance.setNacosId(IdGenerator.genNacosId()); } nacosInstance.setAdminId(contextHolder.getUser()); nacosInstanceRepository.save(nacosInstance); } @Override public void updateNacosInstance(String nacosId, UpdateNacosParam param) { NacosInstance instance = findNacosInstance(nacosId); Optional.ofNullable(param.getNacosName()) .filter(name -> !name.equals(instance.getNacosName())) .flatMap(nacosInstanceRepository::findByNacosName) .ifPresent(nacos -> { throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.NACOS_INSTANCE, param.getNacosName())); }); param.update(instance); nacosInstanceRepository.saveAndFlush(instance); } @Override public void deleteNacosInstance(String nacosId) { NacosInstance nacosInstance = findNacosInstance(nacosId); nacosInstanceRepository.delete(nacosInstance); } @Override public PageResult<MseNacosResult> fetchNacos(QueryNacosParam param, Pageable pageable) { try { // 创建MSE客户端 Client client = new Client(param.toClientConfig()); // 构建请求 ListClustersRequest request = new ListClustersRequest() .setRegionId(param.getRegionId()) .setPageNum(pageable.getPageNumber() + 1) .setPageSize(pageable.getPageSize()); RuntimeOptions runtime = new RuntimeOptions(); // 调用MSE API获取集群列表 ListClustersResponse response = client.listClustersWithOptions(request, runtime); // 转换响应结果,并过滤掉 clusterType 为 "Nacos-Ans" 的实例 Optional<List<MseNacosResult>> nacosResults = Optional.ofNullable(response.getBody()) .map(ListClustersResponseBody::getData) .map(clusters -> clusters.stream() .filter(cluster -> { String type = cluster.getClusterType(); return (type == null || "Nacos-Ans".equalsIgnoreCase(type)) && cluster.getVersionCode().startsWith("NACOS_3"); }) .map(MseNacosResult::fromListClustersResponseBodyData) .collect(Collectors.toList()) ); if (nacosResults.isPresent()) { // 返回分页结果 int total = response.getBody() != null && response.getBody().getTotalCount() != null ? response.getBody().getTotalCount().intValue() : 0; return PageResult.of(nacosResults.get(), pageable.getPageNumber(), pageable.getPageSize(), total); } return PageResult.empty(pageable.getPageNumber(), pageable.getPageSize()); } catch (Exception e) { log.error("Error fetching Nacos clusters from MSE", e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Failed to fetch Nacos clusters from MSE: " + e.getMessage()); } } @Override public PageResult<NacosMCPServerResult> fetchMcpServers(String nacosId, String namespaceId, Pageable pageable) throws Exception { NacosInstance nacosInstance = findNacosInstance(nacosId); McpMaintainerService service = buildDynamicMcpService(nacosInstance); String ns = namespaceId == null ? "" : namespaceId; com.alibaba.nacos.api.model.Page<McpServerBasicInfo> page = service.listMcpServer(ns, "", 1, Integer.MAX_VALUE); if (page == null || page.getPageItems() == null) { return PageResult.empty(pageable.getPageNumber(), pageable.getPageSize()); } return page.getPageItems().stream() .map(basicInfo -> new NacosMCPServerResult().convertFrom(basicInfo)) .skip(pageable.getOffset()) .limit(pageable.getPageSize()) .collect(Collectors.collectingAndThen( Collectors.toList(), list -> PageResult.of(list, pageable.getPageNumber(), pageable.getPageSize(), page.getPageItems().size()) )); } @Override public PageResult<NacosNamespaceResult> fetchNamespaces(String nacosId, Pageable pageable) throws Exception { NacosInstance nacosInstance = findNacosInstance(nacosId); // 使用空 namespace 构建 (列出全部命名空间) NamingMaintainerService namingService = buildDynamicNamingService(nacosInstance, ""); List<?> namespaces; try { namespaces = namingService.getNamespaceList(); } catch (NacosException e) { log.error("Error fetching namespaces from Nacos by nacosId {}", nacosId, e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Failed to fetch namespaces: " + e.getErrMsg()); } if (namespaces == null || namespaces.isEmpty()) { return PageResult.empty(pageable.getPageNumber(), pageable.getPageSize()); } List<NacosNamespaceResult> list = namespaces.stream() .map(o -> new NacosNamespaceResult().convertFrom(o)) .skip(pageable.getOffset()) .limit(pageable.getPageSize()) .collect(Collectors.toList()); return PageResult.of(list, pageable.getPageNumber(), pageable.getPageSize(), namespaces.size()); } @Override public String fetchMcpConfig(String nacosId, NacosRefConfig nacosRefConfig) { NacosInstance nacosInstance = findNacosInstance(nacosId); McpMaintainerService service = buildDynamicMcpService(nacosInstance); try { McpServerDetailInfo detail = service.getMcpServerDetail(nacosRefConfig.getNamespaceId(), nacosRefConfig.getMcpServerName(), null); if (detail == null) { return null; } MCPConfigResult mcpConfig = buildMCPConfigResult(detail); return JSONUtil.toJsonStr(mcpConfig); } catch (Exception e) { log.error("Error fetching Nacos MCP servers", e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Failed to fetch Nacos MCP config"); } } private MCPConfigResult buildMCPConfigResult(McpServerDetailInfo detail) { MCPConfigResult mcpConfig = new MCPConfigResult(); mcpConfig.setMcpServerName(detail.getName()); MCPConfigResult.MCPServerConfig serverConfig = new MCPConfigResult.MCPServerConfig(); if (detail.getLocalServerConfig() != null) { serverConfig.setRawConfig(detail.getLocalServerConfig()); serverConfig.setTransportMode(MCPConfigResult.MCPTransportMode.LOCAL.getMode()); } else if (detail.getRemoteServerConfig() != null || (detail.getBackendEndpoints() != null && !detail.getBackendEndpoints().isEmpty())) { Object remoteConfig = buildRemoteConnectionConfig(detail); serverConfig.setRawConfig(remoteConfig); } else { Map<String, Object> defaultConfig = new HashMap<>(); defaultConfig.put("type", "unknown"); defaultConfig.put("name", detail.getName()); serverConfig.setRawConfig(defaultConfig); } mcpConfig.setMcpServerConfig(serverConfig); if (detail.getToolSpec() != null) { try { NacosToGatewayToolsConverter converter = new NacosToGatewayToolsConverter(); converter.convertFromNacos(detail); String gatewayFormatYaml = converter.toYaml(); mcpConfig.setTools(gatewayFormatYaml); } catch (Exception e) { log.error("Error converting tools to gateway format", e); mcpConfig.setTools(null); } } else { mcpConfig.setTools(null); } MCPConfigResult.McpMetadata meta = new MCPConfigResult.McpMetadata(); meta.setSource(SourceType.NACOS.name()); mcpConfig.setMeta(meta); return mcpConfig; } private Object buildRemoteConnectionConfig(McpServerDetailInfo detail) { List<?> backendEndpoints = detail.getBackendEndpoints(); if (backendEndpoints != null && !backendEndpoints.isEmpty()) { Object firstEndpoint = backendEndpoints.get(0); Map<String, Object> connectionConfig = new HashMap<>(); Map<String, Object> mcpServers = new HashMap<>(); Map<String, Object> serverConfig = new HashMap<>(); String endpointUrl = extractEndpointUrl(firstEndpoint); if (endpointUrl != null) { serverConfig.put("url", endpointUrl); } mcpServers.put(detail.getName(), serverConfig); connectionConfig.put("mcpServers", mcpServers); return connectionConfig; } Map<String, Object> basicConfig = new HashMap<>(); basicConfig.put("type", "remote"); basicConfig.put("name", detail.getName()); basicConfig.put("protocol", "http"); return basicConfig; } private String extractEndpointUrl(Object endpoint) { if (endpoint == null) { return null; } if (endpoint instanceof String) { return (String) endpoint; } if (endpoint instanceof Map) { Map<?, ?> endpointMap = (Map<?, ?>) endpoint; String url = getStringValue(endpointMap, "url"); if (url != null) return url; String endpointUrl = getStringValue(endpointMap, "endpointUrl"); if (endpointUrl != null) return endpointUrl; String host = getStringValue(endpointMap, "host"); String port = getStringValue(endpointMap, "port"); String path = getStringValue(endpointMap, "path"); if (host != null) { StringBuilder urlBuilder = new StringBuilder(); String protocol = getStringValue(endpointMap, "protocol"); urlBuilder.append(protocol != null ? protocol : "http").append("://"); urlBuilder.append(host); if (port != null && !port.isEmpty()) { urlBuilder.append(":").append(port); } if (path != null && !path.isEmpty()) { if (!path.startsWith("/")) { urlBuilder.append("/"); } urlBuilder.append(path); } return urlBuilder.toString(); } } if (endpoint.getClass().getName().contains("McpEndpointInfo")) { return extractUrlFromMcpEndpointInfo(endpoint); } return endpoint.toString(); } private String getStringValue(Map<?, ?> map, String key) { Object value = map.get(key); return value != null ? value.toString() : null; } private String extractUrlFromMcpEndpointInfo(Object endpoint) { String[] possibleFieldNames = {"url", "endpointUrl", "address", "host", "endpoint"}; for (String fieldName : possibleFieldNames) { try { java.lang.reflect.Field field = endpoint.getClass().getDeclaredField(fieldName); field.setAccessible(true); Object value = field.get(endpoint); if (value != null && !value.toString().trim().isEmpty()) { if (value.toString().contains("://") || value.toString().contains(":")) { return value.toString(); } } } catch (Exception e) { continue; } } java.lang.reflect.Field[] fields = endpoint.getClass().getDeclaredFields(); String host = null; String port = null; String path = null; String protocol = null; for (java.lang.reflect.Field field : fields) { try { field.setAccessible(true); Object value = field.get(endpoint); if (value != null && !value.toString().trim().isEmpty()) { String fieldName = field.getName().toLowerCase(); if (fieldName.contains("host") || fieldName.contains("ip") || fieldName.contains("address")) { host = value.toString(); } else if (fieldName.contains("port")) { port = value.toString(); } else if (fieldName.contains("path") || fieldName.contains("endpoint") || fieldName.contains("uri")) { path = value.toString(); } else if (fieldName.contains("protocol") || fieldName.contains("scheme")) { protocol = value.toString(); } } } catch (Exception e) { continue; } } if (host != null) { StringBuilder urlBuilder = new StringBuilder(); urlBuilder.append(protocol != null ? protocol : "http").append("://"); urlBuilder.append(host); if (port != null && !port.isEmpty()) { urlBuilder.append(":").append(port); } if (path != null && !path.isEmpty()) { if (!path.startsWith("/")) { urlBuilder.append("/"); } urlBuilder.append(path); } return urlBuilder.toString(); } return endpoint.toString(); } private NacosInstance findNacosInstance(String nacosId) { return nacosInstanceRepository.findByNacosId(nacosId) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.NACOS_INSTANCE, nacosId)); } private McpMaintainerService buildDynamicMcpService(NacosInstance nacosInstance) { Properties properties = new Properties(); properties.setProperty(PropertyKeyConst.SERVER_ADDR, nacosInstance.getServerUrl()); if (Objects.nonNull(nacosInstance.getUsername())) { properties.setProperty(PropertyKeyConst.USERNAME, nacosInstance.getUsername()); } if (Objects.nonNull(nacosInstance.getPassword())) { properties.setProperty(PropertyKeyConst.PASSWORD, nacosInstance.getPassword()); } properties.setProperty(PropertyKeyConst.CONTEXT_PATH, DEFAULT_CONTEXT_PATH); // instance no longer stores namespace; leave namespace empty to let requests use default/public // if consumers need a specific namespace, they should call an overload that accepts it if (Objects.nonNull(nacosInstance.getAccessKey())) { properties.setProperty(PropertyKeyConst.ACCESS_KEY, nacosInstance.getAccessKey()); } if (Objects.nonNull(nacosInstance.getSecretKey())) { properties.setProperty(PropertyKeyConst.SECRET_KEY, nacosInstance.getSecretKey()); } try { return AiMaintainerFactory.createAiMaintainerService(properties); } catch (Exception e) { log.error("Error init Nacos AiMaintainerService", e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error init Nacos AiMaintainerService"); } } // removed unused no-namespace overload; use the runtime-namespace overload instead // overload to build NamingMaintainerService with a runtime namespace value private NamingMaintainerService buildDynamicNamingService(NacosInstance nacosInstance, String runtimeNamespace) { Properties properties = new Properties(); properties.setProperty(PropertyKeyConst.SERVER_ADDR, nacosInstance.getServerUrl()); if (Objects.nonNull(nacosInstance.getUsername())) { properties.setProperty(PropertyKeyConst.USERNAME, nacosInstance.getUsername()); } if (Objects.nonNull(nacosInstance.getPassword())) { properties.setProperty(PropertyKeyConst.PASSWORD, nacosInstance.getPassword()); } properties.setProperty(PropertyKeyConst.CONTEXT_PATH, DEFAULT_CONTEXT_PATH); properties.setProperty(PropertyKeyConst.NAMESPACE, runtimeNamespace == null ? "" : runtimeNamespace); if (Objects.nonNull(nacosInstance.getAccessKey())) { properties.setProperty(PropertyKeyConst.ACCESS_KEY, nacosInstance.getAccessKey()); } if (Objects.nonNull(nacosInstance.getSecretKey())) { properties.setProperty(PropertyKeyConst.SECRET_KEY, nacosInstance.getSecretKey()); } try { return NamingMaintainerFactory.createNamingMaintainerService(properties); } catch (Exception e) { log.error("Error init Nacos NamingMaintainerService", e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error init Nacos NamingMaintainerService"); } } } ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/service/impl/ConsumerServiceImpl.java: -------------------------------------------------------------------------------- ```java /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.alibaba.apiopenplatform.service.impl; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONUtil; import com.alibaba.apiopenplatform.core.constant.Resources; import com.alibaba.apiopenplatform.core.event.DeveloperDeletingEvent; import com.alibaba.apiopenplatform.core.event.ProductDeletingEvent; import com.alibaba.apiopenplatform.core.exception.BusinessException; import com.alibaba.apiopenplatform.core.exception.ErrorCode; import com.alibaba.apiopenplatform.core.security.ContextHolder; import com.alibaba.apiopenplatform.core.utils.IdGenerator; import com.alibaba.apiopenplatform.dto.params.consumer.QueryConsumerParam; import com.alibaba.apiopenplatform.dto.params.consumer.CreateConsumerParam; import com.alibaba.apiopenplatform.dto.params.consumer.CreateCredentialParam; import com.alibaba.apiopenplatform.dto.params.consumer.UpdateCredentialParam; import com.alibaba.apiopenplatform.dto.result.*; import com.alibaba.apiopenplatform.dto.params.consumer.CreateSubscriptionParam; import com.alibaba.apiopenplatform.dto.params.consumer.QuerySubscriptionParam; import com.alibaba.apiopenplatform.entity.*; import com.alibaba.apiopenplatform.repository.ConsumerRepository; import com.alibaba.apiopenplatform.repository.ConsumerCredentialRepository; import com.alibaba.apiopenplatform.repository.SubscriptionRepository; import com.alibaba.apiopenplatform.service.ConsumerService; import com.alibaba.apiopenplatform.service.GatewayService; import com.alibaba.apiopenplatform.service.PortalService; import com.alibaba.apiopenplatform.service.ProductService; import com.alibaba.apiopenplatform.support.consumer.ApiKeyConfig; import com.alibaba.apiopenplatform.support.consumer.ConsumerAuthConfig; import com.alibaba.apiopenplatform.support.consumer.HmacConfig; import com.alibaba.apiopenplatform.support.enums.CredentialMode; import com.alibaba.apiopenplatform.support.enums.SourceType; import com.alibaba.apiopenplatform.support.gateway.GatewayConfig; import cn.hutool.core.util.BooleanUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; import javax.persistence.criteria.Predicate; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import javax.persistence.criteria.Root; import javax.persistence.criteria.Subquery; import javax.transaction.Transactional; import java.util.*; import java.util.stream.Collectors; import com.alibaba.apiopenplatform.support.enums.SubscriptionStatus; import com.alibaba.apiopenplatform.repository.ConsumerRefRepository; @Service @RequiredArgsConstructor @Transactional @Slf4j public class ConsumerServiceImpl implements ConsumerService { private final PortalService portalService; private final ConsumerRepository consumerRepository; private final GatewayService gatewayService; private final ContextHolder contextHolder; private final ConsumerCredentialRepository credentialRepository; private final SubscriptionRepository subscriptionRepository; private final ProductService productService; private final ConsumerRefRepository consumerRefRepository; @Override public ConsumerResult createConsumer(CreateConsumerParam param) { PortalResult portal = portalService.getPortal(contextHolder.getPortal()); String consumerId = IdGenerator.genConsumerId(); Consumer consumer = param.convertTo(); consumer.setConsumerId(consumerId); consumer.setDeveloperId(contextHolder.getUser()); consumer.setPortalId(portal.getPortalId()); consumerRepository.save(consumer); // 初始化Credential ConsumerCredential credential = initCredential(consumerId); credentialRepository.save(credential); return getConsumer(consumerId); } @Override public PageResult<ConsumerResult> listConsumers(QueryConsumerParam param, Pageable pageable) { Page<Consumer> consumers = consumerRepository.findAll(buildConsumerSpec(param), pageable); return new PageResult<ConsumerResult>().convertFrom(consumers, consumer -> new ConsumerResult().convertFrom(consumer)); } @Override public ConsumerResult getConsumer(String consumerId) { Consumer consumer = contextHolder.isDeveloper() ? findDevConsumer(consumerId) : findConsumer(consumerId); return new ConsumerResult().convertFrom(consumer); } @Override public void deleteConsumer(String consumerId) { Consumer consumer = contextHolder.isDeveloper() ? findDevConsumer(consumerId) : findConsumer(consumerId); // 订阅 subscriptionRepository.deleteAllByConsumerId(consumerId); // 凭证 credentialRepository.deleteAllByConsumerId(consumerId); // 删除网关上的Consumer List<ConsumerRef> consumerRefs = consumerRefRepository.findAllByConsumerId(consumerId); for (ConsumerRef consumerRef : consumerRefs) { try { gatewayService.deleteConsumer(consumerRef.getGwConsumerId(), consumerRef.getGatewayConfig()); } catch (Exception e) { log.error("deleteConsumer gatewayConsumer error, gwConsumerId: {}", consumerRef.getGwConsumerId(), e); } } consumerRepository.delete(consumer); } @Override public void createCredential(String consumerId, CreateCredentialParam param) { existsConsumer(consumerId); // Consumer仅一份Credential credentialRepository.findByConsumerId(consumerId) .ifPresent(c -> { throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在凭证", Resources.CONSUMER, consumerId)); }); ConsumerCredential credential = param.convertTo(); credential.setConsumerId(consumerId); complementCredentials(credential); credentialRepository.save(credential); } private ConsumerCredential initCredential(String consumerId) { ConsumerCredential credential = new ConsumerCredential(); credential.setConsumerId(consumerId); ApiKeyConfig.ApiKeyCredential apiKeyCredential = new ApiKeyConfig.ApiKeyCredential(); ApiKeyConfig apiKeyConfig = new ApiKeyConfig(); apiKeyConfig.setCredentials(Collections.singletonList(apiKeyCredential)); credential.setApiKeyConfig(apiKeyConfig); complementCredentials(credential); return credential; } @Override public ConsumerCredentialResult getCredential(String consumerId) { existsConsumer(consumerId); return credentialRepository.findByConsumerId(consumerId) .map(credential -> new ConsumerCredentialResult().convertFrom(credential)) .orElse(new ConsumerCredentialResult()); } @Override public void updateCredential(String consumerId, UpdateCredentialParam param) { ConsumerCredential credential = credentialRepository.findByConsumerId(consumerId) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.CONSUMER_CREDENTIAL, consumerId)); param.update(credential); List<ConsumerRef> consumerRefs = consumerRefRepository.findAllByConsumerId(consumerId); for (ConsumerRef consumerRef : consumerRefs) { try { gatewayService.updateConsumer(consumerRef.getGwConsumerId(), credential, consumerRef.getGatewayConfig()); } catch (Exception e) { log.error("update gatewayConsumer error, gwConsumerId: {}", consumerRef.getGwConsumerId(), e); } } credentialRepository.saveAndFlush(credential); } @Override public void deleteCredential(String consumerId) { existsConsumer(consumerId); credentialRepository.deleteAllByConsumerId(consumerId); } @Override public SubscriptionResult subscribeProduct(String consumerId, CreateSubscriptionParam param) { Consumer consumer = contextHolder.isDeveloper() ? findDevConsumer(consumerId) : findConsumer(consumerId); // 勿重复订阅 if (subscriptionRepository.findByConsumerIdAndProductId(consumerId, param.getProductId()).isPresent()) { throw new BusinessException(ErrorCode.INVALID_REQUEST, "重复订阅"); } ProductResult product = productService.getProduct(param.getProductId()); ProductRefResult productRef = productService.getProductRef(param.getProductId()); if (productRef == null) { throw new BusinessException(ErrorCode.INTERNAL_ERROR, "API产品未关联API"); } // 非网关型不支持订阅 if (productRef.getSourceType() != SourceType.GATEWAY) { throw new BusinessException(ErrorCode.INVALID_REQUEST, "API产品不支持订阅"); } ConsumerCredential credential = credentialRepository.findByConsumerId(consumerId) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.CONSUMER_CREDENTIAL, consumerId)); ProductSubscription subscription = param.convertTo(); subscription.setConsumerId(consumerId); // 检查产品级别的自动审批设置 boolean autoApprove = false; // 优先检查产品级别的autoApprove配置 if (product.getAutoApprove() != null) { // 如果产品配置了autoApprove,直接使用产品级别的配置 autoApprove = product.getAutoApprove(); log.info("使用产品级别自动审批配置: productId={}, autoApprove={}", param.getProductId(), autoApprove); } else { // 如果产品未配置autoApprove,则使用平台级别的配置 PortalResult portal = portalService.getPortal(consumer.getPortalId()); log.info("portal: {}", JSONUtil.toJsonStr(portal)); autoApprove = portal.getPortalSettingConfig() != null && BooleanUtil.isTrue(portal.getPortalSettingConfig().getAutoApproveSubscriptions()); log.info("使用平台级别自动审批配置: portalId={}, autoApprove={}", consumer.getPortalId(), autoApprove); } if (autoApprove) { // 如果autoApprove为true,立即授权并设置为APPROVED状态 ConsumerAuthConfig consumerAuthConfig = authorizeConsumer(consumer, credential, productRef); subscription.setConsumerAuthConfig(consumerAuthConfig); subscription.setStatus(SubscriptionStatus.APPROVED); } else { // 如果autoApprove为false,暂时不授权,设置为PENDING状态 subscription.setStatus(SubscriptionStatus.PENDING); } subscriptionRepository.save(subscription); SubscriptionResult r = new SubscriptionResult().convertFrom(subscription); r.setProductName(product.getName()); r.setProductType(product.getType()); return r; } @Override public void unsubscribeProduct(String consumerId, String productId) { existsConsumer(consumerId); ProductSubscription subscription = subscriptionRepository .findByConsumerIdAndProductId(consumerId, productId) .orElse(null); if (subscription == null) { return; } if (subscription.getConsumerAuthConfig() != null) { ProductRefResult productRef = productService.getProductRef(productId); GatewayConfig gatewayConfig = gatewayService.getGatewayConfig(productRef.getGatewayId()); // 取消网关上的Consumer授权 Optional.ofNullable(matchConsumerRef(consumerId, gatewayConfig)) .ifPresent(consumerRef -> gatewayService.revokeConsumerAuthorization(productRef.getGatewayId(), consumerRef.getGwConsumerId(), subscription.getConsumerAuthConfig()) ); } subscriptionRepository.deleteByConsumerIdAndProductId(consumerId, productId); } @Override public PageResult<SubscriptionResult> listSubscriptions(String consumerId, QuerySubscriptionParam param, Pageable pageable) { existsConsumer(consumerId); Page<ProductSubscription> subscriptions = subscriptionRepository.findAll(buildCredentialSpec(consumerId, param), pageable); List<String> productIds = subscriptions.getContent().stream() .map(ProductSubscription::getProductId) .collect(Collectors.toList()); Map<String, ProductResult> products = productService.getProducts(productIds); return new PageResult<SubscriptionResult>().convertFrom(subscriptions, s -> { SubscriptionResult r = new SubscriptionResult().convertFrom(s); ProductResult product = products.get(r.getProductId()); if (product != null) { r.setProductType(product.getType()); r.setProductName(product.getName()); } return r; }); } @Override public void deleteSubscription(String consumerId, String productId) { existsConsumer(consumerId); subscriptionRepository.findByConsumerIdAndProductId(consumerId, productId) .ifPresent(subscriptionRepository::delete); } @Override public SubscriptionResult approveSubscription(String consumerId, String productId) { existsConsumer(consumerId); ProductSubscription subscription = subscriptionRepository.findByConsumerIdAndProductId(consumerId, productId) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.SUBSCRIPTION, StrUtil.format("{}:{}", productId, consumerId))); // 检查订阅状态,只有PENDING状态的订阅才能被审批 if (subscription.getStatus() != SubscriptionStatus.PENDING) { throw new BusinessException(ErrorCode.INVALID_REQUEST, "订阅已审批"); } // 获取消费者和凭证信息 Consumer consumer = contextHolder.isDeveloper() ? findDevConsumer(consumerId) : findConsumer(consumerId); ConsumerCredential credential = credentialRepository.findByConsumerId(consumerId) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.CONSUMER_CREDENTIAL, consumerId)); // 获取产品引用信息 ProductRefResult productRef = productService.getProductRef(productId); if (productRef == null) { throw new BusinessException(ErrorCode.INTERNAL_ERROR, "API产品未关联API"); } // 执行授权操作 ConsumerAuthConfig consumerAuthConfig = authorizeConsumer(consumer, credential, productRef); // 更新订阅状态和授权配置 subscription.setConsumerAuthConfig(consumerAuthConfig); subscription.setStatus(SubscriptionStatus.APPROVED); subscriptionRepository.saveAndFlush(subscription); ProductResult product = productService.getProduct(productId); SubscriptionResult result = new SubscriptionResult().convertFrom(subscription); if (product != null) { result.setProductName(product.getName()); result.setProductType(product.getType()); } return result; } private Consumer findConsumer(String consumerId) { return consumerRepository.findByConsumerId(consumerId) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.CONSUMER, consumerId)); } private Consumer findDevConsumer(String consumerId) { return consumerRepository.findByDeveloperIdAndConsumerId(contextHolder.getUser(), consumerId) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.CONSUMER, consumerId)); } private void existsConsumer(String consumerId) { (contextHolder.isDeveloper() ? consumerRepository.findByDeveloperIdAndConsumerId(contextHolder.getUser(), consumerId) : consumerRepository.findByConsumerId(consumerId)) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.CONSUMER, consumerId)); } private Specification<Consumer> buildConsumerSpec(QueryConsumerParam param) { return (root, query, cb) -> { List<Predicate> predicates = new ArrayList<>(); if (contextHolder.isDeveloper()) { param.setDeveloperId(contextHolder.getUser()); } if (StrUtil.isNotBlank(param.getDeveloperId())) { predicates.add(cb.equal(root.get("developerId"), param.getDeveloperId())); } if (StrUtil.isNotBlank(param.getPortalId())) { predicates.add(cb.equal(root.get("portalId"), param.getPortalId())); } if (StrUtil.isNotBlank(param.getName())) { String likePattern = "%" + param.getName() + "%"; predicates.add(cb.like(cb.lower(root.get("name")), likePattern)); } return cb.and(predicates.toArray(new Predicate[0])); }; } private Specification<ProductSubscription> buildCredentialSpec(String consumerId, QuerySubscriptionParam param) { return (root, query, cb) -> { List<Predicate> predicates = new ArrayList<>(); predicates.add(cb.equal(root.get("consumerId"), consumerId)); if (param.getStatus() != null) { predicates.add(cb.equal(root.get("status"), param.getStatus())); } if (StrUtil.isNotBlank(param.getProductName())) { // 使用子查询 Subquery<String> productSubquery = query.subquery(String.class); Root<Product> productRoot = productSubquery.from(Product.class); productSubquery.select(productRoot.get("productId")) .where(cb.like( cb.lower(productRoot.get("name")), "%" + param.getProductName().toLowerCase() + "%" )); predicates.add(root.get("productId").in(productSubquery)); } return cb.and(predicates.toArray(new Predicate[0])); }; } /** * 补充Credentials * * @param credential */ private void complementCredentials(ConsumerCredential credential) { if (credential == null) { return; } // ApiKey if (credential.getApiKeyConfig() != null) { List<ApiKeyConfig.ApiKeyCredential> apiKeyCredentials = credential.getApiKeyConfig().getCredentials(); if (apiKeyCredentials != null) { for (ApiKeyConfig.ApiKeyCredential cred : apiKeyCredentials) { if (cred.getMode() == CredentialMode.SYSTEM && StrUtil.isBlank(cred.getApiKey())) { cred.setApiKey(IdGenerator.genIdWithPrefix("apikey-")); } } } } // HMAC if (credential.getHmacConfig() != null) { List<HmacConfig.HmacCredential> hmacCredentials = credential.getHmacConfig().getCredentials(); if (hmacCredentials != null) { for (HmacConfig.HmacCredential cred : hmacCredentials) { if (cred.getMode() == CredentialMode.SYSTEM && (StrUtil.isBlank(cred.getAk()) || StrUtil.isBlank(cred.getSk()))) { cred.setAk(IdGenerator.genIdWithPrefix("ak-")); cred.setSk(IdGenerator.genIdWithPrefix("sk-")); } } } } } private ConsumerAuthConfig authorizeConsumer(Consumer consumer, ConsumerCredential credential, ProductRefResult productRef) { GatewayConfig gatewayConfig = gatewayService.getGatewayConfig(productRef.getGatewayId()); // 检查是否在网关上有对应的Consumer ConsumerRef existingConsumerRef = matchConsumerRef(consumer.getConsumerId(), gatewayConfig); String gwConsumerId; if (existingConsumerRef != null) { // 如果存在ConsumerRef记录,需要检查实际网关中是否还存在该消费者 gwConsumerId = existingConsumerRef.getGwConsumerId(); // 检查实际网关中是否还存在该消费者 if (!isConsumerExistsInGateway(gwConsumerId, gatewayConfig)) { log.warn("网关中的消费者已被删除,需要重新创建: gwConsumerId={}, gatewayType={}", gwConsumerId, gatewayConfig.getGatewayType()); // 删除过期的ConsumerRef记录 consumerRefRepository.delete(existingConsumerRef); // 重新创建消费者 gwConsumerId = gatewayService.createConsumer(consumer, credential, gatewayConfig); consumerRefRepository.save(ConsumerRef.builder() .consumerId(consumer.getConsumerId()) .gwConsumerId(gwConsumerId) .gatewayType(gatewayConfig.getGatewayType()) .gatewayConfig(gatewayConfig) .build()); } } else { // 如果不存在ConsumerRef记录,直接创建新的消费者 gwConsumerId = gatewayService.createConsumer(consumer, credential, gatewayConfig); consumerRefRepository.save(ConsumerRef.builder() .consumerId(consumer.getConsumerId()) .gwConsumerId(gwConsumerId) .gatewayType(gatewayConfig.getGatewayType()) .gatewayConfig(gatewayConfig) .build()); } // 授权 return gatewayService.authorizeConsumer(productRef.getGatewayId(), gwConsumerId, productRef); } /** * 检查消费者是否在实际网关中存在 */ private boolean isConsumerExistsInGateway(String gwConsumerId, GatewayConfig gatewayConfig) { try { return gatewayService.isConsumerExists(gwConsumerId, gatewayConfig); } catch (Exception e) { log.warn("检查网关消费者存在性失败: gwConsumerId={}, gatewayType={}", gwConsumerId, gatewayConfig.getGatewayType(), e); // 如果检查失败,默认认为存在,避免无谓的重新创建 return true; } } @EventListener @Async("taskExecutor") public void handleDeveloperDeletion(DeveloperDeletingEvent event) { String developerId = event.getDeveloperId(); log.info("Cleaning consumers for developer {}", developerId); List<Consumer> consumers = consumerRepository.findAllByDeveloperId(developerId); consumers.forEach(consumer -> { try { deleteConsumer(consumer.getConsumerId()); } catch (Exception e) { log.error("Failed to delete consumer {}", consumer.getConsumerId(), e); } }); } @EventListener @Async("taskExecutor") public void handleProductDeletion(ProductDeletingEvent event) { String productId = event.getProductId(); log.info("Cleaning subscriptions for product {}", productId); subscriptionRepository.deleteAllByProductId(productId); List<ProductSubscription> subscriptions = subscriptionRepository.findAllByProductId(productId); subscriptions.forEach(subscription -> { try { unsubscribeProduct(subscription.getConsumerId(), subscription.getProductId()); } catch (Exception e) { log.error("Failed to unsubscribe product {} for consumer {}", productId, subscription.getConsumerId(), e); } }); } private ConsumerRef matchConsumerRef(String consumerId, GatewayConfig gatewayConfig) { List<ConsumerRef> consumeRefs = consumerRefRepository.findAllByConsumerIdAndGatewayType(consumerId, gatewayConfig.getGatewayType()); if (consumeRefs.isEmpty()) { return null; } for (ConsumerRef ref : consumeRefs) { // 网关配置相同 if (StrUtil.equals(JSONUtil.toJsonStr(ref.getGatewayConfig()), JSONUtil.toJsonStr(gatewayConfig))) { return ref; } } return null; } } ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/service/gateway/APIGOperator.java: -------------------------------------------------------------------------------- ```java /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.alibaba.apiopenplatform.service.gateway; import cn.hutool.core.codec.Base64; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONUtil; import com.alibaba.apiopenplatform.dto.params.gateway.QueryAPIGParam; import com.alibaba.apiopenplatform.dto.result.*; import com.alibaba.apiopenplatform.support.consumer.APIGAuthConfig; import com.alibaba.apiopenplatform.support.consumer.ApiKeyConfig; import com.alibaba.apiopenplatform.support.consumer.ConsumerAuthConfig; import com.alibaba.apiopenplatform.support.consumer.HmacConfig; import com.alibaba.apiopenplatform.support.enums.APIGAPIType; import com.alibaba.apiopenplatform.core.exception.BusinessException; import com.alibaba.apiopenplatform.core.exception.ErrorCode; import com.alibaba.apiopenplatform.entity.Gateway; import com.alibaba.apiopenplatform.entity.Consumer; import com.alibaba.apiopenplatform.entity.ConsumerCredential; import com.alibaba.apiopenplatform.service.gateway.client.APIGClient; import com.alibaba.apiopenplatform.service.gateway.client.SLSClient; import com.alibaba.apiopenplatform.support.enums.GatewayType; import com.alibaba.apiopenplatform.support.gateway.GatewayConfig; import com.alibaba.apiopenplatform.support.product.APIGRefConfig; import com.aliyun.sdk.gateway.pop.exception.PopClientException; import com.aliyun.sdk.service.apig20240327.models.*; import com.aliyun.sdk.service.apig20240327.models.CreateConsumerAuthorizationRulesRequest.AuthorizationRules; import com.aliyun.sdk.service.apig20240327.models.CreateConsumerAuthorizationRulesRequest.ResourceIdentifier; import com.aliyun.sdk.service.sls20201230.models.*; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; @RequiredArgsConstructor @Service @Slf4j @Primary public class APIGOperator extends GatewayOperator<APIGClient> { @Override public PageResult<APIResult> fetchHTTPAPIs(Gateway gateway, int page, int size) { return fetchAPIs(gateway, APIGAPIType.HTTP, page, size); } public PageResult<APIResult> fetchRESTAPIs(Gateway gateway, int page, int size) { return fetchAPIs(gateway, APIGAPIType.REST, page, size); } @Override public PageResult<? extends GatewayMCPServerResult> fetchMcpServers(Gateway gateway, int page, int size) { throw new UnsupportedOperationException("APIG does not support MCP Servers"); } @Override public String fetchAPIConfig(Gateway gateway, Object config) { APIGClient client = getClient(gateway); try { APIGRefConfig apigRefConfig = (APIGRefConfig) config; CompletableFuture<ExportHttpApiResponse> f = client.execute(c -> { ExportHttpApiRequest request = ExportHttpApiRequest.builder() .httpApiId(apigRefConfig.getApiId()) .build(); return c.exportHttpApi(request); }); ExportHttpApiResponse response = f.join(); if (response.getStatusCode() != 200) { throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); } String contentBase64 = response.getBody().getData().getSpecContentBase64(); APIConfigResult configResult = new APIConfigResult(); // spec String apiSpec = Base64.decodeStr(contentBase64); configResult.setSpec(apiSpec); // meta APIConfigResult.APIMetadata meta = new APIConfigResult.APIMetadata(); meta.setSource(GatewayType.APIG_API.name()); meta.setType("REST"); configResult.setMeta(meta); return JSONUtil.toJsonStr(configResult); } catch (Exception e) { log.error("Error fetching API Spec", e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching API Spec,Cause:" + e.getMessage()); } } @Override public String fetchMcpConfig(Gateway gateway, Object conf) { throw new UnsupportedOperationException("APIG does not support MCP Servers"); } @Override public PageResult<GatewayResult> fetchGateways(Object param, int page, int size) { return fetchGateways((QueryAPIGParam) param, page, size); } public PageResult<GatewayResult> fetchGateways(QueryAPIGParam param, int page, int size) { APIGClient client = new APIGClient(param.convertTo()); List<GatewayResult> gateways = new ArrayList<>(); try { CompletableFuture<ListGatewaysResponse> f = client.execute(c -> { ListGatewaysRequest request = ListGatewaysRequest.builder() .gatewayType(param.getGatewayType().getType()) .pageNumber(page) .pageSize(size) .build(); return c.listGateways(request); }); ListGatewaysResponse response = f.join(); if (response.getStatusCode() != 200) { throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); } for (ListGatewaysResponseBody.Items item : response.getBody().getData().getItems()) { gateways.add(GatewayResult.builder() .gatewayName(item.getName()) .gatewayId(item.getGatewayId()) .gatewayType(param.getGatewayType()) .build()); } int total = Math.toIntExact(response.getBody().getData().getTotalSize()); return PageResult.of(gateways, page, size, total); } catch (Exception e) { log.error("Error fetching Gateways", e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching Gateways,Cause:" + e.getMessage()); } } protected String fetchGatewayEnv(Gateway gateway) { APIGClient client = getClient(gateway); try { CompletableFuture<GetGatewayResponse> f = client.execute(c -> { GetGatewayRequest request = GetGatewayRequest.builder() .gatewayId(gateway.getGatewayId()) .build(); return c.getGateway(request); }); GetGatewayResponse response = f.join(); if (response.getStatusCode() != 200) { throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); } List<GetGatewayResponseBody.Environments> environments = response.getBody().getData().getEnvironments(); if (CollUtil.isEmpty(environments)) { return null; } return environments.get(0).getEnvironmentId(); } catch (Exception e) { log.error("Error fetching Gateway", e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching Gateway,Cause:" + e.getMessage()); } } @Override public String createConsumer(Consumer consumer, ConsumerCredential credential, GatewayConfig config) { APIGClient client = new APIGClient(config.getApigConfig()); String mark = consumer.getConsumerId().substring(Math.max(0, consumer.getConsumerId().length() - 8)); String gwConsumerName = StrUtil.format("{}-{}", consumer.getName(), mark); try { // ApiKey ApiKeyIdentityConfig apikeyIdentityConfig = convertToApiKeyIdentityConfig(credential.getApiKeyConfig()); // Hmac List<AkSkIdentityConfig> akSkIdentityConfigs = convertToAkSkIdentityConfigs(credential.getHmacConfig()); CreateConsumerRequest.Builder builder = CreateConsumerRequest.builder() .name(gwConsumerName) .description("Created by HiMarket") .gatewayType(config.getGatewayType().getType()) .enable(true); if (apikeyIdentityConfig != null) { builder.apikeyIdentityConfig(apikeyIdentityConfig); } if (akSkIdentityConfigs != null) { builder.akSkIdentityConfigs(akSkIdentityConfigs); } CompletableFuture<CreateConsumerResponse> f = client.execute(c -> c.createConsumer(builder.build())); CreateConsumerResponse response = f.join(); if (response.getStatusCode() != 200) { throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); } return response.getBody().getData().getConsumerId(); } catch (Exception e) { Throwable cause = e.getCause(); // Consumer已经存在 if (cause instanceof PopClientException && "Conflict.ConsumerNameDuplicate".equals(((PopClientException) cause).getErrCode())) { return retrievalConsumer(gwConsumerName, config); } log.error("Error creating Consumer", e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error creating Consumer,Cause:" + e.getMessage()); } } private String retrievalConsumer(String name, GatewayConfig gatewayConfig) { APIGClient client = new APIGClient(gatewayConfig.getApigConfig()); try { CompletableFuture<ListConsumersResponse> f = client.execute(c -> { ListConsumersRequest request = ListConsumersRequest.builder() .gatewayType(gatewayConfig.getGatewayType().getType()) .nameLike(name) .pageNumber(1) .pageSize(10) .build(); return c.listConsumers(request); }); ListConsumersResponse response = f.join(); if (response.getStatusCode() != 200) { throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); } for (ListConsumersResponseBody.Items item : response.getBody().getData().getItems()) { if (StrUtil.equals(item.getName(), name)) { return item.getConsumerId(); } } } catch (Exception e) { log.error("Error fetching Consumer", e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching Consumer,Cause:" + e.getMessage()); } return null; } @Override public void updateConsumer(String consumerId, ConsumerCredential credential, GatewayConfig config) { APIGClient client = new APIGClient(config.getApigConfig()); try { // ApiKey ApiKeyIdentityConfig apikeyIdentityConfig = convertToApiKeyIdentityConfig(credential.getApiKeyConfig()); // Hmac List<AkSkIdentityConfig> akSkIdentityConfigs = convertToAkSkIdentityConfigs(credential.getHmacConfig()); UpdateConsumerRequest.Builder builder = UpdateConsumerRequest.builder() .enable(true) .consumerId(consumerId); if (apikeyIdentityConfig != null) { builder.apikeyIdentityConfig(apikeyIdentityConfig); } if (akSkIdentityConfigs != null) { builder.akSkIdentityConfigs(akSkIdentityConfigs); } CompletableFuture<UpdateConsumerResponse> f = client.execute(c -> c.updateConsumer(builder.build())); UpdateConsumerResponse response = f.join(); if (response.getStatusCode() != 200) { throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); } } catch (Exception e) { log.error("Error creating Consumer", e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error creating Consumer,Cause:" + e.getMessage()); } } @Override public void deleteConsumer(String consumerId, GatewayConfig config) { APIGClient client = new APIGClient(config.getApigConfig()); try { DeleteConsumerRequest request = DeleteConsumerRequest.builder() .consumerId(consumerId) .build(); client.execute(c -> { c.deleteConsumer(request); return null; }); } catch (Exception e) { log.error("Error deleting Consumer", e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error deleting Consumer,Cause:" + e.getMessage()); } } @Override public boolean isConsumerExists(String consumerId, GatewayConfig config) { // TODO: 实现APIG网关消费者存在性检查 return true; } @Override public ConsumerAuthConfig authorizeConsumer(Gateway gateway, String consumerId, Object refConfig) { APIGClient client = getClient(gateway); APIGRefConfig config = (APIGRefConfig) refConfig; // REST API 授权 String apiId = config.getApiId(); try { List<HttpApiOperationInfo> operations = fetchRESTOperations(gateway, apiId); if (CollUtil.isEmpty(operations)) { return null; } // 确认Gateway的EnvId String envId = fetchGatewayEnv(gateway); List<AuthorizationRules> rules = new ArrayList<>(); for (HttpApiOperationInfo operation : operations) { AuthorizationRules rule = AuthorizationRules.builder() .consumerId(consumerId) .expireMode("LongTerm") .resourceType("RestApiOperation") .resourceIdentifier(ResourceIdentifier.builder() .resourceId(operation.getOperationId()) .environmentId(envId).build()) .build(); rules.add(rule); } CompletableFuture<CreateConsumerAuthorizationRulesResponse> f = client.execute(c -> { CreateConsumerAuthorizationRulesRequest request = CreateConsumerAuthorizationRulesRequest.builder() .authorizationRules(rules) .build(); return c.createConsumerAuthorizationRules(request); }); CreateConsumerAuthorizationRulesResponse response = f.join(); if (200 != response.getStatusCode()) { throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); } APIGAuthConfig apigAuthConfig = APIGAuthConfig.builder() .authorizationRuleIds(response.getBody().getData().getConsumerAuthorizationRuleIds()) .build(); return ConsumerAuthConfig.builder() .apigAuthConfig(apigAuthConfig) .build(); } catch (Exception e) { log.error("Error authorizing consumer {} to apiId {} in APIG gateway {}", consumerId, apiId, gateway.getGatewayId(), e); throw new BusinessException(ErrorCode.GATEWAY_ERROR, "Failed to authorize consumer to apiId in APIG gateway: " + e.getMessage()); } } @Override public void revokeConsumerAuthorization(Gateway gateway, String consumerId, ConsumerAuthConfig authConfig) { APIGAuthConfig apigAuthConfig = authConfig.getApigAuthConfig(); if (apigAuthConfig == null) { return; } APIGClient client = getClient(gateway); try { BatchDeleteConsumerAuthorizationRuleRequest request = BatchDeleteConsumerAuthorizationRuleRequest.builder() .consumerAuthorizationRuleIds(StrUtil.join(",", apigAuthConfig.getAuthorizationRuleIds())) .build(); CompletableFuture<BatchDeleteConsumerAuthorizationRuleResponse> f = client.execute(c -> c.batchDeleteConsumerAuthorizationRule(request)); BatchDeleteConsumerAuthorizationRuleResponse response = f.join(); if (response.getStatusCode() != 200) { throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); } } catch (Exception e) { Throwable cause = e.getCause(); if (cause instanceof PopClientException && "DatabaseError.RecordNotFound".equals(((PopClientException) cause).getErrCode())) { log.warn("Consumer authorization rules[{}] not found, ignore", apigAuthConfig.getAuthorizationRuleIds()); return; } log.error("Error deleting Consumer Authorization", e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error deleting Consumer Authorization,Cause:" + e.getMessage()); } } @Override public GatewayType getGatewayType() { return GatewayType.APIG_API; } @Override public String getDashboard(Gateway gateway, String type) { SLSClient ticketClient = new SLSClient(gateway.getApigConfig(), true); String ticket = null; try { CreateTicketResponse response = ticketClient.execute(c -> { CreateTicketRequest request = CreateTicketRequest.builder().build(); try { return c.createTicket(request).get(); } catch (InterruptedException | ExecutionException e) { throw new RuntimeException(e); } }); ticket = response.getBody().getTicket(); } catch (Exception e) { log.error("Error fetching API", e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching createTicker API,Cause:" + e.getMessage()); } SLSClient client = new SLSClient(gateway.getApigConfig(), false); String projectName = null; try { ListProjectResponse response = client.execute(c -> { ListProjectRequest request = ListProjectRequest.builder().projectName("product").build(); try { return c.listProject(request).get(); } catch (InterruptedException | ExecutionException e) { throw new RuntimeException(e); } }); projectName = response.getBody().getProjects().get(0).getProjectName(); } catch (Exception e) { log.error("Error fetching Project", e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching Project,Cause:" + e.getMessage()); } String region = gateway.getApigConfig().getRegion(); String gatewayId = gateway.getGatewayId(); String dashboardId = ""; if (type.equals("Portal")) { dashboardId = "dashboard-1758009692051-393998"; } else if (type.equals("MCP")) { dashboardId = "dashboard-1757483808537-433375"; } else if (type.equals("API")) { dashboardId = "dashboard-1756276497392-966932"; } 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); log.info("Dashboard URL: {}", dashboardUrl); return dashboardUrl; } public APIResult fetchAPI(Gateway gateway, String apiId) { APIGClient client = getClient(gateway); try { CompletableFuture<GetHttpApiResponse> f = client.execute(c -> { GetHttpApiRequest request = GetHttpApiRequest.builder() .httpApiId(apiId) .build(); return c.getHttpApi(request); }); GetHttpApiResponse response = f.join(); if (response.getStatusCode() != 200) { throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); } HttpApiApiInfo apiInfo = response.getBody().getData(); return new APIResult().convertFrom(apiInfo); } catch (Exception e) { log.error("Error fetching API", e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching API,Cause:" + e.getMessage()); } } protected HttpRoute fetchHTTPRoute(Gateway gateway, String apiId, String routeId) { APIGClient client = getClient(gateway); try { CompletableFuture<GetHttpApiRouteResponse> f = client.execute(c -> { GetHttpApiRouteRequest request = GetHttpApiRouteRequest.builder() .httpApiId(apiId) .routeId(routeId) .build(); return c.getHttpApiRoute(request); }); GetHttpApiRouteResponse response = f.join(); if (response.getStatusCode() != 200) { throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); } return response.getBody().getData(); } catch (Exception e) { log.error("Error fetching HTTP Route", e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching HTTP Route,Cause:" + e.getMessage()); } } protected PageResult<APIResult> fetchAPIs(Gateway gateway, APIGAPIType type, int page, int size) { APIGClient client = getClient(gateway); try { List<APIResult> apis = new ArrayList<>(); CompletableFuture<ListHttpApisResponse> f = client.execute(c -> { ListHttpApisRequest request = ListHttpApisRequest.builder() .gatewayId(gateway.getGatewayId()) .gatewayType(gateway.getGatewayType().getType()) .types(type.getType()) .pageNumber(page) .pageSize(size) .build(); return c.listHttpApis(request); }); ListHttpApisResponse response = f.join(); if (response.getStatusCode() != 200) { throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); } for (HttpApiInfoByName item : response.getBody().getData().getItems()) { for (HttpApiApiInfo apiInfo : item.getVersionedHttpApis()) { APIResult apiResult = new APIResult().convertFrom(apiInfo); apis.add(apiResult); break; } } int total = response.getBody().getData().getTotalSize(); return PageResult.of(apis, page, size, total); } catch (Exception e) { log.error("Error fetching APIs", e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching APIs,Cause:" + e.getMessage()); } } public PageResult<HttpRoute> fetchHttpRoutes(Gateway gateway, String apiId, int page, int size) { APIGClient client = getClient(gateway); try { CompletableFuture<ListHttpApiRoutesResponse> f = client.execute(c -> { ListHttpApiRoutesRequest request = ListHttpApiRoutesRequest.builder() .gatewayId(gateway.getGatewayId()) .httpApiId(apiId) .pageNumber(page) .pageSize(size) .build(); return c.listHttpApiRoutes(request); }); ListHttpApiRoutesResponse response = f.join(); if (response.getStatusCode() != 200) { throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); } List<HttpRoute> httpRoutes = response.getBody().getData().getItems(); int total = response.getBody().getData().getTotalSize(); return PageResult.of(httpRoutes, page, size, total); } catch (Exception e) { log.error("Error fetching HTTP Roues", e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching HTTP Roues,Cause:" + e.getMessage()); } } public List<HttpApiOperationInfo> fetchRESTOperations(Gateway gateway, String apiId) { APIGClient client = getClient(gateway); try { CompletableFuture<ListHttpApiOperationsResponse> f = client.execute(c -> { ListHttpApiOperationsRequest request = ListHttpApiOperationsRequest.builder() .gatewayId(gateway.getGatewayId()) .httpApiId(apiId) .pageNumber(1) .pageSize(500) .build(); return c.listHttpApiOperations(request); }); ListHttpApiOperationsResponse response = f.join(); if (response.getStatusCode() != 200) { throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); } return response.getBody().getData().getItems(); } catch (Exception e) { log.error("Error fetching REST operations", e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching REST operations,Cause:" + e.getMessage()); } } protected ApiKeyIdentityConfig convertToApiKeyIdentityConfig(ApiKeyConfig config) { if (config == null) { return null; } // ApikeySource ApiKeyIdentityConfig.ApikeySource apikeySource = ApiKeyIdentityConfig.ApikeySource.builder() .source(config.getSource()) .value(config.getKey()) .build(); // credentials List<ApiKeyIdentityConfig.Credentials> credentials = config.getCredentials().stream() .map(cred -> ApiKeyIdentityConfig.Credentials.builder() .apikey(cred.getApiKey()) .generateMode("Custom") .build()) .collect(Collectors.toList()); return ApiKeyIdentityConfig.builder() .apikeySource(apikeySource) .credentials(credentials) .type("Apikey") .build(); } protected List<AkSkIdentityConfig> convertToAkSkIdentityConfigs(HmacConfig hmacConfig) { if (hmacConfig == null || hmacConfig.getCredentials() == null) { return null; } return hmacConfig.getCredentials().stream() .map(cred -> AkSkIdentityConfig.builder() .ak(cred.getAk()) .sk(cred.getSk()) .generateMode("Custom") .type("AkSk") .build()) .collect(Collectors.toList()); } } ``` -------------------------------------------------------------------------------- /portal-web/api-portal-frontend/src/components/consumer/CredentialManager.tsx: -------------------------------------------------------------------------------- ```typescript import {useState, useEffect} from "react"; import { Card, Button, message, Tabs, Modal, Radio, Input, Table, Popconfirm, Select, Form, } from "antd"; import { PlusOutlined, InfoCircleOutlined, CopyOutlined, DeleteOutlined, EditOutlined } from "@ant-design/icons"; import api from "../../lib/api"; import type { ConsumerCredentialResult, CreateCredentialParam, ConsumerCredential, HMACCredential, APIKeyCredential } from "../../types/consumer"; import type {ApiResponse} from "../../types"; interface CredentialManagerProps { consumerId: string; } export function CredentialManager({consumerId}: CredentialManagerProps) { const [credentialType, setCredentialType] = useState<'API_KEY' | 'HMAC'>('API_KEY'); const [credentialModalVisible, setCredentialModalVisible] = useState(false); const [credentialLoading, setCredentialLoading] = useState(false); const [sourceModalVisible, setSourceModalVisible] = useState(false); const [editingSource, setEditingSource] = useState<string>('Default'); const [editingKey, setEditingKey] = useState<string>('Authorization'); // 已保存(展示用)与编辑中的两套状态,取消时回滚到已保存值 const [currentSource, setCurrentSource] = useState<string>('Default'); const [currentKey, setCurrentKey] = useState<string>('Authorization'); // 表单(编辑凭证来源) const [sourceForm] = Form.useForm(); // 表单(创建凭证) const [credentialForm] = Form.useForm(); // 当前完整配置(驱动表格数据源) const [currentConfig, setCurrentConfig] = useState<ConsumerCredentialResult | null>(null); // 初始化时获取当前配置 const fetchCurrentConfig = async () => { try { const response: ApiResponse<ConsumerCredentialResult> = await api.get(`/consumers/${consumerId}/credentials`); if (response.code === "SUCCESS" && response.data) { const config = response.data; setCurrentConfig(config); if (config.apiKeyConfig) { setCurrentSource(config.apiKeyConfig.source || 'Default'); setCurrentKey(config.apiKeyConfig.key || 'Authorization'); } } } catch (error) { console.error('获取当前配置失败:', error); } }; // 组件挂载时获取配置 useEffect(() => { fetchCurrentConfig(); }, [consumerId]); const handleCreateCredential = async () => { try { const values = await credentialForm.validateFields(); setCredentialLoading(true); // 先获取当前的凭证配置 const currentResponse: ApiResponse<ConsumerCredentialResult> = await api.get(`/consumers/${consumerId}/credentials`); let currentConfig: ConsumerCredentialResult = {}; if (currentResponse.code === "SUCCESS" && currentResponse.data) { currentConfig = currentResponse.data; } // 构建新的凭证配置 const param: CreateCredentialParam = { ...currentConfig, }; if (credentialType === 'API_KEY') { const newCredential: ConsumerCredential = { apiKey: values.generationMethod === 'CUSTOM' ? values.customApiKey : generateRandomCredential('apiKey'), mode: values.generationMethod }; param.apiKeyConfig = { ...currentConfig.apiKeyConfig, credentials: [...(currentConfig.apiKeyConfig?.credentials || []), newCredential] }; } else if (credentialType === 'HMAC') { const newCredential: ConsumerCredential = { ak: values.generationMethod === 'CUSTOM' ? values.customAccessKey : generateRandomCredential('accessKey'), sk: values.generationMethod === 'CUSTOM' ? values.customSecretKey : generateRandomCredential('secretKey'), mode: values.generationMethod }; param.hmacConfig = { ...currentConfig.hmacConfig, credentials: [...(currentConfig.hmacConfig?.credentials || []), newCredential] }; } const response: ApiResponse<ConsumerCredentialResult> = await api.put(`/consumers/${consumerId}/credentials`, param); if (response?.code === "SUCCESS") { message.success('凭证添加成功'); setCredentialModalVisible(false); resetCredentialForm(); // 刷新当前配置以驱动表格 await fetchCurrentConfig(); } } catch (error) { console.error('创建凭证失败:', error); // message.error('创建凭证失败'); } finally { setCredentialLoading(false); } }; const handleDeleteCredential = async (credentialType: string, credential: ConsumerCredential) => { try { // 先获取当前的凭证配置 const currentResponse: ApiResponse<ConsumerCredentialResult> = await api.get(`/consumers/${consumerId}/credentials`); let currentConfig: ConsumerCredentialResult = {}; if (currentResponse.code === "SUCCESS" && currentResponse.data) { currentConfig = currentResponse.data; } // 构建删除后的凭证配置,清空对应类型的凭证 const param: CreateCredentialParam = { ...currentConfig, }; if (credentialType === 'API_KEY') { param.apiKeyConfig = { credentials: currentConfig.apiKeyConfig?.credentials?.filter(cred => cred.apiKey !== (credential as APIKeyCredential).apiKey), source: currentConfig.apiKeyConfig?.source || 'Default', key: currentConfig.apiKeyConfig?.key || 'Authorization' }; } else if (credentialType === 'HMAC') { param.hmacConfig = { credentials: currentConfig.hmacConfig?.credentials?.filter(cred => cred.ak !== (credential as HMACCredential).ak), }; } const response: ApiResponse<ConsumerCredentialResult> = await api.put(`/consumers/${consumerId}/credentials`, param); if (response?.code === "SUCCESS") { message.success('凭证删除成功'); await fetchCurrentConfig(); } } catch (error) { console.error('删除凭证失败:', error); // message.error('删除凭证失败'); } }; const handleCopyCredential = (text: string) => { const textArea = document.createElement('textarea'); textArea.value = text; textArea.style.position = 'fixed'; textArea.style.left = '-9999px'; // 避免影响页面布局 document.body.appendChild(textArea); textArea.focus(); textArea.select(); try { const success = document.execCommand('copy'); if (success) { message.success('已复制到剪贴板'); } else { // message.error('复制失败,请手动复制内容'); } } catch (err) { // message.error('复制失败,请手动复制内容'); } finally { document.body.removeChild(textArea); // 清理 DOM } }; const resetCredentialForm = () => { credentialForm.resetFields(); }; const handleEditSource = async (source: string, key: string) => { try { // 先获取当前的凭证配置 const currentResponse: ApiResponse<ConsumerCredentialResult> = await api.get(`/consumers/${consumerId}/credentials`); let currentConfig: ConsumerCredentialResult = {}; if (currentResponse.code === "SUCCESS" && currentResponse.data) { currentConfig = currentResponse.data as ConsumerCredentialResult; } // 构建新的凭证配置 const param: CreateCredentialParam = {}; // 更新API Key配置的source和key if (currentConfig.apiKeyConfig) { param.apiKeyConfig = { source: source, key: source === 'Default' ? 'Authorization' : key, credentials: currentConfig.apiKeyConfig.credentials }; } else { param.apiKeyConfig = { source: source, key: source === 'Default' ? 'Authorization' : key, credentials: [] }; } // 提交配置到后端 const response: ApiResponse<ConsumerCredentialResult> = await api.put(`/consumers/${consumerId}/credentials`, param); if (response?.code === "SUCCESS") { message.success('凭证来源更新成功'); // 重新查询接口获取最新配置,确保数据落盘 const updatedResponse: ApiResponse<ConsumerCredentialResult> = await api.get(`/consumers/${consumerId}/credentials`); if (updatedResponse.code === "SUCCESS" && updatedResponse.data) { const updatedConfig = updatedResponse.data; if (updatedConfig.apiKeyConfig) { setCurrentSource(updatedConfig.apiKeyConfig.source || 'Default'); setCurrentKey(updatedConfig.apiKeyConfig.key || 'Authorization'); } } setSourceModalVisible(false); await fetchCurrentConfig(); } } catch (error) { console.error('更新凭证来源失败:', error); // message.error('更新凭证来源失败'); } }; const openSourceModal = () => { // 打开弹窗前将已保存值拷贝到编辑态和表单 const initSource = currentSource; const initKey = initSource === 'Default' ? 'Authorization' : currentKey; setEditingSource(initSource); setEditingKey(initKey); sourceForm.setFieldsValue({source: initSource, key: initKey}); setSourceModalVisible(true); }; const openCredentialModal = () => { // 打开弹窗前重置表单并设置初始值 credentialForm.resetFields(); credentialForm.setFieldsValue({ generationMethod: 'SYSTEM', customApiKey: '', customAccessKey: '', customSecretKey: '' }); setCredentialModalVisible(true); }; // 生成随机凭证 const generateRandomCredential = (type: 'apiKey' | 'accessKey' | 'secretKey'): string => { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-'; if (type === 'apiKey') { // 生成32位API Key const apiKey = Array.from({length: 32}, () => chars.charAt(Math.floor(Math.random() * chars.length))).join(''); // 确保表单字段已经渲染并设置值 const setValue = () => { try { credentialForm.setFieldsValue({customApiKey: apiKey}); } catch (error) { console.error('设置API Key失败:', error); } }; // 如果表单已经渲染,立即设置;否则延迟设置 if (credentialForm.getFieldValue('customApiKey') !== undefined) { setValue(); } else { setTimeout(setValue, 100); } return apiKey; } else { // 生成32位Access Key和64位Secret Key const ak = Array.from({length: 32}, () => chars.charAt(Math.floor(Math.random() * chars.length))).join(''); const sk = Array.from({length: 64}, () => chars.charAt(Math.floor(Math.random() * chars.length))).join(''); // 确保表单字段已经渲染并设置值 const setValue = () => { try { credentialForm.setFieldsValue({ customAccessKey: ak, customSecretKey: sk }); } catch (error) { console.error('设置AK/SK失败:', error); } }; // 如果表单已经渲染,立即设置;否则延迟设置 if (credentialForm.getFieldValue('customAccessKey') !== undefined) { setValue(); } else { setTimeout(setValue, 100); } // 根据类型返回对应的值 return type === 'accessKey' ? ak : sk; } }; // API Key 列 const apiKeyColumns = [ { title: 'API Key', dataIndex: 'apiKey', key: 'apiKey', render: (apiKey: string) => ( <div className="flex items-center space-x-2"> <code className="text-sm bg-gray-100 px-2 py-1 rounded">{apiKey}</code> <Button type="text" size="small" icon={<CopyOutlined/>} onClick={() => handleCopyCredential(apiKey)}/> </div> ), }, { title: '操作', key: 'action', render: (record: ConsumerCredential) => ( <Popconfirm title="确定要删除该API Key凭证吗?" onConfirm={() => handleDeleteCredential('API_KEY', record)}> <Button type="link" danger size="small" icon={<DeleteOutlined/>}>删除</Button> </Popconfirm> ), }, ]; // 脱敏函数 const maskSecretKey = (secretKey: string): string => { if (!secretKey || secretKey.length < 8) return secretKey; return secretKey.substring(0, 4) + '*'.repeat(secretKey.length - 8) + secretKey.substring(secretKey.length - 4); }; // HMAC 列 const hmacColumns = [ { title: 'Access Key', dataIndex: 'ak', key: 'ak', render: (ak: string) => ( <div className="flex items-center space-x-2"> <code className="text-sm bg-gray-100 px-2 py-1 rounded">{ak}</code> <Button type="text" size="small" icon={<CopyOutlined/>} onClick={() => handleCopyCredential(ak)}/> </div> ), }, { title: 'Secret Key', dataIndex: 'sk', key: 'sk', render: (sk: string) => ( <div className="flex items-center space-x-2"> <code className="text-sm bg-gray-100 px-2 py-1 rounded">{maskSecretKey(sk)}</code> <Button type="text" size="small" icon={<CopyOutlined/>} onClick={() => handleCopyCredential(sk)}/> </div> ), }, { title: '操作', key: 'action', render: (record: ConsumerCredential) => ( <Popconfirm title="确定要删除该AK/SK凭证吗?" onConfirm={() => handleDeleteCredential('HMAC', record)}> <Button type="link" danger size="small" icon={<DeleteOutlined/>}>删除</Button> </Popconfirm> ), }, ]; return ( <> <Card title="认证方式"> <Tabs defaultActiveKey="API_KEY"> <Tabs.TabPane tab="API Key" key="API_KEY"> <div className="mb-4"> <div className="flex items-start space-x-2 mb-4"> <InfoCircleOutlined className="text-blue-500 mt-1"/> <div className="text-sm text-gray-600"> API Key是一种简单的认证方式,客户端需要在请求中添加凭证,网关会验证API Key的合法性和权限。 API Key常用于简单场景,不涉及敏感操作,安全性相对较低,请注意凭证的管理与保护。 </div> </div> {/* 凭证来源配置(展示已保存值)*/} <div className="mb-4 p-3 bg-gray-50 rounded border"> <div className="flex items-center gap-2 mb-2"> <span className="text-sm font-medium text-gray-700">凭证来源</span> <Button type="link" size="small" icon={<EditOutlined/>} onClick={openSourceModal}> 编辑 </Button> </div> {/* <div className="text-sm text-gray-600"> {currentSource === 'Default' ? '' : `${currentSource}`} </div> */} <div className="text-sm text-gray-600"> {currentSource === 'Default' ? 'Authorization: Bearer <token>' : `${currentSource}:${currentKey}`} </div> </div> <Button type="primary" icon={<PlusOutlined/>} onClick={() => { setCredentialType('API_KEY'); openCredentialModal(); }} > 添加凭证 </Button> </div> <Table columns={apiKeyColumns} dataSource={currentConfig?.apiKeyConfig?.credentials || []} rowKey={(record) => record.apiKey || Math.random().toString()} pagination={false} size="small" locale={{emptyText: '暂无API Key凭证,请点击上方按钮创建'}} /> </Tabs.TabPane> <Tabs.TabPane tab="HMAC" key="HMAC"> <div className="mb-4"> <div className="flex items-start space-x-2 mb-4"> <InfoCircleOutlined className="text-blue-500 mt-1"/> <div className="text-sm text-gray-600"> 一种基于HMAC算法的AK/SK签名认证方式。客户端在调用API时,需要使用签名密钥对请求内容进行签名计算, 并将签名同步传输给服务器端进行签名验证。 </div> </div> <Button type="primary" icon={<PlusOutlined/>} onClick={() => { setCredentialType('HMAC'); openCredentialModal(); }} > 添加AK/SK </Button> </div> <Table columns={hmacColumns} dataSource={currentConfig?.hmacConfig?.credentials || []} rowKey={(record) => record.ak || record.sk || Math.random().toString()} pagination={false} size="small" locale={{emptyText: '暂无AK/SK凭证,请点击上方按钮创建'}} /> </Tabs.TabPane> <Tabs.TabPane tab="JWT" key="JWT" disabled> <div className="text-center py-8 text-gray-500"> JWT功能暂未开放 </div> </Tabs.TabPane> </Tabs> </Card> {/* 创建凭证模态框 */} <Modal title={`添加 ${credentialType === 'API_KEY' ? 'API Key' : 'AK/SK'}`} open={credentialModalVisible} onCancel={() => { setCredentialModalVisible(false); resetCredentialForm(); }} onOk={handleCreateCredential} confirmLoading={credentialLoading} okText="添加" cancelText="取消" > <Form form={credentialForm} initialValues={{ generationMethod: 'SYSTEM', customApiKey: '', customAccessKey: '', customSecretKey: '' }}> <div className="mb-4"> <div className="mb-2"> <span className="text-red-500 mr-1">*</span> <span>生成方式</span> </div> <Form.Item name="generationMethod" rules={[{required: true, message: '请选择生成方式'}]} className="mb-0" > <Radio.Group> <Radio value="SYSTEM">系统生成</Radio> <Radio value="CUSTOM">自定义</Radio> </Radio.Group> </Form.Item> </div> <Form.Item noStyle shouldUpdate={(prev, curr) => prev.generationMethod !== curr.generationMethod}> {({getFieldValue}) => { const method = getFieldValue('generationMethod'); if (method === 'CUSTOM') { return ( <> {credentialType === 'API_KEY' && ( <div className="mb-4"> <div className="mb-2"> <span className="text-red-500 mr-1">*</span> <span>凭证</span> </div> <Form.Item name="customApiKey" rules={[ {required: true, message: '请输入自定义API Key'}, { pattern: /^[A-Za-z0-9_-]+$/, message: '支持英文、数字、下划线(_)和短横线(-)' }, {min: 8, message: 'API Key长度至少8个字符'}, {max: 128, message: 'API Key长度不能超过128个字符'} ]} className="mb-2" > <Input placeholder="请输入凭证" maxLength={128}/> </Form.Item> <div className="text-xs text-gray-500"> 长度为8-128个字符,可包含英文、数字、下划线(_)和短横线(-) </div> </div> )} {credentialType === 'HMAC' && ( <> <div className="mb-4"> <div className="mb-2"> <span className="text-red-500 mr-1">*</span> <span>Access Key</span> </div> <Form.Item name="customAccessKey" rules={[ {required: true, message: '请输入自定义Access Key'}, { pattern: /^[A-Za-z0-9_-]+$/, message: '支持英文、数字、下划线(_)和短横线(-)' }, {min: 8, message: 'Access Key长度至少8个字符'}, {max: 128, message: 'Access Key长度不能超过128个字符'} ]} className="mb-2" > <Input placeholder="请输入Access Key" maxLength={128}/> </Form.Item> <div className="text-xs text-gray-500"> 长度为8-128个字符,可包含英文、数字、下划线(_)和短横线(-) </div> </div> <div className="mb-4"> <div className="mb-2"> <span className="text-red-500 mr-1">*</span> <span>Secret Key</span> </div> <Form.Item name="customSecretKey" rules={[ {required: true, message: '请输入自定义Secret Key'}, { pattern: /^[A-Za-z0-9_-]+$/, message: '支持英文、数字、下划线(_)和短横线(-)' }, {min: 8, message: 'Secret Key长度至少8个字符'}, {max: 128, message: 'Secret Key长度不能超过128个字符'} ]} className="mb-2" > <Input placeholder="请输入 Secret Key" maxLength={128}/> </Form.Item> <div className="text-xs text-gray-500"> 长度为8-128个字符,可包含英文、数字、下划线(_)和短横线(-) </div> </div> </> )} </> ); } else if (method === 'SYSTEM') { return ( <div> <div className="flex items-center gap-2 text-sm text-gray-500"> <InfoCircleOutlined/> <span>系统将自动生成符合规范的凭证</span> </div> </div> ); } return null; }} </Form.Item> </Form> </Modal> {/* 编辑凭证来源模态框 */} <Modal title="编辑凭证来源" open={sourceModalVisible} onCancel={() => { // 取消不落盘,回退到已保存值并重置表单 const initSource = currentSource; const initKey = initSource === 'Default' ? 'Authorization' : currentKey; setEditingSource(initSource); setEditingKey(initKey); sourceForm.resetFields(); setSourceModalVisible(false); }} onOk={async () => { try { const values = await sourceForm.validateFields(); setEditingSource(values.source); setEditingKey(values.key); await handleEditSource(values.source, values.key); } catch { // 校验失败,不提交 } }} okText="保存" cancelText="取消" > <Form form={sourceForm} layout="vertical" initialValues={{source: editingSource, key: editingKey}}> <Form.Item label="凭证来源" name="source" rules={[{required: true, message: '请选择凭证来源'}]} > <Select onChange={(value) => { const nextKey = value === 'Default' ? 'Authorization' : ''; sourceForm.setFieldsValue({key: nextKey}); }} style={{width: '100%'}} > <Select.Option value="Header">Header</Select.Option> <Select.Option value="QueryString">QueryString</Select.Option> <Select.Option value="Default">默认</Select.Option> </Select> </Form.Item> <Form.Item noStyle shouldUpdate={(prev, curr) => prev.source !== curr.source}> {({getFieldValue}) => getFieldValue('source') !== 'Default' ? ( <Form.Item label="键名" name="key" rules={[ { required: true, message: '请输入键名', }, { pattern: /^[A-Za-z0-9-_]+$/, message: '仅支持字母/数字/-/_', }, ]} > <Input placeholder="请输入键名"/> </Form.Item> ) : null } </Form.Item> {/* <div className="text-sm text-gray-500"> <div>说明:</div> <div>• Header: 凭证放在HTTP请求头中</div> <div>• QueryString: 凭证放在URL查询参数中</div> <div>• Default: 使用标准的Authorization头</div> </div> */} </Form> </Modal> </> ); } ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/api-product/ApiProductLinkApi.tsx: -------------------------------------------------------------------------------- ```typescript import { Card, Button, Modal, Form, Select, message, Collapse, Tabs, Row, Col } from 'antd' import { PlusOutlined, DeleteOutlined, ExclamationCircleOutlined, CopyOutlined } from '@ant-design/icons' import { useState, useEffect } from 'react' import type { ApiProduct, LinkedService, RestAPIItem, HigressMCPItem, NacosMCPItem, APIGAIMCPItem, ApiItem } from '@/types/api-product' import type { Gateway, NacosInstance } from '@/types/gateway' import { apiProductApi, gatewayApi, nacosApi } from '@/lib/api' import { getGatewayTypeLabel } from '@/lib/constant' import { copyToClipboard } from '@/lib/utils' import * as yaml from 'js-yaml' import { SwaggerUIWrapper } from './SwaggerUIWrapper' interface ApiProductLinkApiProps { apiProduct: ApiProduct linkedService: LinkedService | null onLinkedServiceUpdate: (linkedService: LinkedService | null) => void handleRefresh: () => void } export function ApiProductLinkApi({ apiProduct, linkedService, onLinkedServiceUpdate, handleRefresh }: ApiProductLinkApiProps) { // 移除了内部的 linkedService 状态,现在从 props 接收 const [isModalVisible, setIsModalVisible] = useState(false) const [form] = Form.useForm() const [gateways, setGateways] = useState<Gateway[]>([]) const [nacosInstances, setNacosInstances] = useState<NacosInstance[]>([]) const [gatewayLoading, setGatewayLoading] = useState(false) const [nacosLoading, setNacosLoading] = useState(false) const [selectedGateway, setSelectedGateway] = useState<Gateway | null>(null) const [selectedNacos, setSelectedNacos] = useState<NacosInstance | null>(null) const [nacosNamespaces, setNacosNamespaces] = useState<any[]>([]) const [selectedNamespace, setSelectedNamespace] = useState<string | null>(null) const [apiList, setApiList] = useState<ApiItem[] | NacosMCPItem[]>([]) const [apiLoading, setApiLoading] = useState(false) const [sourceType, setSourceType] = useState<'GATEWAY' | 'NACOS'>('GATEWAY') const [parsedTools, setParsedTools] = useState<Array<{ name: string; description: string; args?: Array<{ name: string; description: string; type: string; required: boolean; position: string; default?: string; enum?: string[]; }>; }>>([]) const [httpJson, setHttpJson] = useState('') const [sseJson, setSseJson] = useState('') const [localJson, setLocalJson] = useState('') useEffect(() => { fetchGateways() fetchNacosInstances() }, []) // 解析MCP tools配置 useEffect(() => { if (apiProduct.type === 'MCP_SERVER' && apiProduct.mcpConfig?.tools) { const parsedConfig = parseYamlConfig(apiProduct.mcpConfig.tools) if (parsedConfig && parsedConfig.tools && Array.isArray(parsedConfig.tools)) { setParsedTools(parsedConfig.tools) } else { // 如果tools字段存在但是空数组,也设置为空数组 setParsedTools([]) } } else { setParsedTools([]) } }, [apiProduct]) // 生成连接配置 useEffect(() => { if (apiProduct.type === 'MCP_SERVER' && apiProduct.mcpConfig) { generateConnectionConfig( apiProduct.mcpConfig.mcpServerConfig.domains, apiProduct.mcpConfig.mcpServerConfig.path, apiProduct.mcpConfig.mcpServerName, apiProduct.mcpConfig.mcpServerConfig.rawConfig, apiProduct.mcpConfig.meta?.protocol ) } }, [apiProduct]) // 解析YAML配置的函数 const parseYamlConfig = (yamlString: string): { tools?: Array<{ name: string; description: string; args?: Array<{ name: string; description: string; type: string; required: boolean; position: string; default?: string; enum?: string[]; }>; }>; } | null => { try { const parsed = yaml.load(yamlString) as { tools?: Array<{ name: string; description: string; args?: Array<{ name: string; description: string; type: string; required: boolean; position: string; default?: string; enum?: string[]; }>; }>; }; return parsed; } catch (error) { console.error('YAML解析失败:', error) return null } } // 生成连接配置 const generateConnectionConfig = ( domains: Array<{ domain: string; protocol: string }> | null | undefined, path: string | null | undefined, serverName: string, localConfig?: unknown, protocolType?: string ) => { // 互斥:优先判断本地模式 if (localConfig) { const localConfigJson = JSON.stringify(localConfig, null, 2); setLocalJson(localConfigJson); setHttpJson(""); setSseJson(""); return; } // HTTP/SSE 模式 if (domains && domains.length > 0 && path) { const domain = domains[0] const fullUrl = `${domain.protocol}://${domain.domain}${path || '/'}` if (protocolType === 'SSE') { // 仅生成SSE配置,不追加/sse const sseConfig = { mcpServers: { [serverName]: { type: "sse", url: fullUrl } } } setSseJson(JSON.stringify(sseConfig, null, 2)) setHttpJson("") setLocalJson("") return; } else if (protocolType === 'StreamableHTTP') { // 仅生成HTTP配置 const httpConfig = { mcpServers: { [serverName]: { url: fullUrl } } } setHttpJson(JSON.stringify(httpConfig, null, 2)) setSseJson("") setLocalJson("") return; } else { // protocol为null或其他值:生成两种配置 const sseConfig = { mcpServers: { [serverName]: { type: "sse", url: `${fullUrl}/sse` } } } const httpConfig = { mcpServers: { [serverName]: { url: fullUrl } } } setSseJson(JSON.stringify(sseConfig, null, 2)) setHttpJson(JSON.stringify(httpConfig, null, 2)) setLocalJson("") return; } } // 无有效配置 setHttpJson(""); setSseJson(""); setLocalJson(""); } const handleCopy = async (text: string) => { try { await copyToClipboard(text); message.success("已复制到剪贴板"); } catch { message.error("复制失败,请手动复制"); } } const fetchGateways = async () => { setGatewayLoading(true) try { const res = await gatewayApi.getGateways() const result = apiProduct.type === 'REST_API' ? res.data?.content?.filter?.((item: Gateway) => item.gatewayType === 'APIG_API') : res.data?.content?.filter?.((item: Gateway) => item.gatewayType === 'HIGRESS' || item.gatewayType === 'APIG_AI' || item.gatewayType === 'ADP_AI_GATEWAY') setGateways(result || []) } catch (error) { console.error('获取网关列表失败:', error) } finally { setGatewayLoading(false) } } const fetchNacosInstances = async () => { setNacosLoading(true) try { const res = await nacosApi.getNacos({ page: 1, size: 1000 // 获取所有 Nacos 实例 }) setNacosInstances(res.data.content || []) } catch (error) { console.error('获取Nacos实例列表失败:', error) } finally { setNacosLoading(false) } } const handleSourceTypeChange = (value: 'GATEWAY' | 'NACOS') => { setSourceType(value) setSelectedGateway(null) setSelectedNacos(null) setSelectedNamespace(null) setNacosNamespaces([]) setApiList([]) form.setFieldsValue({ gatewayId: undefined, nacosId: undefined, apiId: undefined }) } const handleGatewayChange = async (gatewayId: string) => { const gateway = gateways.find(g => g.gatewayId === gatewayId) setSelectedGateway(gateway || null) if (!gateway) return setApiLoading(true) try { if (gateway.gatewayType === 'APIG_API') { // APIG_API类型:获取REST API列表 const restRes = await gatewayApi.getGatewayRestApis(gatewayId, {}) const restApis = (restRes.data?.content || []).map((api: any) => ({ apiId: api.apiId, apiName: api.apiName, type: 'REST API' })) setApiList(restApis) } else if (gateway.gatewayType === 'HIGRESS') { // HIGRESS类型:获取MCP Server列表 const res = await gatewayApi.getGatewayMcpServers(gatewayId, { page: 1, size: 1000 // 获取所有MCP Server }) const mcpServers = (res.data?.content || []).map((api: any) => ({ mcpServerName: api.mcpServerName, fromGatewayType: 'HIGRESS' as const, type: 'MCP Server' })) setApiList(mcpServers) } else if (gateway.gatewayType === 'APIG_AI') { // APIG_AI类型:获取MCP Server列表 const res = await gatewayApi.getGatewayMcpServers(gatewayId, { page: 1, size: 500 // 获取所有MCP Server }) const mcpServers = (res.data?.content || []).map((api: any) => ({ mcpServerName: api.mcpServerName, fromGatewayType: 'APIG_AI' as const, mcpRouteId: api.mcpRouteId, apiId: api.apiId, mcpServerId: api.mcpServerId, type: 'MCP Server' })) setApiList(mcpServers) } else if (gateway.gatewayType === 'ADP_AI_GATEWAY') { // ADP_AI_GATEWAY类型:获取MCP Server列表 const res = await gatewayApi.getGatewayMcpServers(gatewayId, { page: 1, size: 500 // 获取所有MCP Server }) const mcpServers = (res.data?.content || []).map((api: any) => ({ mcpServerName: api.mcpServerName || api.name, fromGatewayType: 'ADP_AI_GATEWAY' as const, mcpRouteId: api.mcpRouteId, mcpServerId: api.mcpServerId, type: 'MCP Server' })) setApiList(mcpServers) } } catch (error) { } finally { setApiLoading(false) } } const handleNacosChange = async (nacosId: string) => { const nacos = nacosInstances.find(n => n.nacosId === nacosId) setSelectedNacos(nacos || null) setSelectedNamespace(null) setApiList([]) setNacosNamespaces([]) if (!nacos) return // 获取命名空间列表 try { const nsRes = await nacosApi.getNamespaces(nacosId, { page: 1, size: 1000 }) const namespaces = (nsRes.data?.content || []).map((ns: any) => ({ namespaceId: ns.namespaceId, namespaceName: ns.namespaceName || ns.namespaceId, namespaceDesc: ns.namespaceDesc })) setNacosNamespaces(namespaces) } catch (e) { console.error('获取命名空间失败', e) } } const handleNamespaceChange = async (namespaceId: string) => { setSelectedNamespace(namespaceId) setApiLoading(true) try { if (!selectedNacos) return const res = await nacosApi.getNacosMcpServers(selectedNacos.nacosId, { page: 1, size: 1000, namespaceId }) const mcpServers = (res.data?.content || []).map((api: any) => ({ mcpServerName: api.mcpServerName, fromGatewayType: 'NACOS' as const, type: `MCP Server (${namespaceId})` })) setApiList(mcpServers) } catch (e) { console.error('获取Nacos MCP Server列表失败:', e) } finally { setApiLoading(false) } } const handleModalOk = () => { form.validateFields().then((values) => { const { sourceType, gatewayId, nacosId, apiId } = values const selectedApi = apiList.find(item => { if ('apiId' in item) { // mcp server 会返回apiId和mcpRouteId,此时mcpRouteId为唯一值,apiId不是 if ('mcpRouteId' in item) { return item.mcpRouteId === apiId } else { return item.apiId === apiId } } else if ('mcpServerName' in item) { return item.mcpServerName === apiId } return false }) const newService: LinkedService = { gatewayId: sourceType === 'GATEWAY' ? gatewayId : undefined, // 对于 Nacos,使用 nacosId 作为 gatewayId nacosId: sourceType === 'NACOS' ? nacosId : undefined, sourceType, productId: apiProduct.productId, apigRefConfig: selectedApi && 'apiId' in selectedApi ? selectedApi as RestAPIItem | APIGAIMCPItem : undefined, higressRefConfig: selectedApi && 'mcpServerName' in selectedApi && 'fromGatewayType' in selectedApi && selectedApi.fromGatewayType === 'HIGRESS' ? selectedApi as HigressMCPItem : undefined, nacosRefConfig: sourceType === 'NACOS' && selectedApi && 'fromGatewayType' in selectedApi && selectedApi.fromGatewayType === 'NACOS' ? { ...selectedApi, namespaceId: selectedNamespace || 'public' } : undefined, adpAIGatewayRefConfig: selectedApi && 'fromGatewayType' in selectedApi && selectedApi.fromGatewayType === 'ADP_AI_GATEWAY' ? selectedApi as APIGAIMCPItem : undefined, } apiProductApi.createApiProductRef(apiProduct.productId, newService).then(async () => { message.success('关联成功') setIsModalVisible(false) // 重新获取关联信息并更新 try { const res = await apiProductApi.getApiProductRef(apiProduct.productId) onLinkedServiceUpdate(res.data || null) } catch (error) { console.error('获取关联API失败:', error) onLinkedServiceUpdate(null) } // 重新获取产品详情(特别重要,因为关联API后apiProduct.apiConfig可能会更新) handleRefresh() form.resetFields() setSelectedGateway(null) setSelectedNacos(null) setApiList([]) setSourceType('GATEWAY') }).catch(() => { message.error('关联失败') }) }) } const handleModalCancel = () => { setIsModalVisible(false) form.resetFields() setSelectedGateway(null) setSelectedNacos(null) setApiList([]) setSourceType('GATEWAY') } const handleDelete = () => { if (!linkedService) return Modal.confirm({ title: '确认解除关联', content: '确定要解除与当前API的关联吗?', icon: <ExclamationCircleOutlined />, onOk() { return apiProductApi.deleteApiProductRef(apiProduct.productId).then(() => { message.success('解除关联成功') onLinkedServiceUpdate(null) // 重新获取产品详情(解除关联后apiProduct.apiConfig可能会更新) handleRefresh() }).catch(() => { message.error('解除关联失败') }) } }) } const getServiceInfo = () => { if (!linkedService) return null let apiName = '' let apiType = '' let sourceInfo = '' let gatewayInfo = '' // 首先根据 Product 的 type 确定基本类型 if (apiProduct.type === 'REST_API') { // REST API 类型产品 - 只能关联 API 网关上的 REST API if (linkedService.sourceType === 'GATEWAY' && linkedService.apigRefConfig && 'apiName' in linkedService.apigRefConfig) { apiName = linkedService.apigRefConfig.apiName || '未命名' apiType = 'REST API' sourceInfo = 'API网关' gatewayInfo = linkedService.gatewayId || '未知' } } else if (apiProduct.type === 'MCP_SERVER') { // MCP Server 类型产品 - 可以关联多种平台上的 MCP Server apiType = 'MCP Server' if (linkedService.sourceType === 'GATEWAY' && linkedService.apigRefConfig && 'mcpServerName' in linkedService.apigRefConfig) { // AI网关上的MCP Server apiName = linkedService.apigRefConfig.mcpServerName || '未命名' sourceInfo = 'AI网关' gatewayInfo = linkedService.gatewayId || '未知' } else if (linkedService.sourceType === 'GATEWAY' && linkedService.higressRefConfig) { // Higress网关上的MCP Server apiName = linkedService.higressRefConfig.mcpServerName || '未命名' sourceInfo = 'Higress网关' gatewayInfo = linkedService.gatewayId || '未知' } else if (linkedService.sourceType === 'GATEWAY' && linkedService.adpAIGatewayRefConfig) { // 专有云AI网关上的MCP Server apiName = linkedService.adpAIGatewayRefConfig.mcpServerName || '未命名' sourceInfo = '专有云AI网关' gatewayInfo = linkedService.gatewayId || '未知' } else if (linkedService.sourceType === 'NACOS' && linkedService.nacosRefConfig) { // Nacos上的MCP Server apiName = linkedService.nacosRefConfig.mcpServerName || '未命名' sourceInfo = 'Nacos服务发现' gatewayInfo = linkedService.nacosId || '未知' } } return { apiName, apiType, sourceInfo, gatewayInfo } } const renderLinkInfo = () => { const serviceInfo = getServiceInfo() // 没有关联任何API if (!linkedService || !serviceInfo) { return ( <Card className="mb-6"> <div className="text-center py-8"> <div className="text-gray-500 mb-4">暂未关联任何API</div> <Button type="primary" icon={<PlusOutlined />} onClick={() => setIsModalVisible(true)}> 关联API </Button> </div> </Card> ) } return ( <Card className="mb-6" title="关联详情" extra={ <Button type="primary" danger icon={<DeleteOutlined />} onClick={handleDelete}> 解除关联 </Button> } > <div> {/* 第一行:名称 + 类型 */} <div className="grid grid-cols-6 gap-8 items-center pt-2 pb-2"> <span className="text-xs text-gray-600">名称:</span> <span className="col-span-2 text-xs text-gray-900">{serviceInfo.apiName || '未命名'}</span> <span className="text-xs text-gray-600">类型:</span> <span className="col-span-2 text-xs text-gray-900">{serviceInfo.apiType}</span> </div> {/* 第二行:来源 + ID */} <div className="grid grid-cols-6 gap-8 items-center pt-2 pb-2"> <span className="text-xs text-gray-600">来源:</span> <span className="col-span-2 text-xs text-gray-900">{serviceInfo.sourceInfo}</span> <span className="text-xs text-gray-600"> {linkedService?.sourceType === 'NACOS' ? 'Nacos ID:' : '网关ID:'} </span> <span className="col-span-2 text-xs text-gray-700">{serviceInfo.gatewayInfo}</span> </div> </div> </Card> ) } const renderApiConfig = () => { const isMcp = apiProduct.type === 'MCP_SERVER' const isOpenApi = apiProduct.type === 'REST_API' // MCP Server类型:无论是否有linkedService都显示tools和连接点配置 if (isMcp && apiProduct.mcpConfig) { return ( <Card title="配置详情"> <Row gutter={24}> {/* 左侧:工具列表 */} <Col span={15}> <Card> <Tabs defaultActiveKey="tools" items={[ { key: "tools", label: `Tools (${parsedTools.length})`, children: parsedTools.length > 0 ? ( <div className="border border-gray-200 rounded-lg bg-gray-50"> {parsedTools.map((tool, idx) => ( <div key={idx} className={idx < parsedTools.length - 1 ? "border-b border-gray-200" : ""}> <Collapse ghost expandIconPosition="end" items={[{ key: idx.toString(), label: tool.name, children: ( <div className="px-4 pb-2"> <div className="text-gray-600 mb-4">{tool.description}</div> {tool.args && tool.args.length > 0 && ( <div> <p className="font-medium text-gray-700 mb-3">输入参数:</p> {tool.args.map((arg, argIdx) => ( <div key={argIdx} className="mb-3"> <div className="flex items-center mb-2"> <span className="font-medium text-gray-800 mr-2">{arg.name}</span> <span className="text-xs bg-gray-200 text-gray-600 px-2 py-1 rounded mr-2"> {arg.type} </span> {arg.required && ( <span className="text-red-500 text-xs mr-2">*</span> )} {arg.description && ( <span className="text-xs text-gray-500"> {arg.description} </span> )} </div> <input type="text" placeholder={arg.description || `请输入${arg.name}`} 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" defaultValue={arg.default !== undefined ? JSON.stringify(arg.default) : ''} /> {arg.enum && ( <div className="text-xs text-gray-500"> 可选值: {arg.enum.map(value => <code key={value} className="mr-1">{value}</code>)} </div> )} </div> ))} </div> )} </div> ) }]} /> </div> ))} </div> ) : ( <div className="text-gray-500 text-center py-8"> No tools available </div> ), }, ]} /> </Card> </Col> {/* 右侧:连接点配置 */} <Col span={9}> <Card> <div className="mb-4"> <h3 className="text-sm font-semibold mb-3">连接点配置</h3> <Tabs size="small" defaultActiveKey={localJson ? "local" : (sseJson ? "sse" : "http")} items={(() => { const tabs = []; if (localJson) { tabs.push({ key: "local", label: "Stdio", children: ( <div className="relative bg-gray-50 border border-gray-200 rounded-md p-3"> <Button size="small" icon={<CopyOutlined />} className="absolute top-2 right-2 z-10" onClick={() => handleCopy(localJson)} > </Button> <div className="text-gray-800 font-mono text-xs overflow-x-auto"> <pre className="whitespace-pre-wrap">{localJson}</pre> </div> </div> ), }); } else { if (sseJson) { tabs.push({ key: "sse", label: "SSE", children: ( <div className="relative bg-gray-50 border border-gray-200 rounded-md p-3"> <Button size="small" icon={<CopyOutlined />} className="absolute top-2 right-2 z-10" onClick={() => handleCopy(sseJson)} > </Button> <div className="text-gray-800 font-mono text-xs overflow-x-auto"> <pre className="whitespace-pre-wrap">{sseJson}</pre> </div> </div> ), }); } if (httpJson) { tabs.push({ key: "http", label: "Streaming HTTP", children: ( <div className="relative bg-gray-50 border border-gray-200 rounded-md p-3"> <Button size="small" icon={<CopyOutlined />} className="absolute top-2 right-2 z-10" onClick={() => handleCopy(httpJson)} > </Button> <div className="text-gray-800 font-mono text-xs overflow-x-auto"> <pre className="whitespace-pre-wrap">{httpJson}</pre> </div> </div> ), }); } } return tabs; })()} /> </div> </Card> </Col> </Row> </Card> ) } // REST API类型:需要linkedService才显示 if (!linkedService) { return null } return ( <Card title="配置详情"> {isOpenApi && apiProduct.apiConfig && apiProduct.apiConfig.spec && ( <div> <h4 className="text-base font-medium mb-4">REST API接口文档</h4> <SwaggerUIWrapper apiSpec={apiProduct.apiConfig.spec} /> </div> )} </Card> ) } return ( <div className="p-6 space-y-6"> <div className="mb-6"> <h1 className="text-2xl font-bold mb-2">API关联</h1> <p className="text-gray-600">管理Product关联的API</p> </div> {renderLinkInfo()} {renderApiConfig()} <Modal title={linkedService ? '重新关联API' : '关联新API'} open={isModalVisible} onOk={handleModalOk} onCancel={handleModalCancel} okText="关联" cancelText="取消" width={600} > <Form form={form} layout="vertical"> <Form.Item name="sourceType" label="来源类型" initialValue="GATEWAY" rules={[{ required: true, message: '请选择来源类型' }]} > <Select placeholder="请选择来源类型" onChange={handleSourceTypeChange}> <Select.Option value="GATEWAY">网关</Select.Option> <Select.Option value="NACOS" disabled={apiProduct.type === 'REST_API'}>Nacos</Select.Option> </Select> </Form.Item> {sourceType === 'GATEWAY' && ( <Form.Item name="gatewayId" label="网关实例" rules={[{ required: true, message: '请选择网关' }]} > <Select placeholder="请选择网关实例" loading={gatewayLoading} showSearch filterOption={(input, option) => (option?.value as unknown as string)?.toLowerCase().includes(input.toLowerCase()) } onChange={handleGatewayChange} optionLabelProp="label" > {gateways.map(gateway => ( <Select.Option key={gateway.gatewayId} value={gateway.gatewayId} label={gateway.gatewayName} > <div> <div className="font-medium">{gateway.gatewayName}</div> <div className="text-sm text-gray-500"> {gateway.gatewayId} - {getGatewayTypeLabel(gateway.gatewayType as any)} </div> </div> </Select.Option> ))} </Select> </Form.Item> )} {sourceType === 'NACOS' && ( <Form.Item name="nacosId" label="Nacos实例" rules={[{ required: true, message: '请选择Nacos实例' }]} > <Select placeholder="请选择Nacos实例" loading={nacosLoading} showSearch filterOption={(input, option) => (option?.value as unknown as string)?.toLowerCase().includes(input.toLowerCase()) } onChange={handleNacosChange} optionLabelProp="label" > {nacosInstances.map(nacos => ( <Select.Option key={nacos.nacosId} value={nacos.nacosId} label={nacos.nacosName} > <div> <div className="font-medium">{nacos.nacosName}</div> <div className="text-sm text-gray-500"> {nacos.serverUrl} </div> </div> </Select.Option> ))} </Select> </Form.Item> )} {sourceType === 'NACOS' && selectedNacos && ( <Form.Item name="namespaceId" label="命名空间" rules={[{ required: true, message: '请选择命名空间' }]} > <Select placeholder="请选择命名空间" loading={apiLoading && nacosNamespaces.length === 0} onChange={handleNamespaceChange} showSearch filterOption={(input, option) => (option?.children as unknown as string)?.toLowerCase().includes(input.toLowerCase())} optionLabelProp="label" > {nacosNamespaces.map(ns => ( <Select.Option key={ns.namespaceId} value={ns.namespaceId} label={ns.namespaceName}> <div> <div className="font-medium">{ns.namespaceName}</div> <div className="text-sm text-gray-500">{ns.namespaceId}</div> </div> </Select.Option> ))} </Select> </Form.Item> )} {(selectedGateway || (selectedNacos && selectedNamespace)) && ( <Form.Item name="apiId" label={apiProduct.type === 'REST_API' ? '选择REST API' : '选择MCP Server'} rules={[{ required: true, message: apiProduct.type === 'REST_API' ? '请选择REST API' : '请选择MCP Server' }]} > <Select placeholder={apiProduct.type === 'REST_API' ? '请选择REST API' : '请选择MCP Server'} loading={apiLoading} showSearch filterOption={(input, option) => (option?.value as unknown as string)?.toLowerCase().includes(input.toLowerCase()) } optionLabelProp="label" > {apiList.map((api: any) => ( <Select.Option key={apiProduct.type === 'REST_API' ? api.apiId : (api.mcpRouteId || api.mcpServerName || api.name)} value={apiProduct.type === 'REST_API' ? api.apiId : (api.mcpRouteId || api.mcpServerName || api.name)} label={api.apiName || api.mcpServerName || api.name} > <div> <div className="font-medium">{api.apiName || api.mcpServerName || api.name}</div> <div className="text-sm text-gray-500"> {api.type} - {apiProduct.type === 'REST_API' ? api.apiId : (api.mcpRouteId || api.mcpServerName || api.name)} </div> </div> </Select.Option> ))} </Select> </Form.Item> )} </Form> </Modal> </div> ) } ```