This is page 4 of 7. Use http://codebase.md/higress-group/himarket?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
│ │ ├── ApsaraGatewayConfigConverter.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
│ │ ├── ApsaraGatewayConfig.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
│ ├── lib
│ │ └── csb220230206-1.5.3.jar
│ ├── 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
│ │ │ │ ├── QueryApsaraGatewayParam.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
│ ├── ApsaraGatewayService.java
│ ├── ConsumerService.java
│ ├── DeveloperService.java
│ ├── gateway
│ │ ├── AdpAIGatewayOperator.java
│ │ ├── AIGatewayOperator.java
│ │ ├── APIGOperator.java
│ │ ├── ApsaraGatewayOperator.java
│ │ ├── client
│ │ │ ├── AdpAIGatewayClient.java
│ │ │ ├── APIGClient.java
│ │ │ ├── ApsaraGatewayClient.java
│ │ │ ├── ApsaraStackGatewayClient.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/components/api-product/ApiProductOverview.tsx:
--------------------------------------------------------------------------------
```typescript
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Row, Col, Statistic, Button, message } from 'antd'
import {
ApiOutlined,
GlobalOutlined,
TeamOutlined,
EditOutlined,
CheckCircleFilled,
MinusCircleFilled,
CopyOutlined,
ExclamationCircleFilled,
ClockCircleFilled
} from '@ant-design/icons'
import type { ApiProduct } from '@/types/api-product'
import { getServiceName, formatDateTime, copyToClipboard } from '@/lib/utils'
import { apiProductApi } from '@/lib/api'
interface ApiProductOverviewProps {
apiProduct: ApiProduct
linkedService: any | null
onEdit: () => void
}
export function ApiProductOverview({ apiProduct, linkedService, onEdit }: ApiProductOverviewProps) {
const [portalCount, setPortalCount] = useState(0)
const [subscriberCount] = useState(0)
const navigate = useNavigate()
useEffect(() => {
if (apiProduct.productId) {
fetchPublishedPortals()
}
}, [apiProduct.productId])
const fetchPublishedPortals = async () => {
try {
const res = await apiProductApi.getApiProductPublications(apiProduct.productId)
setPortalCount(res.data.content?.length || 0)
} catch (error) {
} finally {
}
}
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold mb-2">概览</h1>
<p className="text-gray-600">API产品概览</p>
</div>
{/* 基本信息 */}
<Card
title="基本信息"
extra={
<Button
type="primary"
icon={<EditOutlined />}
onClick={onEdit}
>
编辑
</Button>
}
>
<div>
<div className="grid grid-cols-6 gap-8 items-center pt-0 pb-2">
<span className="text-xs text-gray-600">产品名称:</span>
<span className="col-span-2 text-xs text-gray-900">{apiProduct.name}</span>
<span className="text-xs text-gray-600">产品ID:</span>
<div className="col-span-2 flex items-center gap-2">
<span className="text-xs text-gray-700">{apiProduct.productId}</span>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={async () => {
try {
await copyToClipboard(apiProduct.productId);
message.success('产品ID已复制');
} catch {
message.error('复制失败,请手动复制');
}
}}
className="h-auto p-1 min-w-0"
/>
</div>
</div>
<div className="grid grid-cols-6 gap-8 items-center pt-2 pb-2">
<span className="text-xs text-gray-600">类型:</span>
<span className="col-span-2 text-xs text-gray-900">
{apiProduct.type === 'REST_API' ? 'REST API' : 'MCP Server'}
</span>
<span className="text-xs text-gray-600">状态:</span>
<div className="col-span-2 flex items-center">
{apiProduct.status === "PENDING" ? (
<ExclamationCircleFilled className="text-yellow-500 mr-2" style={{fontSize: '10px'}} />
) : apiProduct.status === "READY" ? (
<ClockCircleFilled className="text-blue-500 mr-2" style={{fontSize: '10px'}} />
) : (
<CheckCircleFilled className="text-green-500 mr-2" style={{fontSize: '10px'}} />
)}
<span className="text-xs text-gray-900">
{apiProduct.status === "PENDING" ? "待配置" : apiProduct.status === "READY" ? "待发布" : "已发布"}
</span>
</div>
</div>
<div className="grid grid-cols-6 gap-8 items-center pt-2 pb-2">
<span className="text-xs text-gray-600">自动审批订阅:</span>
<div className="col-span-2 flex items-center">
{apiProduct.autoApprove === true ? (
<CheckCircleFilled className="text-green-500 mr-2" style={{fontSize: '10px'}} />
) : (
<MinusCircleFilled className="text-gray-400 mr-2" style={{fontSize: '10px'}} />
)}
<span className="text-xs text-gray-900">
{apiProduct.autoApprove === true ? '已开启' : '已关闭'}
</span>
</div>
<span className="text-xs text-gray-600">创建时间:</span>
<span className="col-span-2 text-xs text-gray-700">{formatDateTime(apiProduct.createAt)}</span>
</div>
{apiProduct.description && (
<div className="grid grid-cols-6 gap-8 pt-2 pb-2">
<span className="text-xs text-gray-600">描述:</span>
<span className="col-span-5 text-xs text-gray-700 leading-relaxed">
{apiProduct.description}
</span>
</div>
)}
</div>
</Card>
{/* 统计数据 */}
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={8}>
<Card
className="cursor-pointer hover:shadow-md transition-shadow"
onClick={() => {
navigate(`/api-products/detail?productId=${apiProduct.productId}&tab=portal`)
}}
>
<Statistic
title="发布的门户"
value={portalCount}
prefix={<GlobalOutlined className="text-blue-500" />}
valueStyle={{ color: '#1677ff', fontSize: '24px' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card
className="cursor-pointer hover:shadow-md transition-shadow"
onClick={() => {
navigate(`/api-products/detail?productId=${apiProduct.productId}&tab=link-api`)
}}
>
<Statistic
title="关联API"
value={getServiceName(linkedService) || '未关联'}
prefix={<ApiOutlined className="text-blue-500" />}
valueStyle={{ color: '#1677ff', fontSize: '24px' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card className="hover:shadow-md transition-shadow">
<Statistic
title="订阅用户"
value={subscriberCount}
prefix={<TeamOutlined className="text-blue-500" />}
valueStyle={{ color: '#1677ff', fontSize: '24px' }}
/>
</Card>
</Col>
</Row>
</div>
)
}
```
--------------------------------------------------------------------------------
/portal-web/api-portal-admin/src/components/portal/PortalSecurity.tsx:
--------------------------------------------------------------------------------
```typescript
import {Card, Form, Switch, Divider, message} from 'antd'
import {useMemo} from 'react'
import {Portal, ThirdPartyAuthConfig, AuthenticationType, OidcConfig, OAuth2Config} from '@/types'
import {portalApi} from '@/lib/api'
import {ThirdPartyAuthManager} from './ThirdPartyAuthManager'
interface PortalSecurityProps {
portal: Portal
onRefresh?: () => void
}
export function PortalSecurity({portal, onRefresh}: PortalSecurityProps) {
const [form] = Form.useForm()
const handleSave = async () => {
try {
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('安全设置保存成功')
onRefresh?.()
} catch (error) {
message.error('保存安全设置失败')
}
}
const handleSettingUpdate = () => {
// 立即更新配置
handleSave()
}
// 第三方认证配置保存函数
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
}
}
// 合并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])
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold mb-2">Portal安全配置</h1>
<p className="text-gray-600">配置Portal的认证与审批方式</p>
</div>
<Form
form={form}
layout="vertical"
initialValues={{
builtinAuthEnabled: portal.portalSettingConfig?.builtinAuthEnabled,
oidcAuthEnabled: portal.portalSettingConfig?.oidcAuthEnabled,
autoApproveDevelopers: portal.portalSettingConfig?.autoApproveDevelopers,
autoApproveSubscriptions: portal.portalSettingConfig?.autoApproveSubscriptions,
frontendRedirectUrl: portal.portalSettingConfig?.frontendRedirectUrl,
}}
>
<Card>
<div className="space-y-6">
{/* 基本安全配置标题 */}
<h3 className="text-lg font-medium">基本安全配置</h3>
{/* 基本安全设置内容 */}
<div className="grid grid-cols-2 gap-6">
<Form.Item
name="builtinAuthEnabled"
label="账号密码登录"
valuePropName="checked"
>
<Switch
onChange={() => handleSettingUpdate()}
/>
</Form.Item>
<Form.Item
name="autoApproveDevelopers"
label="开发者自动审批"
valuePropName="checked"
>
<Switch
onChange={() => handleSettingUpdate()}
/>
</Form.Item>
<Form.Item
name="autoApproveSubscriptions"
label="订阅自动审批"
valuePropName="checked"
>
<Switch
onChange={() => handleSettingUpdate()}
/>
</Form.Item>
</div>
<Divider />
{/* 第三方认证管理器 - 内部已有标题,不需要重复添加 */}
<ThirdPartyAuthManager
configs={thirdPartyAuthConfigs}
onSave={handleSaveThirdPartyAuth}
/>
</div>
</Card>
</Form>
</div>
)
}
```
--------------------------------------------------------------------------------
/pom.xml:
--------------------------------------------------------------------------------
```
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ 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.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.alibaba.himarket</groupId>
<artifactId>himarket</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<name>himarket</name>
<description>HiMarket AI OPEN Platform</description>
<url>https://github.com/higress-group/himarket</url>
<licenses>
<license>
<name>Apache License, Version 2.0</name>
<url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
<distribution>repo</distribution>
</license>
</licenses>
<modules>
<module>portal-dal</module>
<module>portal-server</module>
<module>portal-bootstrap</module>
</modules>
<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-boot.version>2.7.18</spring-boot.version>
<mybatis.version>2.3.1</mybatis.version>
<mariadb.version>3.4.1</mariadb.version>
<hutool.version>5.8.32</hutool.version>
<bouncycastle.version>1.78</bouncycastle.version>
<springdoc.version>1.7.0</springdoc.version>
<apigsdk.version>4.0.10</apigsdk.version>
<msesdk.version>7.21.0</msesdk.version>
<aliyunsdk.version>4.4.6</aliyunsdk.version>
<okhttp.version>4.12.0</okhttp.version>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
</properties>
<!-- Dependency Management -->
<dependencyManagement>
<dependencies>
<!-- Spring Boot Dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- MariaDB Driver -->
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<version>${mariadb.version}</version>
</dependency>
<!-- Hutool -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
<!-- Spring Boot Starter Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- Spring Boot Starter OAuth2 Client -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>alibabacloud-apig20240327</artifactId>
<version>${apigsdk.version}</version>
</dependency>
<!-- 阿里云 MSE SDK -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>mse20190531</artifactId>
<version>${msesdk.version}</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>${aliyunsdk.version}</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>${okhttp.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba.nacos</groupId>
<artifactId>nacos-maintainer-client</artifactId>
<version>3.0.2</version>
</dependency>
<!-- Bouncy Castle Provider -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15to18</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<!-- Build Configuration -->
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<encoding>${project.build.sourceEncoding}</encoding>
</configuration>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>
```
--------------------------------------------------------------------------------
/portal-server/src/main/java/com/alibaba/apiopenplatform/core/utils/TokenUtil.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.core.utils;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.spring.SpringUtil;
import cn.hutool.jwt.JWT;
import cn.hutool.jwt.JWTUtil;
import cn.hutool.jwt.signers.JWTSignerUtil;
import com.alibaba.apiopenplatform.core.constant.CommonConstants;
import com.alibaba.apiopenplatform.support.common.User;
import com.alibaba.apiopenplatform.support.enums.UserType;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
public class TokenUtil {
private static String JWT_SECRET;
private static long JWT_EXPIRE_MILLIS;
private static final Map<String, Long> INVALID_TOKENS = new ConcurrentHashMap<>();
private static String getJwtSecret() {
if (JWT_SECRET == null) {
JWT_SECRET = SpringUtil.getProperty("jwt.secret");
}
if (StrUtil.isBlank(JWT_SECRET)) {
throw new RuntimeException("JWT secret cannot be empty");
}
return JWT_SECRET;
}
private static long getJwtExpireMillis() {
if (JWT_EXPIRE_MILLIS == 0) {
String expiration = SpringUtil.getProperty("jwt.expiration");
if (StrUtil.isBlank(expiration)) {
throw new RuntimeException("JWT expiration is empty");
}
if (expiration.matches("\\d+[smhd]")) {
JWT_EXPIRE_MILLIS = Duration.parse("PT" + expiration.toUpperCase()).toMillis();
} else {
JWT_EXPIRE_MILLIS = Long.parseLong(expiration);
}
}
return JWT_EXPIRE_MILLIS;
}
public static String generateAdminToken(String userId) {
return generateToken(UserType.ADMIN, userId);
}
public static String generateDeveloperToken(String userId) {
return generateToken(UserType.DEVELOPER, userId);
}
/**
* 生成令牌
*
* @param userType
* @param userId
* @return
*/
private static String generateToken(UserType userType, String userId) {
long now = System.currentTimeMillis();
Map<String, String> claims = MapUtil.<String, String>builder()
.put(CommonConstants.USER_TYPE, userType.name())
.put(CommonConstants.USER_ID, userId)
.build();
return JWT.create()
.addPayloads(claims)
.setIssuedAt(new Date(now))
.setExpiresAt(new Date(now + getJwtExpireMillis()))
.setSigner(JWTSignerUtil.hs256(getJwtSecret().getBytes(StandardCharsets.UTF_8)))
.sign();
}
/**
* 解析Token
*
* @param token
* @return
*/
public static User parseUser(String token) {
JWT jwt = JWTUtil.parseToken(token);
// 验证签名
boolean isValid = jwt.setSigner(JWTSignerUtil.hs256(getJwtSecret().getBytes(StandardCharsets.UTF_8))).verify();
if (!isValid) {
throw new IllegalArgumentException("Invalid token signature");
}
// 验证过期时间
Object expObj = jwt.getPayloads().get(JWT.EXPIRES_AT);
if (ObjectUtil.isNotNull(expObj)) {
long expireAt = Long.parseLong(expObj.toString());
if (expireAt * 1000 <= System.currentTimeMillis()) {
throw new IllegalArgumentException("Token has expired");
}
}
return jwt.getPayloads().toBean(User.class);
}
public static String getTokenFromRequest(HttpServletRequest request) {
// 从Header中获取token
String authHeader = request.getHeader(CommonConstants.AUTHORIZATION_HEADER);
String token = null;
if (authHeader != null && authHeader.startsWith(CommonConstants.BEARER_PREFIX)) {
token = authHeader.substring(CommonConstants.BEARER_PREFIX.length());
}
// 从Cookie中获取token
if (StrUtil.isBlank(token)) {
token = Optional.ofNullable(request.getCookies())
.flatMap(cookies -> Arrays.stream(cookies)
.filter(cookie -> CommonConstants.AUTH_TOKEN_COOKIE.equals(cookie.getName()))
.map(Cookie::getValue)
.findFirst())
.orElse(null);
}
if (StrUtil.isBlank(token) || isTokenRevoked(token)) {
return null;
}
return token;
}
public static void revokeToken(String token) {
if (StrUtil.isBlank(token)) {
return;
}
long expireAt = getTokenExpireTime(token);
INVALID_TOKENS.put(token, expireAt);
cleanExpiredTokens();
}
private static long getTokenExpireTime(String token) {
JWT jwt = JWTUtil.parseToken(token);
Object expObj = jwt.getPayloads().get(JWT.EXPIRES_AT);
if (ObjectUtil.isNotNull(expObj)) {
return Long.parseLong(expObj.toString()) * 1000; // JWT过期时间是秒,转换为毫秒
}
return System.currentTimeMillis() + getJwtExpireMillis(); // 默认过期时间
}
public static void revokeToken(HttpServletRequest request) {
String token = getTokenFromRequest(request);
if (StrUtil.isNotBlank(token)) {
revokeToken(token);
}
}
public static boolean isTokenRevoked(String token) {
if (StrUtil.isBlank(token)) {
return false;
}
Long expireAt = INVALID_TOKENS.get(token);
if (expireAt == null) {
return false;
}
if (expireAt <= System.currentTimeMillis()) {
INVALID_TOKENS.remove(token);
return false;
}
return true;
}
private static void cleanExpiredTokens() {
long now = System.currentTimeMillis();
INVALID_TOKENS.entrySet().removeIf(entry -> entry.getValue() <= now);
}
public static long getTokenExpiresIn() {
return getJwtExpireMillis() / 1000;
}
}
```
--------------------------------------------------------------------------------
/portal-web/api-portal-frontend/src/pages/Consumers.tsx:
--------------------------------------------------------------------------------
```typescript
import { Card, Table, Button, Space, Typography, Input, Avatar } from "antd";
import { SearchOutlined, DeleteOutlined, EyeOutlined } from "@ant-design/icons";
import { Layout } from "../components/Layout";
import { useEffect, useState, useCallback } from "react";
import { getConsumers, deleteConsumer, createConsumer } from "../lib/api";
import { message, Modal } from "antd";
import { Link, useSearchParams } from "react-router-dom";
import { formatDateTime } from "../lib/utils";
import type { Consumer } from "../types/consumer";
const { Title, Paragraph } = Typography;
const { Search } = Input;
function ConsumersPage() {
const [searchParams] = useSearchParams();
const productId = searchParams.get('productId');
const [consumers, setConsumers] = useState<Consumer[]>([]);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [searchInput, setSearchInput] = useState(""); // 输入框的值
const [searchName, setSearchName] = useState(""); // 实际搜索的值
const [addModalOpen, setAddModalOpen] = useState(false);
const [addLoading, setAddLoading] = useState(false);
const [addForm, setAddForm] = useState({ name: '', description: '' });
const fetchConsumers = useCallback(async (searchKeyword?: string, targetPage?: number) => {
setLoading(true);
try {
const res = await getConsumers(
{ name: searchKeyword || '' },
{ page: targetPage || page, size: pageSize }
);
setConsumers(res.data?.content || []);
setTotal(res.data?.totalElements || 0);
} catch {
// message.error("获取消费者列表失败");
} finally {
setLoading(false);
}
}, [page, pageSize]); // 不依赖 searchName
// 初始加载和分页变化时调用
useEffect(() => {
fetchConsumers(searchName);
}, [page, pageSize, fetchConsumers]); // 包含fetchConsumers以确保初始加载
// 处理搜索
const handleSearch = useCallback(async (searchValue?: string) => {
const actualSearchValue = searchValue !== undefined ? searchValue : searchInput;
setSearchName(actualSearchValue);
setPage(1);
// 直接调用API,不依赖状态变化
await fetchConsumers(actualSearchValue, 1);
}, [searchInput, fetchConsumers]);
const handleDelete = (record: Consumer) => {
Modal.confirm({
title: `确定要删除消费者「${record.name}」吗?`,
onOk: async () => {
try {
await deleteConsumer(record.consumerId);
message.success("删除成功");
await fetchConsumers(searchName); // 使用当前搜索条件重新加载
} catch {
// message.error("删除失败");
}
},
});
};
const handleAdd = async () => {
if (!addForm.name.trim()) {
message.warning('请输入消费者名称');
return;
}
setAddLoading(true);
try {
await createConsumer({ name: addForm.name, description: addForm.description });
message.success('新增成功');
setAddModalOpen(false);
setAddForm({ name: '', description: '' });
await fetchConsumers(searchName); // 使用当前搜索条件重新加载
} catch {
// message.error('新增失败');
} finally {
setAddLoading(false);
}
};
const columns = [
{
title: '消费者',
dataIndex: 'name',
key: 'name',
render: (name: string, record: Consumer) => (
<div className="flex items-center space-x-3">
<Avatar className="bg-blue-500">
{name?.charAt(0).toUpperCase()}
</Avatar>
<div>
<div className="font-medium">{name}</div>
<div className="text-xs text-gray-400">{record.description}</div>
</div>
</div>
),
},
{
title: '创建时间',
dataIndex: 'createAt',
key: 'createAt',
render: (date: string) => date ? formatDateTime(date) : '-',
},
{
title: '操作',
key: 'action',
render: (_: unknown, record: Consumer) => (
<Space>
<Link to={`/consumers/${record.consumerId}`}>
<Button
type="link"
icon={<EyeOutlined />}
>
查看详情
</Button>
</Link>
<Button type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record)}>
删除
</Button>
</Space>
),
},
];
return (
<Layout>
<div className="mb-8">
<Title level={1} className="mb-2">
{productId ? '产品订阅管理' : '消费者管理'}
</Title>
<Paragraph className="text-gray-600">
{productId ? '管理此产品的消费者订阅情况' : '管理API的消费者用户和订阅信息'}
</Paragraph>
</div>
<Card>
<div className="mb-4 flex gap-4">
{!productId && (
<Button type="primary" onClick={() => setAddModalOpen(true)}>
新增消费者
</Button>
)}
<Search
placeholder={"搜索消费者..."}
prefix={<SearchOutlined />}
style={{ width: 300 }}
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
onSearch={handleSearch}
allowClear
/>
</div>
<Table
columns={columns}
dataSource={consumers}
rowKey="consumerId"
loading={loading}
pagination={{
total,
current: page,
pageSize,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条,共 ${total} 条`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal
title="新增消费者"
open={addModalOpen}
onCancel={() => { setAddModalOpen(false); setAddForm({ name: '', description: '' }); }}
onOk={handleAdd}
confirmLoading={addLoading}
okText="提交"
cancelText="取消"
>
<div style={{ marginBottom: 16 }}>
<Input
placeholder="消费者名称"
value={addForm.name}
maxLength={50}
onChange={e => setAddForm(f => ({ ...f, name: e.target.value }))}
disabled={addLoading}
/>
</div>
<div>
<Input.TextArea
placeholder="描述(可选),长度限制64"
value={addForm.description}
maxLength={64}
onChange={e => setAddForm(f => ({ ...f, description: e.target.value }))}
disabled={addLoading}
rows={3}
/>
</div>
</Modal>
</Card>
<Card title="消费者统计" className="mt-8">
<div className="flex justify-center">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{total}</div>
<div className="text-sm text-gray-500">总消费者</div>
</div>
{/* 其他统计项可根据接口返回字段补充 */}
</div>
</Card>
</Layout>
);
}
export default ConsumersPage;
```
--------------------------------------------------------------------------------
/portal-web/api-portal-frontend/src/pages/Mcp.tsx:
--------------------------------------------------------------------------------
```typescript
import { useEffect, useState } from "react";
import { Card, Tag, Typography, Input, Avatar, Skeleton } from "antd";
import { Link } from "react-router-dom";
import { Layout } from "../components/Layout";
import api from "../lib/api";
import { ProductStatus } from "../types";
import type { Product, ApiResponse, PaginatedResponse, ProductIcon } from "../types";
// import { getCategoryText, getCategoryColor } from "../lib/statusUtils";
const { Title, Paragraph } = Typography;
const { Search } = Input;
interface McpServer {
key: string;
name: string;
description: string;
status: string;
version: string;
endpoints: number;
category: string;
creator: string;
icon?: ProductIcon;
mcpConfig?: any;
updatedAt: string;
}
function McpPage() {
const [loading, setLoading] = useState(false);
const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
const [searchText, setSearchText] = useState('');
useEffect(() => {
fetchMcpServers();
}, []);
// 处理产品图标的函数
const getIconUrl = (icon?: ProductIcon | null): string => {
const fallback = "/MCP.svg";
if (!icon) {
return fallback;
}
switch (icon.type) {
case "URL":
return icon.value || fallback;
case "BASE64":
// 如果value已经包含data URL前缀,直接使用;否则添加前缀
return icon.value ? (icon.value.startsWith('data:') ? icon.value : `data:image/png;base64,${icon.value}`) : fallback;
default:
return fallback;
}
};
const fetchMcpServers = async () => {
setLoading(true);
try {
const response: ApiResponse<PaginatedResponse<Product>> = await api.get("/products?type=MCP_SERVER&page=0&size=100");
if (response.code === "SUCCESS" && response.data) {
// 移除重复过滤,简化数据映射
const mapped = response.data.content.map((item: Product) => ({
key: item.productId,
name: item.name,
description: item.description,
status: item.status === ProductStatus.ENABLE ? 'active' : 'inactive',
version: 'v1.0.0',
endpoints: 0,
category: item.category,
creator: 'Unknown',
icon: item.icon || undefined,
mcpConfig: item.mcpConfig,
updatedAt: item.updatedAt?.slice(0, 10) || ''
}));
setMcpServers(mapped);
}
} catch (error) {
console.error('获取MCP服务器列表失败:', error);
} finally {
setLoading(false);
}
};
const filteredMcpServers = mcpServers.filter(server => {
return server.name.toLowerCase().includes(searchText.toLowerCase()) ||
server.description.toLowerCase().includes(searchText.toLowerCase()) ||
server.creator.toLowerCase().includes(searchText.toLowerCase());
});
return (
<Layout>
{/* Header Section */}
<div className="text-center mb-8">
<Title level={1} className="mb-4">
MCP 市场
</Title>
<Paragraph className="text-gray-600 text-lg max-w-4xl mx-auto text-flow text-flow-grey slow">
支持私有化部署,共建和兼容MCP市场官方协议,具备更多管理能力,支持自动注册、智能路由的MCP市场
</Paragraph>
</div>
{/* Search Section */}
<div className="flex justify-center mb-8">
<div className="relative w-full max-w-2xl">
<Search
placeholder="请输入内容"
size="large"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="rounded-lg shadow-lg"
/>
</div>
</div>
{/* Servers Section */}
<div className="mb-6">
<Title level={3} className="mb-4">
热门/推荐 MCP Servers: {filteredMcpServers.length}
</Title>
</div>
{/* Servers Grid */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
{Array.from({ length: 6 }).map((_, index) => (
<Card key={index} className="h-full rounded-lg shadow-lg">
<Skeleton loading active>
<div className="flex items-start space-x-4 mb-2">
<Skeleton.Avatar size={48} active />
<div className="flex-1 min-w-0">
<Skeleton.Input active size="small" style={{ width: '80%', marginBottom: 8 }} />
<Skeleton.Input active size="small" style={{ width: '100%', marginBottom: 12 }} />
<Skeleton.Input active size="small" style={{ width: '60%' }} />
</div>
</div>
</Skeleton>
</Card>
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
{filteredMcpServers.map((server) => (
<Link key={server.key} to={`/mcp/${server.key}`} className="block">
<Card
hoverable
className="h-full transition-all duration-200 hover:shadow-lg cursor-pointer rounded-lg shadow-lg"
>
<div className="flex items-start space-x-4 mb-2">
{/* Server Icon */}
{server.icon ? (
<Avatar
size={48}
src={getIconUrl(server.icon)}
/>
) : (
<Avatar
size={48}
className="bg-gradient-to-br from-blue-500 to-blue-600 shadow-lg"
style={{ fontSize: "18px", fontWeight: "600" }}
>
{server.name[0]}
</Avatar>
)}
{/* Server Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
<Title level={5} className="mb-0 truncate">
{server.name}
</Title>
<Tag className="text-xs text-green-700 border-0 bg-transparent px-0">
{server.mcpConfig?.mcpServerConfig?.transportMode || 'remote'}
</Tag>
</div>
</div>
</div>
<Paragraph className="text-sm text-gray-600 mb-3 line-clamp-2">
{server.description}
</Paragraph>
<div className="flex items-center justify-between">
{/* <Tag color={getCategoryColor(server.category || 'OFFICIAL')} className="">
{getCategoryText(server.category || 'OFFICIAL')}
</Tag> */}
<div className="text-xs text-gray-400">
更新 {server.updatedAt}
</div>
</div>
</Card>
</Link>
))}
</div>
)}
{/* Empty State */}
{filteredMcpServers.length === 0 && (
<div className="text-center py-8">
<div className="text-gray-500">暂无MCP服务器</div>
</div>
)}
</Layout>
);
}
export default McpPage;
```
--------------------------------------------------------------------------------
/portal-web/api-portal-admin/src/components/portal/PortalPublishedApis.tsx:
--------------------------------------------------------------------------------
```typescript
import { useState, useEffect } from 'react'
import { Card, Table, Modal, Form, Button, Space, Select, message, Checkbox } from 'antd'
import { EyeOutlined, DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
import { Portal, ApiProduct } from '@/types'
import { apiProductApi } from '@/lib/api'
import { useNavigate } from 'react-router-dom'
import { ProductTypeMap } from '@/lib/utils'
interface PortalApiProductsProps {
portal: Portal
}
export function PortalPublishedApis({ portal }: PortalApiProductsProps) {
const navigate = useNavigate()
const [apiProducts, setApiProducts] = useState<ApiProduct[]>([])
const [apiProductsOptions, setApiProductsOptions] = useState<ApiProduct[]>([])
const [isModalVisible, setIsModalVisible] = useState(false)
const [selectedApiIds, setSelectedApiIds] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [modalLoading, setModalLoading] = useState(false)
// 分页状态
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [form] = Form.useForm()
useEffect(() => {
if (portal.portalId) {
fetchApiProducts()
}
}, [portal.portalId, currentPage, pageSize])
const fetchApiProducts = () => {
setLoading(true)
apiProductApi.getApiProducts({
portalId: portal.portalId,
page: currentPage,
size: pageSize
}).then((res) => {
setApiProducts(res.data.content)
setTotal(res.data.totalElements || 0)
}).finally(() => {
setLoading(false)
})
}
useEffect(() => {
if (isModalVisible) {
setModalLoading(true)
apiProductApi.getApiProducts({
page: 1,
size: 500, // 获取所有可用的API
status: 'READY'
}).then((res) => {
// 过滤掉已发布在该门户里的api
setApiProductsOptions(res.data.content.filter((api: ApiProduct) =>
!apiProducts.some((a: ApiProduct) => a.productId === api.productId)
))
}).finally(() => {
setModalLoading(false)
})
}
}, [isModalVisible]) // 移除apiProducts依赖,避免重复请求
const handlePageChange = (page: number, size?: number) => {
setCurrentPage(page)
if (size) {
setPageSize(size)
}
}
const columns = [
{
title: '名称/ID',
key: 'nameAndId',
width: 280,
render: (_: any, record: ApiProduct) => (
<div>
<div className="text-sm font-medium text-gray-900 truncate">{record.name}</div>
<div className="text-xs text-gray-500 truncate">{record.productId}</div>
</div>
),
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
width: 120,
render: (text: string) => ProductTypeMap[text] || text
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
width: 400,
},
// {
// title: '分类',
// dataIndex: 'category',
// key: 'category',
// },
{
title: '操作',
key: 'action',
width: 180,
render: (_: any, record: ApiProduct) => (
<Space size="middle">
<Button
onClick={() => {
navigate(`/api-products/detail?productId=${record.productId}`)
}}
type="link" icon={<EyeOutlined />}>
查看
</Button>
<Button type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.productId, record.name)}>
移除
</Button>
</Space>
),
},
]
const modalColumns = [
{
title: '选择',
dataIndex: 'select',
key: 'select',
width: 60,
render: (_: any, record: ApiProduct) => (
<Checkbox
checked={selectedApiIds.includes(record.productId)}
onChange={(e) => {
if (e.target.checked) {
setSelectedApiIds([...selectedApiIds, record.productId])
} else {
setSelectedApiIds(selectedApiIds.filter(id => id !== record.productId))
}
}}
/>
),
},
{
title: '名称',
dataIndex: 'name',
key: 'name',
width: 320,
render: (_: any, record: ApiProduct) => (
<div>
<div className="text-sm font-medium text-gray-900 truncate">
{record.name}
</div>
<div className="text-xs text-gray-500 truncate">
{record.productId}
</div>
</div>
),
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
width: 100,
render: (type: string) => ProductTypeMap[type] || type,
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
width: 300,
},
]
const handleDelete = (productId: string, productName: string) => {
Modal.confirm({
title: '确认移除',
icon: <ExclamationCircleOutlined />,
content: `确定要从门户中移除API产品 "${productName}" 吗?此操作不可恢复。`,
okText: '确认移除',
okType: 'danger',
cancelText: '取消',
onOk() {
apiProductApi.cancelPublishToPortal(productId, portal.portalId).then((res) => {
message.success('移除成功')
fetchApiProducts()
setIsModalVisible(false)
}).catch((error) => {
// message.error('移除失败')
})
},
})
}
const handlePublish = async () => {
if (selectedApiIds.length === 0) {
message.warning('请至少选择一个API')
return
}
setModalLoading(true)
try {
// 批量发布选中的API
for (const productId of selectedApiIds) {
await apiProductApi.publishToPortal(productId, portal.portalId)
}
message.success(`成功发布 ${selectedApiIds.length} 个API`)
setSelectedApiIds([])
fetchApiProducts()
setIsModalVisible(false)
} catch (error) {
// message.error('发布失败')
} finally {
setModalLoading(false)
}
}
const handleModalCancel = () => {
setIsModalVisible(false)
setSelectedApiIds([])
}
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold mb-2">API Product</h1>
<p className="text-gray-600">管理在此Portal中发布的API产品</p>
</div>
<Button type="primary" onClick={() => setIsModalVisible(true)}>
发布新API
</Button>
</div>
<Card>
<Table
columns={columns}
dataSource={apiProducts}
rowKey="productId"
loading={loading}
pagination={{
current: currentPage,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `共 ${total} 条`,
onChange: handlePageChange,
onShowSizeChange: handlePageChange,
}}
/>
</Card>
<Modal
title="发布API产品"
open={isModalVisible}
onOk={handlePublish}
onCancel={handleModalCancel}
okText="发布"
cancelText="取消"
width={800}
confirmLoading={modalLoading}
>
<Table
columns={modalColumns}
dataSource={apiProductsOptions}
rowKey="productId"
loading={modalLoading}
pagination={false}
scroll={{ y: 400 }}
/>
</Modal>
</div>
)
}
```
--------------------------------------------------------------------------------
/portal-web/api-portal-frontend/src/pages/Apis.tsx:
--------------------------------------------------------------------------------
```typescript
import { useEffect, useState } from "react";
import { Card, Tag, Typography, Input, Avatar, Skeleton } from "antd";
import { Link } from "react-router-dom";
import { Layout } from "../components/Layout";
import api from "../lib/api";
import { ProductStatus } from "../types";
import type { Product, ApiResponse, PaginatedResponse, ProductIcon } from "../types";
// import { getCategoryText, getCategoryColor } from "../lib/statusUtils";
import './Test.css';
const { Title, Paragraph } = Typography;
const { Search } = Input;
interface ApiProductListItem {
key: string;
name: string;
description: string;
status: string;
version: string;
endpoints: number;
category: string;
creator: string;
icon?: ProductIcon;
updatedAt: string;
}
function APIsPage() {
const [loading, setLoading] = useState(false);
const [apiProducts, setApiProducts] = useState<ApiProductListItem[]>([]);
const [searchText, setSearchText] = useState('');
useEffect(() => {
fetchApiProducts();
}, []);
// 处理产品图标的函数
const getIconUrl = (icon?: ProductIcon | null): string => {
const fallback = "/logo.svg";
if (!icon) {
return fallback;
}
switch (icon.type) {
case "URL":
return icon.value || fallback;
case "BASE64":
// 如果value已经包含data URL前缀,直接使用;否则添加前缀
return icon.value ? (icon.value.startsWith('data:') ? icon.value : `data:image/png;base64,${icon.value}`) : fallback;
default:
return fallback;
}
};
const fetchApiProducts = async () => {
setLoading(true);
try {
const response: ApiResponse<PaginatedResponse<Product>> = await api.get("/products?type=REST_API&page=0&size=100");
if (response.code === "SUCCESS" && response.data) {
// 移除重复过滤,简化数据映射
const mapped = response.data.content.map((item: Product) => ({
key: item.productId,
name: item.name,
description: item.description,
status: item.status === ProductStatus.ENABLE ? 'active' : 'inactive',
version: 'v1.0.0',
endpoints: 0,
category: item.category,
creator: 'Unknown',
icon: item.icon || undefined,
updatedAt: item.updatedAt?.slice(0, 10) || ''
}));
setApiProducts(mapped);
}
} catch (error) {
console.error('获取API产品列表失败:', error);
} finally {
setLoading(false);
}
};
const filteredApiProducts = apiProducts.filter(product => {
return product.name.toLowerCase().includes(searchText.toLowerCase()) ||
product.description.toLowerCase().includes(searchText.toLowerCase()) ||
product.creator.toLowerCase().includes(searchText.toLowerCase());
});
const getApiIcon = (name: string) => {
// Generate initials for API icon
const words = name.split(' ');
if (words.length >= 2) {
return words[0][0] + words[1][0];
}
return name.substring(0, 2).toUpperCase();
};
const getApiIconColor = (name: string) => {
const colors = ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#13c2c2'];
const index = name.charCodeAt(0) % colors.length;
return colors[index];
};
return (
<Layout>
{/* Header Section */}
<div className="text-center mb-8">
<Title level={1} className="mb-4">
API 市场
</Title>
<Paragraph className="text-gray-600 text-lg max-w-4xl mx-auto text-flow text-flow-grey slow">
支持私有化部署,具备更多管理能力,支持自动注册、智能路由的API市场
</Paragraph>
</div>
{/* Search Section */}
<div className="flex justify-center mb-8">
<div className="relative w-full max-w-2xl">
<Search
placeholder="请输入内容"
size="large"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="rounded-lg shadow-lg"
/>
</div>
</div>
{/* APIs Section */}
<div className="mb-6">
<Title level={3} className="mb-4">
热门/推荐 APIs: {filteredApiProducts.length}
</Title>
</div>
{/* APIs Grid */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
{Array.from({ length: 6 }).map((_, index) => (
<Card key={index} className="h-full rounded-lg shadow-lg">
<Skeleton loading active>
<div className="flex items-start space-x-4">
<Skeleton.Avatar size={48} active />
<div className="flex-1 min-w-0">
<Skeleton.Input active size="small" style={{ width: '80%', marginBottom: 8 }} />
<Skeleton.Input active size="small" style={{ width: '100%', marginBottom: 12 }} />
<Skeleton.Input active size="small" style={{ width: '60%' }} />
</div>
</div>
</Skeleton>
</Card>
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
{filteredApiProducts.map((product) => (
<Link key={product.key} to={`/apis/${product.key}`} className="block">
<Card
hoverable
className="h-full transition-all duration-200 hover:shadow-lg cursor-pointer rounded-lg shadow-lg"
>
<div className="flex items-start space-x-4">
{/* API Icon */}
<Avatar
size={48}
src={product.icon ? getIconUrl(product.icon) : undefined}
style={{
backgroundColor: getApiIconColor(product.name),
fontSize: '18px',
fontWeight: 'bold'
}}
>
{!product.icon && getApiIcon(product.name)}
</Avatar>
{/* API Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
<Title level={5} className="mb-0 truncate">
{product.name}
</Title>
<Tag className="text-xs text-green-700 border-0 bg-transparent px-0">
REST
</Tag>
</div>
{/* <div className="text-sm text-gray-500 mb-2">
创建者: {product.creator}
</div> */}
<Paragraph className="text-sm text-gray-600 mb-3 line-clamp-2">
{product.description}
</Paragraph>
<div className="flex items-center justify-between">
{/* <Tag color={getCategoryColor(product.category)} className="">
{getCategoryText(product.category)}
</Tag> */}
<div className="text-xs text-gray-400">
更新 {product.updatedAt}
</div>
</div>
</div>
</div>
</Card>
</Link>
))}
</div>
)}
{/* Empty State */}
{filteredApiProducts.length === 0 && (
<div className="text-center py-8">
<div className="text-gray-500">暂无API产品</div>
</div>
)}
</Layout>
);
}
export default APIsPage;
```
--------------------------------------------------------------------------------
/portal-web/api-portal-admin/src/lib/api.ts:
--------------------------------------------------------------------------------
```typescript
import axios, { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import { getToken, removeToken } from './utils'
import { message } from 'antd'
const api: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
withCredentials: true, // 确保跨域请求时携带 cookie
})
// 请求拦截器
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = getToken()
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
api.interceptors.response.use(
(response: AxiosResponse) => {
return response.data
},
(error) => {
message.error(error.response?.data?.message || '请求发生错误');
if (error.response?.status === 403 || error.response?.status === 401) {
removeToken()
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default api
// 用户相关API
export const authApi = {
getNeedInit: () => {
return api.get('/admins/need-init')
}
}
// Portal相关API
export const portalApi = {
// 获取portal列表
getPortals: (params?: { page?: number; size?: number }) => {
return api.get(`/portals`, { params })
},
// 获取Portal Dashboard URL
getPortalDashboard: (portalId: string, type: string = 'Portal') => {
return api.get(`/portals/${portalId}/dashboard`, { params: { type } })
},
deletePortal: (portalId: string) => {
return api.delete(`/portals/${portalId}`)
},
createPortal: (data: any) => {
return api.post(`/portals`, data)
},
// 获取portal详情
getPortalDetail: (portalId: string) => {
return api.get(`/portals/${portalId}`)
},
// 绑定域名
bindDomain: (portalId: string, domainData: { domain: string; protocol: string; type: string }) => {
return api.post(`/portals/${portalId}/domains`, domainData)
},
// 解绑域名
unbindDomain: (portalId: string, domain: string) => {
const encodedDomain = encodeURIComponent(domain)
return api.delete(`/portals/${portalId}/domains/${encodedDomain}`)
},
// 更新Portal
updatePortal: (portalId: string, data: any) => {
return api.put(`/portals/${portalId}`, data)
},
// 更新Portal设置
updatePortalSettings: (portalId: string, settings: any) => {
return api.put(`/portals/${portalId}/setting`, settings)
},
// 获取Portal的开发者列表
getDeveloperList: (portalId: string, pagination?: { page: number; size: number }) => {
return api.get(`/developers`, {
params: {
portalId,
...pagination
}
})
},
// 更新开发者状态
updateDeveloperStatus: (portalId: string, developerId: string, status: string) => {
return api.patch(`/developers/${developerId}/status`, {
portalId,
status
})
},
deleteDeveloper: (developerId: string) => {
return api.delete(`/developers/${developerId}`)
},
getConsumerList: (portalId: string, developerId: string, pagination?: { page: number; size: number }) => {
return api.get(`/consumers`, {
params: {
portalId,
developerId,
...pagination
}
})
},
// 审批consumer
approveConsumer: (consumerId: string) => {
return api.patch(`/consumers/${consumerId}/status`)
},
// 获取Consumer的订阅列表
getConsumerSubscriptions: (consumerId: string, params?: { page?: number; size?: number; status?: string }) => {
return api.get(`/consumers/${consumerId}/subscriptions`, { params })
},
// 审批订阅申请
approveSubscription: (consumerId: string, productId: string) => {
return api.patch(`/consumers/${consumerId}/subscriptions/${productId}`)
},
// 删除订阅
deleteSubscription: (consumerId: string, productId: string) => {
return api.delete(`/consumers/${consumerId}/subscriptions/${productId}`)
}
}
// API Product相关API
export const apiProductApi = {
// 获取API产品列表
getApiProducts: (params?: any) => {
return api.get(`/products`, { params })
},
// 获取API产品详情
getApiProductDetail: (productId: string) => {
return api.get(`/products/${productId}`)
},
// 创建API产品
createApiProduct: (data: any) => {
return api.post(`/products`, data)
},
// 删除API产品
deleteApiProduct: (productId: string) => {
return api.delete(`/products/${productId}`)
},
// 更新API产品
updateApiProduct: (productId: string, data: any) => {
return api.put(`/products/${productId}`, data)
},
// 获取API产品关联的服务
getApiProductRef: (productId: string) => {
return api.get(`/products/${productId}/ref`)
},
// 创建API产品关联
createApiProductRef: (productId: string, data: any) => {
return api.post(`/products/${productId}/ref`, data)
},
// 删除API产品关联
deleteApiProductRef: (productId: string) => {
return api.delete(`/products/${productId}/ref`)
},
// 获取API产品已发布的门户列表
getApiProductPublications: (productId: string, params?: any) => {
return api.get(`/products/${productId}/publications`, { params })
},
// 发布API产品到门户
publishToPortal: (productId: string, portalId: string) => {
return api.post(`/products/${productId}/publications/${portalId}`)
},
// 取消发布API产品到门户
cancelPublishToPortal: (productId: string, portalId: string) => {
return api.delete(`/products/${productId}/publications/${portalId}`)
},
// 获取API产品的Dashboard监控面板URL
getProductDashboard: (productId: string) => {
return api.get(`/products/${productId}/dashboard`)
}
}
// Gateway相关API
export const gatewayApi = {
// 获取网关列表
getGateways: (params?: any) => {
return api.get(`/gateways`, { params })
},
// 获取APIG网关
getApigGateway: (data: any) => {
return api.get(`/gateways/apig`, { params: {
...data,
} })
},
// 获取Apsara网关
getApsaraGateways: (data: any) => {
return api.post(`/gateways/apsara`, data)
},
// 获取ADP网关
getAdpGateways: (data: any) => {
return api.post(`/gateways/adp`, data)
},
// 删除网关
deleteGateway: (gatewayId: string) => {
return api.delete(`/gateways/${gatewayId}`)
},
// 导入网关
importGateway: (data: any) => {
return api.post(`/gateways`, { ...data })
},
// 获取网关的REST API列表
getGatewayRestApis: (gatewayId: string, data: any) => {
return api.get(`/gateways/${gatewayId}/rest-apis`, {
params: data
})
},
// 获取网关的MCP Server列表
getGatewayMcpServers: (gatewayId: string, data: any) => {
return api.get(`/gateways/${gatewayId}/mcp-servers`, {
params: data
})
},
// 获取网关的Dashboard URL
getDashboard: (gatewayId: string) => {
return api.get(`/gateways/${gatewayId}/dashboard`)
}
}
export const nacosApi = {
getNacos: (params?: any) => {
return api.get(`/nacos`, { params })
},
// 从阿里云 MSE 获取 Nacos 集群列表
getMseNacos: (params: { regionId: string; accessKey: string; secretKey: string; page?: number; size?: number }) => {
return api.get(`/nacos/mse`, { params })
},
createNacos: (data: any) => {
return api.post(`/nacos`, data)
},
deleteNacos: (nacosId: string) => {
return api.delete(`/nacos/${nacosId}`)
},
updateNacos: (nacosId: string, data: any) => {
return api.put(`/nacos/${nacosId}`, data)
},
getNacosMcpServers: (nacosId: string, data: any) => {
return api.get(`/nacos/${nacosId}/mcp-servers`, {
params: data
})
},
// 获取指定 Nacos 实例的命名空间列表
getNamespaces: (nacosId: string, params?: { page?: number; size?: number }) => {
return api.get(`/nacos/${nacosId}/namespaces`, { params })
}
}
```
--------------------------------------------------------------------------------
/portal-web/api-portal-admin/src/components/api-product/ApiProductPolicy.tsx:
--------------------------------------------------------------------------------
```typescript
import { Card, Button, Table, Tag, Space, Modal, Form, Input, Select, Switch, message } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, SettingOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
import { useState } from 'react'
import type { ApiProduct } from '@/types/api-product';
import { formatDateTime } from '@/lib/utils'
interface ApiProductPolicyProps {
apiProduct: ApiProduct
}
interface Policy {
id: string
name: string
type: string
status: string
description: string
createdAt: string
config: any
}
const mockPolicies: Policy[] = [
{
id: "1",
name: "Rate Limiting",
type: "rate-limiting",
status: "enabled",
description: "限制API调用频率",
createdAt: "2025-01-01T10:00:00Z",
config: {
minute: 100,
hour: 1000
}
},
{
id: "2",
name: "Authentication",
type: "key-auth",
status: "enabled",
description: "API密钥认证",
createdAt: "2025-01-02T11:00:00Z",
config: {
key_names: ["apikey"],
hide_credentials: true
}
},
{
id: "3",
name: "CORS",
type: "cors",
status: "enabled",
description: "跨域资源共享",
createdAt: "2025-01-03T12:00:00Z",
config: {
origins: ["*"],
methods: ["GET", "POST", "PUT", "DELETE"]
}
}
]
export function ApiProductPolicy({ apiProduct }: ApiProductPolicyProps) {
const [policies, setPolicies] = useState<Policy[]>(mockPolicies)
const [isModalVisible, setIsModalVisible] = useState(false)
const [editingPolicy, setEditingPolicy] = useState<Policy | null>(null)
const [form] = Form.useForm()
const columns = [
{
title: '策略名称',
dataIndex: 'name',
key: 'name',
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
render: (type: string) => {
const typeMap: { [key: string]: string } = {
'rate-limiting': '限流',
'key-auth': '认证',
'cors': 'CORS',
'acl': '访问控制'
}
return <Tag color="blue">{typeMap[type] || type}</Tag>
}
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => (
<Tag color={status === 'enabled' ? 'green' : 'red'}>
{status === 'enabled' ? '启用' : '禁用'}
</Tag>
)
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (date: string) => formatDateTime(date)
},
{
title: '操作',
key: 'action',
render: (_: any, record: Policy) => (
<Space size="middle">
<Button type="link" icon={<SettingOutlined />}>
配置
</Button>
<Button type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}>
编辑
</Button>
<Button
type="link"
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(record.id, record.name)}
>
删除
</Button>
</Space>
),
},
]
const handleAdd = () => {
setEditingPolicy(null)
setIsModalVisible(true)
}
const handleEdit = (policy: Policy) => {
setEditingPolicy(policy)
form.setFieldsValue({
name: policy.name,
type: policy.type,
description: policy.description,
status: policy.status
})
setIsModalVisible(true)
}
const handleDelete = (id: string, policyName: string) => {
Modal.confirm({
title: '确认删除',
icon: <ExclamationCircleOutlined />,
content: `确定要删除策略 "${policyName}" 吗?此操作不可恢复。`,
okText: '确认删除',
okType: 'danger',
cancelText: '取消',
onOk() {
setPolicies(policies.filter(policy => policy.id !== id))
message.success('策略删除成功')
},
})
}
const handleModalOk = () => {
form.validateFields().then((values) => {
if (editingPolicy) {
// 编辑现有策略
setPolicies(policies.map(policy =>
policy.id === editingPolicy.id
? { ...policy, ...values }
: policy
))
} else {
// 添加新策略
const newPolicy: Policy = {
id: Date.now().toString(),
name: values.name,
type: values.type,
status: values.status,
description: values.description,
createdAt: new Date().toISOString(),
config: {}
}
setPolicies([...policies, newPolicy])
}
setIsModalVisible(false)
form.resetFields()
setEditingPolicy(null)
})
}
const handleModalCancel = () => {
setIsModalVisible(false)
form.resetFields()
setEditingPolicy(null)
}
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">管理API产品的策略配置</p>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
添加策略
</Button>
</div>
<Card>
<Table
columns={columns}
dataSource={policies}
rowKey="id"
pagination={false}
/>
</Card>
<Card title="策略设置">
<div className="space-y-4">
<div className="flex justify-between items-center">
<span>策略继承</span>
<Switch defaultChecked />
</div>
<div className="flex justify-between items-center">
<span>策略优先级</span>
<Switch defaultChecked />
</div>
<div className="flex justify-between items-center">
<span>策略日志</span>
<Switch defaultChecked />
</div>
</div>
</Card>
<Modal
title={editingPolicy ? "编辑策略" : "添加策略"}
open={isModalVisible}
onOk={handleModalOk}
onCancel={handleModalCancel}
okText={editingPolicy ? "更新" : "添加"}
cancelText="取消"
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="策略名称"
rules={[{ required: true, message: '请输入策略名称' }]}
>
<Input placeholder="请输入策略名称" />
</Form.Item>
<Form.Item
name="type"
label="策略类型"
rules={[{ required: true, message: '请选择策略类型' }]}
>
<Select placeholder="请选择策略类型">
<Select.Option value="rate-limiting">限流</Select.Option>
<Select.Option value="key-auth">认证</Select.Option>
<Select.Option value="cors">CORS</Select.Option>
<Select.Option value="acl">访问控制</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="description"
label="描述"
rules={[{ required: true, message: '请输入策略描述' }]}
>
<Input.TextArea placeholder="请输入策略描述" rows={3} />
</Form.Item>
<Form.Item
name="status"
label="状态"
rules={[{ required: true, message: '请选择状态' }]}
>
<Select placeholder="请选择状态">
<Select.Option value="enabled">启用</Select.Option>
<Select.Option value="disabled">禁用</Select.Option>
</Select>
</Form.Item>
</Form>
</Modal>
</div>
)
}
```
--------------------------------------------------------------------------------
/portal-web/api-portal-admin/src/pages/ApiProductDetail.tsx:
--------------------------------------------------------------------------------
```typescript
import { useState, useEffect } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { Button, Dropdown, MenuProps, Modal, message } from 'antd'
import {
MoreOutlined,
LeftOutlined,
EyeOutlined,
LinkOutlined,
BookOutlined,
GlobalOutlined,
DashboardOutlined
} from '@ant-design/icons'
import { ApiProductOverview } from '@/components/api-product/ApiProductOverview'
import { ApiProductLinkApi } from '@/components/api-product/ApiProductLinkApi'
import { ApiProductUsageGuide } from '@/components/api-product/ApiProductUsageGuide'
import { ApiProductPortal } from '@/components/api-product/ApiProductPortal'
import { ApiProductDashboard } from '@/components/api-product/ApiProductDashboard'
import { apiProductApi } from '@/lib/api';
import type { ApiProduct, LinkedService } from '@/types/api-product';
import ApiProductFormModal from '@/components/api-product/ApiProductFormModal';
const menuItems = [
{
key: "overview",
label: "Overview",
description: "产品概览",
icon: EyeOutlined
},
{
key: "link-api",
label: "Link API",
description: "API关联",
icon: LinkOutlined
},
{
key: "usage-guide",
label: "Usage Guide",
description: "使用指南",
icon: BookOutlined
},
{
key: "portal",
label: "Portal",
description: "发布的门户",
icon: GlobalOutlined
},
{
key: "dashboard",
label: "Dashboard",
description: "实时监控和统计",
icon: DashboardOutlined
}
]
export default function ApiProductDetail() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const [apiProduct, setApiProduct] = useState<ApiProduct | null>(null)
const [linkedService, setLinkedService] = useState<LinkedService | null>(null)
const [loading, setLoading] = useState(true) // 添加 loading 状态
// 从URL query参数获取当前tab,默认为overview
const currentTab = searchParams.get('tab') || 'overview'
// 验证tab值是否有效,如果无效则使用默认值
const validTab = menuItems.some(item => item.key === currentTab) ? currentTab : 'overview'
const [activeTab, setActiveTab] = useState(validTab)
const [editModalVisible, setEditModalVisible] = useState(false)
const fetchApiProduct = async () => {
const productId = searchParams.get('productId')
if (productId) {
setLoading(true)
try {
// 并行获取Product详情和关联信息
const [productRes, refRes] = await Promise.all([
apiProductApi.getApiProductDetail(productId),
apiProductApi.getApiProductRef(productId).catch(() => ({ data: null })) // 关联信息获取失败不影响页面显示
])
setApiProduct(productRes.data)
setLinkedService(refRes.data || null)
} catch (error) {
console.error('获取Product详情失败:', error)
} finally {
setLoading(false)
}
}
}
// 更新关联信息的回调函数
const handleLinkedServiceUpdate = (newLinkedService: LinkedService | null) => {
setLinkedService(newLinkedService)
}
useEffect(() => {
fetchApiProduct()
}, [searchParams.get('productId')])
// 同步URL参数和activeTab状态
useEffect(() => {
setActiveTab(validTab)
}, [validTab, searchParams])
const handleBackToApiProducts = () => {
navigate('/api-products')
}
const handleTabChange = (tabKey: string) => {
setActiveTab(tabKey)
// 更新URL query参数
const newSearchParams = new URLSearchParams(searchParams)
newSearchParams.set('tab', tabKey)
setSearchParams(newSearchParams)
}
const renderContent = () => {
if (!apiProduct) {
return <div className="p-6">Loading...</div>
}
switch (activeTab) {
case "overview":
return <ApiProductOverview apiProduct={apiProduct} linkedService={linkedService} onEdit={handleEdit} />
case "link-api":
return <ApiProductLinkApi
apiProduct={apiProduct}
linkedService={linkedService}
onLinkedServiceUpdate={handleLinkedServiceUpdate}
handleRefresh={fetchApiProduct}
/>
case "usage-guide":
return <ApiProductUsageGuide apiProduct={apiProduct} handleRefresh={fetchApiProduct} />
case "portal":
return <ApiProductPortal apiProduct={apiProduct} />
case "dashboard":
return <ApiProductDashboard apiProduct={apiProduct} />
default:
return <ApiProductOverview apiProduct={apiProduct} linkedService={linkedService} onEdit={handleEdit} />
}
}
const dropdownItems: MenuProps['items'] = [
{
key: 'delete',
label: '删除',
onClick: () => {
Modal.confirm({
title: '确认删除',
content: '确定要删除该产品吗?',
onOk: () => {
handleDeleteApiProduct()
},
})
},
danger: true,
},
]
const handleDeleteApiProduct = () => {
if (!apiProduct) return;
apiProductApi.deleteApiProduct(apiProduct.productId).then(() => {
message.success('删除成功')
navigate('/api-products')
}).catch((error) => {
// message.error(error.response?.data?.message || '删除失败')
})
}
const handleEdit = () => {
setEditModalVisible(true)
}
const handleEditSuccess = () => {
setEditModalVisible(false)
fetchApiProduct()
}
const handleEditCancel = () => {
setEditModalVisible(false)
}
return (
<div className="flex h-full w-full overflow-hidden">
{/* API Product 详情侧边栏 */}
<div className="w-64 border-r bg-white flex flex-col flex-shrink-0">
{/* 返回按钮 */}
<div className="pb-4 border-b">
<Button
type="text"
// className="w-full justify-start"
onClick={handleBackToApiProducts}
icon={<LeftOutlined />}
>
返回
</Button>
</div>
{/* API Product 信息 */}
<div className="p-4 border-b">
<div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-semibold">{apiProduct?.name || 'Loading...'}</h2>
<Dropdown menu={{ items: dropdownItems }} trigger={['click']}>
<Button type="text" icon={<MoreOutlined />} />
</Dropdown>
</div>
</div>
{/* 导航菜单 */}
<nav className="flex-1 p-4 space-y-1">
{menuItems.map((item) => {
const Icon = item.icon;
return (
<button
key={item.key}
onClick={() => handleTabChange(item.key)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-colors ${
activeTab === item.key
? "bg-blue-500 text-white"
: "hover:bg-gray-100"
}`}
>
<Icon className="h-4 w-4 flex-shrink-0" />
<div>
<div className="font-medium">{item.label}</div>
<div className="text-xs opacity-70">{item.description}</div>
</div>
</button>
);
})}
</nav>
</div>
{/* 主内容区域 */}
<div className="flex-1 overflow-auto min-w-0">
<div className="w-full max-w-full">
{renderContent()}
</div>
</div>
{apiProduct && (
<ApiProductFormModal
visible={editModalVisible}
onCancel={handleEditCancel}
onSuccess={handleEditSuccess}
productId={apiProduct.productId}
initialData={apiProduct}
/>
)}
</div>
)
}
```
--------------------------------------------------------------------------------
/portal-web/api-portal-admin/src/pages/PortalDetail.tsx:
--------------------------------------------------------------------------------
```typescript
import { useState, useEffect } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { Button, Dropdown, MenuProps, Typography, Spin, Modal, message } from 'antd'
import {
MoreOutlined,
LeftOutlined,
EyeOutlined,
ApiOutlined,
TeamOutlined,
SafetyOutlined,
CloudOutlined,
DashboardOutlined
} from '@ant-design/icons'
import { PortalOverview } from '@/components/portal/PortalOverview'
import { PortalPublishedApis } from '@/components/portal/PortalPublishedApis'
import { PortalDevelopers } from '@/components/portal/PortalDevelopers'
import { PortalConsumers } from '@/components/portal/PortalConsumers'
import { PortalDashboard } from '@/components/portal/PortalDashboard'
import { PortalSecurity } from '@/components/portal/PortalSecurity'
import { PortalDomain } from '@/components/portal/PortalDomain'
import PortalFormModal from '@/components/portal/PortalFormModal'
import { portalApi } from '@/lib/api'
import { Portal } from '@/types'
const { Title } = Typography
// 移除mockPortal,使用真实API数据
const menuItems = [
{
key: "overview",
label: "Overview",
icon: EyeOutlined,
description: "Portal概览"
},
{
key: "published-apis",
label: "Products",
icon: ApiOutlined,
description: "已发布的API产品"
},
{
key: "developers",
label: "Developers",
icon: TeamOutlined,
description: "开发者管理"
},
{
key: "security",
label: "Security",
icon: SafetyOutlined,
description: "安全设置"
},
{
key: "domain",
label: "Domain",
icon: CloudOutlined,
description: "域名管理"
},
// {
// key: "consumers",
// label: "Consumers",
// icon: UserOutlined,
// description: "消费者管理"
// },
{
key: "dashboard",
label: "Dashboard",
icon: DashboardOutlined,
description: "监控面板"
}
]
export default function PortalDetail() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const [portal, setPortal] = useState<Portal | null>(null)
const [loading, setLoading] = useState(true) // 初始状态为 loading
const [error, setError] = useState<string | null>(null)
const [editModalVisible, setEditModalVisible] = useState(false)
// 从URL查询参数获取当前tab,默认为overview
const currentTab = searchParams.get('tab') || 'overview'
const [activeTab, setActiveTab] = useState(currentTab)
const fetchPortalData = async () => {
try {
setLoading(true)
const portalId = searchParams.get('id') || 'portal-6882e06f4fd0c963020e3485'
const response = await portalApi.getPortalDetail(portalId) as any
if (response && response.code === 'SUCCESS') {
setPortal(response.data)
} else {
setError(response?.message || '获取Portal信息失败')
}
} catch (err) {
console.error('获取Portal信息失败:', err)
setError('获取Portal信息失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchPortalData()
}, [])
// 当URL中的tab参数变化时,更新activeTab
useEffect(() => {
setActiveTab(currentTab)
}, [currentTab])
const handleBackToPortals = () => {
navigate('/portals')
}
// 处理tab切换,同时更新URL查询参数
const handleTabChange = (tabKey: string) => {
setActiveTab(tabKey)
const newSearchParams = new URLSearchParams(searchParams)
newSearchParams.set('tab', tabKey)
setSearchParams(newSearchParams)
}
const handleEdit = () => {
setEditModalVisible(true)
}
const handleEditSuccess = () => {
setEditModalVisible(false)
fetchPortalData()
}
const handleEditCancel = () => {
setEditModalVisible(false)
}
const renderContent = () => {
if (!portal) return null
switch (activeTab) {
case "overview":
return <PortalOverview portal={portal} onEdit={handleEdit} />
case "published-apis":
return <PortalPublishedApis portal={portal} />
case "developers":
return <PortalDevelopers portal={portal} />
case "security":
return <PortalSecurity portal={portal} onRefresh={fetchPortalData} />
case "domain":
return <PortalDomain portal={portal} onRefresh={fetchPortalData} />
case "consumers":
return <PortalConsumers portal={portal} />
case "dashboard":
return <PortalDashboard portal={portal} />
default:
return <PortalOverview portal={portal} onEdit={handleEdit} />
}
}
const dropdownItems: MenuProps['items'] = [
{
key: "delete",
label: "删除",
danger: true,
onClick: () => {
Modal.confirm({
title: "删除Portal",
content: "确定要删除该Portal吗?",
onOk: () => {
return handleDeletePortal();
},
});
},
},
]
const handleDeletePortal = () => {
return portalApi.deletePortal(searchParams.get('id') || '').then(() => {
message.success('删除成功')
navigate('/portals')
}).catch((error) => {
message.error(error?.response?.data?.message || '删除失败,请稍后重试')
throw error; // 重新抛出错误,让Modal保持loading状态
})
}
if (error || !portal) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
{error && <><p className=" mb-4">{error || 'Portal信息不存在'}</p>
<Button onClick={() => navigate('/portals')}>返回Portal列表</Button></>}
{!error && <Spin fullscreen spinning={loading} />}
</div>
</div>
)
}
return (
<div className="flex h-full">
<Spin fullscreen spinning={loading} />
{/* Portal详情侧边栏 */}
<div className="w-64 border-r bg-white flex flex-col">
{/* 返回按钮 */}
<div className="pb-4 border-b">
<Button
type="text"
// className="w-full justify-start text-gray-600 hover:text-gray-900"
onClick={handleBackToPortals}
icon={<LeftOutlined />}
>
返回
</Button>
</div>
{/* Portal 信息 */}
<div className="p-4 border-b">
<div className="flex items-center justify-between mb-2">
<Title level={5} className="mb-0">{portal.name}</Title>
<Dropdown menu={{ items: dropdownItems }} trigger={['click']}>
<Button type="text" icon={<MoreOutlined />} size="small" />
</Dropdown>
</div>
</div>
{/* 导航菜单 */}
<nav className="flex-1 p-4 space-y-2">
{menuItems.map((item) => {
const Icon = item.icon
return (
<button
key={item.key}
onClick={() => handleTabChange(item.key)}
className={`w-full flex items-center gap-3 px-3 py-3 rounded-lg text-left transition-colors ${
activeTab === item.key
? "bg-blue-50 text-blue-600 border border-blue-200"
: "hover:bg-gray-50 text-gray-700"
}`}
>
<Icon className="h-4 w-4 flex-shrink-0" />
<div className="flex-1">
<div className="font-medium">{item.label}</div>
<div className="text-xs text-gray-500 mt-1">{item.description}</div>
</div>
</button>
)
})}
</nav>
</div>
{/* 主内容区域 */}
<div className="flex-1 overflow-auto">
{renderContent()}
</div>
{portal && (
<PortalFormModal
visible={editModalVisible}
onCancel={handleEditCancel}
onSuccess={handleEditSuccess}
portal={portal}
/>
)}
</div>
)
}
```
--------------------------------------------------------------------------------
/portal-server/src/main/java/com/alibaba/apiopenplatform/dto/converter/NacosToGatewayToolsConverter.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.dto.converter;
import com.alibaba.nacos.api.ai.model.mcp.McpServerDetailInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import lombok.Data;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@Data
public class NacosToGatewayToolsConverter {
private Server server = new Server();
private List<Tool> tools = new ArrayList<>();
private List<String> allowTools = new ArrayList<>();
public void convertFromNacos(McpServerDetailInfo nacosDetail) {
server.setName(nacosDetail.getName());
server.getConfig().put("apiKey", "your-api-key-here");
allowTools.add(nacosDetail.getName());
if (nacosDetail.getToolSpec() != null) {
convertTools(nacosDetail.getToolSpec());
}
}
public String toYaml() {
try {
ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());
return yamlMapper.writeValueAsString(this);
} catch (Exception e) {
throw new RuntimeException("Failed to convert to YAML", e);
}
}
private void convertTools(Object toolSpec) {
try {
ObjectMapper jsonMapper = new ObjectMapper();
String toolSpecJson = jsonMapper.writeValueAsString(toolSpec);
JsonNode toolSpecNode = jsonMapper.readTree(toolSpecJson);
if (toolSpecNode.isArray()) {
for (JsonNode toolNode : toolSpecNode) {
Tool tool = convertToolNode(toolNode);
if (tool != null) {
tools.add(tool);
}
}
} else if (toolSpecNode.has("tools") && toolSpecNode.get("tools").isArray()) {
JsonNode toolsNode = toolSpecNode.get("tools");
for (JsonNode toolNode : toolsNode) {
Tool tool = convertToolNode(toolNode);
if (tool != null) {
tools.add(tool);
}
}
}
} catch (Exception e) {
// 转换失败时,tools保持空列表
}
}
private Tool convertToolNode(JsonNode toolNode) {
Tool result = new Tool();
result.setName(getStringValue(toolNode, "name"));
result.setDescription(getStringValue(toolNode, "description"));
if (result.getName() == null) {
return null;
}
List<Arg> args = convertArgs(toolNode);
result.setArgs(args);
result.setRequestTemplate(buildDefaultRequestTemplate(result.getName()));
result.setResponseTemplate(buildDefaultResponseTemplate());
return result;
}
private List<Arg> convertArgs(JsonNode toolNode) {
List<Arg> args = new ArrayList<>();
try {
if (toolNode.has("inputSchema") && toolNode.get("inputSchema").has("properties")) {
JsonNode properties = toolNode.get("inputSchema").get("properties");
properties.fields().forEachRemaining(entry -> {
String argName = entry.getKey();
JsonNode argNode = entry.getValue();
Arg arg = new Arg();
arg.setName(argName);
arg.setDescription(getStringValue(argNode, "description"));
arg.setType(getStringValue(argNode, "type"));
arg.setRequired(getBooleanValue(argNode, "required", false));
arg.setPosition("query");
args.add(arg);
});
} else if (toolNode.has("args") && toolNode.get("args").isArray()) {
JsonNode argsNode = toolNode.get("args");
for (JsonNode argNode : argsNode) {
Arg arg = new Arg();
arg.setName(getStringValue(argNode, "name"));
arg.setDescription(getStringValue(argNode, "description"));
arg.setType(getStringValue(argNode, "type"));
arg.setRequired(getBooleanValue(argNode, "required", false));
arg.setPosition(getStringValue(argNode, "position"));
arg.setDefaultValue(getStringValue(argNode, "default"));
args.add(arg);
}
}
} catch (Exception e) {
// 转换失败时,args保持空列表
}
return args;
}
private RequestTemplate buildDefaultRequestTemplate(String toolName) {
RequestTemplate template = new RequestTemplate();
template.setUrl("https://api.example.com/v1/" + toolName);
template.setMethod("GET");
Header header = new Header();
header.setKey("Content-Type");
header.setValue("application/json");
template.getHeaders().add(header);
return template;
}
private ResponseTemplate buildDefaultResponseTemplate() {
ResponseTemplate template = new ResponseTemplate();
template.setBody("");
return template;
}
private String getStringValue(JsonNode node, String fieldName) {
return node.has(fieldName) && !node.get(fieldName).isNull() ?
node.get(fieldName).asText() : null;
}
private boolean getBooleanValue(JsonNode node, String fieldName, boolean defaultValue) {
return node.has(fieldName) && !node.get(fieldName).isNull() ?
node.get(fieldName).asBoolean() : defaultValue;
}
@Data
public static class Server {
private String name;
private Map<String, Object> config = new HashMap<>();
}
@Data
public static class Tool {
private String name;
private String description;
private List<Arg> args = new ArrayList<>();
private RequestTemplate requestTemplate;
private ResponseTemplate responseTemplate;
}
@Data
public static class Arg {
private String name;
private String description;
private String type;
private boolean required;
private String position;
private String defaultValue;
private List<String> enumValues;
}
@Data
public static class RequestTemplate {
private String url;
private String method;
private List<Header> headers = new ArrayList<>();
}
@Data
public static class ResponseTemplate {
private String body;
}
@Data
public static class Header {
private String key;
private String value;
}
}
```
--------------------------------------------------------------------------------
/portal-web/api-portal-admin/src/components/api-product/SwaggerUIWrapper.css:
--------------------------------------------------------------------------------
```css
/* Swagger UI 自定义样式 */
.swagger-ui-wrapper {
/* 隐藏顶部的信息栏,因为我们已经在上层显示了 */
.swagger-ui .info {
display: none;
}
/* 完全隐藏服务器选择器的容器样式 */
.swagger-ui .scheme-container {
padding: 0;
background: transparent;
border: none;
margin-bottom: 16px;
position: relative;
box-shadow: none;
}
/* 隐藏服务器区域的所有边框和背景 */
.swagger-ui .scheme-container > div {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
}
/* 服务器URL样式优化 */
.swagger-ui .servers-title {
font-weight: 600;
margin-bottom: 8px;
color: #262626;
}
.swagger-ui .servers select {
font-family: Monaco, Consolas, monospace;
background: white;
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 8px 40px 8px 12px;
font-size: 14px;
color: #1890ff;
cursor: pointer;
min-width: 300px;
position: relative;
}
.swagger-ui .servers select:focus {
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
outline: none;
}
/* 服务器选择器容器 */
.swagger-ui .servers {
position: relative;
}
/* 调整操作项的样式 */
.swagger-ui .opblock {
border-radius: 6px;
border: 1px solid #e5e7eb;
margin-bottom: 8px;
box-shadow: none;
width: 100%;
margin-left: 0;
margin-right: 0;
}
.swagger-ui .opblock.opblock-get {
border-color: #61affe;
background: rgba(97, 175, 254, 0.03);
}
.swagger-ui .opblock.opblock-post {
border-color: #49cc90;
background: rgba(73, 204, 144, 0.03);
}
.swagger-ui .opblock.opblock-put {
border-color: #fca130;
background: rgba(252, 161, 48, 0.03);
}
.swagger-ui .opblock.opblock-delete {
border-color: #f93e3e;
background: rgba(249, 62, 62, 0.03);
}
.swagger-ui .opblock.opblock-patch {
border-color: #50e3c2;
background: rgba(80, 227, 194, 0.03);
}
/* 调整展开的操作项样式 */
.swagger-ui .opblock.is-open {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* 调整参数表格样式 */
.swagger-ui .parameters-container {
background: transparent;
}
.swagger-ui .parameter__name {
font-family: Monaco, Consolas, monospace;
font-weight: 600;
}
/* 调整响应区域样式 */
.swagger-ui .responses-wrapper {
background: transparent;
}
/* 调整Try it out按钮 */
.swagger-ui .btn.try-out__btn {
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
padding: 6px 16px;
font-size: 14px;
}
.swagger-ui .btn.try-out__btn:hover {
background: #40a9ff;
}
/* 调整Execute按钮 */
.swagger-ui .btn.execute {
background: #52c41a;
color: white;
border: none;
border-radius: 4px;
padding: 8px 20px;
font-size: 14px;
font-weight: 500;
}
.swagger-ui .btn.execute:hover {
background: #73d13d;
}
/* 调整Clear按钮 */
.swagger-ui .btn.btn-clear {
background: #ff4d4f;
color: white;
border: none;
border-radius: 4px;
padding: 6px 16px;
font-size: 14px;
}
.swagger-ui .btn.btn-clear:hover {
background: #ff7875;
}
/* 调整模型区域 */
.swagger-ui .model-container {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
}
/* 调整代码高亮 */
.swagger-ui .highlight-code {
background: #2d3748;
border-radius: 4px;
}
/* 调整输入框样式 */
.swagger-ui input[type=text],
.swagger-ui input[type=password],
.swagger-ui input[type=search],
.swagger-ui input[type=email],
.swagger-ui input[type=url],
.swagger-ui input[type=number] {
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 6px 11px;
font-size: 14px;
line-height: 1.5;
}
.swagger-ui input[type=text]:focus,
.swagger-ui input[type=password]:focus,
.swagger-ui input[type=search]:focus,
.swagger-ui input[type=email]:focus,
.swagger-ui input[type=url]:focus,
.swagger-ui input[type=number]:focus {
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
outline: none;
}
/* 调整文本域样式 */
.swagger-ui textarea {
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 6px 11px;
font-size: 14px;
line-height: 1.5;
}
.swagger-ui textarea:focus {
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
outline: none;
}
/* 调整下拉选择样式 */
.swagger-ui select {
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 6px 11px;
font-size: 14px;
line-height: 1.5;
}
.swagger-ui select:focus {
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
outline: none;
}
/* 隐藏授权部分(如果不需要) */
.swagger-ui .auth-wrapper {
display: none;
}
/* 调整整体字体 */
.swagger-ui {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
/* 调整标题样式 */
.swagger-ui .opblock-summary-description {
font-size: 14px;
color: #666;
}
/* 调整HTTP方法标签 */
.swagger-ui .opblock-summary-method {
font-weight: bold;
text-transform: uppercase;
border-radius: 3px;
padding: 6px 12px;
font-size: 12px;
min-width: 60px;
text-align: center;
}
/* 调整路径显示 */
.swagger-ui .opblock-summary-path {
font-family: Monaco, Consolas, monospace;
font-size: 16px;
font-weight: 500;
}
/* 移除不必要的边距 */
.swagger-ui .swagger-container {
max-width: none !important;
width: 100% !important;
padding: 0;
margin: 0;
}
/* 调整顶层wrapper */
.swagger-ui .wrapper {
padding: 0;
margin: 0;
width: 100% !important;
max-width: none !important;
}
/* 移除左侧空白 */
.swagger-ui .information-container {
margin: 0;
padding: 0;
}
/* 移除整体左边距 */
.swagger-ui {
margin-left: 0 !important;
padding-left: 0 !important;
}
/* 移除操作块的左边距 */
.swagger-ui .opblock-tag-section {
margin-left: 0 !important;
padding-left: 0 !important;
margin-right: 0 !important;
padding-right: 0 !important;
width: 100% !important;
}
/* 确保接口标签区域占满宽度 */
.swagger-ui .opblock-tag {
width: 100%;
margin: 0;
}
/* 强制所有Swagger UI容器占满宽度 */
.swagger-ui-wrapper {
width: 100% !important;
}
.swagger-ui {
width: 100% !important;
max-width: none !important;
}
.swagger-ui .info {
width: 100% !important;
}
.swagger-ui .scheme-container {
width: 100% !important;
max-width: none !important;
}
/* 强制内容区域占满宽度 */
.swagger-ui .swagger-container .wrapper {
width: 100% !important;
max-width: none !important;
}
/* 强制操作列表容器占满宽度 */
.swagger-ui .swagger-container .wrapper .col-12 {
width: 100% !important;
max-width: none !important;
flex: 0 0 100% !important;
}
/* Servers标题样式 */
.swagger-ui .servers-title {
font-size: 14px !important;
font-weight: 500 !important;
margin-bottom: 8px !important;
color: #262626 !important;
text-align: left !important;
}
/* 接口列表标题样式 */
.swagger-ui .opblock-tag {
font-size: 16px !important;
font-weight: 500 !important;
text-align: left !important;
margin-left: 0 !important;
}
/* 去掉复制按钮的边框 */
.copy-server-btn {
border: none !important;
background: transparent !important;
padding: 6px 8px !important;
color: #666 !important;
transition: all 0.2s !important;
}
.copy-server-btn:hover {
background: #f5f5f5 !important;
color: #1890ff !important;
}
/* 调整接口列表与上方分割线的距离 */
.swagger-ui .opblock-tag-section {
margin-top: 20px !important;
}
/* 调整分割线样式,确保与接口边框分开 */
.swagger-ui .opblock-tag h3 {
margin-bottom: 20px !important;
padding-bottom: 12px !important;
border-bottom: 1px solid #e8e8e8 !important;
}
/* 确保第一个接口容器与分割线有足够间距 */
.swagger-ui .opblock-tag-section .opblock:first-child {
margin-top: 16px !important;
}
}
```
--------------------------------------------------------------------------------
/portal-web/api-portal-frontend/src/components/SwaggerUIWrapper.css:
--------------------------------------------------------------------------------
```css
/* Swagger UI 自定义样式 */
.swagger-ui-wrapper {
/* 隐藏顶部的信息栏,因为我们已经在上层显示了 */
.swagger-ui .info {
display: none;
}
/* 完全隐藏服务器选择器的容器样式 */
.swagger-ui .scheme-container {
padding: 0;
background: transparent;
border: none;
margin-bottom: 16px;
position: relative;
box-shadow: none;
}
/* 隐藏服务器区域的所有边框和背景 */
.swagger-ui .scheme-container > div {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
}
/* 服务器URL样式优化 */
.swagger-ui .servers-title {
font-weight: 600;
margin-bottom: 8px;
color: #262626;
}
.swagger-ui .servers select {
font-family: Monaco, Consolas, monospace;
background: white;
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 8px 40px 8px 12px;
font-size: 14px;
color: #1890ff;
cursor: pointer;
min-width: 300px;
position: relative;
}
.swagger-ui .servers select:focus {
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
outline: none;
}
/* 服务器选择器容器 */
.swagger-ui .servers {
position: relative;
}
/* 调整操作项的样式 */
.swagger-ui .opblock {
border-radius: 6px;
border: 1px solid #e5e7eb;
margin-bottom: 8px;
box-shadow: none;
width: 100%;
margin-left: 0;
margin-right: 0;
}
.swagger-ui .opblock.opblock-get {
border-color: #61affe;
background: rgba(97, 175, 254, 0.03);
}
.swagger-ui .opblock.opblock-post {
border-color: #49cc90;
background: rgba(73, 204, 144, 0.03);
}
.swagger-ui .opblock.opblock-put {
border-color: #fca130;
background: rgba(252, 161, 48, 0.03);
}
.swagger-ui .opblock.opblock-delete {
border-color: #f93e3e;
background: rgba(249, 62, 62, 0.03);
}
.swagger-ui .opblock.opblock-patch {
border-color: #50e3c2;
background: rgba(80, 227, 194, 0.03);
}
/* 调整展开的操作项样式 */
.swagger-ui .opblock.is-open {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* 调整参数表格样式 */
.swagger-ui .parameters-container {
background: transparent;
}
.swagger-ui .parameter__name {
font-family: Monaco, Consolas, monospace;
font-weight: 600;
}
/* 调整响应区域样式 */
.swagger-ui .responses-wrapper {
background: transparent;
}
/* 调整Try it out按钮 */
.swagger-ui .btn.try-out__btn {
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
padding: 6px 16px;
font-size: 14px;
}
.swagger-ui .btn.try-out__btn:hover {
background: #40a9ff;
}
/* 调整Execute按钮 */
.swagger-ui .btn.execute {
background: #52c41a;
color: white;
border: none;
border-radius: 4px;
padding: 8px 20px;
font-size: 14px;
font-weight: 500;
}
.swagger-ui .btn.execute:hover {
background: #73d13d;
}
/* 调整Clear按钮 */
.swagger-ui .btn.btn-clear {
background: #ff4d4f;
color: white;
border: none;
border-radius: 4px;
padding: 6px 16px;
font-size: 14px;
}
.swagger-ui .btn.btn-clear:hover {
background: #ff7875;
}
/* 调整模型区域 */
.swagger-ui .model-container {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
}
/* 调整代码高亮 */
.swagger-ui .highlight-code {
background: #2d3748;
border-radius: 4px;
}
/* 调整输入框样式 */
.swagger-ui input[type=text],
.swagger-ui input[type=password],
.swagger-ui input[type=search],
.swagger-ui input[type=email],
.swagger-ui input[type=url],
.swagger-ui input[type=number] {
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 6px 11px;
font-size: 14px;
line-height: 1.5;
}
.swagger-ui input[type=text]:focus,
.swagger-ui input[type=password]:focus,
.swagger-ui input[type=search]:focus,
.swagger-ui input[type=email]:focus,
.swagger-ui input[type=url]:focus,
.swagger-ui input[type=number]:focus {
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
outline: none;
}
/* 调整文本域样式 */
.swagger-ui textarea {
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 6px 11px;
font-size: 14px;
line-height: 1.5;
}
.swagger-ui textarea:focus {
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
outline: none;
}
/* 调整下拉选择样式 */
.swagger-ui select {
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 6px 11px;
font-size: 14px;
line-height: 1.5;
}
.swagger-ui select:focus {
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
outline: none;
}
/* 隐藏授权部分(如果不需要) */
.swagger-ui .auth-wrapper {
display: none;
}
/* 调整整体字体 */
.swagger-ui {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
/* 调整标题样式 */
.swagger-ui .opblock-summary-description {
font-size: 14px;
color: #666;
}
/* 调整HTTP方法标签 */
.swagger-ui .opblock-summary-method {
font-weight: bold;
text-transform: uppercase;
border-radius: 3px;
padding: 6px 12px;
font-size: 12px;
min-width: 60px;
text-align: center;
}
/* 调整路径显示 */
.swagger-ui .opblock-summary-path {
font-family: Monaco, Consolas, monospace;
font-size: 16px;
font-weight: 500;
}
/* 移除不必要的边距 */
.swagger-ui .swagger-container {
max-width: none !important;
width: 100% !important;
padding: 0;
margin: 0;
}
/* 调整顶层wrapper */
.swagger-ui .wrapper {
padding: 0;
margin: 0;
width: 100% !important;
max-width: none !important;
}
/* 移除左侧空白 */
.swagger-ui .information-container {
margin: 0;
padding: 0;
}
/* 移除整体左边距 */
.swagger-ui {
margin-left: 0 !important;
padding-left: 0 !important;
}
/* 移除操作块的左边距 */
.swagger-ui .opblock-tag-section {
margin-left: 0 !important;
padding-left: 0 !important;
margin-right: 0 !important;
padding-right: 0 !important;
width: 100% !important;
}
/* 确保接口标签区域占满宽度 */
.swagger-ui .opblock-tag {
width: 100%;
margin: 0;
}
/* 强制所有Swagger UI容器占满宽度 */
.swagger-ui-wrapper {
width: 100% !important;
}
.swagger-ui {
width: 100% !important;
max-width: none !important;
}
.swagger-ui .info {
width: 100% !important;
}
.swagger-ui .scheme-container {
width: 100% !important;
max-width: none !important;
}
/* 强制内容区域占满宽度 */
.swagger-ui .swagger-container .wrapper {
width: 100% !important;
max-width: none !important;
}
/* 强制操作列表容器占满宽度 */
.swagger-ui .swagger-container .wrapper .col-12 {
width: 100% !important;
max-width: none !important;
flex: 0 0 100% !important;
}
/* Servers标题样式 */
.swagger-ui .servers-title {
font-size: 14px !important;
font-weight: 500 !important;
margin-bottom: 8px !important;
color: #262626 !important;
text-align: left !important;
}
/* 接口列表标题样式 */
.swagger-ui .opblock-tag {
font-size: 16px !important;
font-weight: 500 !important;
text-align: left !important;
margin-left: 0 !important;
}
/* 去掉复制按钮的边框 */
.copy-server-btn {
border: none !important;
background: transparent !important;
padding: 6px 8px !important;
color: #666 !important;
transition: all 0.2s !important;
}
.copy-server-btn:hover {
background: #f5f5f5 !important;
color: #1890ff !important;
}
/* 调整接口列表与上方分割线的距离 */
.swagger-ui .opblock-tag-section {
margin-top: 20px !important;
}
/* 调整分割线样式,确保与接口边框分开 */
.swagger-ui .opblock-tag h3 {
margin-bottom: 20px !important;
padding-bottom: 12px !important;
border-bottom: 1px solid #e8e8e8 !important;
}
/* 确保第一个接口容器与分割线有足够间距 */
.swagger-ui .opblock-tag-section .opblock:first-child {
margin-top: 16px !important;
}
}
```
--------------------------------------------------------------------------------
/portal-server/src/main/java/com/alibaba/apiopenplatform/service/gateway/client/HigressClient.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.client;
import cn.hutool.core.map.MapBuilder;
import cn.hutool.json.JSONUtil;
import com.alibaba.apiopenplatform.service.gateway.HigressOperator;
import com.alibaba.apiopenplatform.service.gateway.factory.HTTPClientFactory;
import com.alibaba.apiopenplatform.support.gateway.HigressConfig;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.*;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
import java.util.List;
import java.util.Map;
@Slf4j
public class HigressClient extends GatewayClient {
private static final String HIGRESS_COOKIE_NAME = "_hi_sess";
private final RestTemplate restTemplate;
private final HigressConfig config;
private String higressToken;
private final ThreadLocal<Boolean> isRetrying = new ThreadLocal<>();
public HigressClient(HigressConfig higressConfig) {
this.config = higressConfig;
this.restTemplate = HTTPClientFactory.createRestTemplate();
}
public <T, R> T execute(String path,
HttpMethod method,
Map<String, String> queryParams,
R body,
ParameterizedTypeReference<T> responseType) {
return execute(path, method, null, queryParams, body, responseType);
}
public <T, R> T execute(String path,
HttpMethod method,
Map<String, String> queryParams,
R body,
Class<T> responseType) {
return execute(path, method, queryParams, body,
ParameterizedTypeReference.forType(responseType));
}
public <T, R> T execute(String path,
HttpMethod method,
HttpHeaders headers,
Map<String, String> queryParams,
R body,
ParameterizedTypeReference<T> responseType) {
try {
return doExecute(path, method, headers, queryParams, body, responseType);
} finally {
isRetrying.remove();
}
}
private <T, R> T doExecute(String path,
HttpMethod method,
HttpHeaders headers,
Map<String, String> queryParams,
R body,
ParameterizedTypeReference<T> responseType) {
try {
ensureConsoleToken();
// 构建URL
String url = buildUrlWithParams(path, queryParams);
// Headers
HttpHeaders mergedHeaders = new HttpHeaders();
if (headers != null) {
mergedHeaders.putAll(headers);
}
mergedHeaders.add("Cookie", HIGRESS_COOKIE_NAME + "=" + higressToken);
ResponseEntity<T> response = restTemplate.exchange(
url,
method,
new HttpEntity<>(body, mergedHeaders),
responseType
);
log.info("Higress response: status={}, body={}",
response.getStatusCode(), JSONUtil.toJsonStr(response.getBody()));
return response.getBody();
} catch (HttpClientErrorException e) {
// 401重新登录,且只重试一次
if (e.getStatusCode() == HttpStatus.UNAUTHORIZED
&& !Boolean.TRUE.equals(isRetrying.get())) {
log.warn("Token expired, trying to relogin");
higressToken = null;
isRetrying.set(true);
return doExecute(path, method, headers, queryParams, body, responseType);
}
log.error("HTTP error executing Higress request: status={}, body={}",
e.getStatusCode(), e.getResponseBodyAsString());
throw e;
} catch (Exception e) {
log.error("Error executing Higress request: {}", e.getMessage());
throw new RuntimeException("Failed to execute Higress request", e);
}
}
private String buildUrlWithParams(String path, Map<String, String> queryParams) {
StringBuilder url = new StringBuilder(buildUrl(path));
if (queryParams != null && !queryParams.isEmpty()) {
url.append('?');
queryParams.forEach((key, value) -> {
if (url.charAt(url.length() - 1) != '?') {
url.append('&');
}
url.append(key).append('=').append(value);
});
}
return url.toString();
}
private String buildUrl(String path) {
String baseUrl = config.getAddress();
baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
path = path.startsWith("/") ? path : "/" + path;
return baseUrl + path;
}
private void ensureConsoleToken() {
if (higressToken == null) {
login();
}
}
private void login() {
Map<Object, Object> loginParam = MapBuilder.create()
.put("username", config.getUsername())
.put("password", config.getPassword())
.build();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
ResponseEntity<String> response = restTemplate.exchange(
buildUrl("/session/login"),
HttpMethod.POST,
new HttpEntity<>(loginParam, headers),
String.class
);
List<String> cookies = response.getHeaders().get("Set-Cookie");
if (cookies == null || cookies.isEmpty()) {
throw new RuntimeException("No cookies received from server");
}
this.higressToken = cookies.stream()
.filter(cookie -> cookie.startsWith(HIGRESS_COOKIE_NAME + "="))
.findFirst()
.map(cookie -> {
int endIndex = cookie.indexOf(';');
return endIndex == -1
? cookie.substring(HIGRESS_COOKIE_NAME.length() + 1)
: cookie.substring(HIGRESS_COOKIE_NAME.length() + 1, endIndex);
})
.orElseThrow(() -> new RuntimeException("Failed to get Higress session token"));
}
@Override
public void close() {
HTTPClientFactory.closeClient(restTemplate);
}
public static void main(String[] args) {
HigressConfig higressConfig = new HigressConfig();
higressConfig.setAddress("http://demo.higress.io");
higressConfig.setUsername("admin");
higressConfig.setPassword("admin");
HigressClient higressClient = new HigressClient(higressConfig);
// Object mcpServerInfo = higressClient.execute("/v1/mcpServer", HttpMethod.GET, null, null, new ParameterizedTypeReference<Object>() {
// });
HigressOperator.HigressPageResponse<HigressOperator.HigressMCPConfig> response = higressClient.execute("/v1/mcpServer", HttpMethod.GET, null, null, new ParameterizedTypeReference<HigressOperator.HigressPageResponse<HigressOperator.HigressMCPConfig>>() {
});
System.out.println(JSONUtil.toJsonStr(response));
}
}
```
--------------------------------------------------------------------------------
/portal-web/api-portal-admin/src/components/portal/PortalOverview.tsx:
--------------------------------------------------------------------------------
```typescript
import {Card, Row, Col, Statistic, Button, message} from 'antd'
import {
UserOutlined,
ApiOutlined,
LinkOutlined,
CheckCircleFilled,
MinusCircleFilled,
EditOutlined,
CopyOutlined
} from '@ant-design/icons'
import {Portal} from '@/types'
import {useState, useEffect} from 'react'
import {portalApi, apiProductApi} from '@/lib/api'
import {copyToClipboard} from '@/lib/utils'
import {useNavigate} from 'react-router-dom'
interface PortalOverviewProps {
portal: Portal
onEdit?: () => void
}
export function PortalOverview({portal, onEdit}: PortalOverviewProps) {
const navigate = useNavigate()
const [apiCount, setApiCount] = useState(0)
const [developerCount, setDeveloperCount] = useState(0)
useEffect(() => {
if (!portal.portalId) return;
portalApi.getDeveloperList(portal.portalId, {
page: 1,
size: 10
}).then((res: any) => {
setDeveloperCount(res.data.totalElements || 0)
})
apiProductApi.getApiProducts({
portalId: portal.portalId,
page: 1,
size: 10
}).then((res: any) => {
setApiCount(res.data.totalElements || 0)
})
}, [portal.portalId]) // 只依赖portalId,而不是整个portal对象
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold mb-2">概览</h1>
<p className="text-gray-600">Portal概览</p>
</div>
{/* 基本信息 */}
<Card
title="基本信息"
extra={
onEdit && (
<Button
type="primary"
icon={<EditOutlined />}
onClick={onEdit}
>
编辑
</Button>
)
}
>
<div>
<div className="grid grid-cols-6 gap-8 items-center pt-0 pb-2">
<span className="text-xs text-gray-600">Portal名称:</span>
<span className="col-span-2 text-xs text-gray-900">{portal.name}</span>
<span className="text-xs text-gray-600">Portal ID:</span>
<div className="col-span-2 flex items-center gap-2">
<span className="text-xs text-gray-700">{portal.portalId}</span>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={async () => {
try {
await copyToClipboard(portal.portalId);
message.success('Portal ID已复制');
} catch {
message.error('复制失败,请手动复制');
}
}}
className="h-auto p-1 min-w-0"
/>
</div>
</div>
<div className="grid grid-cols-6 gap-8 items-center pt-2 pb-2">
<span className="text-xs text-gray-600">域名:</span>
<div className="col-span-2 flex items-center gap-2">
<LinkOutlined className="text-blue-500" />
<a
href={`http://${portal.portalDomainConfig?.[0]?.domain}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:underline"
>
{portal.portalDomainConfig?.[0]?.domain}
</a>
</div>
<span className="text-xs text-gray-600">账号密码登录:</span>
<div className="col-span-2 flex items-center">
{portal.portalSettingConfig?.builtinAuthEnabled ? (
<CheckCircleFilled className="text-green-500 mr-2" style={{fontSize: '10px'}} />
) : (
<MinusCircleFilled className="text-gray-400 mr-2" style={{fontSize: '10px'}} />
)}
<span className="text-xs text-gray-900">
{portal.portalSettingConfig?.builtinAuthEnabled ? '已启用' : '已停用'}
</span>
</div>
</div>
<div className="grid grid-cols-6 gap-8 items-center pt-2 pb-2">
<span className="text-xs text-gray-600">开发者自动审批:</span>
<div className="col-span-2 flex items-center">
{portal.portalSettingConfig?.autoApproveDevelopers ? (
<CheckCircleFilled className="text-green-500 mr-2" style={{fontSize: '10px'}} />
) : (
<MinusCircleFilled className="text-gray-400 mr-2" style={{fontSize: '10px'}} />
)}
<span className="text-xs text-gray-900">
{portal.portalSettingConfig?.autoApproveDevelopers ? '已启用' : '已停用'}
</span>
</div>
<span className="text-xs text-gray-600">订阅自动审批:</span>
<div className="col-span-2 flex items-center">
{portal.portalSettingConfig?.autoApproveSubscriptions ? (
<CheckCircleFilled className="text-green-500 mr-2" style={{fontSize: '10px'}} />
) : (
<MinusCircleFilled className="text-gray-400 mr-2" style={{fontSize: '10px'}} />
)}
<span className="text-xs text-gray-900">
{portal.portalSettingConfig?.autoApproveSubscriptions ? '已启用' : '已停用'}
</span>
</div>
</div>
<div className="grid grid-cols-6 gap-8 items-start pt-2 pb-2">
<span className="text-xs text-gray-600">描述:</span>
<span className="col-span-5 text-xs text-gray-900 leading-relaxed">
{portal.description || '-'}
</span>
</div>
</div>
</Card>
{/* 统计数据 */}
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={12}>
<Card
className="cursor-pointer hover:shadow-md transition-shadow"
onClick={() => {
navigate(`/portals/detail?id=${portal.portalId}&tab=developers`)
}}
>
<Statistic
title="注册开发者"
value={developerCount}
prefix={<UserOutlined className="text-blue-500" />}
valueStyle={{ color: '#1677ff', fontSize: '24px' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={12}>
<Card
className="cursor-pointer hover:shadow-md transition-shadow"
onClick={() => {
navigate(`/portals/detail?id=${portal.portalId}&tab=published-apis`)
}}
>
<Statistic
title="已发布的API"
value={apiCount}
prefix={<ApiOutlined className="text-blue-500" />}
valueStyle={{ color: '#1677ff', fontSize: '24px' }}
/>
</Card>
</Col>
</Row>
</div>
)
}
```
--------------------------------------------------------------------------------
/portal-web/api-portal-frontend/src/components/consumer/SubscriptionManager.tsx:
--------------------------------------------------------------------------------
```typescript
import { useState } from "react";
import {
Card,
Button,
message,
Input,
Modal,
Table,
Badge,
Popconfirm,
Select,
} from "antd";
import {
PlusOutlined,
} from "@ant-design/icons";
import api from "../../lib/api";
import type { Subscription } from "../../types/consumer";
import type { ApiResponse, Product } from "../../types";
import { getSubscriptionStatusText, getSubscriptionStatusColor } from "../../lib/statusUtils";
import { formatDateTime } from "../../lib/utils";
interface SubscriptionManagerProps {
consumerId: string;
subscriptions: Subscription[];
onSubscriptionsChange: (searchParams?: { productName: string; status: string }) => void;
loading?: boolean;
}
export function SubscriptionManager({ consumerId, subscriptions, onSubscriptionsChange, loading = false }: SubscriptionManagerProps) {
const [productModalVisible, setProductModalVisible] = useState(false);
const [filteredProducts, setFilteredProducts] = useState<Product[]>([]);
const [productLoading, setProductLoading] = useState(false);
const [subscribeLoading, setSubscribeLoading] = useState(false);
const [selectedProduct, setSelectedProduct] = useState<string>('');
const [subscriptionSearch, setSubscriptionSearch] = useState({ productName: '', status: '' as 'PENDING' | 'APPROVED' | '' });
// 过滤产品:移除已订阅的产品
const filterProducts = (allProducts: Product[]) => {
// 获取已订阅的产品ID列表
const subscribedProductIds = subscriptions.map(sub => sub.productId);
// 过滤掉已订阅的产品
return allProducts.filter(product =>
!subscribedProductIds.includes(product.productId)
);
};
const openProductModal = async () => {
setProductModalVisible(true);
setProductLoading(true);
try {
const response: ApiResponse<{ content: Product[] }> = await api.get("/products?page=0&size=100");
if (response?.code === "SUCCESS" && response?.data) {
const allProducts = response.data.content || [];
// 初始化时过滤掉已订阅的产品
const filtered = filterProducts(allProducts);
setFilteredProducts(filtered);
}
} catch (error) {
console.error('获取产品列表失败:', error);
// message.error('获取产品列表失败');
} finally {
setProductLoading(false);
}
};
const handleSubscribeProducts = async () => {
if (!selectedProduct) {
message.warning('请选择要订阅的产品');
return;
}
setSubscribeLoading(true);
try {
await api.post(`/consumers/${consumerId}/subscriptions`, { productId: selectedProduct });
message.success('订阅成功');
setProductModalVisible(false);
setSelectedProduct('');
onSubscriptionsChange();
} catch (error) {
console.error('订阅失败:', error);
// message.error('订阅失败');
} finally {
setSubscribeLoading(false);
}
};
const handleUnsubscribe = async (productId: string) => {
try {
await api.delete(`/consumers/${consumerId}/subscriptions/${productId}`);
message.success('取消订阅成功');
onSubscriptionsChange();
} catch (error) {
console.error('取消订阅失败:', error);
// message.error('取消订阅失败');
}
};
const subscriptionColumns = [
{
title: '产品名称',
dataIndex: 'productName',
key: 'productName',
render: (productName: Product['productName']) => productName || '-',
},
{
title: '产品类型',
dataIndex: 'productType',
key: 'productType',
render: (productType: Product['productType']) => {
const typeMap = {
'REST_API': 'REST API',
'HTTP_API': 'HTTP API',
'MCP_SERVER': 'MCP Server'
};
return typeMap[productType as keyof typeof typeMap] || productType || '-';
}
},
{
title: '订阅状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => (
<Badge status={getSubscriptionStatusColor(status) as 'success' | 'processing' | 'error' | 'default' | 'warning'} text={getSubscriptionStatusText(status)} />
),
},
{
title: '订阅时间',
dataIndex: 'createAt',
key: 'createAt',
render: (date: string) => date ? formatDateTime(date) : '-',
},
{
title: '操作',
key: 'action',
render: (record: Subscription) => (
<Popconfirm
title="确定要取消订阅吗?"
onConfirm={() => handleUnsubscribe(record.productId)}
>
<Button type="link" danger size="small">
取消订阅
</Button>
</Popconfirm>
),
},
];
// 确保 subscriptions 始终是数组
const safeSubscriptions = Array.isArray(subscriptions) ? subscriptions : [];
return (
<>
<Card>
<div className="mb-4 flex justify-between items-center">
<div className="flex space-x-4">
<Button
type="primary"
icon={<PlusOutlined />}
onClick={openProductModal}
>
订阅
</Button>
<Input.Search
placeholder="请输入API名称进行搜索"
style={{ width: 300 }}
onSearch={(value) => {
const newSearch = { ...subscriptionSearch, productName: value };
setSubscriptionSearch(newSearch);
onSubscriptionsChange(newSearch);
}}
/>
<Select
placeholder="订阅状态"
style={{ width: 120 }}
allowClear
value={subscriptionSearch.status || undefined}
onChange={(value) => {
const newSearch = { ...subscriptionSearch, status: value as 'PENDING' | 'APPROVED' | '' };
setSubscriptionSearch(newSearch);
onSubscriptionsChange(newSearch);
}}
>
<Select.Option value="PENDING">待审批</Select.Option>
<Select.Option value="APPROVED">已通过</Select.Option>
</Select>
</div>
</div>
<Table
columns={subscriptionColumns}
dataSource={safeSubscriptions}
rowKey={(record) => record.productId}
pagination={false}
size="small"
loading={loading}
locale={{ emptyText: '暂无订阅记录,请点击上方按钮进行订阅' }}
/>
</Card>
{/* 产品选择弹窗 */}
<Modal
title="订阅产品"
open={productModalVisible}
onCancel={() => {
if (!subscribeLoading) {
setProductModalVisible(false);
setSelectedProduct('');
}
}}
footer={
<div className="flex justify-end space-x-2">
<Button
onClick={() => {
if (!subscribeLoading) {
setProductModalVisible(false);
setSelectedProduct('');
}
}}
disabled={subscribeLoading}
>
取消
</Button>
<Button
type="primary"
onClick={handleSubscribeProducts}
disabled={!selectedProduct}
loading={subscribeLoading}
>
确定订阅
</Button>
</div>
}
width={500}
styles={{
content: {
borderRadius: '8px',
padding: 0
},
header: {
borderRadius: '8px 8px 0 0',
marginBottom: 0,
paddingBottom: '8px'
},
body: {
padding: '24px'
}
}}
>
<div>
<div className="text-sm text-gray-700 mb-3 font-medium">选择要订阅的产品:</div>
<Select
placeholder="请输入产品名称进行搜索或直接选择"
style={{ width: '100%' }}
value={selectedProduct}
onChange={setSelectedProduct}
loading={productLoading}
showSearch={true}
filterOption={(input, option) => {
const product = filteredProducts.find(p => p.productId === option?.value);
if (!product) return false;
const searchText = input.toLowerCase();
return (
product.name?.toLowerCase().includes(searchText) ||
product.description?.toLowerCase().includes(searchText)
);
}}
notFoundContent={productLoading ? '加载中...' : '暂无可订阅的产品'}
>
{filteredProducts.map(product => (
<Select.Option key={product.productId} value={product.productId}>
{product.name}
</Select.Option>
))}
</Select>
</div>
</Modal>
</>
);
}
```
--------------------------------------------------------------------------------
/portal-server/src/main/java/com/alibaba/apiopenplatform/service/impl/OAuth2ServiceImpl.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.convert.Convert;
import cn.hutool.core.util.EnumUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.jwt.JWT;
import cn.hutool.jwt.JWTUtil;
import cn.hutool.jwt.signers.JWTSigner;
import cn.hutool.jwt.signers.JWTSignerUtil;
import com.alibaba.apiopenplatform.core.constant.JwtConstants;
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.utils.TokenUtil;
import com.alibaba.apiopenplatform.dto.params.developer.CreateExternalDeveloperParam;
import com.alibaba.apiopenplatform.dto.result.AuthResult;
import com.alibaba.apiopenplatform.dto.result.DeveloperResult;
import com.alibaba.apiopenplatform.dto.result.PortalResult;
import com.alibaba.apiopenplatform.service.DeveloperService;
import com.alibaba.apiopenplatform.service.IdpService;
import com.alibaba.apiopenplatform.service.OAuth2Service;
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.enums.JwtAlgorithm;
import com.alibaba.apiopenplatform.support.portal.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.security.PublicKey;
import java.util.*;
/**
* @author zh
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class OAuth2ServiceImpl implements OAuth2Service {
private final PortalService portalService;
private final DeveloperService developerService;
private final IdpService idpService;
@Override
public AuthResult authenticate(String grantType, String jwtToken) {
if (!GrantType.JWT_BEARER.getType().equals(grantType)) {
throw new BusinessException(ErrorCode.INVALID_REQUEST, "不支持的授权模式");
}
// 解析JWT
JWT jwt = JWTUtil.parseToken(jwtToken);
String kid = (String) jwt.getHeader(JwtConstants.HEADER_KID);
if (StrUtil.isBlank(kid)) {
throw new BusinessException(ErrorCode.INVALID_REQUEST, "JWT header缺少字段kid");
}
String provider = (String) jwt.getPayload(JwtConstants.PAYLOAD_PROVIDER);
if (StrUtil.isBlank(provider)) {
throw new BusinessException(ErrorCode.INVALID_REQUEST, "JWT payload缺少字段provider");
}
String portalId = (String) jwt.getPayload(JwtConstants.PAYLOAD_PORTAL);
if (StrUtil.isBlank(portalId)) {
throw new BusinessException(ErrorCode.INVALID_REQUEST, "JWT payload缺少字段portal");
}
// 根据provider确定OAuth2配置
PortalResult portal = portalService.getPortal(portalId);
List<OAuth2Config> oauth2Configs = Optional.ofNullable(portal.getPortalSettingConfig())
.map(PortalSettingConfig::getOauth2Configs)
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.OAUTH2_CONFIG, portalId));
OAuth2Config oAuth2Config = oauth2Configs.stream()
// JWT Bearer模式
.filter(config -> config.getGrantType() == GrantType.JWT_BEARER)
.filter(config -> config.getJwtBearerConfig() != null
&& CollUtil.isNotEmpty(config.getJwtBearerConfig().getPublicKeys()))
// provider标识
.filter(config -> config.getProvider().equals(provider))
.findFirst()
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.OAUTH2_CONFIG, provider));
// 根据kid找到对应公钥
JwtBearerConfig jwtConfig = oAuth2Config.getJwtBearerConfig();
PublicKeyConfig publicKeyConfig = jwtConfig.getPublicKeys().stream()
.filter(key -> kid.equals(key.getKid()))
.findFirst()
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.PUBLIC_KEY, kid));
// 验签
if (!verifySignature(jwt, publicKeyConfig)) {
throw new BusinessException(ErrorCode.INVALID_REQUEST, "JWT签名验证失败");
}
// 验证Claims
validateJwtClaims(jwt);
// Developer
String developerId = createOrGetDeveloper(jwt, oAuth2Config);
// 生成Access Token
String accessToken = TokenUtil.generateDeveloperToken(developerId);
log.info("JWT Bearer认证成功,provider: {}, developer: {}", oAuth2Config.getProvider(), developerId);
return AuthResult.of(accessToken, TokenUtil.getTokenExpiresIn());
}
private boolean verifySignature(JWT jwt, PublicKeyConfig keyConfig) {
// 加载公钥
PublicKey publicKey = idpService.loadPublicKey(keyConfig.getFormat(), keyConfig.getValue());
// 验证JWT
JWTSigner signer = createJWTSigner(keyConfig.getAlgorithm(), publicKey);
return jwt.setSigner(signer).verify();
}
private JWTSigner createJWTSigner(String algorithm, PublicKey publicKey) {
JwtAlgorithm alg = EnumUtil.fromString(JwtAlgorithm.class, algorithm.toUpperCase());
switch (alg) {
case RS256:
return JWTSignerUtil.rs256(publicKey);
case RS384:
return JWTSignerUtil.rs384(publicKey);
case RS512:
return JWTSignerUtil.rs512(publicKey);
case ES256:
return JWTSignerUtil.es256(publicKey);
case ES384:
return JWTSignerUtil.es384(publicKey);
case ES512:
return JWTSignerUtil.es512(publicKey);
default:
throw new BusinessException(ErrorCode.INVALID_PARAMETER, "不支持的JWT签名算法");
}
}
private void validateJwtClaims(JWT jwt) {
// 过期时间
Object expObj = jwt.getPayload(JwtConstants.PAYLOAD_EXP);
Long exp = Convert.toLong(expObj);
// 签发时间
Object iatObj = jwt.getPayload(JwtConstants.PAYLOAD_IAT);
Long iat = Convert.toLong(iatObj);
if (iat == null || exp == null || iat > exp) {
throw new BusinessException(ErrorCode.INVALID_REQUEST, "JWT payload中exp或iat不合法");
}
long currentTime = System.currentTimeMillis() / 1000;
if (exp <= currentTime) {
throw new BusinessException(ErrorCode.INVALID_REQUEST, "JWT已过期");
}
}
private String createOrGetDeveloper(JWT jwt, OAuth2Config config) {
IdentityMapping identityMapping = config.getIdentityMapping();
// userId & userName
String userIdField = StrUtil.isBlank(identityMapping.getUserIdField()) ?
JwtConstants.PAYLOAD_USER_ID : identityMapping.getUserIdField();
String userNameField = StrUtil.isBlank(identityMapping.getUserNameField()) ?
JwtConstants.PAYLOAD_USER_NAME : identityMapping.getUserNameField();
Object userIdObj = jwt.getPayload(userIdField);
Object userNameObj = jwt.getPayload(userNameField);
String userId = Convert.toStr(userIdObj);
String userName = Convert.toStr(userNameObj);
if (StrUtil.isBlank(userId) || StrUtil.isBlank(userName)) {
throw new BusinessException(ErrorCode.INVALID_REQUEST, "JWT payload中缺少用户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)
.authType(DeveloperAuthType.OAUTH2)
.build();
return developerService.createExternalDeveloper(param).getDeveloperId();
});
}
}
```
--------------------------------------------------------------------------------
/portal-server/src/main/java/com/alibaba/apiopenplatform/service/impl/IdpServiceImpl.java:
--------------------------------------------------------------------------------
```java
package com.alibaba.apiopenplatform.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.alibaba.apiopenplatform.core.constant.IdpConstants;
import com.alibaba.apiopenplatform.core.exception.BusinessException;
import com.alibaba.apiopenplatform.core.exception.ErrorCode;
import com.alibaba.apiopenplatform.service.IdpService;
import com.alibaba.apiopenplatform.support.enums.GrantType;
import com.alibaba.apiopenplatform.support.enums.PublicKeyFormat;
import com.alibaba.apiopenplatform.support.portal.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.math.BigInteger;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.RSAPublicKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.*;
import java.util.stream.Collectors;
/**
* @author zh
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class IdpServiceImpl implements IdpService {
private final RestTemplate restTemplate;
@Override
public void validateOidcConfigs(List<OidcConfig> oidcConfigs) {
if (CollUtil.isEmpty(oidcConfigs)) {
return;
}
// provider唯一
Set<String> providers = oidcConfigs.stream()
.map(OidcConfig::getProvider)
.filter(StrUtil::isNotBlank)
.collect(Collectors.toSet());
if (providers.size() != oidcConfigs.size()) {
throw new BusinessException(ErrorCode.CONFLICT, "OIDC配置中存在空或重复的provider");
}
oidcConfigs.forEach(config -> {
AuthCodeConfig authConfig = Optional.ofNullable(config.getAuthCodeConfig())
.orElseThrow(() -> new BusinessException(ErrorCode.INVALID_PARAMETER,
StrUtil.format("OIDC配置{}缺少授权码配置", config.getProvider())));
// 基础参数
if (StrUtil.isBlank(authConfig.getClientId()) ||
StrUtil.isBlank(authConfig.getClientSecret()) ||
StrUtil.isBlank(authConfig.getScopes())) {
throw new BusinessException(ErrorCode.INVALID_PARAMETER,
StrUtil.format("OIDC配置{}缺少必要参数: Client ID, Client Secret 或 Scopes", config.getProvider()));
}
// 端点配置
if (StrUtil.isNotBlank(authConfig.getIssuer())) {
discoverAndSetEndpoints(config.getProvider(), authConfig);
} else {
if (StrUtil.isBlank(authConfig.getAuthorizationEndpoint()) ||
StrUtil.isBlank(authConfig.getTokenEndpoint()) ||
StrUtil.isBlank(authConfig.getUserInfoEndpoint())) {
throw new BusinessException(ErrorCode.INVALID_PARAMETER,
StrUtil.format("OIDC配置{}缺少必要端点配置", config.getProvider()));
}
}
});
}
@SuppressWarnings("unchecked")
private void discoverAndSetEndpoints(String provider, AuthCodeConfig config) {
String discoveryUrl = config.getIssuer().replaceAll("/$", "") + "/.well-known/openid-configuration";
try {
Map<String, Object> discovery = restTemplate.exchange(
discoveryUrl,
HttpMethod.GET,
null,
Map.class)
.getBody();
// 验证并设置端点
String authEndpoint = getRequiredEndpoint(discovery, IdpConstants.AUTHORIZATION_ENDPOINT);
String tokenEndpoint = getRequiredEndpoint(discovery, IdpConstants.TOKEN_ENDPOINT);
String userInfoEndpoint = getRequiredEndpoint(discovery, IdpConstants.USERINFO_ENDPOINT);
config.setAuthorizationEndpoint(authEndpoint);
config.setTokenEndpoint(tokenEndpoint);
config.setUserInfoEndpoint(userInfoEndpoint);
} catch (Exception e) {
log.error("Failed to discover OIDC endpoints from discovery URL: {}", discoveryUrl, e);
throw new BusinessException(ErrorCode.INVALID_PARAMETER, StrUtil.format("OIDC配置{}的Issuer无效或无法访问", provider));
}
}
private String getRequiredEndpoint(Map<String, Object> discovery, String name) {
return Optional.ofNullable(discovery.get(name))
.map(Object::toString)
.filter(StrUtil::isNotBlank)
.orElseThrow(() -> new BusinessException(ErrorCode.INVALID_PARAMETER,
"OIDC Discovery配置中缺少端点: " + name));
}
@Override
public void validateOAuth2Configs(List<OAuth2Config> oauth2Configs) {
if (CollUtil.isEmpty(oauth2Configs)) {
return;
}
// provider唯一
Set<String> providers = oauth2Configs.stream()
.map(OAuth2Config::getProvider)
.filter(StrUtil::isNotBlank)
.collect(Collectors.toSet());
if (providers.size() != oauth2Configs.size()) {
throw new BusinessException(ErrorCode.CONFLICT, "OAuth2配置中存在空或重复的provider");
}
oauth2Configs.forEach(config -> {
if (GrantType.JWT_BEARER.equals(config.getGrantType())) {
validateJwtBearerConfig(config);
}
});
}
private void validateJwtBearerConfig(OAuth2Config config) {
JwtBearerConfig jwtBearerConfig = config.getJwtBearerConfig();
if (jwtBearerConfig == null) {
throw new BusinessException(ErrorCode.INVALID_PARAMETER,
StrUtil.format("OAuth2配置{}使用JWT断言模式但缺少JWT断言配置", config.getProvider()));
}
List<PublicKeyConfig> publicKeys = jwtBearerConfig.getPublicKeys();
if (CollUtil.isEmpty(publicKeys)) {
throw new BusinessException(ErrorCode.INVALID_PARAMETER,
StrUtil.format("OAuth2配置{}缺少公钥配置", config.getProvider()));
}
if (publicKeys.stream()
.map(key -> {
// 加载公钥验证有效性
loadPublicKey(key.getFormat(), key.getValue());
return key.getKid();
})
.collect(Collectors.toSet()).size() != publicKeys.size()) {
throw new BusinessException(ErrorCode.CONFLICT,
StrUtil.format("OAuth2配置{}的公钥ID存在重复", config.getProvider()));
}
}
@Override
public PublicKey loadPublicKey(PublicKeyFormat format, String publicKey) {
switch (format) {
case PEM:
return loadPublicKeyFromPem(publicKey);
case JWK:
return loadPublicKeyFromJwk(publicKey);
default:
throw new BusinessException(ErrorCode.INVALID_PARAMETER, "公钥格式不支持");
}
}
private PublicKey loadPublicKeyFromPem(String pemContent) {
// 清理PEM格式标记和空白字符
String publicKeyPEM = pemContent
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replace("-----BEGIN RSA PUBLIC KEY-----", "")
.replace("-----END RSA PUBLIC KEY-----", "")
.replaceAll("\\s", "");
if (StrUtil.isBlank(publicKeyPEM)) {
throw new IllegalArgumentException("PEM内容为空");
}
try {
// Base64解码
byte[] decoded = Base64.getDecoder().decode(publicKeyPEM);
// 公钥对象
X509EncodedKeySpec spec = new X509EncodedKeySpec(decoded);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return keyFactory.generatePublic(spec);
} catch (Exception e) {
log.error("PEM公钥解析失败", e);
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "PEM公钥解析失败: " + e.getMessage());
}
}
private PublicKey loadPublicKeyFromJwk(String jwkContent) {
JSONObject jwk = JSONUtil.parseObj(jwkContent);
// 验证必需字段
String kty = getRequiredField(jwk, "kty");
if (!"RSA".equals(kty)) {
throw new IllegalArgumentException("当前仅支持RSA类型的JWK");
}
return loadRSAPublicKeyFromJwk(jwk);
}
private PublicKey loadRSAPublicKeyFromJwk(JSONObject jwk) {
// 获取必需的RSA参数
String nStr = getRequiredField(jwk, "n");
String eStr = getRequiredField(jwk, "e");
try {
// Base64解码参数
byte[] nBytes = Base64.getUrlDecoder().decode(nStr);
byte[] eBytes = Base64.getUrlDecoder().decode(eStr);
// 构建RSA公钥
BigInteger modulus = new BigInteger(1, nBytes);
BigInteger exponent = new BigInteger(1, eBytes);
RSAPublicKeySpec spec = new RSAPublicKeySpec(modulus, exponent);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return keyFactory.generatePublic(spec);
} catch (Exception e) {
log.error("JWK RSA参数解析失败", e);
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "JWK RSA参数解析失败: " + e.getMessage());
}
}
private String getRequiredField(JSONObject jwk, String fieldName) {
String value = jwk.getStr(fieldName);
if (StrUtil.isBlank(value)) {
throw new BusinessException(ErrorCode.INVALID_REQUEST, "JWK中缺少字段: " + fieldName);
}
return value;
}
}
```
--------------------------------------------------------------------------------
/portal-web/api-portal-admin/src/components/portal/PublicKeyManager.tsx:
--------------------------------------------------------------------------------
```typescript
import {useState} from 'react'
import {Button, Form, Input, Select, Table, Modal, Space, Tag, message, Card, Row, Col} from 'antd'
import {PlusOutlined, EditOutlined, DeleteOutlined, ExclamationCircleOutlined} from '@ant-design/icons'
import {PublicKeyConfig, PublicKeyFormat} from '@/types'
interface PublicKeyManagerProps {
provider?: string | null
publicKeys: PublicKeyConfig[]
onSave: (publicKeys: PublicKeyConfig[]) => void
}
interface PublicKeyFormData {
kid: string
format: PublicKeyFormat
algorithm: string
value: string
}
export function PublicKeyManager({provider, publicKeys, onSave}: PublicKeyManagerProps) {
const [form] = Form.useForm<PublicKeyFormData>()
const [modalVisible, setModalVisible] = useState(false)
const [editingIndex, setEditingIndex] = useState<number | null>(null)
const [localPublicKeys, setLocalPublicKeys] = useState<PublicKeyConfig[]>(publicKeys)
const [selectedFormat, setSelectedFormat] = useState<PublicKeyFormat>(PublicKeyFormat.PEM)
const handleAdd = () => {
setEditingIndex(null)
setModalVisible(true)
form.resetFields()
setSelectedFormat(PublicKeyFormat.PEM)
form.setFieldsValue({
format: PublicKeyFormat.PEM,
algorithm: 'RS256'
})
}
const handleEdit = (index: number) => {
setEditingIndex(index)
setModalVisible(true)
const publicKey = localPublicKeys[index]
setSelectedFormat(publicKey.format)
form.setFieldsValue(publicKey)
}
const handleDelete = (index: number) => {
Modal.confirm({
title: '确认删除',
icon: <ExclamationCircleOutlined/>,
content: '确定要删除这个公钥配置吗?',
okText: '确认删除',
okType: 'danger',
cancelText: '取消',
onOk() {
const updatedKeys = localPublicKeys.filter((_, i) => i !== index)
setLocalPublicKeys(updatedKeys)
onSave(updatedKeys)
message.success('公钥删除成功')
},
})
}
const handleModalOk = async () => {
try {
const values = await form.validateFields()
// 验证Kid的唯一性
const isKidExists = localPublicKeys.some((key, index) =>
key.kid === values.kid && index !== editingIndex
)
if (isKidExists) {
message.error('公钥ID已存在,请使用不同的ID')
return
}
let updatedKeys
if (editingIndex !== null) {
// 编辑模式
updatedKeys = localPublicKeys.map((key, index) =>
index === editingIndex ? values as PublicKeyConfig : key
)
} else {
// 新增模式
updatedKeys = [...localPublicKeys, values as PublicKeyConfig]
}
setLocalPublicKeys(updatedKeys)
onSave(updatedKeys)
setModalVisible(false)
message.success(editingIndex !== null ? '公钥更新成功' : '公钥添加成功')
} catch (error) {
message.error('保存公钥失败')
}
}
const handleModalCancel = () => {
setModalVisible(false)
setEditingIndex(null)
setSelectedFormat(PublicKeyFormat.PEM)
form.resetFields()
}
// 验证公钥内容格式
const validatePublicKey = (_: any, value: string) => {
if (!value) {
return Promise.reject(new Error('请输入公钥内容'))
}
if (selectedFormat === PublicKeyFormat.PEM) {
// 简单的PEM格式验证
if (!value.includes('-----BEGIN') || !value.includes('-----END')) {
return Promise.reject(new Error('PEM格式公钥应包含BEGIN和END标记'))
}
} else if (selectedFormat === PublicKeyFormat.JWK) {
// 简单的JWK格式验证
try {
const jwk = JSON.parse(value)
if (!jwk.kty || !jwk.n || !jwk.e) {
return Promise.reject(new Error('JWK格式应包含kty、n、e字段'))
}
} catch {
return Promise.reject(new Error('JWK格式应为有效的JSON'))
}
}
return Promise.resolve()
}
const columns = [
{
title: '公钥ID (kid)',
dataIndex: 'kid',
key: 'kid',
render: (kid: string) => (
<Tag color="blue">{kid}</Tag>
)
},
{
title: '格式',
dataIndex: 'format',
key: 'format',
render: (format: PublicKeyFormat) => (
<Tag color={format === PublicKeyFormat.PEM ? 'green' : 'orange'}>
{format}
</Tag>
)
},
{
title: '算法',
dataIndex: 'algorithm',
key: 'algorithm',
render: (algorithm: string) => (
<Tag color="purple">{algorithm}</Tag>
)
},
{
title: '公钥内容',
key: 'value',
render: (record: PublicKeyConfig) => (
<span className="font-mono text-xs text-gray-600">
{record.format === PublicKeyFormat.PEM
? record.value.substring(0, 50) + '...'
: JSON.stringify(JSON.parse(record.value || '{}')).substring(0, 50) + '...'
}
</span>
)
},
{
title: '操作',
key: 'action',
render: (_: any, _record: PublicKeyConfig, index: number) => (
<Space>
<Button
type="link"
size="small"
icon={<EditOutlined/>}
onClick={() => handleEdit(index)}
>
编辑
</Button>
<Button
type="link"
danger
size="small"
icon={<DeleteOutlined/>}
onClick={() => handleDelete(index)}
>
删除
</Button>
</Space>
)
}
]
return (
<div>
<div className="flex justify-between items-center mb-4">
<div>
<h4 className="text-lg font-medium">
{provider && `${provider} - `}JWT签名公钥管理
</h4>
<p className="text-sm text-gray-500">
管理用于验证JWT签名的公钥,支持PEM和JWK格式
</p>
</div>
<Button
type="primary"
icon={<PlusOutlined/>}
onClick={handleAdd}
>
添加公钥
</Button>
</div>
<Table
columns={columns}
dataSource={localPublicKeys}
rowKey="kid"
pagination={false}
size="small"
locale={{
emptyText: '暂无公钥配置'
}}
/>
{/* 公钥配置说明 */}
<Card size="small" className="mt-4 bg-blue-50">
<Row gutter={16}>
<Col span={12}>
<div className="text-sm">
<h5 className="font-medium mb-2 text-blue-800">PEM格式示例:</h5>
<div className="bg-white p-2 rounded font-mono text-xs border">
-----BEGIN PUBLIC KEY-----<br/>
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...<br/>
-----END PUBLIC KEY-----
</div>
</div>
</Col>
<Col span={12}>
<div className="text-sm">
<h5 className="font-medium mb-2 text-blue-800">JWK格式示例:</h5>
<div className="bg-white p-2 rounded font-mono text-xs border">
{`{
"kty": "RSA",
"kid": "key1",
"n": "...",
"e": "AQAB"
}`}
</div>
</div>
</Col>
</Row>
</Card>
{/* 公钥配置模态框 */}
<Modal
title={editingIndex !== null ? '编辑公钥' : '添加公钥'}
open={modalVisible}
onOk={handleModalOk}
onCancel={handleModalCancel}
width={700}
okText={editingIndex !== null ? '更新' : '添加'}
cancelText="取消"
>
<Form
form={form}
layout="vertical"
>
<div className="grid grid-cols-2 gap-4">
<Form.Item
name="kid"
label="公钥ID (kid)"
rules={[
{required: true, message: '请输入公钥ID'},
{pattern: /^[a-zA-Z0-9_-]+$/, message: '公钥ID只能包含字母、数字、下划线和连字符'}
]}
>
<Input placeholder="如: key1, auth-key-2024"/>
</Form.Item>
<Form.Item
name="algorithm"
label="签名算法"
rules={[{required: true, message: '请选择签名算法'}]}
>
<Select placeholder="选择签名算法">
<Select.Option value="RS256">RS256</Select.Option>
<Select.Option value="RS384">RS384</Select.Option>
<Select.Option value="RS512">RS512</Select.Option>
<Select.Option value="ES256">ES256</Select.Option>
<Select.Option value="ES384">ES384</Select.Option>
<Select.Option value="ES512">ES512</Select.Option>
</Select>
</Form.Item>
</div>
<Form.Item
name="format"
label="公钥格式"
rules={[{required: true, message: '请选择公钥格式'}]}
>
<Select
placeholder="选择公钥格式"
onChange={(value) => setSelectedFormat(value as PublicKeyFormat)}
>
<Select.Option value={PublicKeyFormat.PEM}>PEM格式</Select.Option>
<Select.Option value={PublicKeyFormat.JWK}>JWK格式</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="value"
label="公钥内容"
rules={[
{required: true, message: '请输入公钥内容'},
{validator: validatePublicKey}
]}
>
<Input.TextArea
rows={8}
placeholder={
selectedFormat === PublicKeyFormat.JWK
? '请输入JWK格式的公钥,例如:\n{\n "kty": "RSA",\n "kid": "key1",\n "n": "...",\n "e": "AQAB"\n}'
: '请输入PEM格式的公钥,例如:\n-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...\n-----END PUBLIC KEY-----'
}
style={{fontFamily: 'monospace'}}
/>
</Form.Item>
</Form>
</Modal>
</div>
)
}
```
--------------------------------------------------------------------------------
/portal-web/api-portal-admin/src/components/api-product/ApiProductPortal.tsx:
--------------------------------------------------------------------------------
```typescript
import { useNavigate } from 'react-router-dom'
import { Card, Button, Table, Tag, Space, Switch, Modal, Form, Input, Select, message } from 'antd'
import { PlusOutlined, EyeOutlined, EditOutlined, DeleteOutlined, ExclamationCircleOutlined, GlobalOutlined, CheckCircleFilled, MinusCircleFilled } from '@ant-design/icons'
import { useState, useEffect } from 'react'
import type { ApiProduct } from '@/types/api-product';
import { apiProductApi, portalApi } from '@/lib/api';
interface ApiProductPortalProps {
apiProduct: ApiProduct
}
interface Portal {
portalId: string
portalName: string
autoApproveSubscription: boolean
createdAt: string
}
export function ApiProductPortal({ apiProduct }: ApiProductPortalProps) {
const [publishedPortals, setPublishedPortals] = useState<Portal[]>([])
const [allPortals, setAllPortals] = useState<Portal[]>([])
const [isModalVisible, setIsModalVisible] = useState(false)
const [selectedPortalIds, setSelectedPortalIds] = useState<string[]>([])
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [portalLoading, setPortalLoading] = useState(false)
const [modalLoading, setModalLoading] = useState(false)
// 分页状态
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const navigate = useNavigate()
// 获取已发布的门户列表
useEffect(() => {
if (apiProduct.productId) {
fetchPublishedPortals()
}
}, [apiProduct.productId, currentPage, pageSize])
// 获取所有门户列表
useEffect(() => {
fetchAllPortals()
}, [])
const fetchPublishedPortals = async () => {
setLoading(true)
try {
const res = await apiProductApi.getApiProductPublications(apiProduct.productId, {
page: currentPage,
size: pageSize
})
setPublishedPortals(res.data.content?.map((item: any) => ({
...item,
autoApproveSubscription: item.autoApproveSubscriptions || false,
})) || [])
setTotal(res.data.totalElements || 0)
} catch (error) {
console.error('获取已发布门户失败:', error)
// message.error('获取已发布门户失败')
} finally {
setLoading(false)
}
}
const fetchAllPortals = async () => {
setPortalLoading(true)
try {
const res = await portalApi.getPortals({
page: 1,
size: 500 // 获取所有门户
})
setAllPortals(res.data.content?.map((item: any) => ({
...item,
portalName: item.name,
autoApproveSubscription: item.portalSettingConfig?.autoApproveSubscriptions || false,
})) || [])
} catch (error) {
console.error('获取门户列表失败:', error)
// message.error('获取门户列表失败')
} finally {
setPortalLoading(false)
}
}
const handlePageChange = (page: number, size?: number) => {
setCurrentPage(page)
if (size) {
setPageSize(size)
}
}
const columns = [
{
title: '门户信息',
key: 'portalInfo',
width: 400,
render: (_: any, record: Portal) => (
<div>
<div className="text-sm font-medium text-gray-900 truncate">
{record.portalName}
</div>
<div className="text-xs text-gray-500 truncate">
{record.portalId}
</div>
</div>
),
},
{
title: '订阅自动审批',
key: 'autoApprove',
width: 160,
render: (_: any, record: Portal) => (
<div className="flex items-center">
{record.autoApproveSubscription ? (
<>
<CheckCircleFilled className="text-green-500 mr-1" style={{fontSize: '10px'}} />
<span className="text-xs text-gray-900">已开启</span>
</>
) : (
<>
<MinusCircleFilled className="text-gray-400 mr-1" style={{fontSize: '10px'}} />
<span className="text-xs text-gray-900">已关闭</span>
</>
)}
</div>
),
},
{
title: '操作',
key: 'action',
width: 180,
render: (_: any, record: Portal) => (
<Space size="middle">
<Button onClick={() => {
navigate(`/portals/detail?id=${record.portalId}`)
}} type="link" icon={<EyeOutlined />}>
查看
</Button>
<Button
type="link"
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(record.portalId, record.portalName)}
>
移除
</Button>
</Space>
),
},
]
const modalColumns = [
{
title: '门户信息',
key: 'portalInfo',
render: (_: any, record: Portal) => (
<div>
<div className="text-xs font-normal text-gray-900 truncate">
{record.portalName}
</div>
<div className="text-xs text-gray-500">
{record.portalId}
</div>
</div>
),
},
{
title: '订阅自动审批',
key: 'autoApprove',
width: 140,
render: (_: any, record: Portal) => (
<div className="flex items-center">
{record.autoApproveSubscription ? (
<>
<CheckCircleFilled className="text-green-500 mr-1" style={{fontSize: '10px'}} />
<span className="text-xs text-gray-900">已开启</span>
</>
) : (
<>
<MinusCircleFilled className="text-gray-400 mr-1" style={{fontSize: '10px'}} />
<span className="text-xs text-gray-900">已关闭</span>
</>
)}
</div>
),
},
]
const handleAdd = () => {
setIsModalVisible(true)
}
const handleDelete = (portalId: string, portalName: string) => {
Modal.confirm({
title: '确认移除',
icon: <ExclamationCircleOutlined />,
content: `确定要从API产品中移除门户 "${portalName}" 吗?此操作不可恢复。`,
okText: '确认移除',
okType: 'danger',
cancelText: '取消',
onOk() {
apiProductApi.cancelPublishToPortal(apiProduct.productId, portalId).then((res) => {
message.success('移除成功')
fetchPublishedPortals()
}).catch((error) => {
console.error('移除失败:', error)
// message.error('移除失败')
})
},
})
}
const handleModalOk = async () => {
if (selectedPortalIds.length === 0) {
message.warning('请至少选择一个门户')
return
}
setModalLoading(true)
try {
// 批量发布到选中的门户
for (const portalId of selectedPortalIds) {
await apiProductApi.publishToPortal(apiProduct.productId, portalId)
}
message.success(`成功发布到 ${selectedPortalIds.length} 个门户`)
setSelectedPortalIds([])
setIsModalVisible(false)
// 重新获取已发布的门户列表
fetchPublishedPortals()
} catch (error) {
console.error('发布失败:', error)
// message.error('发布失败')
} finally {
setModalLoading(false)
}
}
const handleModalCancel = () => {
setIsModalVisible(false)
setSelectedPortalIds([])
}
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">管理API产品发布的门户</p>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
发布到门户
</Button>
</div>
<Card>
{publishedPortals.length === 0 && !loading ? (
<div className="text-center py-8 text-gray-500">
<p>暂未发布到任何门户</p>
</div>
) : (
<Table
columns={columns}
dataSource={publishedPortals}
rowKey="portalId"
loading={loading}
pagination={{
current: currentPage,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `共 ${total} 条`,
onChange: handlePageChange,
onShowSizeChange: handlePageChange,
}}
/>
)}
</Card>
<Modal
title="发布到门户"
open={isModalVisible}
onOk={handleModalOk}
onCancel={handleModalCancel}
okText="发布"
cancelText="取消"
width={700}
confirmLoading={modalLoading}
destroyOnClose
>
<div className="border border-gray-200 rounded-lg overflow-hidden">
<Table
columns={modalColumns}
dataSource={allPortals.filter(portal =>
!publishedPortals.some(published => published.portalId === portal.portalId)
)}
rowKey="portalId"
loading={portalLoading}
pagination={false}
scroll={{ y: 350 }}
size="middle"
className="portal-selection-table"
rowSelection={{
type: 'checkbox',
selectedRowKeys: selectedPortalIds,
onChange: (selectedRowKeys) => {
setSelectedPortalIds(selectedRowKeys as string[])
},
columnWidth: 50,
}}
rowClassName={(record) =>
selectedPortalIds.includes(record.portalId)
? 'bg-blue-50 hover:bg-blue-100'
: 'hover:bg-gray-50'
}
locale={{
emptyText: (
<div className="py-8">
<div className="text-gray-400 mb-2">
<GlobalOutlined style={{ fontSize: '24px' }} />
</div>
<div className="text-gray-500 text-sm">暂无可发布的门户</div>
</div>
)
}}
/>
</div>
</Modal>
</div>
)
}
```
--------------------------------------------------------------------------------
/portal-server/src/main/java/com/alibaba/apiopenplatform/service/gateway/client/ApsaraStackGatewayClient.java:
--------------------------------------------------------------------------------
```java
package com.alibaba.apiopenplatform.service.gateway.client;
import com.alibaba.apiopenplatform.entity.Gateway;
import com.alibaba.apiopenplatform.support.gateway.ApsaraGatewayConfig;
import com.aliyun.teaopenapi.models.Config;
import com.aliyun.apsarastack.csb220230206.Client;
import com.aliyun.apsarastack.csb220230206.models.*;
import com.aliyun.teautil.models.RuntimeOptions;
import lombok.extern.slf4j.Slf4j;
import java.util.HashMap;
import java.util.Map;
@Slf4j
public class ApsaraStackGatewayClient extends GatewayClient {
private final ApsaraGatewayConfig config;
private final Client client;
private final RuntimeOptions runtime;
public ApsaraStackGatewayClient(ApsaraGatewayConfig config) {
this.config = config;
this.client = createClient(config);
this.runtime = new RuntimeOptions();
// 根据示例设置运行时参数
this.runtime.ignoreSSL = true;
this.runtime.setConnectTimeout(3000);
this.runtime.setReadTimeout(30000);
}
public static ApsaraStackGatewayClient fromGateway(Gateway gateway) {
return new ApsaraStackGatewayClient(gateway.getApsaraGatewayConfig());
}
private Client createClient(ApsaraGatewayConfig config) {
try {
Config clientConfig = new Config()
.setRegionId(config.getRegionId())
.setAccessKeyId(config.getAccessKeyId())
.setAccessKeySecret(config.getAccessKeySecret());
// 设置endpoint
clientConfig.endpoint = config.getDomain();
return new Client(clientConfig);
} catch (Exception e) {
log.error("Error creating ApsaraStack client", e);
throw new RuntimeException(e);
}
}
@Override
public void close() {
// Client doesn't need explicit closing
}
/**
* 构建ApsaraStack请求头
* 根据配置设置必要的业务头信息
*/
private Map<String, String> buildRequestHeaders() {
Map<String, String> headers = new HashMap<>();
if (config.getXAcsCallerSdkSource() != null) {
headers.put("x-acs-caller-sdk-source", config.getXAcsCallerSdkSource());
}
if (config.getXAcsResourceGroupId() != null) {
headers.put("x-acs-resourcegroupid", config.getXAcsResourceGroupId());
}
if (config.getXAcsOrganizationId() != null) {
headers.put("x-acs-organizationid", config.getXAcsOrganizationId());
}
if (config.getXAcsCallerType() != null) {
headers.put("x-acs-caller-type", config.getXAcsCallerType());
}
// 角色id
if (config.getXAcsRoleId() != null) {
headers.put("x-acs-roleId", config.getXAcsRoleId());
}
return headers;
}
public ListRoutesResponse ListRoutes(String instanceId, int current, int size) {
try {
ListRoutesRequest request = new ListRoutesRequest();
request.setCurrent(current);
request.setSize(size);
request.setGwInstanceId(instanceId);
return client.listRoutesWithOptions(request, buildRequestHeaders(), runtime);
} catch (Exception e) {
log.error("Error listing routes", e);
throw new RuntimeException(e);
}
}
public ListMcpServersResponse ListMcpServers(String instanceId, int current, int size) {
try {
ListMcpServersRequest request = new ListMcpServersRequest();
request.setCurrent(current);
request.setSize(size);
request.setGwInstanceId(instanceId);
return client.listMcpServersWithOptions(request, buildRequestHeaders(), runtime);
} catch (Exception e) {
log.error("Error listing MCP servers", e);
throw new RuntimeException(e);
}
}
public ListInstancesResponse ListInstances(int current, int size, String brokerEngineType) {
try {
ListInstancesRequest request = new ListInstancesRequest();
request.setCurrent(current);
request.setSize(size);
if (brokerEngineType != null && !brokerEngineType.isEmpty()) {
request.setBrokerEngineType(brokerEngineType);
}
return client.listInstancesWithOptions(request, buildRequestHeaders(), runtime);
} catch (Exception e) {
log.error("Error listing instances", e);
throw new RuntimeException(e);
}
}
/**
* 获取MCP Server详情
*/
public GetMcpServerResponse GetMcpServer(String gwInstanceId, String mcpServerName) {
try {
GetMcpServerRequest request = new GetMcpServerRequest();
request.setGwInstanceId(gwInstanceId);
request.setMcpServerName(mcpServerName);
return client.getMcpServerWithOptions(request, buildRequestHeaders(), runtime);
} catch (Exception e) {
log.error("Error getting MCP server", e);
throw new RuntimeException(e);
}
}
/**
* 获取网关实例详情
*/
public GetInstanceInfoResponse GetInstance(String gwInstanceId) {
try {
GetInstanceInfoRequest request = new GetInstanceInfoRequest();
request.setGwInstanceId(gwInstanceId);
return client.getInstanceInfoWithOptions(request, buildRequestHeaders(), runtime);
} catch (Exception e) {
log.error("Error getting instance", e);
throw new RuntimeException(e);
}
}
/**
* 创建应用(Consumer)
*/
public CreateAppResponse CreateApp(String gwInstanceId, String appName, String key, Integer authType) {
try {
CreateAppRequest request = new CreateAppRequest();
request.setGwInstanceId(gwInstanceId);
request.setAppName(appName);
request.setKey(key);
request.setAuthType(authType);
return client.createAppWithOptions(request, buildRequestHeaders(), runtime);
} catch (Exception e) {
log.error("Error creating app", e);
throw new RuntimeException(e);
}
}
/**
* 更新应用(Consumer)
*/
public ModifyAppResponse ModifyApp(String gwInstanceId, String appId, String appName, String key, Integer authType, String description, Boolean enable) {
try {
ModifyAppRequest request = new ModifyAppRequest();
request.setGwInstanceId(gwInstanceId);
request.setAppId(appId);
request.setAppName(appName);
if (key != null) {
request.setKey(key);
}
request.setAuthType(authType);
if (description != null) {
request.setDescription(description);
}
if (enable != null) {
// SDK使用isDisable字段,需要取反
request.setIsDisable(!enable);
}
return client.modifyAppWithOptions(request, buildRequestHeaders(), runtime);
} catch (Exception e) {
log.error("Error modifying app", e);
throw new RuntimeException(e);
}
}
/**
* 删除应用(Consumer)
*/
public BatchDeleteAppResponse DeleteApp(String gwInstanceId, String appId) {
try {
BatchDeleteAppRequest request = new BatchDeleteAppRequest();
request.setGwInstanceId(gwInstanceId);
request.setAppIds(java.util.Collections.singletonList(appId));
return client.batchDeleteAppWithOptions(request, buildRequestHeaders(), runtime);
} catch (Exception e) {
log.error("Error deleting app", e);
throw new RuntimeException(e);
}
}
/**
* 查询应用列表(用于检查Consumer是否存在)
* @param gwInstanceId 网关实例ID
* @param serviceType 服务类型(可选)
*/
public ListAppsByGwInstanceIdResponse ListAppsByGwInstanceId(String gwInstanceId, Integer serviceType) {
try {
ListAppsByGwInstanceIdRequest request = new ListAppsByGwInstanceIdRequest();
request.setGwInstanceId(gwInstanceId);
if (serviceType != null) {
request.setServiceType(serviceType);
}
return client.listAppsByGwInstanceIdWithOptions(request, buildRequestHeaders(), runtime);
} catch (Exception e) {
log.error("Error listing apps by instance", e);
throw new RuntimeException(e);
}
}
/**
* 为MCP Server添加授权的消费者
*/
public AddMcpServerConsumersResponse AddMcpServerConsumers(String gwInstanceId, String mcpServerName, java.util.List<String> consumers) {
try {
AddMcpServerConsumersRequest request = new AddMcpServerConsumersRequest();
request.setGwInstanceId(gwInstanceId);
request.setMcpServerName(mcpServerName);
request.setConsumers(consumers);
return client.addMcpServerConsumersWithOptions(request, buildRequestHeaders(), runtime);
} catch (Exception e) {
log.error("Error adding MCP server consumers", e);
throw new RuntimeException(e);
}
}
/**
* 删除MCP Server的授权消费者
*/
public DeleteMcpServerConsumersResponse DeleteMcpServerConsumers(String gwInstanceId, String mcpServerName, java.util.List<String> consumers) {
try {
DeleteMcpServerConsumersRequest request = new DeleteMcpServerConsumersRequest();
request.setGwInstanceId(gwInstanceId);
request.setMcpServerName(mcpServerName);
request.setConsumers(consumers);
return client.deleteMcpServerConsumersWithOptions(request, buildRequestHeaders(), runtime);
} catch (Exception e) {
log.error("Error deleting MCP server consumers", e);
throw new RuntimeException(e);
}
}
}
```