This is page 5 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-admin/src/pages/GatewayConsoles.tsx: -------------------------------------------------------------------------------- ```typescript import { useState, useEffect, useCallback } from 'react' import { Button, Table, message, Modal, Tabs } from 'antd' import { PlusOutlined } from '@ant-design/icons' import { gatewayApi } from '@/lib/api' import ImportGatewayModal from '@/components/console/ImportGatewayModal' import ImportHigressModal from '@/components/console/ImportHigressModal' import GatewayTypeSelector from '@/components/console/GatewayTypeSelector' import { formatDateTime } from '@/lib/utils' import { Gateway, GatewayType } from '@/types' export default function Consoles() { const [gateways, setGateways] = useState<Gateway[]>([]) const [typeSelectorVisible, setTypeSelectorVisible] = useState(false) const [importVisible, setImportVisible] = useState(false) const [higressImportVisible, setHigressImportVisible] = useState(false) const [selectedGatewayType, setSelectedGatewayType] = useState<GatewayType>('APIG_API') const [loading, setLoading] = useState(false) const [activeTab, setActiveTab] = useState<GatewayType>('HIGRESS') const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0, }) const fetchGatewaysByType = useCallback(async (gatewayType: GatewayType, page = 1, size = 10) => { setLoading(true) try { const res = await gatewayApi.getGateways({ gatewayType, page, size }) setGateways(res.data?.content || []) setPagination({ current: page, pageSize: size, total: res.data?.totalElements || 0, }) } catch (error) { // message.error('获取网关列表失败') } finally { setLoading(false) } }, []) useEffect(() => { fetchGatewaysByType(activeTab, 1, 10) }, [fetchGatewaysByType, activeTab]) // 处理导入成功 const handleImportSuccess = () => { fetchGatewaysByType(activeTab, pagination.current, pagination.pageSize) } // 处理网关类型选择 const handleGatewayTypeSelect = (type: GatewayType) => { setSelectedGatewayType(type) setTypeSelectorVisible(false) if (type === 'HIGRESS') { setHigressImportVisible(true) } else { setImportVisible(true) } } // 处理分页变化 const handlePaginationChange = (page: number, pageSize: number) => { fetchGatewaysByType(activeTab, page, pageSize) } // 处理Tab切换 const handleTabChange = (tabKey: string) => { const gatewayType = tabKey as GatewayType setActiveTab(gatewayType) // Tab切换时重置到第一页 setPagination(prev => ({ ...prev, current: 1 })) } const handleDeleteGateway = async (gatewayId: string) => { Modal.confirm({ title: '确认删除', content: '确定要删除该网关吗?', onOk: async () => { try { await gatewayApi.deleteGateway(gatewayId) message.success('删除成功') fetchGatewaysByType(activeTab, pagination.current, pagination.pageSize) } catch (error) { // message.error('删除失败') } }, }) } // APIG 网关的列定义 const apigColumns = [ { title: '网关名称/ID', key: 'nameAndId', width: 280, render: (_: any, record: Gateway) => ( <div> <div className="text-sm font-medium text-gray-900 truncate"> {record.gatewayName} </div> <div className="text-xs text-gray-500 truncate"> {record.gatewayId} </div> </div> ), }, { title: '区域', dataIndex: 'region', key: 'region', render: (_: any, record: Gateway) => { return record.apigConfig?.region || '-' } }, { title: '创建时间', dataIndex: 'createAt', key: 'createAt', render: (date: string) => formatDateTime(date) }, { title: '操作', key: 'action', render: (_: any, record: Gateway) => ( <Button type="link" danger onClick={() => handleDeleteGateway(record.gatewayId)}>删除</Button> ), }, ] // 专有云 AI 网关的列定义 const adpAiColumns = [ { title: '网关名称/ID', key: 'nameAndId', width: 280, render: (_: any, record: Gateway) => ( <div> <div className="text-sm font-medium text-gray-900 truncate"> {record.gatewayName} </div> <div className="text-xs text-gray-500 truncate"> {record.gatewayId} </div> </div> ), }, { title: '创建时间', dataIndex: 'createAt', key: 'createAt', render: (date: string) => formatDateTime(date) }, { title: '操作', key: 'action', render: (_: any, record: Gateway) => ( <Button type="link" danger onClick={() => handleDeleteGateway(record.gatewayId)}>删除</Button> ), } ] // Higress 网关的列定义 const higressColumns = [ { title: '网关名称/ID', key: 'nameAndId', width: 280, render: (_: any, record: Gateway) => ( <div> <div className="text-sm font-medium text-gray-900 truncate"> {record.gatewayName} </div> <div className="text-xs text-gray-500 truncate"> {record.gatewayId} </div> </div> ), }, { title: '服务地址', dataIndex: 'address', key: 'address', render: (_: any, record: Gateway) => { return record.higressConfig?.address || '-' } }, { title: '用户名', dataIndex: 'username', key: 'username', render: (_: any, record: Gateway) => { return record.higressConfig?.username || '-' } }, { title: '创建时间', dataIndex: 'createAt', key: 'createAt', render: (date: string) => formatDateTime(date) }, { title: '操作', key: 'action', render: (_: any, record: Gateway) => ( <Button type="link" danger onClick={() => handleDeleteGateway(record.gatewayId)}>删除</Button> ), }, ] return ( <div className="space-y-6"> <div className="flex items-center justify-between"> <div> <h1 className="text-3xl font-bold tracking-tight">网关实例</h1> <p className="text-gray-500 mt-2"> 管理和配置您的网关实例 </p> </div> <Button type="primary" icon={<PlusOutlined />} onClick={() => setTypeSelectorVisible(true)}> 导入网关实例 </Button> </div> <Tabs activeKey={activeTab} onChange={handleTabChange} items={[ { key: 'HIGRESS', label: 'Higress 网关', children: ( <div className="bg-white rounded-lg"> <div className="py-4 pl-4 border-b border-gray-200"> <h3 className="text-lg font-medium text-gray-900">Higress 网关</h3> <p className="text-sm text-gray-500 mt-1">Higress 云原生网关</p> </div> <Table columns={higressColumns} dataSource={gateways} rowKey="gatewayId" loading={loading} pagination={{ current: pagination.current, pageSize: pagination.pageSize, total: pagination.total, showSizeChanger: true, showQuickJumper: true, showTotal: (total) => `共 ${total} 条`, onChange: handlePaginationChange, onShowSizeChange: handlePaginationChange, }} /> </div> ), }, { key: 'APIG_API', label: 'API 网关', children: ( <div className="bg-white rounded-lg"> <div className="py-4 pl-4 border-b border-gray-200"> <h3 className="text-lg font-medium text-gray-900">API 网关</h3> <p className="text-sm text-gray-500 mt-1">阿里云 API 网关服务</p> </div> <Table columns={apigColumns} dataSource={gateways} rowKey="gatewayId" loading={loading} pagination={{ current: pagination.current, pageSize: pagination.pageSize, total: pagination.total, showSizeChanger: true, showQuickJumper: true, showTotal: (total) => `共 ${total} 条`, onChange: handlePaginationChange, onShowSizeChange: handlePaginationChange, }} /> </div> ), }, { key: 'APIG_AI', label: 'AI 网关', children: ( <div className="bg-white rounded-lg"> <div className="py-4 pl-4 border-b border-gray-200"> <h3 className="text-lg font-medium text-gray-900">AI 网关</h3> <p className="text-sm text-gray-500 mt-1">阿里云 AI 网关服务</p> </div> <Table columns={apigColumns} dataSource={gateways} rowKey="gatewayId" loading={loading} pagination={{ current: pagination.current, pageSize: pagination.pageSize, total: pagination.total, showSizeChanger: true, showQuickJumper: true, showTotal: (total) => `共 ${total} 条`, onChange: handlePaginationChange, onShowSizeChange: handlePaginationChange, }} /> </div> ), }, { key: 'ADP_AI_GATEWAY', label: '专有云 AI 网关', children: ( <div className="bg-white rounded-lg"> <div className="py-4 pl-4 border-b border-gray-200"> <h3 className="text-lg font-medium text-gray-900">AI 网关</h3> <p className="text-sm text-gray-500 mt-1">专有云 AI 网关服务</p> </div> <Table columns={adpAiColumns} dataSource={gateways} rowKey="gatewayId" loading={loading} pagination={{ current: pagination.current, pageSize: pagination.pageSize, total: pagination.total, showSizeChanger: true, showQuickJumper: true, showTotal: (total) => `共 ${total} 条`, onChange: handlePaginationChange, onShowSizeChange: handlePaginationChange, }} /> </div> ), }, ]} /> <ImportGatewayModal visible={importVisible} gatewayType={selectedGatewayType as 'APIG_API' | 'APIG_AI' | 'ADP_AI_GATEWAY'} onCancel={() => setImportVisible(false)} onSuccess={handleImportSuccess} /> <ImportHigressModal visible={higressImportVisible} onCancel={() => setHigressImportVisible(false)} onSuccess={handleImportSuccess} /> <GatewayTypeSelector visible={typeSelectorVisible} onCancel={() => setTypeSelectorVisible(false)} onSelect={handleGatewayTypeSelect} /> </div> ) } ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/service/impl/GatewayServiceImpl.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.EnumUtil; 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.gateway.ImportGatewayParam; import com.alibaba.apiopenplatform.dto.params.gateway.QueryAPIGParam; import com.alibaba.apiopenplatform.dto.params.gateway.QueryAdpAIGatewayParam; import com.alibaba.apiopenplatform.dto.params.gateway.QueryGatewayParam; import com.alibaba.apiopenplatform.dto.result.*; import com.alibaba.apiopenplatform.entity.*; import com.alibaba.apiopenplatform.repository.GatewayRepository; import com.alibaba.apiopenplatform.repository.ProductRefRepository; import com.alibaba.apiopenplatform.service.AdpAIGatewayService; import com.alibaba.apiopenplatform.service.GatewayService; import com.alibaba.apiopenplatform.service.gateway.GatewayOperator; import com.alibaba.apiopenplatform.support.consumer.ConsumerAuthConfig; import com.alibaba.apiopenplatform.support.enums.APIGAPIType; import com.alibaba.apiopenplatform.support.enums.GatewayType; import com.alibaba.apiopenplatform.support.gateway.GatewayConfig; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.BeansException; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import javax.persistence.criteria.Predicate; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @Service @RequiredArgsConstructor @SuppressWarnings("unchecked") @Slf4j public class GatewayServiceImpl implements GatewayService, ApplicationContextAware, AdpAIGatewayService { private final GatewayRepository gatewayRepository; private final ProductRefRepository productRefRepository; private Map<GatewayType, GatewayOperator> gatewayOperators; private final ContextHolder contextHolder; @Override public PageResult<GatewayResult> fetchGateways(QueryAPIGParam param, int page, int size) { return gatewayOperators.get(param.getGatewayType()).fetchGateways(param, page, size); } @Override public PageResult<GatewayResult> fetchGateways(QueryAdpAIGatewayParam param, int page, int size) { return gatewayOperators.get(GatewayType.ADP_AI_GATEWAY).fetchGateways(param, page, size); } @Override public void importGateway(ImportGatewayParam param) { gatewayRepository.findByGatewayId(param.getGatewayId()) .ifPresent(gateway -> { throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.GATEWAY, param.getGatewayId())); }); Gateway gateway = param.convertTo(); if (gateway.getGatewayType().isHigress()) { gateway.setGatewayId(IdGenerator.genHigressGatewayId()); } gateway.setAdminId(contextHolder.getUser()); gatewayRepository.save(gateway); } @Override public GatewayResult getGateway(String gatewayId) { Gateway gateway = findGateway(gatewayId); return new GatewayResult().convertFrom(gateway); } @Override public PageResult<GatewayResult> listGateways(QueryGatewayParam param, Pageable pageable) { Page<Gateway> gateways = gatewayRepository.findAll(buildGatewaySpec(param), pageable); return new PageResult<GatewayResult>().convertFrom(gateways, gateway -> new GatewayResult().convertFrom(gateway)); } @Override public void deleteGateway(String gatewayId) { Gateway gateway = findGateway(gatewayId); // 已有Product引用时不允许删除 if (productRefRepository.existsByGatewayId(gatewayId)) { throw new BusinessException(ErrorCode.INVALID_REQUEST, "网关已被Product引用"); } gatewayRepository.delete(gateway); } @Override public PageResult<APIResult> fetchAPIs(String gatewayId, String apiType, int page, int size) { Gateway gateway = findGateway(gatewayId); GatewayType gatewayType = gateway.getGatewayType(); if (gatewayType.isAPIG()) { APIGAPIType type = EnumUtil.fromString(APIGAPIType.class, apiType); switch (type) { case REST: return fetchRESTAPIs(gatewayId, page, size); case HTTP: return fetchHTTPAPIs(gatewayId, page, size); default: } } if (gatewayType.isHigress()) { return fetchRoutes(gatewayId, page, size); } throw new BusinessException(ErrorCode.INTERNAL_ERROR, String.format("Gateway type %s does not support API type %s", gatewayType, apiType)); } @Override public PageResult<APIResult> fetchHTTPAPIs(String gatewayId, int page, int size) { Gateway gateway = findGateway(gatewayId); return getOperator(gateway).fetchHTTPAPIs(gateway, page, size); } @Override public PageResult<APIResult> fetchRESTAPIs(String gatewayId, int page, int size) { Gateway gateway = findGateway(gatewayId); return getOperator(gateway).fetchRESTAPIs(gateway, page, size); } @Override public PageResult<APIResult> fetchRoutes(String gatewayId, int page, int size) { return null; } @Override public PageResult<GatewayMCPServerResult> fetchMcpServers(String gatewayId, int page, int size) { Gateway gateway = findGateway(gatewayId); return getOperator(gateway).fetchMcpServers(gateway, page, size); } @Override public String fetchAPIConfig(String gatewayId, Object config) { Gateway gateway = findGateway(gatewayId); return getOperator(gateway).fetchAPIConfig(gateway, config); } @Override public String fetchMcpConfig(String gatewayId, Object conf) { Gateway gateway = findGateway(gatewayId); return getOperator(gateway).fetchMcpConfig(gateway, conf); } @Override public String createConsumer(Consumer consumer, ConsumerCredential credential, GatewayConfig config) { return gatewayOperators.get(config.getGatewayType()).createConsumer(consumer, credential, config); } @Override public void updateConsumer(String gwConsumerId, ConsumerCredential credential, GatewayConfig config) { gatewayOperators.get(config.getGatewayType()).updateConsumer(gwConsumerId, credential, config); } @Override public void deleteConsumer(String gwConsumerId, GatewayConfig config) { gatewayOperators.get(config.getGatewayType()).deleteConsumer(gwConsumerId, config); } @Override public boolean isConsumerExists(String gwConsumerId, GatewayConfig config) { return gatewayOperators.get(config.getGatewayType()).isConsumerExists(gwConsumerId, config); } @Override public ConsumerAuthConfig authorizeConsumer(String gatewayId, String gwConsumerId, ProductRefResult productRef) { Gateway gateway = findGateway(gatewayId); Object refConfig; if (gateway.getGatewayType().isHigress()) { refConfig = productRef.getHigressRefConfig(); } else if (gateway.getGatewayType().isAdpAIGateway()) { refConfig = productRef.getAdpAIGatewayRefConfig(); } else { refConfig = productRef.getApigRefConfig(); } return getOperator(gateway).authorizeConsumer(gateway, gwConsumerId, refConfig); } @Override public void revokeConsumerAuthorization(String gatewayId, String gwConsumerId, ConsumerAuthConfig config) { Gateway gateway = findGateway(gatewayId); getOperator(gateway).revokeConsumerAuthorization(gateway, gwConsumerId, config); } @Override public GatewayConfig getGatewayConfig(String gatewayId) { Gateway gateway = findGateway(gatewayId); return GatewayConfig.builder() .gatewayType(gateway.getGatewayType()) .apigConfig(gateway.getApigConfig()) .higressConfig(gateway.getHigressConfig()) .adpAIGatewayConfig(gateway.getAdpAIGatewayConfig()) .gateway(gateway) // 添加Gateway实体引用 .build(); } @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { Map<String, GatewayOperator> operators = applicationContext.getBeansOfType(GatewayOperator.class); gatewayOperators = operators.values().stream() .collect(Collectors.toMap( operator -> operator.getGatewayType(), operator -> operator, (existing, replacement) -> existing)); } private Gateway findGateway(String gatewayId) { return gatewayRepository.findByGatewayId(gatewayId) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.GATEWAY, gatewayId)); } private GatewayOperator getOperator(Gateway gateway) { GatewayOperator gatewayOperator = gatewayOperators.get(gateway.getGatewayType()); if (gatewayOperator == null) { throw new BusinessException(ErrorCode.INTERNAL_ERROR, "No gateway operator found for gateway type: " + gateway.getGatewayType()); } return gatewayOperator; } @Override public String getDashboard(String gatewayId,String type) { Gateway gateway = findGateway(gatewayId); return getOperator(gateway).getDashboard(gateway,type); //type: Portal,MCP,API } private Specification<Gateway> buildGatewaySpec(QueryGatewayParam param) { return (root, query, cb) -> { List<Predicate> predicates = new ArrayList<>(); if (param != null && param.getGatewayType() != null) { predicates.add(cb.equal(root.get("gatewayType"), param.getGatewayType())); } String adminId = contextHolder.getUser(); if (StrUtil.isNotBlank(adminId)) { predicates.add(cb.equal(root.get("adminId"), adminId)); } return cb.and(predicates.toArray(new Predicate[0])); }; } } ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/console/ImportGatewayModal.tsx: -------------------------------------------------------------------------------- ```typescript import { useState } from 'react' import { Button, Table, Modal, Form, Input, message, Select } from 'antd' import { PlusOutlined } from '@ant-design/icons' import { gatewayApi } from '@/lib/api' import { Gateway, ApigConfig } from '@/types' import { getGatewayTypeLabel } from '@/lib/constant' interface ImportGatewayModalProps { visible: boolean gatewayType: 'APIG_API' | 'APIG_AI' | 'ADP_AI_GATEWAY' onCancel: () => void onSuccess: () => void } export default function ImportGatewayModal({ visible, gatewayType, onCancel, onSuccess }: ImportGatewayModalProps) { const [importForm] = Form.useForm() const [gatewayLoading, setGatewayLoading] = useState(false) const [gatewayList, setGatewayList] = useState<Gateway[]>([]) const [selectedGateway, setSelectedGateway] = useState<Gateway | null>(null) const [apigConfig, setApigConfig] = useState<ApigConfig>({ region: '', accessKey: '', secretKey: '', }) const [gatewayPagination, setGatewayPagination] = useState({ current: 1, pageSize: 10, total: 0, }) // 监听表单中的认证方式,确保切换时联动渲染 const authType = Form.useWatch('authType', importForm) // 获取网关列表 const fetchGateways = async (values: any, page = 1, size = 10) => { setGatewayLoading(true) try { const res = await gatewayApi.getApigGateway({ ...values, page, size, }) setGatewayList(res.data?.content || []) setGatewayPagination({ current: page, pageSize: size, total: res.data?.totalElements || 0, }) } catch { // message.error('获取网关列表失败') } finally { setGatewayLoading(false) } } const fetchAdpGateways = async (values: any, page = 1, size = 50) => { setGatewayLoading(true) try { const res = await gatewayApi.getAdpGateways({...values, page, size}) setGatewayList(res.data?.content || []) setGatewayPagination({ current: page, pageSize: size, total: res.data?.totalElements || 0, }) } catch { // message.error('获取网关列表失败') } finally { setGatewayLoading(false) } } // 处理网关选择 const handleGatewaySelect = (gateway: Gateway) => { setSelectedGateway(gateway) } // 处理网关分页变化 const handleGatewayPaginationChange = (page: number, pageSize: number) => { const values = importForm.getFieldsValue() const data = JSON.parse(sessionStorage.getItem('importFormConfig') || ''); if (JSON.stringify(values) === '{}') { fetchGateways({...data, gatewayType: gatewayType}, page, pageSize) } else { fetchGateways({...values, gatewayType: gatewayType}, page, pageSize) } } // 处理导入 const handleImport = () => { if (!selectedGateway) { message.warning('请选择一个Gateway') return } const payload: any = { ...selectedGateway, gatewayType: gatewayType, } if (gatewayType === 'ADP_AI_GATEWAY') { payload.adpAIGatewayConfig = apigConfig } else { payload.apigConfig = apigConfig } gatewayApi.importGateway(payload).then(() => { message.success('导入成功!') handleCancel() onSuccess() }).catch(() => { // message.error(error.response?.data?.message || '导入失败!') }) } // 重置弹窗状态 const handleCancel = () => { setSelectedGateway(null) setGatewayList([]) setGatewayPagination({ current: 1, pageSize: 10, total: 0 }) importForm.resetFields() onCancel() } return ( <Modal title="导入网关实例" open={visible} onCancel={handleCancel} footer={null} width={800} > <Form form={importForm} layout="vertical" preserve={false}> {gatewayList.length === 0 && ['APIG_API', 'APIG_AI'].includes(gatewayType) && ( <div className="mb-4"> <h3 className="text-lg font-medium mb-3">认证信息</h3> <Form.Item label="Region" name="region" rules={[{ required: true, message: '请输入region' }]}> <Input /> </Form.Item> <Form.Item label="Access Key" name="accessKey" rules={[{ required: true, message: '请输入accessKey' }]}> <Input /> </Form.Item> <Form.Item label="Secret Key" name="secretKey" rules={[{ required: true, message: '请输入secretKey' }]}> <Input.Password /> </Form.Item> <Button type="primary" onClick={() => { importForm.validateFields().then((values) => { setApigConfig(values) sessionStorage.setItem('importFormConfig', JSON.stringify(values)) fetchGateways({...values, gatewayType: gatewayType}) }) }} loading={gatewayLoading} > 获取网关列表 </Button> </div> )} {['ADP_AI_GATEWAY'].includes(gatewayType) && gatewayList.length === 0 && ( <div className="mb-4"> <h3 className="text-lg font-medium mb-3">认证信息</h3> <Form.Item label="服务地址" name="baseUrl" rules={[{ required: true, message: '请输入服务地址' }, { pattern: /^https?:\/\//i, message: '必须以 http:// 或 https:// 开头' }]}> <Input placeholder="如:http://apigateway.example.com 或者 http://10.236.6.144" /> </Form.Item> <Form.Item label="端口" name="port" initialValue={80} rules={[ { required: true, message: '请输入端口号' }, { validator: (_, value) => { if (value === undefined || value === null || value === '') return Promise.resolve() const n = Number(value) return n >= 1 && n <= 65535 ? Promise.resolve() : Promise.reject(new Error('端口范围需在 1-65535')) } } ]} > <Input type="text" placeholder="如:8080" /> </Form.Item> <Form.Item label="认证方式" name="authType" initialValue="Seed" rules={[{ required: true, message: '请选择认证方式' }]} > <Select> <Select.Option value="Seed">Seed</Select.Option> <Select.Option value="Header">固定Header</Select.Option> </Select> </Form.Item> {authType === 'Seed' && ( <Form.Item label="Seed" name="authSeed" rules={[{ required: true, message: '请输入Seed' }]}> <Input placeholder="通过configmap获取" /> </Form.Item> )} {authType === 'Header' && ( <Form.Item label="Headers"> <Form.List name="authHeaders" initialValue={[{ key: '', value: '' }]}> {(fields, { add, remove }) => ( <> {fields.map(({ key, name, ...restField }) => ( <div key={key} style={{ display: 'flex', marginBottom: 8, alignItems: 'center' }}> <Form.Item {...restField} name={[name, 'key']} rules={[{ required: true, message: '请输入Header名称' }]} style={{ flex: 1, marginRight: 8, marginBottom: 0 }} > <Input placeholder="Header名称,如:X-Auth-Token" /> </Form.Item> <Form.Item {...restField} name={[name, 'value']} rules={[{ required: true, message: '请输入Header值' }]} style={{ flex: 1, marginRight: 8, marginBottom: 0 }} > <Input placeholder="Header值" /> </Form.Item> {fields.length > 1 && ( <Button type="text" danger onClick={() => remove(name)} style={{ marginBottom: 0 }} > 删除 </Button> )} </div> ))} <Form.Item style={{ marginBottom: 0 }}> <Button type="dashed" onClick={() => add({ key: '', value: '' })} block icon={<PlusOutlined />} > 添加Header </Button> </Form.Item> </> )} </Form.List> </Form.Item> )} <Button type="primary" onClick={() => { importForm.validateFields().then((values) => { // 处理认证参数 const processedValues = { ...values }; // 根据认证方式设置相应的参数 if (values.authType === 'Seed') { processedValues.authSeed = values.authSeed; delete processedValues.authHeaders; } else if (values.authType === 'Header') { processedValues.authHeaders = values.authHeaders; delete processedValues.authSeed; } setApigConfig(processedValues) sessionStorage.setItem('importFormConfig', JSON.stringify(processedValues)) fetchAdpGateways({...processedValues, gatewayType: gatewayType}) }) }} loading={gatewayLoading} > 获取网关列表 </Button> </div> )} {gatewayList.length > 0 && ( <div className="mb-4"> <h3 className="text-lg font-medium mb-3">选择网关实例</h3> <Table rowKey="gatewayId" columns={[ { title: 'ID', dataIndex: 'gatewayId' }, { title: '类型', dataIndex: 'gatewayType', render: (gatewayType: string) => getGatewayTypeLabel(gatewayType as any) }, { title: '名称', dataIndex: 'gatewayName' }, ]} dataSource={gatewayList} rowSelection={{ type: 'radio', selectedRowKeys: selectedGateway ? [selectedGateway.gatewayId] : [], onChange: (_selectedRowKeys, selectedRows) => { handleGatewaySelect(selectedRows[0]) }, }} pagination={{ current: gatewayPagination.current, pageSize: gatewayPagination.pageSize, total: gatewayPagination.total, onChange: handleGatewayPaginationChange, showSizeChanger: true, showQuickJumper: true, showTotal: (total) => `共 ${total} 条`, }} size="small" /> </div> )} {selectedGateway && ( <div className="text-right"> <Button type="primary" onClick={handleImport}> 完成导入 </Button> </div> )} </Form> </Modal> ) } ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/service/impl/PortalServiceImpl.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.collection.CollUtil; import cn.hutool.core.util.BooleanUtil; import cn.hutool.core.util.StrUtil; import com.alibaba.apiopenplatform.core.constant.Resources; import com.alibaba.apiopenplatform.core.event.PortalDeletingEvent; 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.QuerySubscriptionParam; import com.alibaba.apiopenplatform.dto.params.portal.*; import com.alibaba.apiopenplatform.dto.result.PageResult; import com.alibaba.apiopenplatform.dto.result.PortalResult; import com.alibaba.apiopenplatform.dto.result.SubscriptionResult; import com.alibaba.apiopenplatform.entity.Portal; import com.alibaba.apiopenplatform.entity.PortalDomain; import com.alibaba.apiopenplatform.entity.ProductSubscription; import com.alibaba.apiopenplatform.repository.PortalDomainRepository; import com.alibaba.apiopenplatform.repository.PortalRepository; import com.alibaba.apiopenplatform.repository.SubscriptionRepository; import com.alibaba.apiopenplatform.repository.ProductPublicationRepository; import com.alibaba.apiopenplatform.repository.ProductRefRepository; import com.alibaba.apiopenplatform.service.GatewayService; import com.alibaba.apiopenplatform.entity.ProductPublication; import com.alibaba.apiopenplatform.entity.ProductRef; import org.springframework.data.domain.PageRequest; import com.alibaba.apiopenplatform.service.IdpService; import com.alibaba.apiopenplatform.service.PortalService; import com.alibaba.apiopenplatform.support.enums.DomainType; import com.alibaba.apiopenplatform.support.portal.OidcConfig; import com.alibaba.apiopenplatform.support.portal.PortalSettingConfig; import com.alibaba.apiopenplatform.support.portal.PortalUiConfig; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import javax.persistence.criteria.Predicate; import javax.transaction.Transactional; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; @Service @Slf4j @RequiredArgsConstructor @Transactional public class PortalServiceImpl implements PortalService { private final PortalRepository portalRepository; private final PortalDomainRepository portalDomainRepository; private final ApplicationEventPublisher eventPublisher; private final SubscriptionRepository subscriptionRepository; private final ContextHolder contextHolder; private final IdpService idpService; private final String domainFormat = "%s.api.portal.local"; private final ProductPublicationRepository publicationRepository; private final ProductRefRepository productRefRepository; private final GatewayService gatewayService; public PortalResult createPortal(CreatePortalParam param) { portalRepository.findByName(param.getName()) .ifPresent(portal -> { throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.PORTAL, portal.getName())); }); String portalId = IdGenerator.genPortalId(); Portal portal = param.convertTo(); portal.setPortalId(portalId); portal.setAdminId(contextHolder.getUser()); // Setting & Ui portal.setPortalSettingConfig(new PortalSettingConfig()); portal.setPortalUiConfig(new PortalUiConfig()); // Domain PortalDomain portalDomain = new PortalDomain(); portalDomain.setDomain(String.format(domainFormat, portalId)); portalDomain.setPortalId(portalId); portalDomainRepository.save(portalDomain); portalRepository.save(portal); return getPortal(portalId); } @Override public PortalResult getPortal(String portalId) { Portal portal = findPortal(portalId); List<PortalDomain> domains = portalDomainRepository.findAllByPortalId(portalId); portal.setPortalDomains(domains); return new PortalResult().convertFrom(portal); } @Override public void existsPortal(String portalId) { portalRepository.findByPortalId(portalId) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.PORTAL, portalId)); } @Override public PageResult<PortalResult> listPortals(Pageable pageable) { Page<Portal> portals = portalRepository.findAll(pageable); // 填充Domain if (portals.hasContent()) { List<String> portalIds = portals.getContent().stream() .map(Portal::getPortalId) .collect(Collectors.toList()); List<PortalDomain> allDomains = portalDomainRepository.findAllByPortalIdIn(portalIds); Map<String, List<PortalDomain>> portalDomains = allDomains.stream() .collect(Collectors.groupingBy(PortalDomain::getPortalId)); portals.getContent().forEach(portal -> { List<PortalDomain> domains = portalDomains.getOrDefault(portal.getPortalId(), new ArrayList<>()); portal.setPortalDomains(domains); }); } return new PageResult<PortalResult>().convertFrom(portals, portal -> new PortalResult().convertFrom(portal)); } @Override public PortalResult updatePortal(String portalId, UpdatePortalParam param) { Portal portal = findPortal(portalId); Optional.ofNullable(param.getName()) .filter(name -> !name.equals(portal.getName())) .flatMap(portalRepository::findByName) .ifPresent(p -> { throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.PORTAL, portal.getName())); }); param.update(portal); // 验证OIDC配置 PortalSettingConfig setting = portal.getPortalSettingConfig(); if (CollUtil.isNotEmpty(setting.getOidcConfigs())) { idpService.validateOidcConfigs(setting.getOidcConfigs()); } if (CollUtil.isNotEmpty(setting.getOauth2Configs())) { idpService.validateOAuth2Configs(setting.getOauth2Configs()); } // 至少保留一种认证方式 if (BooleanUtil.isFalse(setting.getBuiltinAuthEnabled())) { boolean enabledOidc = Optional.ofNullable(setting.getOidcConfigs()) .filter(CollUtil::isNotEmpty) .map(configs -> configs.stream().anyMatch(OidcConfig::isEnabled)) .orElse(false); if (!enabledOidc) { throw new BusinessException(ErrorCode.INVALID_REQUEST, "至少配置一种认证方式"); } } portalRepository.saveAndFlush(portal); return getPortal(portal.getPortalId()); } @Override public void deletePortal(String portalId) { Portal portal = findPortal(portalId); // 清理Domain portalDomainRepository.deleteAllByPortalId(portalId); // 异步清理门户资源 eventPublisher.publishEvent(new PortalDeletingEvent(portalId)); portalRepository.delete(portal); } @Override public String resolvePortal(String domain) { return portalDomainRepository.findByDomain(domain) .map(PortalDomain::getPortalId) .orElse(null); } @Override public PortalResult bindDomain(String portalId, BindDomainParam param) { existsPortal(portalId); portalDomainRepository.findByPortalIdAndDomain(portalId, param.getDomain()) .ifPresent(portalDomain -> { throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.PORTAL_DOMAIN, portalDomain.getDomain())); }); PortalDomain portalDomain = param.convertTo(); portalDomain.setPortalId(portalId); portalDomainRepository.save(portalDomain); return getPortal(portalId); } @Override public PortalResult unbindDomain(String portalId, String domain) { portalDomainRepository.findByPortalIdAndDomain(portalId, domain) .ifPresent(portalDomain -> { // 默认域名不允许解绑 if (portalDomain.getType() == DomainType.DEFAULT) { throw new BusinessException(ErrorCode.INVALID_REQUEST, "默认域名不允许解绑"); } portalDomainRepository.delete(portalDomain); }); return getPortal(portalId); } @Override public PageResult<SubscriptionResult> listSubscriptions(String portalId, QuerySubscriptionParam param, Pageable pageable) { // Ensure portal exists existsPortal(portalId); Specification<ProductSubscription> spec = (root, query, cb) -> { List<Predicate> predicates = new ArrayList<>(); predicates.add(cb.equal(root.get("portalId"), portalId)); if (param != null && param.getStatus() != null) { predicates.add(cb.equal(root.get("status"), param.getStatus())); } return cb.and(predicates.toArray(new Predicate[0])); }; Page<ProductSubscription> page = subscriptionRepository.findAll(spec, pageable); return new PageResult<SubscriptionResult>().convertFrom(page, s -> new SubscriptionResult().convertFrom(s)); } @Override public String getDefaultPortal() { Portal portal = portalRepository.findFirstByOrderByIdAsc().orElse(null); if (portal == null) { return null; } return portal.getPortalId(); } @Override public String getDashboard(String portalId) { existsPortal(portalId); // 找到该门户下任一已发布产品(取第一页第一条) ProductPublication pub = publicationRepository.findByPortalId(portalId, PageRequest.of(0, 1)) .stream() .findFirst() .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.PORTAL, portalId)); // 取产品的网关引用 ProductRef productRef = productRefRepository.findFirstByProductId(pub.getProductId()) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.PRODUCT, pub.getProductId())); if (productRef.getGatewayId() == null) { throw new BusinessException(ErrorCode.NOT_FOUND, "网关", "该门户下的产品尚未关联网关服务"); } // 复用网关的Dashboard能力 return gatewayService.getDashboard(productRef.getGatewayId(),"Portal"); } private Portal findPortal(String portalId) { return portalRepository.findByPortalId(portalId) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.PORTAL, portalId)); } } ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/portal/PortalDevelopers.tsx: -------------------------------------------------------------------------------- ```typescript import {Card, Table, Badge, Button, Space, message, Modal} from 'antd' import { EditOutlined, DeleteOutlined, ExclamationCircleOutlined, EyeOutlined, UnorderedListOutlined, CheckCircleFilled, ClockCircleOutlined } from '@ant-design/icons' import {useEffect, useState} from 'react' import {Portal, Developer, Consumer} from '@/types' import {portalApi} from '@/lib/api' import {formatDateTime} from '@/lib/utils' import {SubscriptionListModal} from '@/components/subscription/SubscriptionListModal' interface PortalDevelopersProps { portal: Portal } export function PortalDevelopers({portal}: PortalDevelopersProps) { const [developers, setDevelopers] = useState<Developer[]>([]) const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showQuickJumper: true, showTotal: (total: number, range: [number, number]) => `共 ${total} 条` }) // Consumer相关状态 const [consumers, setConsumers] = useState<Consumer[]>([]) const [consumerModalVisible, setConsumerModalVisible] = useState(false) const [currentDeveloper, setCurrentDeveloper] = useState<Developer | null>(null) const [consumerPagination, setConsumerPagination] = useState({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showQuickJumper: true, showTotal: (total: number, range: [number, number]) => `共 ${total} 条` }) // 订阅列表相关状态 const [subscriptionModalVisible, setSubscriptionModalVisible] = useState(false) const [currentConsumer, setCurrentConsumer] = useState<Consumer | null>(null) useEffect(() => { fetchDevelopers() }, [portal.portalId, pagination.current, pagination.pageSize]) const fetchDevelopers = () => { portalApi.getDeveloperList(portal.portalId, { page: pagination.current, // 后端从0开始 size: pagination.pageSize }).then((res) => { setDevelopers(res.data.content) setPagination(prev => ({ ...prev, total: res.data.totalElements || 0 })) }) } const handleUpdateDeveloperStatus = (developerId: string, status: string) => { portalApi.updateDeveloperStatus(portal.portalId, developerId, status).then(() => { if (status === 'PENDING') { message.success('取消授权成功') } else { message.success('审批成功') } fetchDevelopers() }).catch(() => { message.error('审批失败') }) } const handleTableChange = (paginationInfo: any) => { setPagination(prev => ({ ...prev, current: paginationInfo.current, pageSize: paginationInfo.pageSize })) } const handleDeleteDeveloper = (developerId: string, username: string) => { Modal.confirm({ title: '确认删除', icon: <ExclamationCircleOutlined/>, content: `确定要删除开发者 "${username}" 吗?此操作不可恢复。`, okText: '确认删除', okType: 'danger', cancelText: '取消', onOk() { portalApi.deleteDeveloper(developerId).then(() => { message.success('删除成功') fetchDevelopers() }).catch(() => { message.error('删除失败') }) }, }) } // Consumer相关函数 const handleViewConsumers = (developer: Developer) => { setCurrentDeveloper(developer) setConsumerModalVisible(true) setConsumerPagination(prev => ({...prev, current: 1})) fetchConsumers(developer.developerId, 1, consumerPagination.pageSize) } const fetchConsumers = (developerId: string, page: number, size: number) => { portalApi.getConsumerList(portal.portalId, developerId, {page: page, size}).then((res) => { setConsumers(res.data.content || []) setConsumerPagination(prev => ({ ...prev, total: res.data.totalElements || 0 })) }).then((res: any) => { setConsumers(res.data.content || []) setConsumerPagination(prev => ({ ...prev, total: res.data.totalElements || 0 })) }) } const handleConsumerTableChange = (paginationInfo: any) => { if (currentDeveloper) { setConsumerPagination(prev => ({ ...prev, current: paginationInfo.current, pageSize: paginationInfo.pageSize })) fetchConsumers(currentDeveloper.developerId, paginationInfo.current, paginationInfo.pageSize) } } const handleConsumerStatusUpdate = (consumerId: string) => { if (currentDeveloper) { portalApi.approveConsumer(consumerId).then((res) => { message.success('审批成功') fetchConsumers(currentDeveloper.developerId, consumerPagination.current, consumerPagination.pageSize) }).catch((err) => { // message.error('审批失败') }) } } // 查看订阅列表 const handleViewSubscriptions = (consumer: Consumer) => { setCurrentConsumer(consumer) setSubscriptionModalVisible(true) } // 关闭订阅列表模态框 const handleSubscriptionModalCancel = () => { setSubscriptionModalVisible(false) setCurrentConsumer(null) } const columns = [ { title: '开发者名称/ID', dataIndex: 'username', key: 'username', fixed: 'left' as const, width: 280, render: (username: string, record: Developer) => ( <div className="ml-2"> <div className="font-medium">{username}</div> <div className="text-sm text-gray-500">{record.developerId}</div> </div> ), }, { title: '状态', dataIndex: 'status', key: 'status', width: 120, render: (status: string) => ( <div className="flex items-center"> {status === 'APPROVED' ? ( <> <CheckCircleFilled className="text-green-500 mr-2" style={{fontSize: '10px'}} /> <span className="text-xs text-gray-900">可用</span> </> ) : ( <> <ClockCircleOutlined className="text-orange-500 mr-2" style={{fontSize: '10px'}} /> <span className="text-xs text-gray-900">待审核</span> </> )} </div> ) }, { title: '创建时间', dataIndex: 'createAt', key: 'createAt', width: 160, render: (date: string) => formatDateTime(date) }, { title: '操作', key: 'action', fixed: 'right' as const, width: 250, render: (_: any, record: Developer) => ( <Space size="middle"> <Button onClick={() => handleViewConsumers(record)} type="link" icon={<EyeOutlined/>}> 查看Consumer </Button> { !portal.portalSettingConfig.autoApproveDevelopers && ( record.status === 'APPROVED' ? ( <Button onClick={() => handleUpdateDeveloperStatus(record.developerId, 'PENDING')} type="link" icon={<EditOutlined/>}> 取消授权 </Button> ) : ( <Button onClick={() => handleUpdateDeveloperStatus(record.developerId, 'APPROVED')} type="link" icon={<EditOutlined/>}> 审批通过 </Button> ) ) } <Button onClick={() => handleDeleteDeveloper(record.developerId, record.username)} type="link" danger icon={<DeleteOutlined/>}> 删除 </Button> </Space> ), }, ] // Consumer表格列定义 const consumerColumns = [ { title: 'Consumer名称', dataIndex: 'name', key: 'name', width: 200, }, { title: 'Consumer ID', dataIndex: 'consumerId', key: 'consumerId', width: 200, }, { title: '描述', dataIndex: 'description', key: 'description', ellipsis: true, width: 200, }, // { // title: '状态', // dataIndex: 'status', // key: 'status', // width: 120, // render: (status: string) => ( // <Badge status={status === 'APPROVED' ? 'success' : 'default'} text={status === 'APPROVED' ? '可用' : '待审核'} /> // ) // }, { title: '创建时间', dataIndex: 'createAt', key: 'createAt', width: 150, render: (date: string) => formatDateTime(date) }, { title: '操作', key: 'action', width: 120, render: (_: any, record: Consumer) => ( <Button onClick={() => handleViewSubscriptions(record)} type="link" icon={<UnorderedListOutlined/>} > 订阅列表 </Button> ), }, ] return ( <div className="p-6 space-y-6"> <div className="flex justify-between items-center"> <div> <h1 className="text-2xl font-bold mb-2">开发者</h1> <p className="text-gray-600">管理Portal的开发者用户</p> </div> </div> <Card> {/* <div className="mb-4"> <Input placeholder="搜索开发者..." prefix={<SearchOutlined />} value={searchText} onChange={(e) => setSearchText(e.target.value)} style={{ width: 300 }} /> </div> */} <Table columns={columns} dataSource={developers} rowKey="developerId" pagination={pagination} onChange={handleTableChange} scroll={{ y: 'calc(100vh - 400px)', x: 'max-content' }} /> </Card> {/* Consumer弹窗 */} <Modal title={`查看Consumer - ${currentDeveloper?.username || ''}`} open={consumerModalVisible} onCancel={() => setConsumerModalVisible(false)} footer={null} width={1000} destroyOnClose > <Table columns={consumerColumns} dataSource={consumers} rowKey="consumerId" pagination={consumerPagination} onChange={handleConsumerTableChange} scroll={{y: 'calc(100vh - 400px)'}} /> </Modal> {/* 订阅列表弹窗 */} {currentConsumer && ( <SubscriptionListModal visible={subscriptionModalVisible} consumerId={currentConsumer.consumerId} consumerName={currentConsumer.name} onCancel={handleSubscriptionModalCancel} /> )} </div> ) } ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/api-product/ApiProductFormModal.tsx: -------------------------------------------------------------------------------- ```typescript import { useState, useEffect } from "react"; import { Modal, Form, Input, Select, Image, message, UploadFile, Switch, Radio, Space, } from "antd"; import { CameraOutlined } from "@ant-design/icons"; import { apiProductApi } from "@/lib/api"; import type { ApiProduct } from "@/types/api-product"; interface ApiProductFormModalProps { visible: boolean; onCancel: () => void; onSuccess: () => void; productId?: string; initialData?: Partial<ApiProduct>; } export default function ApiProductFormModal({ visible, onCancel, onSuccess, productId, initialData, }: ApiProductFormModalProps) { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const [previewOpen, setPreviewOpen] = useState(false); const [previewImage, setPreviewImage] = useState(""); const [fileList, setFileList] = useState<UploadFile[]>([]); const [iconMode, setIconMode] = useState<'BASE64' | 'URL'>('URL'); const isEditMode = !!productId; // 初始化时加载已有数据 useEffect(() => { if (visible && isEditMode && initialData && initialData.name) { setTimeout(() => { // 1. 先设置所有字段 form.setFieldsValue({ name: initialData.name, description: initialData.description, type: initialData.type, autoApprove: initialData.autoApprove, }); }, 100); // 2. 处理 icon 字段 if (initialData.icon) { if (typeof initialData.icon === 'object' && initialData.icon.type && initialData.icon.value) { // 新格式:{ type: 'BASE64' | 'URL', value: string } const iconType = initialData.icon.type as 'BASE64' | 'URL'; const iconValue = initialData.icon.value; setIconMode(iconType); if (iconType === 'BASE64') { setFileList([ { uid: "-1", name: "头像.png", status: "done", url: iconValue, }, ]); form.setFieldsValue({ icon: iconValue }); } else { form.setFieldsValue({ iconUrl: iconValue }); } } else { // 兼容旧格式(字符串格式) const iconStr = initialData.icon as unknown as string; if (iconStr && typeof iconStr === 'string' && iconStr.includes("value=")) { const startIndex = iconStr.indexOf("value=") + 6; const endIndex = iconStr.length - 1; const base64Data = iconStr.substring(startIndex, endIndex).trim(); setIconMode('BASE64'); setFileList([ { uid: "-1", name: "头像.png", status: "done", url: base64Data, }, ]); form.setFieldsValue({ icon: base64Data }); } } } } else if (visible && !isEditMode) { // 新建模式下清空表单 form.resetFields(); setFileList([]); setIconMode('URL'); } }, [visible, isEditMode, initialData, form]); // 将文件转为 Base64 const getBase64 = (file: File): Promise<string> => new Promise((resolve, reject) => { const reader = new FileReader(); reader.readAsDataURL(file); reader.onload = () => resolve(reader.result as string); reader.onerror = (error) => reject(error); }); const uploadButton = ( <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: '#999' }}> <CameraOutlined style={{ fontSize: '16px', marginBottom: '6px' }} /> <span style={{ fontSize: '12px', color: '#999' }}>上传图片</span> </div> ); // 处理Icon模式切换 const handleIconModeChange = (mode: 'BASE64' | 'URL') => { setIconMode(mode); // 清空相关字段 if (mode === 'URL') { form.setFieldsValue({ icon: undefined }); setFileList([]); } else { form.setFieldsValue({ iconUrl: undefined }); } }; const resetForm = () => { form.resetFields(); setFileList([]); setPreviewImage(""); setPreviewOpen(false); setIconMode('URL'); }; const handleCancel = () => { resetForm(); onCancel(); }; const handleSubmit = async () => { try { const values = await form.validateFields(); setLoading(true); const { icon, iconUrl, ...otherValues } = values; if (isEditMode) { let params = { ...otherValues }; // 处理icon字段 if (iconMode === 'BASE64' && icon) { params.icon = { type: "BASE64", value: icon, }; } else if (iconMode === 'URL' && iconUrl) { params.icon = { type: "URL", value: iconUrl, }; } else if (!icon && !iconUrl) { // 如果两种模式都没有提供icon,保持原有icon不变 delete params.icon; } await apiProductApi.updateApiProduct(productId!, params); message.success("API Product 更新成功"); } else { let params = { ...otherValues }; // 处理icon字段 if (iconMode === 'BASE64' && icon) { params.icon = { type: "BASE64", value: icon, }; } else if (iconMode === 'URL' && iconUrl) { params.icon = { type: "URL", value: iconUrl, }; } await apiProductApi.createApiProduct(params); message.success("API Product 创建成功"); } resetForm(); onSuccess(); } catch (error: any) { if (error?.errorFields) return; message.error("操作失败"); } finally { setLoading(false); } }; return ( <Modal title={isEditMode ? "编辑 API Product" : "创建 API Product"} open={visible} onOk={handleSubmit} onCancel={handleCancel} confirmLoading={loading} width={600} > <Form form={form} layout="vertical" preserve={false}> <Form.Item label="名称" name="name" rules={[{ required: true, message: "请输入API Product名称" }]} > <Input placeholder="请输入API Product名称" /> </Form.Item> <Form.Item label="描述" name="description" rules={[{ required: true, message: "请输入描述" }]} > <Input.TextArea placeholder="请输入描述" rows={3} /> </Form.Item> <Form.Item label="类型" name="type" rules={[{ required: true, message: "请选择类型" }]} > <Select placeholder="请选择类型"> <Select.Option value="REST_API">REST API</Select.Option> <Select.Option value="MCP_SERVER">MCP Server</Select.Option> </Select> </Form.Item> <Form.Item label="自动审批订阅" name="autoApprove" tooltip={{ title: ( <div style={{ color: '#000000', backgroundColor: '#ffffff', fontSize: '13px', lineHeight: '1.4', padding: '4px 0' }}> 启用后,该产品的订阅申请将自动审批通过,否则使用Portal的消费者订阅审批设置。 </div> ), placement: "topLeft", overlayInnerStyle: { backgroundColor: '#ffffff', color: '#000000', border: '1px solid #d9d9d9', borderRadius: '6px', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', }, overlayStyle: { maxWidth: '300px' } }} valuePropName="checked" > <Switch /> </Form.Item> <Form.Item label="Icon设置" style={{ marginBottom: '16px' }}> <Space direction="vertical" style={{ width: '100%' }}> <Radio.Group value={iconMode} onChange={(e) => handleIconModeChange(e.target.value)} > <Radio value="URL">图片链接</Radio> <Radio value="BASE64">本地上传</Radio> </Radio.Group> {iconMode === 'URL' ? ( <Form.Item name="iconUrl" style={{ marginBottom: 0 }} rules={[ { type: 'url', message: '请输入有效的图片链接' } ]} > <Input placeholder="请输入图片链接地址" /> </Form.Item> ) : ( <Form.Item name="icon" style={{ marginBottom: 0 }}> <div style={{ width: '80px', height: '80px', border: '1px dashed #d9d9d9', borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'border-color 0.3s', position: 'relative' }} onClick={() => { // 触发文件选择 const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; input.onchange = (e) => { const file = (e.target as HTMLInputElement).files?.[0]; if (file) { // 验证文件大小,限制为16KB const maxSize = 16 * 1024; // 16KB if (file.size > maxSize) { message.error(`图片大小不能超过 16KB,当前图片大小为 ${Math.round(file.size / 1024)}KB`); return; } const newFileList: UploadFile[] = [{ uid: Date.now().toString(), name: file.name, status: 'done' as const, url: URL.createObjectURL(file) }]; setFileList(newFileList); getBase64(file).then((base64) => { form.setFieldsValue({ icon: base64 }); }); } }; input.click(); }} onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#1890ff'; }} onMouseLeave={(e) => { e.currentTarget.style.borderColor = '#d9d9d9'; }} > {fileList.length >= 1 ? ( <img src={fileList[0].url} alt="uploaded" style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '6px' }} onClick={(e) => { e.stopPropagation(); // 预览图片 setPreviewImage(fileList[0].url || ''); setPreviewOpen(true); }} /> ) : ( uploadButton )} {fileList.length >= 1 && ( <div style={{ position: 'absolute', top: '4px', right: '4px', background: 'rgba(0, 0, 0, 0.5)', borderRadius: '50%', width: '16px', height: '16px', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: 'white', fontSize: '10px' }} onClick={(e) => { e.stopPropagation(); setFileList([]); form.setFieldsValue({ icon: null }); }} > × </div> )} </div> </Form.Item> )} </Space> </Form.Item> {/* 图片预览弹窗 */} {previewImage && ( <Image wrapperStyle={{ display: "none" }} preview={{ visible: previewOpen, onVisibleChange: (visible) => setPreviewOpen(visible), afterOpenChange: (visible) => { if (!visible) setPreviewImage(""); }, }} src={previewImage} /> )} </Form> </Modal> ); } ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/service/impl/OidcServiceImpl.java: -------------------------------------------------------------------------------- ```java package com.alibaba.apiopenplatform.service.impl; import cn.hutool.core.codec.Base64; import cn.hutool.core.convert.Convert; import cn.hutool.core.map.MapUtil; import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONUtil; import cn.hutool.jwt.JWT; import cn.hutool.jwt.JWTUtil; import com.alibaba.apiopenplatform.core.constant.CommonConstants; import com.alibaba.apiopenplatform.core.constant.IdpConstants; 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.TokenUtil; import com.alibaba.apiopenplatform.dto.params.developer.CreateExternalDeveloperParam; import com.alibaba.apiopenplatform.dto.result.*; import com.alibaba.apiopenplatform.service.OidcService; import com.alibaba.apiopenplatform.service.DeveloperService; import com.alibaba.apiopenplatform.service.PortalService; import com.alibaba.apiopenplatform.support.enums.DeveloperAuthType; import com.alibaba.apiopenplatform.support.enums.GrantType; import com.alibaba.apiopenplatform.support.portal.AuthCodeConfig; import com.alibaba.apiopenplatform.support.portal.IdentityMapping; import com.alibaba.apiopenplatform.support.portal.OidcConfig; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.util.*; import java.util.stream.Collectors; @Slf4j @Service @RequiredArgsConstructor public class OidcServiceImpl implements OidcService { private final PortalService portalService; private final DeveloperService developerService; private final RestTemplate restTemplate; private final ContextHolder contextHolder; @Override public String buildAuthorizationUrl(String provider, String apiPrefix, HttpServletRequest request) { OidcConfig oidcConfig = findOidcConfig(provider); AuthCodeConfig authCodeConfig = oidcConfig.getAuthCodeConfig(); // state保存上下文信息 String state = buildState(provider, apiPrefix); String redirectUri = buildRedirectUri(request); // 重定向URL String authUrl = UriComponentsBuilder .fromUriString(authCodeConfig.getAuthorizationEndpoint()) // 授权码模式 .queryParam(IdpConstants.RESPONSE_TYPE, IdpConstants.CODE) .queryParam(IdpConstants.CLIENT_ID, authCodeConfig.getClientId()) .queryParam(IdpConstants.REDIRECT_URI, redirectUri) .queryParam(IdpConstants.SCOPE, authCodeConfig.getScopes()) .queryParam(IdpConstants.STATE, state) .build() .toUriString(); log.info("Generated OIDC authorization URL: {}", authUrl); return authUrl; } @Override public AuthResult handleCallback(String code, String state, HttpServletRequest request, HttpServletResponse response) { log.info("Processing OIDC callback with code: {}, state: {}", code, state); // 解析state获取provider信息 IdpState idpState = parseState(state); String provider = idpState.getProvider(); if (StrUtil.isBlank(provider)) { throw new BusinessException(ErrorCode.INVALID_REQUEST, "缺少OIDC provider"); } OidcConfig oidcConfig = findOidcConfig(provider); // 使用授权码获取Token IdpTokenResult tokenResult = requestToken(code, oidcConfig, request); // 获取用户信息,优先使用ID Token,降级到UserInfo端点 Map<String, Object> userInfo = getUserInfo(tokenResult, oidcConfig); log.info("Get OIDC user info: {}", userInfo); // 处理用户认证逻辑 String developerId = createOrGetDeveloper(userInfo, oidcConfig); String accessToken = TokenUtil.generateDeveloperToken(developerId); return AuthResult.of(accessToken, TokenUtil.getTokenExpiresIn()); } @Override public List<IdpResult> getAvailableProviders() { return Optional.ofNullable(portalService.getPortal(contextHolder.getPortal())) .filter(portal -> portal.getPortalSettingConfig() != null) .filter(portal -> portal.getPortalSettingConfig().getOidcConfigs() != null) .map(portal -> portal.getPortalSettingConfig().getOidcConfigs()) // 确定当前Portal下启用的OIDC配置,返回Idp信息 .map(configs -> configs.stream() .filter(OidcConfig::isEnabled) .map(config -> IdpResult.builder() .provider(config.getProvider()) .displayName(config.getName()) .build()) .collect(Collectors.toList())) .orElse(Collections.emptyList()); } private String buildRedirectUri(HttpServletRequest request) { String scheme = request.getScheme(); // String serverName = "localhost"; // int serverPort = 5173; String serverName = request.getServerName(); int serverPort = request.getServerPort(); String baseUrl = scheme + "://" + serverName; if (serverPort != CommonConstants.HTTP_PORT && serverPort != CommonConstants.HTTPS_PORT) { baseUrl += ":" + serverPort; } // 重定向到前端的Callback接口 return baseUrl + "/oidc/callback"; } private OidcConfig findOidcConfig(String provider) { return Optional.ofNullable(portalService.getPortal(contextHolder.getPortal())) .filter(portal -> portal.getPortalSettingConfig() != null) .filter(portal -> portal.getPortalSettingConfig().getOidcConfigs() != null) // 根据provider字段过滤 .flatMap(portal -> portal.getPortalSettingConfig() .getOidcConfigs() .stream() .filter(config -> provider.equals(config.getProvider()) && config.isEnabled()) .findFirst()) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.OIDC_CONFIG, provider)); } private String buildState(String provider, String apiPrefix) { IdpState state = IdpState.builder() .provider(provider) .timestamp(System.currentTimeMillis()) .nonce(IdUtil.fastSimpleUUID()) .apiPrefix(apiPrefix) .build(); return Base64.encode(JSONUtil.toJsonStr(state)); } private IdpState parseState(String encodedState) { String stateJson = Base64.decodeStr(encodedState); IdpState idpState = JSONUtil.toBean(stateJson, IdpState.class); // 验证时间戳,10分钟有效期 if (idpState.getTimestamp() != null) { long currentTime = System.currentTimeMillis(); if (currentTime - idpState.getTimestamp() > 10 * 60 * 1000) { throw new BusinessException(ErrorCode.INVALID_REQUEST, "请求已过期"); } } return idpState; } private IdpTokenResult requestToken(String code, OidcConfig oidcConfig, HttpServletRequest request) { AuthCodeConfig authCodeConfig = oidcConfig.getAuthCodeConfig(); String redirectUri = buildRedirectUri(request); MultiValueMap<String, String> params = new LinkedMultiValueMap<>(); params.add(IdpConstants.GRANT_TYPE, GrantType.AUTHORIZATION_CODE.getType()); params.add(IdpConstants.CODE, code); params.add(IdpConstants.REDIRECT_URI, redirectUri); params.add(IdpConstants.CLIENT_ID, authCodeConfig.getClientId()); params.add(IdpConstants.CLIENT_SECRET, authCodeConfig.getClientSecret()); log.info("Request tokens at: {}, params: {}", authCodeConfig.getTokenEndpoint(), params); return executeRequest(authCodeConfig.getTokenEndpoint(), HttpMethod.POST, null, params, IdpTokenResult.class); } private Map<String, Object> getUserInfo(IdpTokenResult tokenResult, OidcConfig oidcConfig) { // 优先使用ID Token if (StrUtil.isNotBlank(tokenResult.getIdToken())) { log.info("Get user info form id token: {}", tokenResult.getIdToken()); return parseUserInfo(tokenResult.getIdToken(), oidcConfig); } // 降级策略:使用UserInfo端点 log.warn("ID Token not available, falling back to UserInfo endpoint"); if (StrUtil.isBlank(tokenResult.getAccessToken())) { throw new BusinessException(ErrorCode.INTERNAL_ERROR, "OIDC获取用户信息失败"); } AuthCodeConfig authCodeConfig = oidcConfig.getAuthCodeConfig(); if (StrUtil.isBlank(authCodeConfig.getUserInfoEndpoint())) { throw new BusinessException(ErrorCode.INVALID_PARAMETER, "OIDC配置缺少用户信息端点"); } return requestUserInfo(tokenResult.getAccessToken(), authCodeConfig, oidcConfig); } private Map<String, Object> parseUserInfo(String idToken, OidcConfig oidcConfig) { JWT jwt = JWTUtil.parseToken(idToken); // 验证过期时间 Object exp = jwt.getPayload("exp"); if (exp != null) { long expTime = Convert.toLong(exp); long currentTime = System.currentTimeMillis() / 1000; if (expTime <= currentTime) { throw new BusinessException(ErrorCode.INVALID_REQUEST, "ID Token已过期"); } } // TODO 验签 Map<String, Object> userInfo = jwt.getPayload().getClaimsJson(); log.info("Successfully extracted user info from ID Token, sub: {}", userInfo); return userInfo; } @SuppressWarnings("unchecked") private Map<String, Object> requestUserInfo(String accessToken, AuthCodeConfig authCodeConfig, OidcConfig oidcConfig) { try { HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(accessToken); log.info("Fetching user info from endpoint: {}", authCodeConfig.getUserInfoEndpoint()); Map<String, Object> userInfo = executeRequest(authCodeConfig.getUserInfoEndpoint(), HttpMethod.GET, headers, null, Map.class); log.info("Successfully fetched user info from endpoint, sub: {}", userInfo); return userInfo; } catch (Exception e) { log.error("Failed to fetch user info from endpoint: {}", authCodeConfig.getUserInfoEndpoint(), e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "获取用户信息失败"); } } private String createOrGetDeveloper(Map<String, Object> userInfo, OidcConfig config) { IdentityMapping identityMapping = config.getIdentityMapping(); // userId & userName & email String userIdField = StrUtil.isBlank(identityMapping.getUserIdField()) ? IdpConstants.SUBJECT : identityMapping.getUserIdField(); String userNameField = StrUtil.isBlank(identityMapping.getUserNameField()) ? IdpConstants.NAME : identityMapping.getUserNameField(); String emailField = StrUtil.isBlank(identityMapping.getEmailField()) ? IdpConstants.EMAIL : identityMapping.getEmailField(); Object userIdObj = userInfo.get(userIdField); Object userNameObj = userInfo.get(userNameField); Object emailObj = userInfo.get(emailField); String userId = Convert.toStr(userIdObj); String userName = Convert.toStr(userNameObj); String email = Convert.toStr(emailObj); if (StrUtil.isBlank(userId) || StrUtil.isBlank(userName)) { throw new BusinessException(ErrorCode.INVALID_REQUEST, "Id Token中缺少用户ID字段或用户名称"); } // 复用已有的Developer,否则创建 return Optional.ofNullable(developerService.getExternalDeveloper(config.getProvider(), userId)) .map(DeveloperResult::getDeveloperId) .orElseGet(() -> { CreateExternalDeveloperParam param = CreateExternalDeveloperParam.builder() .provider(config.getProvider()) .subject(userId) .displayName(userName) .email(email) .authType(DeveloperAuthType.OIDC) .build(); return developerService.createExternalDeveloper(param).getDeveloperId(); }); } private <T> T executeRequest(String url, HttpMethod method, HttpHeaders headers, Object body, Class<T> responseType) { HttpEntity<?> requestEntity = new HttpEntity<>(body, headers); log.info("Executing HTTP request to: {}", url); ResponseEntity<String> response = restTemplate.exchange( url, method, requestEntity, String.class ); log.info("Received HTTP response from: {}, status: {}, body: {}", url, response.getStatusCode(), response.getBody()); return JSONUtil.toBean(response.getBody(), responseType); } } ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/portal/PortalSettings.tsx: -------------------------------------------------------------------------------- ```typescript import {Card, Form, Input, Select, Switch, Button, Divider, Space, Tag, Table, Modal, message, Tabs} from 'antd' import {SaveOutlined, PlusOutlined, DeleteOutlined, ExclamationCircleOutlined} from '@ant-design/icons' import {useState, useMemo} from 'react' import {Portal, ThirdPartyAuthConfig, AuthenticationType, OidcConfig, OAuth2Config} from '@/types' import {portalApi} from '@/lib/api' import {ThirdPartyAuthManager} from './ThirdPartyAuthManager' interface PortalSettingsProps { portal: Portal onRefresh?: () => void } export function PortalSettings({portal, onRefresh}: PortalSettingsProps) { const [form] = Form.useForm() const [loading, setLoading] = useState(false) const [domainModalVisible, setDomainModalVisible] = useState(false) const [domainForm] = Form.useForm() const [domainLoading, setDomainLoading] = useState(false) // 本地OIDC配置状态,避免频繁刷新 // local的有点问题,一切tab就坏了 const handleSave = async () => { try { setLoading(true) const values = await form.validateFields() await portalApi.updatePortal(portal.portalId, { name: portal.name, // 保持现有名称不变 description: portal.description, // 保持现有描述不变 portalSettingConfig: { ...portal.portalSettingConfig, builtinAuthEnabled: values.builtinAuthEnabled, oidcAuthEnabled: values.oidcAuthEnabled, autoApproveDevelopers: values.autoApproveDevelopers, autoApproveSubscriptions: values.autoApproveSubscriptions, frontendRedirectUrl: values.frontendRedirectUrl, }, portalDomainConfig: portal.portalDomainConfig, portalUiConfig: portal.portalUiConfig, }) message.success('Portal设置保存成功') onRefresh?.() } catch (error) { message.error('保存Portal设置失败') } finally { setLoading(false) } } const handleSettingUpdate = async (key: string, value: any) => { try { await portalApi.updatePortal(portal.portalId, { ...portal, portalSettingConfig: { ...portal.portalSettingConfig, [key]: value } }) message.success('设置已更新') onRefresh?.() } catch (error) { message.error('设置更新失败') } } const handleAddDomain = () => { setDomainModalVisible(true) domainForm.resetFields() } const handleDomainModalOk = async () => { try { setDomainLoading(true) const values = await domainForm.validateFields() const newDomain = { domain: values.domain, protocol: values.protocol, type: 'CUSTOM' } await portalApi.bindDomain(portal.portalId, newDomain) message.success('域名绑定成功') onRefresh?.() setDomainModalVisible(false) } catch (error) { message.error('绑定域名失败') } finally { setDomainLoading(false) } } const handleDomainModalCancel = () => { setDomainModalVisible(false) domainForm.resetFields() } const handleDeleteDomain = async (domain: string) => { Modal.confirm({ title: '确认解绑', icon: <ExclamationCircleOutlined/>, content: `确定要解绑域名 "${domain}" 吗?此操作不可恢复。`, okText: '确认解绑', okType: 'danger', cancelText: '取消', async onOk() { try { await portalApi.unbindDomain(portal.portalId, domain) message.success('域名解绑成功') onRefresh?.() } catch (error) { message.error('解绑域名失败') } }, }) } // 合并OIDC和OAuth2配置用于统一显示 const thirdPartyAuthConfigs = useMemo((): ThirdPartyAuthConfig[] => { const configs: ThirdPartyAuthConfig[] = [] // 添加OIDC配置 if (portal.portalSettingConfig?.oidcConfigs) { portal.portalSettingConfig.oidcConfigs.forEach(oidcConfig => { configs.push({ ...oidcConfig, type: AuthenticationType.OIDC }) }) } // 添加OAuth2配置 if (portal.portalSettingConfig?.oauth2Configs) { portal.portalSettingConfig.oauth2Configs.forEach(oauth2Config => { configs.push({ ...oauth2Config, type: AuthenticationType.OAUTH2 }) }) } return configs }, [portal.portalSettingConfig?.oidcConfigs, portal.portalSettingConfig?.oauth2Configs]) // 第三方认证配置保存函数 const handleSaveThirdPartyAuth = async (configs: ThirdPartyAuthConfig[]) => { try { // 分离OIDC和OAuth2配置,去掉type字段 const oidcConfigs = configs .filter(config => config.type === AuthenticationType.OIDC) .map(config => { const { type, ...oidcConfig } = config as (OidcConfig & { type: AuthenticationType.OIDC }) return oidcConfig }) const oauth2Configs = configs .filter(config => config.type === AuthenticationType.OAUTH2) .map(config => { const { type, ...oauth2Config } = config as (OAuth2Config & { type: AuthenticationType.OAUTH2 }) return oauth2Config }) const updateData = { ...portal, portalSettingConfig: { ...portal.portalSettingConfig, // 直接保存分离的配置数组 oidcConfigs: oidcConfigs, oauth2Configs: oauth2Configs } } await portalApi.updatePortal(portal.portalId, updateData) onRefresh?.() } catch (error) { throw error } } // 域名表格列定义 const domainColumns = [ { title: '域名', dataIndex: 'domain', key: 'domain', }, { title: '协议', dataIndex: 'protocol', key: 'protocol', }, { title: '类型', dataIndex: 'type', key: 'type', render: (type: string) => ( <Tag color={type === 'DEFAULT' ? 'blue' : 'green'}> {type === 'DEFAULT' ? '默认域名' : '自定义域名'} </Tag> ) }, { title: '操作', key: 'action', render: (_: any, record: any) => ( <Space> {record.type === 'CUSTOM' && ( <Button type="link" danger icon={<DeleteOutlined/>} onClick={() => handleDeleteDomain(record.domain)} > 解绑 </Button> )} </Space> ) } ] const tabItems = [ { key: 'auth', label: '安全设置', children: ( <div className="space-y-6"> {/* 基本安全设置 */} <div className="grid grid-cols-2 gap-6"> <Form.Item name="builtinAuthEnabled" label="账号密码登录" valuePropName="checked" > <Switch onChange={(checked) => handleSettingUpdate('builtinAuthEnabled', checked)} /> </Form.Item> {/* <Form.Item name="oidcAuthEnabled" label="OIDC认证" valuePropName="checked" > <Switch onChange={(checked) => handleSettingUpdate('oidcAuthEnabled', checked)} /> </Form.Item> */} <Form.Item name="autoApproveDevelopers" label="开发者自动审批" valuePropName="checked" > <Switch onChange={(checked) => handleSettingUpdate('autoApproveDevelopers', checked)} /> </Form.Item> <Form.Item name="autoApproveSubscriptions" label="订阅自动审批" valuePropName="checked" > <Switch onChange={(checked) => handleSettingUpdate('autoApproveSubscriptions', checked)} /> </Form.Item> </div> {/* 第三方认证管理 */} <Divider/> <ThirdPartyAuthManager configs={thirdPartyAuthConfigs} onSave={handleSaveThirdPartyAuth} /> </div> ) }, { key: 'domain', label: '域名管理', children: ( <div> <div className="flex justify-between items-center mb-4"> <div> <h3 className="text-lg font-medium">域名列表</h3> <p className="text-sm text-gray-500">管理Portal的域名配置</p> </div> <Button type="primary" icon={<PlusOutlined/>} onClick={handleAddDomain} > 绑定域名 </Button> </div> <Table columns={domainColumns} dataSource={portal.portalDomainConfig || []} rowKey="domain" pagination={false} size="small" /> </div> ) } ] return ( <div className="p-6 space-y-6"> <div className="flex justify-between items-center"> <div> <h1 className="text-2xl font-bold mb-2">Portal设置</h1> <p className="text-gray-600">配置Portal的基本设置和高级选项</p> </div> <Space> <Button type="primary" icon={<SaveOutlined/>} loading={loading} onClick={handleSave}> 保存设置 </Button> </Space> </div> <Form form={form} layout="vertical" initialValues={{ portalSettingConfig: portal.portalSettingConfig, builtinAuthEnabled: portal.portalSettingConfig?.builtinAuthEnabled, oidcAuthEnabled: portal.portalSettingConfig?.oidcAuthEnabled, autoApproveDevelopers: portal.portalSettingConfig?.autoApproveDevelopers, autoApproveSubscriptions: portal.portalSettingConfig?.autoApproveSubscriptions, frontendRedirectUrl: portal.portalSettingConfig?.frontendRedirectUrl, portalDomainConfig: portal.portalDomainConfig, }} > <Card> <Tabs items={tabItems} defaultActiveKey="auth" type="card" /> </Card> </Form> {/* 域名绑定模态框 */} <Modal title="绑定域名" open={domainModalVisible} onOk={handleDomainModalOk} onCancel={handleDomainModalCancel} confirmLoading={domainLoading} okText="绑定" cancelText="取消" > <Form form={domainForm} layout="vertical" > <Form.Item name="domain" label="域名" rules={[ {required: true, message: '请输入域名'}, { pattern: /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/, message: '请输入有效的域名格式' } ]} > <Input placeholder="example.com"/> </Form.Item> <Form.Item name="protocol" label="协议" rules={[{required: true, message: '请选择协议'}]} > <Select placeholder="请选择协议"> <Select.Option value="HTTP">HTTP</Select.Option> <Select.Option value="HTTPS">HTTPS</Select.Option> </Select> </Form.Item> </Form> </Modal> </div> ) } ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/pages/Portals.tsx: -------------------------------------------------------------------------------- ```typescript import { useState, useCallback, memo, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { Button, Card, Avatar, Dropdown, Modal, Form, Input, message, Tooltip, Pagination, Skeleton, } from "antd"; import { PlusOutlined, MoreOutlined, LinkOutlined } from "@ant-design/icons"; import type { MenuProps } from "antd"; import { portalApi } from "../lib/api"; import { Portal } from '@/types' // 优化的Portal卡片组件 const PortalCard = memo( ({ portal, onNavigate, fetchPortals, }: { portal: Portal; onNavigate: (id: string) => void; fetchPortals: () => void; }) => { const handleCardClick = useCallback(() => { onNavigate(portal.portalId); }, [portal.portalId, onNavigate]); const handleLinkClick = useCallback((e: React.MouseEvent) => { e.stopPropagation(); }, []); const dropdownItems: MenuProps["items"] = [ { key: "delete", label: "删除", danger: true, onClick: (e) => { e?.domEvent?.stopPropagation(); // 阻止事件冒泡 Modal.confirm({ title: "删除Portal", content: "确定要删除该Portal吗?", onOk: () => { return handleDeletePortal(portal.portalId); }, }); }, }, ]; const handleDeletePortal = useCallback((portalId: string) => { return portalApi.deletePortal(portalId).then(() => { message.success("Portal删除成功"); fetchPortals(); }).catch((error) => { message.error(error?.response?.data?.message || "删除失败,请稍后重试"); throw error; }); }, [fetchPortals]); return ( <Card className="cursor-pointer hover:shadow-xl transition-all duration-300 hover:scale-[1.02] border border-gray-100 hover:border-blue-300 bg-gradient-to-br from-white to-gray-50/30" onClick={handleCardClick} bodyStyle={{ padding: "20px" }} > <div className="flex items-center justify-between mb-6"> <div className="flex items-center space-x-4"> <div className="relative"> <Avatar size={48} className="bg-gradient-to-br from-blue-500 to-blue-600 shadow-lg" style={{ fontSize: "18px", fontWeight: "600" }} > {portal.title.charAt(0).toUpperCase()} </Avatar> <div className="absolute -bottom-1 -right-1 w-4 h-4 bg-green-400 rounded-full border-2 border-white"></div> </div> <div> <h3 className="text-xl font-bold text-gray-800 mb-1"> {portal.title} </h3> <p className="text-sm text-gray-500">{portal.description}</p> </div> </div> <Dropdown menu={{ items: dropdownItems }} trigger={["click"]}> <Button type="text" icon={<MoreOutlined />} onClick={(e) => e.stopPropagation()} className="hover:bg-gray-100 rounded-full" /> </Dropdown> </div> <div className="space-y-6"> <div className="flex items-center space-x-3 p-3 bg-blue-50 rounded-lg border border-blue-100"> <LinkOutlined className="h-4 w-4 text-blue-500" /> <Tooltip title={portal.portalDomainConfig?.[0].domain} placement="top" color="#000" > <a href={`http://${portal.portalDomainConfig?.[0].domain}`} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:text-blue-700 font-medium text-sm" onClick={handleLinkClick} style={{ display: "inline-block", maxWidth: 200, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", verticalAlign: "bottom", cursor: "pointer", }} > {portal.portalDomainConfig?.[0].domain} </a> </Tooltip> </div> <div className="space-y-3"> {/* 第一行:账号密码登录 + 开发者自动审批 */} <div className="grid grid-cols-2 gap-4"> <div className="flex items-center justify-between p-2 bg-gray-50 rounded-md"> <span className="text-xs font-medium text-gray-600"> 账号密码登录 </span> <span className={`px-2 py-1 rounded-full text-xs font-medium ${ portal.portalSettingConfig?.builtinAuthEnabled ? "bg-green-100 text-green-700" : "bg-red-100 text-red-700" }`} > {portal.portalSettingConfig?.builtinAuthEnabled ? "支持" : "不支持"} </span> </div> <div className="flex items-center justify-between p-2 bg-gray-50 rounded-md"> <span className="text-xs font-medium text-gray-600"> 开发者自动审批 </span> <span className={`px-2 py-1 rounded-full text-xs font-medium ${ portal.portalSettingConfig?.autoApproveDevelopers ? "bg-green-100 text-green-700" : "bg-yellow-100 text-yellow-700" }`} > {portal.portalSettingConfig?.autoApproveDevelopers ? "是" : "否"} </span> </div> </div> {/* 第二行:订阅自动审批 + 域名配置 */} <div className="grid grid-cols-2 gap-4"> <div className="flex items-center justify-between p-2 bg-gray-50 rounded-md"> <span className="text-xs font-medium text-gray-600"> 订阅自动审批 </span> <span className={`px-2 py-1 rounded-full text-xs font-medium ${ portal.portalSettingConfig?.autoApproveSubscriptions ? "bg-green-100 text-green-700" : "bg-yellow-100 text-yellow-700" }`} > {portal.portalSettingConfig?.autoApproveSubscriptions ? "是" : "否"} </span> </div> <div className="flex items-center justify-between p-2 bg-gray-50 rounded-md"> <span className="text-xs font-medium text-gray-600"> 域名配置 </span> <span className="px-2 py-1 bg-purple-100 text-purple-700 rounded-full text-xs font-medium"> {portal.portalDomainConfig?.length || 0}个 </span> </div> </div> </div> <div className="text-center pt-4 border-t border-gray-100"> <div className="inline-flex items-center space-x-2 text-sm text-gray-500 hover:text-blue-600 transition-colors"> <span onClick={handleCardClick}>点击查看详情</span> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> </svg> </div> </div> </div> </Card> ); } ); PortalCard.displayName = "PortalCard"; export default function Portals() { const navigate = useNavigate(); const [portals, setPortals] = useState<Portal[]>([]); const [loading, setLoading] = useState<boolean>(true); // 初始状态为 loading const [error, setError] = useState<string | null>(null); const [isModalVisible, setIsModalVisible] = useState<boolean>(false); const [form] = Form.useForm(); const [pagination, setPagination] = useState({ current: 1, pageSize: 12, total: 0, }); const fetchPortals = useCallback((page = 1, size = 12) => { setLoading(true); portalApi.getPortals({ page, size }).then((res: any) => { const list = res?.data?.content || []; const portals: Portal[] = list.map((item: any) => ({ portalId: item.portalId, name: item.name, title: item.name, description: item.description, adminId: item.adminId, portalSettingConfig: item.portalSettingConfig, portalUiConfig: item.portalUiConfig, portalDomainConfig: item.portalDomainConfig || [], })); setPortals(portals); setPagination({ current: page, pageSize: size, total: res?.data?.totalElements || 0, }); }).catch((err: any) => { setError(err?.message || "加载失败"); }).finally(() => { setLoading(false); }); }, []); useEffect(() => { setError(null); fetchPortals(1, 12); }, [fetchPortals]); // 处理分页变化 const handlePaginationChange = (page: number, pageSize: number) => { fetchPortals(page, pageSize); }; const handleCreatePortal = useCallback(() => { setIsModalVisible(true); }, []); const handleModalOk = useCallback(async () => { try { const values = await form.validateFields(); setLoading(true); const newPortal = { name: values.name, title: values.title, description: values.description, }; await portalApi.createPortal(newPortal); message.success("Portal创建成功"); setIsModalVisible(false); form.resetFields(); fetchPortals() } catch (error: any) { // message.error(error?.message || "创建失败"); } finally { setLoading(false); } }, [form]); const handleModalCancel = useCallback(() => { setIsModalVisible(false); form.resetFields(); }, [form]); const handlePortalClick = useCallback( (portalId: string) => { navigate(`/portals/detail?id=${portalId}`); }, [navigate] ); return ( <div className="space-y-6"> <div className="flex items-center justify-between"> <div> <h1 className="text-3xl font-bold tracking-tight">Portal</h1> <p className="text-gray-500 mt-2">管理和配置您的开发者门户</p> </div> <Button type="primary" icon={<PlusOutlined />} onClick={handleCreatePortal} > 创建 Portal </Button> </div> {error && <div className="text-red-500">{error}</div>} {loading ? ( <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> {Array.from({ length: pagination.pageSize || 12 }).map((_, index) => ( <div key={index} className="h-full rounded-lg shadow-lg bg-white p-4"> <div className="flex items-start space-x-4"> <Skeleton.Avatar size={48} active /> <div className="flex-1 min-w-0"> <div className="flex items-center justify-between mb-2"> <Skeleton.Input active size="small" style={{ width: 120 }} /> <Skeleton.Input active size="small" style={{ width: 60 }} /> </div> <Skeleton.Input active size="small" style={{ width: '100%', marginBottom: 12 }} /> <Skeleton.Input active size="small" style={{ width: '80%', marginBottom: 8 }} /> <div className="flex items-center justify-between"> <Skeleton.Input active size="small" style={{ width: 60 }} /> <Skeleton.Input active size="small" style={{ width: 80 }} /> </div> </div> </div> </div> ))} </div> ) : ( <> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> {portals.map((portal) => ( <PortalCard key={portal.portalId} portal={portal} onNavigate={handlePortalClick} fetchPortals={() => fetchPortals(pagination.current, pagination.pageSize)} /> ))} </div> {pagination.total > 0 && ( <div className="flex justify-center mt-6"> <Pagination current={pagination.current} pageSize={pagination.pageSize} total={pagination.total} onChange={handlePaginationChange} showSizeChanger showQuickJumper showTotal={(total) => `共 ${total} 条`} pageSizeOptions={['6', '12', '24', '48']} /> </div> )} </> )} <Modal title="创建Portal" open={isModalVisible} onOk={handleModalOk} onCancel={handleModalCancel} confirmLoading={loading} width={600} > <Form form={form} layout="vertical"> <Form.Item name="name" label="名称" rules={[{ required: true, message: "请输入Portal名称" }]} > <Input placeholder="请输入Portal名称" /> </Form.Item> {/* <Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入Portal标题" }]} > <Input placeholder="请输入Portal标题" /> </Form.Item> */} <Form.Item name="description" label="描述" rules={[{ message: "请输入描述" }]} > <Input.TextArea rows={3} placeholder="请输入Portal描述" /> </Form.Item> </Form> </Modal> </div> ); } ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/service/impl/DeveloperServiceImpl.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.BooleanUtil; import cn.hutool.core.util.StrUtil; import com.alibaba.apiopenplatform.core.constant.Resources; import com.alibaba.apiopenplatform.core.event.DeveloperDeletingEvent; import com.alibaba.apiopenplatform.core.event.PortalDeletingEvent; import com.alibaba.apiopenplatform.core.utils.TokenUtil; import com.alibaba.apiopenplatform.dto.params.developer.CreateDeveloperParam; import com.alibaba.apiopenplatform.dto.params.developer.CreateExternalDeveloperParam; import com.alibaba.apiopenplatform.dto.params.developer.QueryDeveloperParam; import com.alibaba.apiopenplatform.dto.params.developer.UpdateDeveloperParam; import com.alibaba.apiopenplatform.dto.result.AuthResult; import com.alibaba.apiopenplatform.dto.result.DeveloperResult; import com.alibaba.apiopenplatform.dto.result.PageResult; import com.alibaba.apiopenplatform.entity.Developer; import com.alibaba.apiopenplatform.entity.Portal; import com.alibaba.apiopenplatform.repository.DeveloperRepository; import com.alibaba.apiopenplatform.repository.PortalRepository; import com.alibaba.apiopenplatform.service.DeveloperService; import com.alibaba.apiopenplatform.core.utils.PasswordHasher; import com.alibaba.apiopenplatform.core.utils.IdGenerator; import com.alibaba.apiopenplatform.repository.DeveloperExternalIdentityRepository; import com.alibaba.apiopenplatform.entity.DeveloperExternalIdentity; import com.alibaba.apiopenplatform.support.enums.DeveloperAuthType; import com.alibaba.apiopenplatform.support.enums.DeveloperStatus; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; 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 org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.alibaba.apiopenplatform.core.exception.BusinessException; import com.alibaba.apiopenplatform.core.exception.ErrorCode; import com.alibaba.apiopenplatform.core.security.ContextHolder; import javax.persistence.criteria.Predicate; import java.util.*; import javax.servlet.http.HttpServletRequest; @Service @RequiredArgsConstructor @Slf4j @Transactional public class DeveloperServiceImpl implements DeveloperService { private final DeveloperRepository developerRepository; private final DeveloperExternalIdentityRepository externalRepository; private final PortalRepository portalRepository; private final ContextHolder contextHolder; private final ApplicationEventPublisher eventPublisher; @Override public AuthResult registerDeveloper(CreateDeveloperParam param) { DeveloperResult developer = createDeveloper(param); // 检查是否自动审批 String portalId = contextHolder.getPortal(); Portal portal = findPortal(portalId); boolean autoApprove = portal.getPortalSettingConfig() != null && BooleanUtil.isTrue(portal.getPortalSettingConfig().getAutoApproveDevelopers()); if (autoApprove) { String token = generateToken(developer.getDeveloperId()); return AuthResult.of(token, TokenUtil.getTokenExpiresIn()); } return null; } @Override public DeveloperResult createDeveloper(CreateDeveloperParam param) { String portalId = contextHolder.getPortal(); developerRepository.findByPortalIdAndUsername(portalId, param.getUsername()).ifPresent(developer -> { throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.DEVELOPER, param.getUsername())); }); Developer developer = param.convertTo(); developer.setDeveloperId(generateDeveloperId()); developer.setPortalId(portalId); developer.setPasswordHash(PasswordHasher.hash(param.getPassword())); Portal portal = findPortal(portalId); boolean autoApprove = portal.getPortalSettingConfig() != null && BooleanUtil.isTrue(portal.getPortalSettingConfig().getAutoApproveDevelopers()); developer.setStatus(autoApprove ? DeveloperStatus.APPROVED : DeveloperStatus.PENDING); developer.setAuthType(DeveloperAuthType.BUILTIN); developerRepository.save(developer); return new DeveloperResult().convertFrom(developer); } @Override public AuthResult login(String username, String password) { String portalId = contextHolder.getPortal(); Developer developer = developerRepository.findByPortalIdAndUsername(portalId, username) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.DEVELOPER, username)); if (!DeveloperStatus.APPROVED.equals(developer.getStatus())) { throw new BusinessException(ErrorCode.INVALID_REQUEST, "账号审批中"); } if (!PasswordHasher.verify(password, developer.getPasswordHash())) { throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户名或密码错误"); } String token = generateToken(developer.getDeveloperId()); return AuthResult.builder() .accessToken(token) .expiresIn(TokenUtil.getTokenExpiresIn()) .build(); } @Override public void existsDeveloper(String developerId) { developerRepository.findByDeveloperId(developerId) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.DEVELOPER, developerId)); } @Override public DeveloperResult createExternalDeveloper(CreateExternalDeveloperParam param) { Developer developer = Developer.builder() .developerId(IdGenerator.genDeveloperId()) .portalId(contextHolder.getPortal()) .username(buildExternalName(param.getProvider(), param.getDisplayName())) .email(param.getEmail()) // 默认APPROVED .status(DeveloperStatus.APPROVED) .build(); DeveloperExternalIdentity externalIdentity = DeveloperExternalIdentity.builder() .provider(param.getProvider()) .subject(param.getSubject()) .displayName(param.getDisplayName()) .authType(param.getAuthType()) .developer(developer) .build(); developerRepository.save(developer); externalRepository.save(externalIdentity); return new DeveloperResult().convertFrom(developer); } @Override public DeveloperResult getExternalDeveloper(String provider, String subject) { return externalRepository.findByProviderAndSubject(provider, subject) .map(o -> new DeveloperResult().convertFrom(o.getDeveloper())) .orElse(null); } private String buildExternalName(String provider, String subject) { return StrUtil.format("{}_{}", provider, subject); } @Override public void deleteDeveloper(String developerId) { eventPublisher.publishEvent(new DeveloperDeletingEvent(developerId)); externalRepository.deleteByDeveloper_DeveloperId(developerId); developerRepository.findByDeveloperId(developerId).ifPresent(developerRepository::delete); } @Override public DeveloperResult getDeveloper(String developerId) { Developer developer = findDeveloper(developerId); return new DeveloperResult().convertFrom(developer); } @Override public PageResult<DeveloperResult> listDevelopers(QueryDeveloperParam param, Pageable pageable) { if (contextHolder.isDeveloper()) { param.setPortalId(contextHolder.getPortal()); } Page<Developer> developers = developerRepository.findAll(buildSpecification(param), pageable); return new PageResult<DeveloperResult>().convertFrom(developers, developer -> new DeveloperResult().convertFrom(developer)); } @Override public void setDeveloperStatus(String developerId, DeveloperStatus status) { Developer developer = findDeveloper(developerId); developer.setStatus(status); developerRepository.save(developer); } @Override @Transactional public boolean resetPassword(String developerId, String oldPassword, String newPassword) { Developer developer = findDeveloper(developerId); if (!PasswordHasher.verify(oldPassword, developer.getPasswordHash())) { throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户名或密码错误"); } developer.setPasswordHash(PasswordHasher.hash(newPassword)); developerRepository.save(developer); return true; } @Override public boolean updateProfile(UpdateDeveloperParam param) { Developer developer = findDeveloper(contextHolder.getUser()); String username = param.getUsername(); if (username != null && !username.equals(developer.getUsername())) { if (developerRepository.findByPortalIdAndUsername(developer.getPortalId(), username).isPresent()) { throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.DEVELOPER, username)); } } param.update(developer); developerRepository.save(developer); return true; } @EventListener @Async("taskExecutor") public void handlePortalDeletion(PortalDeletingEvent event) { String portalId = event.getPortalId(); List<Developer> developers = developerRepository.findByPortalId(portalId); developers.forEach(developer -> deleteDeveloper(developer.getDeveloperId())); } private String generateToken(String developerId) { return TokenUtil.generateDeveloperToken(developerId); } private Developer createExternalDeveloper(String providerName, String providerSubject, String email, String displayName, String rawInfoJson) { String portalId = contextHolder.getPortal(); String username = generateUniqueUsername(portalId, displayName, providerName, providerSubject); Developer developer = Developer.builder() .developerId(generateDeveloperId()) .portalId(portalId) .username(username) .email(email) .status(DeveloperStatus.APPROVED) .authType(DeveloperAuthType.OIDC) .build(); developer = developerRepository.save(developer); DeveloperExternalIdentity ext = DeveloperExternalIdentity.builder() .provider(providerName) .subject(providerSubject) .displayName(displayName) .rawInfoJson(rawInfoJson) .developer(developer) .build(); externalRepository.save(ext); return developer; } private String generateUniqueUsername(String portalId, String displayName, String providerName, String providerSubject) { String username = displayName != null ? displayName : providerName + "_" + providerSubject; String originalUsername = username; int suffix = 1; while (developerRepository.findByPortalIdAndUsername(portalId, username).isPresent()) { username = originalUsername + "_" + suffix; suffix++; } return username; } private String generateDeveloperId() { return IdGenerator.genDeveloperId(); } private Developer findDeveloper(String developerId) { return developerRepository.findByDeveloperId(developerId) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.DEVELOPER, developerId)); } private Portal findPortal(String portalId) { return portalRepository.findByPortalId(portalId) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.PORTAL, portalId)); } private Specification<Developer> buildSpecification(QueryDeveloperParam param) { return (root, query, cb) -> { List<Predicate> predicates = new ArrayList<>(); if (StrUtil.isNotBlank(param.getPortalId())) { predicates.add(cb.equal(root.get("portalId"), param.getPortalId())); } if (StrUtil.isNotBlank(param.getUsername())) { String likePattern = "%" + param.getUsername() + "%"; predicates.add(cb.like(root.get("username"), likePattern)); } if (param.getStatus() != null) { predicates.add(cb.equal(root.get("status"), param.getStatus())); } return cb.and(predicates.toArray(new Predicate[0])); }; } @Override public void logout(HttpServletRequest request) { // 使用TokenUtil处理登出逻辑 com.alibaba.apiopenplatform.core.utils.TokenUtil.revokeToken(request); } @Override public DeveloperResult getCurrentDeveloperInfo() { String currentUserId = contextHolder.getUser(); Developer developer = findDeveloper(currentUserId); return new DeveloperResult().convertFrom(developer); } @Override public boolean changeCurrentDeveloperPassword(String oldPassword, String newPassword) { String currentUserId = contextHolder.getUser(); return resetPassword(currentUserId, oldPassword, newPassword); } } ``` -------------------------------------------------------------------------------- /portal-web/api-portal-frontend/src/pages/ApiDetail.tsx: -------------------------------------------------------------------------------- ```typescript import { useEffect, useState } from "react"; import { useParams } from "react-router-dom"; import { Card, Alert, Row, Col, Tabs } from "antd"; import { Layout } from "../components/Layout"; import { ProductHeader } from "../components/ProductHeader"; import { SwaggerUIWrapper } from "../components/SwaggerUIWrapper"; import api from "../lib/api"; import type { Product, ApiResponse } from "../types"; import ReactMarkdown from "react-markdown"; import remarkGfm from 'remark-gfm'; import 'react-markdown-editor-lite/lib/index.css'; import * as yaml from 'js-yaml'; import { Button, Typography, Space, Divider, message } from "antd"; import { CopyOutlined, RocketOutlined, DownloadOutlined } from "@ant-design/icons"; const { Title, Paragraph } = Typography; interface UpdatedProduct extends Omit<Product, 'apiSpec'> { apiConfig?: { spec: string; meta: { source: string; type: string; }; }; createAt: string; enabled: boolean; } function ApiDetailPage() { const { id } = useParams(); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [apiData, setApiData] = useState<UpdatedProduct | null>(null); const [baseUrl, setBaseUrl] = useState<string>(''); const [examplePath, setExamplePath] = useState<string>('/{path}'); const [exampleMethod, setExampleMethod] = useState<string>('GET'); useEffect(() => { if (!id) return; fetchApiDetail(); }, [id]); const fetchApiDetail = async () => { setLoading(true); setError(''); try { const response: ApiResponse<UpdatedProduct> = await api.get(`/products/${id}`); if (response.code === "SUCCESS" && response.data) { setApiData(response.data); // 提取基础URL和示例路径用于curl示例 if (response.data.apiConfig?.spec) { try { let openApiDoc: any; try { openApiDoc = yaml.load(response.data.apiConfig.spec); } catch { openApiDoc = JSON.parse(response.data.apiConfig.spec); } // 提取服务器URL并处理尾部斜杠 let serverUrl = openApiDoc?.servers?.[0]?.url || ''; if (serverUrl && serverUrl.endsWith('/')) { serverUrl = serverUrl.slice(0, -1); // 移除末尾的斜杠 } setBaseUrl(serverUrl); // 提取第一个可用的路径和方法作为示例 const paths = openApiDoc?.paths; if (paths && typeof paths === 'object') { const pathEntries = Object.entries(paths); if (pathEntries.length > 0) { const [firstPath, pathMethods] = pathEntries[0] as [string, any]; if (pathMethods && typeof pathMethods === 'object') { const methods = Object.keys(pathMethods); if (methods.length > 0) { const firstMethod = methods[0].toUpperCase(); setExamplePath(firstPath); setExampleMethod(firstMethod); } } } } } catch (error) { console.error('解析OpenAPI规范失败:', error); } } } } catch (error) { console.error('获取API详情失败:', error); setError('加载失败,请稍后重试'); } finally { setLoading(false); } }; if (error) { return ( <Layout loading={loading}> <Alert message={error} type="error" showIcon className="my-8" /> </Layout> ); } if (!apiData) { return ( <Layout loading={loading}> <Alert message="未找到API信息" type="warning" showIcon className="my-8" /> </Layout> ); } return ( <Layout loading={loading}> <div className="mb-6"> <ProductHeader name={apiData.name} description={apiData.description} icon={apiData.icon} defaultIcon="/logo.svg" updatedAt={apiData.updatedAt} productType="REST_API" /> <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: apiData.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]}>{apiData.document}</ReactMarkdown> </div> </div> ) : ( <div className="text-gray-500 text-center py-8"> 暂无文档内容 </div> ), }, { key: "openapi-spec", label: "OpenAPI Specification", children: ( <div> {apiData.apiConfig && apiData.apiConfig.spec ? ( <SwaggerUIWrapper apiSpec={apiData.apiConfig.spec} /> ) : ( <div className="text-gray-500 text-center py-8"> 暂无OpenAPI规范 </div> )} </div> ), }, ]} /> </Card> </Col> {/* 右侧内容 */} <Col span={9}> <Card className="rounded-lg border-gray-200" title={ <Space> <RocketOutlined /> <span>快速开始</span> </Space> }> <Space direction="vertical" className="w-full" size="middle"> {/* cURL示例 */} <div> <Title level={5}>cURL调用示例</Title> <div className="bg-gray-50 p-3 rounded border relative"> <pre className="text-sm mb-0"> {`curl -X ${exampleMethod} \\ '${baseUrl || 'https://api.example.com'}${examplePath}' \\ -H 'Accept: application/json' \\ -H 'Content-Type: application/json'`} </pre> <Button type="text" size="small" icon={<CopyOutlined />} className="absolute top-2 right-2" onClick={() => { const curlCommand = `curl -X ${exampleMethod} \\\n '${baseUrl || 'https://api.example.com'}${examplePath}' \\\n -H 'Accept: application/json' \\\n -H 'Content-Type: application/json'`; navigator.clipboard.writeText(curlCommand); message.success('cURL命令已复制到剪贴板', 1); }} /> </div> </div> <Divider /> {/* 下载OAS文件 */} <div> <Title level={5}>OpenAPI规范文件</Title> <Paragraph type="secondary"> 下载完整的OpenAPI规范文件,用于代码生成、API测试等场景 </Paragraph> <Space> <Button type="primary" icon={<DownloadOutlined />} onClick={() => { if (apiData?.apiConfig?.spec) { const blob = new Blob([apiData.apiConfig.spec], { type: 'text/yaml' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `${apiData.name || 'api'}-openapi.yaml`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); message.success('OpenAPI规范文件下载成功', 1); } }} > 下载YAML </Button> <Button icon={<DownloadOutlined />} onClick={() => { if (apiData?.apiConfig?.spec) { try { const yamlDoc = yaml.load(apiData.apiConfig.spec); const jsonSpec = JSON.stringify(yamlDoc, null, 2); const blob = new Blob([jsonSpec], { type: 'application/json' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `${apiData.name || 'api'}-openapi.json`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); message.success('OpenAPI规范文件下载成功', 1); } catch (error) { message.error('转换JSON格式失败'); } } }} > 下载JSON </Button> </Space> </div> </Space> </Card> </Col> </Row> </Layout> ); } export default ApiDetailPage; ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/pages/ApiProducts.tsx: -------------------------------------------------------------------------------- ```typescript import { memo, useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import type { MenuProps } from 'antd'; import { Badge, Button, Card, Dropdown, Modal, message, Pagination, Skeleton, Input, Select, Tag, Space } from 'antd'; import type { ApiProduct, ProductIcon } from '@/types/api-product'; import { ApiOutlined, MoreOutlined, PlusOutlined, ExclamationCircleOutlined, ExclamationCircleFilled, ClockCircleFilled, CheckCircleFilled, SearchOutlined } from '@ant-design/icons'; import McpServerIcon from '@/components/icons/McpServerIcon'; import { apiProductApi } from '@/lib/api'; import ApiProductFormModal from '@/components/api-product/ApiProductFormModal'; // 优化的产品卡片组件 const ProductCard = memo(({ product, onNavigate, handleRefresh, onEdit }: { product: ApiProduct; onNavigate: (productId: string) => void; handleRefresh: () => void; onEdit: (product: ApiProduct) => void; }) => { // 处理产品图标的函数 const getTypeIcon = (icon: ProductIcon | null | undefined, type: string) => { if (icon) { switch (icon.type) { case "URL": return <img src={icon.value} alt="icon" style={{ borderRadius: '8px', minHeight: '40px', width: '40px', height: '40px', objectFit: 'cover' }} /> case "BASE64": // 如果value已经包含data URL前缀,直接使用;否则添加前缀 const src = icon.value.startsWith('data:') ? icon.value : `data:image/png;base64,${icon.value}`; return <img src={src} alt="icon" style={{ borderRadius: '8px', minHeight: '40px', width: '40px', height: '40px', objectFit: 'cover' }} /> default: return type === "REST_API" ? <ApiOutlined style={{ fontSize: '16px', width: '16px', height: '16px' }} /> : <McpServerIcon style={{ fontSize: '16px', width: '16px', height: '16px' }} /> } } else { return type === "REST_API" ? <ApiOutlined style={{ fontSize: '16px', width: '16px', height: '16px' }} /> : <McpServerIcon style={{ fontSize: '16px', width: '16px', height: '16px' }} /> } } const handleClick = useCallback(() => { onNavigate(product.productId) }, [product.productId, onNavigate]); const handleDelete = useCallback((productId: string, productName: string, e?: React.MouseEvent | any) => { if (e && e.stopPropagation) e.stopPropagation(); Modal.confirm({ title: '确认删除', icon: <ExclamationCircleOutlined />, content: `确定要删除API产品 "${productName}" 吗?此操作不可恢复。`, okText: '确认删除', okType: 'danger', cancelText: '取消', onOk() { apiProductApi.deleteApiProduct(productId).then(() => { message.success('API Product 删除成功'); handleRefresh(); }); }, }); }, [handleRefresh]); const handleEdit = useCallback((e?: React.MouseEvent | any) => { if (e && e?.domEvent?.stopPropagation) e.domEvent.stopPropagation(); onEdit(product); }, [product, onEdit]); const dropdownItems: MenuProps['items'] = [ { key: 'edit', label: '编辑', onClick: handleEdit, }, { type: 'divider', }, { key: 'delete', label: '删除', danger: true, onClick: (info: any) => handleDelete(product.productId, product.name, info?.domEvent), }, ] return ( <Card className="hover:shadow-lg transition-shadow cursor-pointer rounded-xl border border-gray-200 shadow-sm hover:border-blue-300" onClick={handleClick} bodyStyle={{ padding: '16px' }} > <div className="flex items-center justify-between mb-4"> <div className="flex items-center space-x-3"> <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-100"> {getTypeIcon(product.icon, product.type)} </div> <div> <h3 className="text-lg font-semibold">{product.name}</h3> <div className="flex items-center gap-3 mt-1 flex-wrap"> {product.category && <Badge color="green" text={product.category} />} <div className="flex items-center"> {product.type === "REST_API" ? ( <ApiOutlined className="text-blue-500 mr-1" style={{fontSize: '12px', width: '12px', height: '12px'}} /> ) : ( <McpServerIcon className="text-black mr-1" style={{fontSize: '12px', width: '12px', height: '12px'}} /> )} <span className="text-xs text-gray-700"> {product.type === "REST_API" ? "REST API" : "MCP Server"} </span> </div> <div className="flex items-center"> {product.status === "PENDING" ? ( <ExclamationCircleFilled className="text-yellow-500 mr-1" style={{fontSize: '12px', width: '12px', height: '12px'}} /> ) : product.status === "READY" ? ( <ClockCircleFilled className="text-blue-500 mr-1" style={{fontSize: '12px', width: '12px', height: '12px'}} /> ) : ( <CheckCircleFilled className="text-green-500 mr-1" style={{fontSize: '12px', width: '12px', height: '12px'}} /> )} <span className="text-xs text-gray-700"> {product.status === "PENDING" ? "待配置" : product.status === "READY" ? "待发布" : "已发布"} </span> </div> </div> </div> </div> <Dropdown menu={{ items: dropdownItems }} trigger={['click']}> <Button type="text" icon={<MoreOutlined />} onClick={(e) => e.stopPropagation()} /> </Dropdown> </div> <div className="space-y-4"> {product.description && ( <p className="text-sm text-gray-600">{product.description}</p> )} </div> </Card> ) }) ProductCard.displayName = 'ProductCard' export default function ApiProducts() { const navigate = useNavigate(); const [apiProducts, setApiProducts] = useState<ApiProduct[]>([]); const [filters, setFilters] = useState<{ type?: string, name?: string }>({}); const [loading, setLoading] = useState(true); // 初始状态为 loading const [pagination, setPagination] = useState({ current: 1, pageSize: 12, total: 0, }); const [modalVisible, setModalVisible] = useState(false); const [editingProduct, setEditingProduct] = useState<ApiProduct | null>(null); // 搜索状态 const [searchValue, setSearchValue] = useState(''); const [searchType, setSearchType] = useState<'name' | 'type'>('name'); const [activeFilters, setActiveFilters] = useState<Array<{ type: string; value: string; label: string }>>([]); const fetchApiProducts = useCallback((page = 1, size = 12, queryFilters?: { type?: string, name?: string }) => { setLoading(true); const params = { page, size, ...(queryFilters || {}) }; apiProductApi.getApiProducts(params).then((res: any) => { const products = res.data.content; setApiProducts(products); setPagination({ current: page, pageSize: size, total: res.data.totalElements || 0, }); }).finally(() => { setLoading(false); }); }, []); // 不依赖任何状态,避免无限循环 useEffect(() => { fetchApiProducts(1, 12); }, []); // 只在组件初始化时执行一次 // 产品类型选项 const typeOptions = [ { label: 'REST API', value: 'REST_API' }, { label: 'MCP Server', value: 'MCP_SERVER' }, ]; // 搜索类型选项 const searchTypeOptions = [ { label: '产品名称', value: 'name' as const }, { label: '产品类型', value: 'type' as const }, ]; // 搜索处理函数 const handleSearch = () => { if (searchValue.trim()) { let labelText = ''; let filterValue = searchValue.trim(); if (searchType === 'name') { labelText = `产品名称:${searchValue.trim()}`; } else { const typeLabel = typeOptions.find(opt => opt.value === searchValue.trim())?.label || searchValue.trim(); labelText = `产品类型:${typeLabel}`; } const newFilter = { type: searchType, value: filterValue, label: labelText }; const updatedFilters = activeFilters.filter(f => f.type !== searchType); updatedFilters.push(newFilter); setActiveFilters(updatedFilters); const filters: { type?: string, name?: string } = {}; updatedFilters.forEach(filter => { if (filter.type === 'type' || filter.type === 'name') { filters[filter.type] = filter.value; } }); setFilters(filters); fetchApiProducts(1, pagination.pageSize, filters); setSearchValue(''); } }; // 移除单个筛选条件 const removeFilter = (filterType: string) => { const updatedFilters = activeFilters.filter(f => f.type !== filterType); setActiveFilters(updatedFilters); const newFilters: { type?: string, name?: string } = {}; updatedFilters.forEach(filter => { if (filter.type === 'type' || filter.type === 'name') { newFilters[filter.type] = filter.value; } }); setFilters(newFilters); fetchApiProducts(1, pagination.pageSize, newFilters); }; // 清空所有筛选条件 const clearAllFilters = () => { setActiveFilters([]); setFilters({}); fetchApiProducts(1, pagination.pageSize, {}); }; // 处理分页变化 const handlePaginationChange = (page: number, pageSize: number) => { fetchApiProducts(page, pageSize, filters); // 传递当前filters }; // 直接使用服务端返回的列表 // 优化的导航处理函数 const handleNavigateToProduct = useCallback((productId: string) => { navigate(`/api-products/detail?productId=${productId}`); }, [navigate]); // 处理创建 const handleCreate = () => { setEditingProduct(null); setModalVisible(true); }; // 处理编辑 const handleEdit = (product: ApiProduct) => { setEditingProduct(product); setModalVisible(true); }; // 处理模态框成功 const handleModalSuccess = () => { setModalVisible(false); setEditingProduct(null); fetchApiProducts(pagination.current, pagination.pageSize, filters); }; // 处理模态框取消 const handleModalCancel = () => { setModalVisible(false); setEditingProduct(null); }; return ( <div className="space-y-6"> <div className="flex items-center justify-between"> <div> <h1 className="text-3xl font-bold tracking-tight">API Products</h1> <p className="text-gray-500 mt-2"> 管理和配置您的API产品 </p> </div> <Button onClick={handleCreate} type="primary" icon={<PlusOutlined/>}> 创建 API Product </Button> </div> {/* 搜索和筛选 */} <div className="space-y-4"> {/* 搜索框 */} <div className="flex items-center max-w-xl"> {/* 左侧:搜索类型选择器 */} <Select value={searchType} onChange={setSearchType} style={{ width: 120, borderTopRightRadius: 0, borderBottomRightRadius: 0, backgroundColor: '#f5f5f5', }} className="h-10" size="large" > {searchTypeOptions.map(option => ( <Select.Option key={option.value} value={option.value}> {option.label} </Select.Option> ))} </Select> {/* 中间:搜索值输入框或选择框 */} {searchType === 'type' ? ( <Select placeholder="请选择产品类型" value={searchValue} onChange={(value) => { setSearchValue(value); // 对于类型选择,立即执行搜索 if (value) { const typeLabel = typeOptions.find(opt => opt.value === value)?.label || value; const labelText = `产品类型:${typeLabel}`; const newFilter = { type: 'type', value, label: labelText }; const updatedFilters = activeFilters.filter(f => f.type !== 'type'); updatedFilters.push(newFilter); setActiveFilters(updatedFilters); const filters: { type?: string, name?: string } = {}; updatedFilters.forEach(filter => { if (filter.type === 'type' || filter.type === 'name') { filters[filter.type] = filter.value; } }); setFilters(filters); fetchApiProducts(1, pagination.pageSize, filters); setSearchValue(''); } }} style={{ flex: 1, borderTopLeftRadius: 0, borderBottomLeftRadius: 0, borderTopRightRadius: 0, borderBottomRightRadius: 0, }} allowClear onClear={clearAllFilters} className="h-10" size="large" > {typeOptions.map(option => ( <Select.Option key={option.value} value={option.value}> {option.label} </Select.Option> ))} </Select> ) : ( <Input placeholder="请输入要检索的产品名称" value={searchValue} onChange={(e) => setSearchValue(e.target.value)} style={{ flex: 1, borderTopLeftRadius: 0, borderBottomLeftRadius: 0, borderTopRightRadius: 0, borderBottomRightRadius: 0, }} onPressEnter={handleSearch} allowClear onClear={() => setSearchValue('')} size="large" className="h-10" /> )} {/* 右侧:搜索按钮 */} <Button icon={<SearchOutlined />} onClick={handleSearch} style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, width: 48, }} className="h-10" size="large" /> </div> {/* 筛选条件标签 */} {activeFilters.length > 0 && ( <div className="flex items-center gap-2"> <span className="text-sm text-gray-500">筛选条件:</span> <Space wrap> {activeFilters.map(filter => ( <Tag key={filter.type} closable onClose={() => removeFilter(filter.type)} style={{ backgroundColor: '#f5f5f5', border: '1px solid #d9d9d9', borderRadius: '16px', color: '#666', fontSize: '12px', padding: '4px 12px', }} > {filter.label} </Tag> ))} </Space> <Button type="link" size="small" onClick={clearAllFilters} className="text-blue-500 hover:text-blue-600 text-sm" > 清除筛选条件 </Button> </div> )} </div> {loading ? ( <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> {Array.from({ length: pagination.pageSize || 12 }).map((_, index) => ( <div key={index} className="h-full rounded-lg shadow-lg bg-white p-4"> <div className="flex items-start space-x-4"> <Skeleton.Avatar size={48} active /> <div className="flex-1 min-w-0"> <div className="flex items-center justify-between mb-2"> <Skeleton.Input active size="small" style={{ width: 120 }} /> <Skeleton.Input active size="small" style={{ width: 60 }} /> </div> <Skeleton.Input active size="small" style={{ width: '100%', marginBottom: 12 }} /> <Skeleton.Input active size="small" style={{ width: '80%', marginBottom: 8 }} /> <div className="flex items-center justify-between"> <Skeleton.Input active size="small" style={{ width: 60 }} /> <Skeleton.Input active size="small" style={{ width: 80 }} /> </div> </div> </div> </div> ))} </div> ) : ( <> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> {apiProducts.map((product) => ( <ProductCard key={product.productId} product={product} onNavigate={handleNavigateToProduct} handleRefresh={() => fetchApiProducts(pagination.current, pagination.pageSize, filters)} onEdit={handleEdit} /> ))} </div> {pagination.total > 0 && ( <div className="flex justify-center mt-6"> <Pagination current={pagination.current} pageSize={pagination.pageSize} total={pagination.total} onChange={handlePaginationChange} showSizeChanger showQuickJumper showTotal={(total) => `共 ${total} 条`} pageSizeOptions={['6', '12', '24', '48']} /> </div> )} </> )} <ApiProductFormModal visible={modalVisible} onCancel={handleModalCancel} onSuccess={handleModalSuccess} productId={editingProduct?.productId} initialData={editingProduct || undefined} /> </div> ) } ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/service/gateway/AIGatewayOperator.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.map.MapUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONArray; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import com.alibaba.apiopenplatform.core.exception.BusinessException; import com.alibaba.apiopenplatform.core.exception.ErrorCode; import com.alibaba.apiopenplatform.dto.result.GatewayMCPServerResult; import com.alibaba.apiopenplatform.dto.result.*; import com.alibaba.apiopenplatform.entity.Gateway; import com.alibaba.apiopenplatform.service.gateway.client.APIGClient; import com.alibaba.apiopenplatform.service.gateway.client.PopGatewayClient; import com.alibaba.apiopenplatform.service.gateway.client.SLSClient; import com.alibaba.apiopenplatform.support.consumer.APIGAuthConfig; import com.alibaba.apiopenplatform.support.consumer.ConsumerAuthConfig; import com.alibaba.apiopenplatform.support.enums.APIGAPIType; import com.alibaba.apiopenplatform.support.enums.GatewayType; import com.alibaba.apiopenplatform.support.product.APIGRefConfig; import com.aliyuncs.http.MethodType; import com.aliyun.sdk.gateway.pop.exception.PopClientException; import com.aliyun.sdk.service.apig20240327.models.*; import com.aliyun.sdk.service.sls20201230.models.*; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; @Service @Slf4j public class AIGatewayOperator extends APIGOperator { @Override public PageResult<? extends GatewayMCPServerResult> fetchMcpServers(Gateway gateway, int page, int size) { PopGatewayClient client = new PopGatewayClient(gateway.getApigConfig()); Map<String , String> queryParams = MapUtil.<String, String>builder() .put("gatewayId", gateway.getGatewayId()) .put("pageNumber", String.valueOf(page)) .put("pageSize", String.valueOf(size)) .build(); return client.execute("/v1/mcp-servers", MethodType.GET, queryParams, data -> { List<APIGMCPServerResult> mcpServers = Optional.ofNullable(data.getJSONArray("items")) .map(items -> items.stream() .map(JSONObject.class::cast) .map(json -> { APIGMCPServerResult result = new APIGMCPServerResult(); result.setMcpServerName(json.getStr("name")); result.setMcpServerId(json.getStr("mcpServerId")); result.setMcpRouteId(json.getStr("routeId")); result.setApiId(json.getStr("apiId")); return result; }) .collect(Collectors.toList())) .orElse(new ArrayList<>()); return PageResult.of(mcpServers, page, size, data.getInt("totalSize")); }); } public PageResult<? extends GatewayMCPServerResult> fetchMcpServers_V1(Gateway gateway, int page, int size) { PageResult<APIResult> apiPage = fetchAPIs(gateway, APIGAPIType.MCP, 0, 1); if (apiPage.getTotalElements() == 0) { return PageResult.empty(page, size); } // MCP Server定义在一个API下 String apiId = apiPage.getContent().get(0).getApiId(); try { PageResult<HttpRoute> routesPage = fetchHttpRoutes(gateway, apiId, page, size); if (routesPage.getTotalElements() == 0) { return PageResult.empty(page, size); } return PageResult.<APIGMCPServerResult>builder().build() .mapFrom(routesPage, route -> { APIGMCPServerResult r = new APIGMCPServerResult().convertFrom(route); r.setApiId(apiId); return r; }); } catch (Exception e) { log.error("Error fetching MCP servers", e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching MCP servers,Cause:" + e.getMessage()); } } @Override public String fetchMcpConfig(Gateway gateway, Object conf) { APIGRefConfig config = (APIGRefConfig) conf; PopGatewayClient client = new PopGatewayClient(gateway.getApigConfig()); String mcpServerId = config.getMcpServerId(); MCPConfigResult mcpConfig = new MCPConfigResult(); return client.execute("/v1/mcp-servers/" + mcpServerId, MethodType.GET, null, data -> { mcpConfig.setMcpServerName(data.getStr("name")); // mcpServer config MCPConfigResult.MCPServerConfig serverConfig = new MCPConfigResult.MCPServerConfig(); String path = data.getStr("mcpServerPath"); String exposedUriPath = data.getStr("exposedUriPath"); if (StrUtil.isNotBlank(exposedUriPath)) { path += exposedUriPath; } serverConfig.setPath(path); JSONArray domains = data.getJSONArray("domainInfos"); if (domains != null && !domains.isEmpty()) { serverConfig.setDomains(domains.stream() .map(JSONObject.class::cast) .map(json -> MCPConfigResult.Domain.builder() .domain(json.getStr("name")) .protocol(Optional.ofNullable(json.getStr("protocol")) .map(String::toLowerCase) .orElse(null)) .build()) .collect(Collectors.toList())); } mcpConfig.setMcpServerConfig(serverConfig); // meta MCPConfigResult.McpMetadata meta = new MCPConfigResult.McpMetadata(); meta.setSource(GatewayType.APIG_AI.name()); meta.setProtocol(data.getStr("protocol")); meta.setCreateFromType(data.getStr("createFromType")); mcpConfig.setMeta(meta); // tools String tools = data.getStr("mcpServerConfig"); if (StrUtil.isNotBlank(tools)) { mcpConfig.setTools(Base64.decodeStr(tools)); } return JSONUtil.toJsonStr(mcpConfig); }); } public String fetchMcpConfig_V1(Gateway gateway, Object conf) { APIGRefConfig config = (APIGRefConfig) conf; HttpRoute httpRoute = fetchHTTPRoute(gateway, config.getApiId(), config.getMcpRouteId()); MCPConfigResult m = new MCPConfigResult(); m.setMcpServerName(httpRoute.getName()); // mcpServer config MCPConfigResult.MCPServerConfig c = new MCPConfigResult.MCPServerConfig(); if (httpRoute.getMatch() != null) { c.setPath(httpRoute.getMatch().getPath().getValue()); } if (httpRoute.getDomainInfos() != null) { c.setDomains(httpRoute.getDomainInfos().stream() .map(domainInfo -> MCPConfigResult.Domain.builder() .domain(domainInfo.getName()) .protocol(Optional.ofNullable(domainInfo.getProtocol()) .map(String::toLowerCase) .orElse(null)) .build()) .collect(Collectors.toList())); } m.setMcpServerConfig(c); // meta MCPConfigResult.McpMetadata meta = new MCPConfigResult.McpMetadata(); meta.setSource(GatewayType.APIG_AI.name()); // tools HttpRoute.McpServerInfo mcpServerInfo = httpRoute.getMcpServerInfo(); boolean fetchTool = true; if (mcpServerInfo.getMcpRouteConfig() != null) { String protocol = mcpServerInfo.getMcpRouteConfig().getProtocol(); meta.setCreateFromType(protocol); // HTTP转MCP需从插件获取tools配置 fetchTool = StrUtil.equalsIgnoreCase(protocol, "HTTP"); } if (fetchTool) { String toolSpec = fetchMcpTools(gateway, config.getMcpRouteId()); if (StrUtil.isNotBlank(toolSpec)) { m.setTools(toolSpec); // 默认为HTTP转MCP if (StrUtil.isBlank(meta.getCreateFromType())) { meta.setCreateFromType("HTTP"); } } } m.setMeta(meta); return JSONUtil.toJsonStr(m); } @Override public GatewayType getGatewayType() { return GatewayType.APIG_AI; } @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 createTicket 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 = ""; String gatewayFilter = ""; if (type.equals("Portal")) { dashboardId = "dashboard-1758009692051-393998"; gatewayFilter = ""; } else if (type.equals("MCP")) { dashboardId = "dashboard-1757483808537-433375"; gatewayFilter = "filters=cluster_id%%253A%%2520" + gatewayId; } else if (type.equals("API")) { dashboardId = "dashboard-1756276497392-966932"; gatewayFilter = "filters=cluster_id%%253A%%2520" + gatewayId; ; } String dashboardUrl = String.format("https://sls.console.aliyun.com/lognext/project/%s/dashboard/%s?%s&slsRegion=%s&sls_ticket=%s&isShare=true&hideTopbar=true&hideSidebar=true&ignoreTabLocalStorage=true", projectName, dashboardId, gatewayFilter, region, ticket); log.info("Dashboard URL: {}", dashboardUrl); return dashboardUrl; } public String fetchMcpTools(Gateway gateway, String routeId) { APIGClient client = getClient(gateway); try { CompletableFuture<ListPluginAttachmentsResponse> f = client.execute(c -> { ListPluginAttachmentsRequest request = ListPluginAttachmentsRequest.builder() .gatewayId(gateway.getGatewayId()) .attachResourceId(routeId) .attachResourceType("GatewayRoute") .pageNumber(1) .pageSize(100) .build(); return c.listPluginAttachments(request); }); ListPluginAttachmentsResponse response = f.join(); if (response.getStatusCode() != 200) { throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); } for (ListPluginAttachmentsResponseBody.Items item : response.getBody().getData().getItems()) { PluginClassInfo classInfo = item.getPluginClassInfo(); if (!StrUtil.equalsIgnoreCase(classInfo.getName(), "mcp-server")) { continue; } String pluginConfig = item.getPluginConfig(); if (StrUtil.isNotBlank(pluginConfig)) { return Base64.decodeStr(pluginConfig); } } } catch (Exception e) { log.error("Error fetching Plugin Attachment", e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Error fetching Plugin Attachment,Cause:" + e.getMessage()); } return null; } @Override public ConsumerAuthConfig authorizeConsumer(Gateway gateway, String consumerId, Object refConfig) { APIGClient client = getClient(gateway); APIGRefConfig config = (APIGRefConfig) refConfig; // MCP Server 授权 String mcpRouteId = config.getMcpRouteId(); try { // 确认Gateway的EnvId String envId = fetchGatewayEnv(gateway); CreateConsumerAuthorizationRulesRequest.AuthorizationRules rule = CreateConsumerAuthorizationRulesRequest.AuthorizationRules.builder() .consumerId(consumerId) .expireMode("LongTerm") .resourceType("MCP") .resourceIdentifier(CreateConsumerAuthorizationRulesRequest.ResourceIdentifier.builder() .resourceId(mcpRouteId) .environmentId(envId).build()) .build(); CompletableFuture<CreateConsumerAuthorizationRulesResponse> f = client.execute(c -> { CreateConsumerAuthorizationRulesRequest request = CreateConsumerAuthorizationRulesRequest.builder() .authorizationRules(Collections.singletonList(rule)) .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) { Throwable cause = e.getCause(); if (cause instanceof PopClientException && "Conflict.ConsumerAuthorizationForbidden".equals(((PopClientException) cause).getErrCode())) { return getConsumerAuthorization(gateway, consumerId, mcpRouteId); } log.error("Error authorizing consumer {} to mcp server {} in AI gateway {}", consumerId, mcpRouteId, gateway.getGatewayId(), e); throw new BusinessException(ErrorCode.GATEWAY_ERROR, "Failed to authorize consumer to mcp server in AI gateway: " + e.getMessage()); } } public ConsumerAuthConfig getConsumerAuthorization(Gateway gateway, String consumerId, String resourceId) { APIGClient client = getClient(gateway); CompletableFuture<QueryConsumerAuthorizationRulesResponse> f = client.execute(c -> { QueryConsumerAuthorizationRulesRequest request = QueryConsumerAuthorizationRulesRequest.builder() .consumerId(consumerId) .resourceId(resourceId) .resourceType("MCP") .build(); return c.queryConsumerAuthorizationRules(request); }); QueryConsumerAuthorizationRulesResponse response = f.join(); if (200 != response.getStatusCode()) { throw new BusinessException(ErrorCode.GATEWAY_ERROR, response.getBody().getMessage()); } QueryConsumerAuthorizationRulesResponseBody.Items items = response.getBody().getData().getItems().get(0); APIGAuthConfig apigAuthConfig = APIGAuthConfig.builder() .authorizationRuleIds(Collections.singletonList(items.getConsumerAuthorizationRuleId())) .build(); return ConsumerAuthConfig.builder() .apigAuthConfig(apigAuthConfig) .build(); } } ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/service/impl/ProductServiceImpl.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.collection.CollUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONUtil; import com.alibaba.apiopenplatform.core.constant.Resources; import com.alibaba.apiopenplatform.core.event.PortalDeletingEvent; 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.product.*; import com.alibaba.apiopenplatform.dto.result.*; import com.alibaba.apiopenplatform.entity.*; import com.alibaba.apiopenplatform.repository.*; import com.alibaba.apiopenplatform.service.GatewayService; import com.alibaba.apiopenplatform.service.PortalService; import com.alibaba.apiopenplatform.service.ProductService; import com.alibaba.apiopenplatform.service.NacosService; import com.alibaba.apiopenplatform.support.enums.ProductStatus; import com.alibaba.apiopenplatform.support.enums.ProductType; import com.alibaba.apiopenplatform.support.enums.SourceType; import com.alibaba.apiopenplatform.support.product.NacosRefConfig; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.event.EventListener; import org.springframework.data.domain.Page; import javax.persistence.criteria.*; import javax.transaction.Transactional; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import java.util.*; import java.util.stream.Collectors; @Service @Slf4j @RequiredArgsConstructor @Transactional public class ProductServiceImpl implements ProductService { private final ContextHolder contextHolder; private final PortalService portalService; private final GatewayService gatewayService; private final ProductRepository productRepository; private final ProductRefRepository productRefRepository; private final ProductPublicationRepository publicationRepository; private final SubscriptionRepository subscriptionRepository; private final ConsumerRepository consumerRepository; private final NacosService nacosService; private final ApplicationEventPublisher eventPublisher; @Override public ProductResult createProduct(CreateProductParam param) { productRepository.findByNameAndAdminId(param.getName(), contextHolder.getUser()) .ifPresent(product -> { throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.PRODUCT, product.getName())); }); String productId = IdGenerator.genApiProductId(); Product product = param.convertTo(); product.setProductId(productId); product.setAdminId(contextHolder.getUser()); // 设置默认的自动审批配置,如果未指定则默认为null(使用平台级别配置) if (param.getAutoApprove() != null) { product.setAutoApprove(param.getAutoApprove()); } productRepository.save(product); return getProduct(productId); } @Override public ProductResult getProduct(String productId) { Product product = contextHolder.isAdministrator() ? findProduct(productId) : findPublishedProduct(contextHolder.getPortal(), productId); ProductResult result = new ProductResult().convertFrom(product); // 补充Product信息 fullFillProduct(result); return result; } @Override public PageResult<ProductResult> listProducts(QueryProductParam param, Pageable pageable) { log.info("zhaoh-test-listProducts-start"); if (contextHolder.isDeveloper()) { param.setPortalId(contextHolder.getPortal()); } Page<Product> products = productRepository.findAll(buildSpecification(param), pageable); return new PageResult<ProductResult>().convertFrom( products, product -> { ProductResult result = new ProductResult().convertFrom(product); fullFillProduct(result); return result; }); } @Override public ProductResult updateProduct(String productId, UpdateProductParam param) { Product product = findProduct(productId); // 更换API产品类型 if (param.getType() != null && product.getType() != param.getType()) { productRefRepository.findFirstByProductId(productId) .ifPresent(productRef -> { throw new BusinessException(ErrorCode.INVALID_REQUEST, "API产品已关联API"); }); } param.update(product); // Consumer鉴权配置同步至网关 Optional.ofNullable(param.getEnableConsumerAuth()).ifPresent(product::setEnableConsumerAuth); // 更新自动审批配置 Optional.ofNullable(param.getAutoApprove()).ifPresent(product::setAutoApprove); productRepository.saveAndFlush(product); return getProduct(product.getProductId()); } @Override public void publishProduct(String productId, String portalId) { portalService.existsPortal(portalId); if (publicationRepository.findByPortalIdAndProductId(portalId, productId).isPresent()) { return; } Product product = findProduct(productId); product.setStatus(ProductStatus.PUBLISHED); // 未关联不允许发布 if (getProductRef(productId) == null) { throw new BusinessException(ErrorCode.INVALID_REQUEST, "API产品未关联API"); } ProductPublication productPublication = new ProductPublication(); productPublication.setPortalId(portalId); productPublication.setProductId(productId); publicationRepository.save(productPublication); productRepository.save(product); } @Override public PageResult<ProductPublicationResult> getPublications(String productId, Pageable pageable) { Page<ProductPublication> publications = publicationRepository.findByProductId(productId, pageable); return new PageResult<ProductPublicationResult>().convertFrom( publications, publication -> { ProductPublicationResult publicationResult = new ProductPublicationResult().convertFrom(publication); PortalResult portal; try { portal = portalService.getPortal(publication.getPortalId()); } catch (Exception e) { log.error("Failed to get portal: {}", publication.getPortalId(), e); return null; } publicationResult.setPortalName(portal.getName()); publicationResult.setAutoApproveSubscriptions(portal.getPortalSettingConfig().getAutoApproveSubscriptions()); return publicationResult; }); } @Override public void unpublishProduct(String productId, String portalId) { portalService.existsPortal(portalId); publicationRepository.findByPortalIdAndProductId(portalId, productId) .ifPresent(publicationRepository::delete); } @Override public void deleteProduct(String productId) { Product Product = findProduct(productId); // 下线后删除 publicationRepository.deleteByProductId(productId); productRepository.delete(Product); // 异步清理Product资源 eventPublisher.publishEvent(new ProductDeletingEvent(productId)); } /** * 查找产品,如果不存在则抛出异常 */ private Product findProduct(String productId) { return productRepository.findByProductId(productId) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.PRODUCT, productId)); } @Override public void addProductRef(String productId, CreateProductRefParam param) { Product product = findProduct(productId); // 是否已存在API引用 productRefRepository.findByProductId(product.getProductId()) .ifPresent(productRef -> { throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已关联API", Resources.PRODUCT, productId)); }); ProductRef productRef = param.convertTo(); productRef.setProductId(productId); syncConfig(product, productRef); productRepository.save(product); productRefRepository.save(productRef); } @Override public ProductRefResult getProductRef(String productId) { return productRefRepository.findFirstByProductId(productId) .map(productRef -> new ProductRefResult().convertFrom(productRef)) .orElse(null); } @Override public void deleteProductRef(String productId) { Product product = findProduct(productId); product.setStatus(ProductStatus.PENDING); ProductRef productRef = productRefRepository.findFirstByProductId(productId) .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_REQUEST, "API产品未关联API")); // 已发布的产品不允许解绑 if (publicationRepository.existsByProductId(productId)) { throw new BusinessException(ErrorCode.INVALID_REQUEST, "API产品已发布"); } productRefRepository.delete(productRef); productRepository.save(product); } private void syncConfig(Product product, ProductRef productRef) { SourceType sourceType = productRef.getSourceType(); if (sourceType.isGateway()) { GatewayResult gateway = gatewayService.getGateway(productRef.getGatewayId()); Object config = gateway.getGatewayType().isHigress() ? productRef.getHigressRefConfig() : gateway.getGatewayType().isAdpAIGateway() ? productRef.getAdpAIGatewayRefConfig() : productRef.getApigRefConfig(); if (product.getType() == ProductType.REST_API) { String apiConfig = gatewayService.fetchAPIConfig(gateway.getGatewayId(), config); productRef.setApiConfig(apiConfig); } else { String mcpConfig = gatewayService.fetchMcpConfig(gateway.getGatewayId(), config); productRef.setMcpConfig(mcpConfig); } } else if (sourceType.isNacos()) { // 从Nacos获取MCP Server配置 NacosRefConfig nacosRefConfig = productRef.getNacosRefConfig(); if (nacosRefConfig != null) { String mcpConfig = nacosService.fetchMcpConfig(productRef.getNacosId(), nacosRefConfig); productRef.setMcpConfig(mcpConfig); } } product.setStatus(ProductStatus.READY); productRef.setEnabled(true); } private void fullFillProduct(ProductResult product) { productRefRepository.findFirstByProductId(product.getProductId()) .ifPresent(productRef -> { product.setEnabled(productRef.getEnabled()); if (StrUtil.isNotBlank(productRef.getApiConfig())) { product.setApiConfig(JSONUtil.toBean(productRef.getApiConfig(), APIConfigResult.class)); } // API Config if (StrUtil.isNotBlank(productRef.getMcpConfig())) { product.setMcpConfig(JSONUtil.toBean(productRef.getMcpConfig(), MCPConfigResult.class)); } product.setStatus(ProductStatus.READY); }); if (publicationRepository.existsByProductId(product.getProductId())) { product.setStatus(ProductStatus.PUBLISHED); } } private Product findPublishedProduct(String portalId, String productId) { ProductPublication publication = publicationRepository.findByPortalIdAndProductId(portalId, productId) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.PRODUCT, productId)); return findProduct(publication.getProductId()); } private Specification<Product> buildSpecification(QueryProductParam param) { return (root, query, cb) -> { List<Predicate> predicates = new ArrayList<>(); if (StrUtil.isNotBlank(param.getPortalId())) { Subquery<String> subquery = query.subquery(String.class); Root<ProductPublication> publicationRoot = subquery.from(ProductPublication.class); subquery.select(publicationRoot.get("productId")) .where(cb.equal(publicationRoot.get("portalId"), param.getPortalId())); predicates.add(root.get("productId").in(subquery)); } if (param.getType() != null) { predicates.add(cb.equal(root.get("type"), param.getType())); } if (StrUtil.isNotBlank(param.getCategory())) { predicates.add(cb.equal(root.get("category"), param.getCategory())); } if (param.getStatus() != null) { predicates.add(cb.equal(root.get("status"), param.getStatus())); } if (StrUtil.isNotBlank(param.getName())) { String likePattern = "%" + param.getName() + "%"; predicates.add(cb.like(root.get("name"), likePattern)); } return cb.and(predicates.toArray(new Predicate[0])); }; } @EventListener @Async("taskExecutor") @Override public void handlePortalDeletion(PortalDeletingEvent event) { String portalId = event.getPortalId(); try { log.info("Starting to cleanup publications for portal {}", portalId); publicationRepository.deleteAllByPortalId(portalId); log.info("Completed cleanup publications for portal {}", portalId); } catch (Exception e) { log.error("Failed to cleanup developers for portal {}: {}", portalId, e.getMessage()); } } @Override public Map<String, ProductResult> getProducts(List<String> productIds) { List<Product> products = productRepository.findByProductIdIn(productIds); return products.stream() .collect(Collectors.toMap(Product::getProductId, product -> new ProductResult().convertFrom(product))); } @Override public String getProductDashboard(String productId) { // 获取产品关联的网关信息 ProductRef productRef = productRefRepository.findFirstByProductId(productId) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.PRODUCT, productId)); if (productRef.getGatewayId() == null) { throw new BusinessException(ErrorCode.INVALID_REQUEST, "该产品尚未关联网关服务"); } // 基于产品类型选择Dashboard类型 Product product = findProduct(productId); String dashboardType; if (product.getType() == ProductType.MCP_SERVER) { dashboardType = "MCP"; } else { // REST_API、HTTP_API 统一走 API 面板 dashboardType = "API"; } // 通过网关服务获取Dashboard URL return gatewayService.getDashboard(productRef.getGatewayId(), dashboardType); } @Override public PageResult<SubscriptionResult> listProductSubscriptions(String productId, QueryProductSubscriptionParam param, Pageable pageable) { existsProduct(productId); Page<ProductSubscription> subscriptions = subscriptionRepository.findAll(buildProductSubscriptionSpec(productId, param), pageable); List<String> consumerIds = subscriptions.getContent().stream() .map(ProductSubscription::getConsumerId) .collect(Collectors.toList()); if (CollUtil.isEmpty(consumerIds)) { return PageResult.empty(pageable.getPageNumber(), pageable.getPageSize()); } Map<String, Consumer> consumers = consumerRepository.findByConsumerIdIn(consumerIds) .stream() .collect(Collectors.toMap(Consumer::getConsumerId, consumer -> consumer)); return new PageResult<SubscriptionResult>().convertFrom(subscriptions, s -> { SubscriptionResult r = new SubscriptionResult().convertFrom(s); Consumer consumer = consumers.get(r.getConsumerId()); if (consumer != null) { r.setConsumerName(consumer.getName()); } return r; }); } @Override public void existsProduct(String productId) { productRepository.findByProductId(productId) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.PRODUCT, productId)); } private Specification<ProductSubscription> buildProductSubscriptionSpec(String productId, QueryProductSubscriptionParam param) { return (root, query, cb) -> { List<Predicate> predicates = new ArrayList<>(); predicates.add(cb.equal(root.get("productId"), productId)); // 如果是开发者,只能查看自己的Consumer订阅 if (contextHolder.isDeveloper()) { Subquery<String> consumerSubquery = query.subquery(String.class); Root<Consumer> consumerRoot = consumerSubquery.from(Consumer.class); consumerSubquery.select(consumerRoot.get("consumerId")) .where(cb.equal(consumerRoot.get("developerId"), contextHolder.getUser())); predicates.add(root.get("consumerId").in(consumerSubquery)); } if (param.getStatus() != null) { predicates.add(cb.equal(root.get("status"), param.getStatus())); } if (StrUtil.isNotBlank(param.getConsumerName())) { Subquery<String> consumerSubquery = query.subquery(String.class); Root<Consumer> consumerRoot = consumerSubquery.from(Consumer.class); consumerSubquery.select(consumerRoot.get("consumerId")) .where(cb.like( cb.lower(consumerRoot.get("name")), "%" + param.getConsumerName().toLowerCase() + "%" )); predicates.add(root.get("consumerId").in(consumerSubquery)); } return cb.and(predicates.toArray(new Predicate[0])); }; } } ```