This is page 4 of 7. Use http://codebase.md/higress-group/himarket?lines=false&page={x} to view the full context. # Directory Structure ``` ├── .cursor │ └── rules │ ├── api-style.mdc │ └── project-architecture.mdc ├── .gitignore ├── build.sh ├── deploy │ ├── docker │ │ ├── docker-compose.yml │ │ └── Docker部署说明.md │ └── helm │ ├── Chart.yaml │ ├── Helm部署说明.md │ ├── templates │ │ ├── _helpers.tpl │ │ ├── himarket-admin-cm.yaml │ │ ├── himarket-admin-deployment.yaml │ │ ├── himarket-admin-service.yaml │ │ ├── himarket-frontend-cm.yaml │ │ ├── himarket-frontend-deployment.yaml │ │ ├── himarket-frontend-service.yaml │ │ ├── himarket-server-cm.yaml │ │ ├── himarket-server-deployment.yaml │ │ ├── himarket-server-service.yaml │ │ ├── mysql.yaml │ │ └── serviceaccount.yaml │ └── values.yaml ├── LICENSE ├── NOTICE ├── pom.xml ├── portal-bootstrap │ ├── Dockerfile │ ├── pom.xml │ └── src │ ├── main │ │ ├── java │ │ │ └── com │ │ │ └── alibaba │ │ │ └── apiopenplatform │ │ │ ├── config │ │ │ │ ├── AsyncConfig.java │ │ │ │ ├── FilterConfig.java │ │ │ │ ├── PageConfig.java │ │ │ │ ├── RestTemplateConfig.java │ │ │ │ ├── SecurityConfig.java │ │ │ │ └── SwaggerConfig.java │ │ │ ├── filter │ │ │ │ └── PortalResolvingFilter.java │ │ │ └── PortalApplication.java │ │ └── resources │ │ └── application.yaml │ └── test │ └── java │ └── com │ └── alibaba │ └── apiopenplatform │ └── integration │ └── AdministratorAuthIntegrationTest.java ├── portal-dal │ ├── pom.xml │ └── src │ └── main │ └── java │ └── com │ └── alibaba │ └── apiopenplatform │ ├── converter │ │ ├── AdpAIGatewayConfigConverter.java │ │ ├── APIGConfigConverter.java │ │ ├── APIGRefConfigConverter.java │ │ ├── ApiKeyConfigConverter.java │ │ ├── ConsumerAuthConfigConverter.java │ │ ├── GatewayConfigConverter.java │ │ ├── HigressConfigConverter.java │ │ ├── HigressRefConfigConverter.java │ │ ├── HmacConfigConverter.java │ │ ├── JsonConverter.java │ │ ├── JwtConfigConverter.java │ │ ├── NacosRefConfigConverter.java │ │ ├── PortalSettingConfigConverter.java │ │ ├── PortalUiConfigConverter.java │ │ └── ProductIconConverter.java │ ├── entity │ │ ├── Administrator.java │ │ ├── BaseEntity.java │ │ ├── Consumer.java │ │ ├── ConsumerCredential.java │ │ ├── ConsumerRef.java │ │ ├── Developer.java │ │ ├── DeveloperExternalIdentity.java │ │ ├── Gateway.java │ │ ├── NacosInstance.java │ │ ├── Portal.java │ │ ├── PortalDomain.java │ │ ├── Product.java │ │ ├── ProductPublication.java │ │ ├── ProductRef.java │ │ └── ProductSubscription.java │ ├── repository │ │ ├── AdministratorRepository.java │ │ ├── BaseRepository.java │ │ ├── ConsumerCredentialRepository.java │ │ ├── ConsumerRefRepository.java │ │ ├── ConsumerRepository.java │ │ ├── DeveloperExternalIdentityRepository.java │ │ ├── DeveloperRepository.java │ │ ├── GatewayRepository.java │ │ ├── NacosInstanceRepository.java │ │ ├── PortalDomainRepository.java │ │ ├── PortalRepository.java │ │ ├── ProductPublicationRepository.java │ │ ├── ProductRefRepository.java │ │ ├── ProductRepository.java │ │ └── SubscriptionRepository.java │ └── support │ ├── common │ │ ├── Encrypted.java │ │ ├── Encryptor.java │ │ └── User.java │ ├── consumer │ │ ├── AdpAIAuthConfig.java │ │ ├── APIGAuthConfig.java │ │ ├── ApiKeyConfig.java │ │ ├── ConsumerAuthConfig.java │ │ ├── HigressAuthConfig.java │ │ ├── HmacConfig.java │ │ └── JwtConfig.java │ ├── enums │ │ ├── APIGAPIType.java │ │ ├── ConsumerAuthType.java │ │ ├── ConsumerStatus.java │ │ ├── CredentialMode.java │ │ ├── DeveloperAuthType.java │ │ ├── DeveloperStatus.java │ │ ├── DomainType.java │ │ ├── GatewayType.java │ │ ├── GrantType.java │ │ ├── HigressAPIType.java │ │ ├── JwtAlgorithm.java │ │ ├── ProductIconType.java │ │ ├── ProductStatus.java │ │ ├── ProductType.java │ │ ├── ProtocolType.java │ │ ├── PublicKeyFormat.java │ │ ├── SourceType.java │ │ ├── SubscriptionStatus.java │ │ └── UserType.java │ ├── gateway │ │ ├── AdpAIGatewayConfig.java │ │ ├── APIGConfig.java │ │ ├── GatewayConfig.java │ │ └── HigressConfig.java │ ├── portal │ │ ├── AuthCodeConfig.java │ │ ├── IdentityMapping.java │ │ ├── JwtBearerConfig.java │ │ ├── OAuth2Config.java │ │ ├── OidcConfig.java │ │ ├── PortalSettingConfig.java │ │ ├── PortalUiConfig.java │ │ └── PublicKeyConfig.java │ └── product │ ├── APIGRefConfig.java │ ├── HigressRefConfig.java │ ├── NacosRefConfig.java │ └── ProductIcon.java ├── portal-server │ ├── pom.xml │ └── src │ └── main │ └── java │ └── com │ └── alibaba │ └── apiopenplatform │ ├── controller │ │ ├── AdministratorController.java │ │ ├── ConsumerController.java │ │ ├── DeveloperController.java │ │ ├── GatewayController.java │ │ ├── NacosController.java │ │ ├── OAuth2Controller.java │ │ ├── OidcController.java │ │ ├── PortalController.java │ │ └── ProductController.java │ ├── core │ │ ├── advice │ │ │ ├── ExceptionAdvice.java │ │ │ └── ResponseAdvice.java │ │ ├── annotation │ │ │ ├── AdminAuth.java │ │ │ ├── AdminOrDeveloperAuth.java │ │ │ └── DeveloperAuth.java │ │ ├── constant │ │ │ ├── CommonConstants.java │ │ │ ├── IdpConstants.java │ │ │ ├── JwtConstants.java │ │ │ └── Resources.java │ │ ├── event │ │ │ ├── DeveloperDeletingEvent.java │ │ │ ├── PortalDeletingEvent.java │ │ │ └── ProductDeletingEvent.java │ │ ├── exception │ │ │ ├── BusinessException.java │ │ │ └── ErrorCode.java │ │ ├── response │ │ │ └── Response.java │ │ ├── security │ │ │ ├── ContextHolder.java │ │ │ ├── DeveloperAuthenticationProvider.java │ │ │ └── JwtAuthenticationFilter.java │ │ └── utils │ │ ├── IdGenerator.java │ │ ├── PasswordHasher.java │ │ └── TokenUtil.java │ ├── dto │ │ ├── converter │ │ │ ├── InputConverter.java │ │ │ ├── NacosToGatewayToolsConverter.java │ │ │ └── OutputConverter.java │ │ ├── params │ │ │ ├── admin │ │ │ │ ├── AdminCreateParam.java │ │ │ │ ├── AdminLoginParam.java │ │ │ │ └── ResetPasswordParam.java │ │ │ ├── consumer │ │ │ │ ├── CreateConsumerParam.java │ │ │ │ ├── CreateCredentialParam.java │ │ │ │ ├── CreateSubscriptionParam.java │ │ │ │ ├── QueryConsumerParam.java │ │ │ │ ├── QuerySubscriptionParam.java │ │ │ │ └── UpdateCredentialParam.java │ │ │ ├── developer │ │ │ │ ├── CreateDeveloperParam.java │ │ │ │ ├── CreateExternalDeveloperParam.java │ │ │ │ ├── DeveloperLoginParam.java │ │ │ │ ├── QueryDeveloperParam.java │ │ │ │ ├── UnbindExternalIdentityParam.java │ │ │ │ ├── UpdateDeveloperParam.java │ │ │ │ └── UpdateDeveloperStatusParam.java │ │ │ ├── gateway │ │ │ │ ├── ImportGatewayParam.java │ │ │ │ ├── QueryAdpAIGatewayParam.java │ │ │ │ ├── QueryAPIGParam.java │ │ │ │ └── QueryGatewayParam.java │ │ │ ├── nacos │ │ │ │ ├── CreateNacosParam.java │ │ │ │ ├── QueryNacosNamespaceParam.java │ │ │ │ ├── QueryNacosParam.java │ │ │ │ └── UpdateNacosParam.java │ │ │ ├── portal │ │ │ │ ├── BindDomainParam.java │ │ │ │ ├── CreatePortalParam.java │ │ │ │ └── UpdatePortalParam.java │ │ │ └── product │ │ │ ├── CreateProductParam.java │ │ │ ├── CreateProductRefParam.java │ │ │ ├── PublishProductParam.java │ │ │ ├── QueryProductParam.java │ │ │ ├── QueryProductSubscriptionParam.java │ │ │ ├── UnPublishProductParam.java │ │ │ └── UpdateProductParam.java │ │ └── result │ │ ├── AdminResult.java │ │ ├── AdpGatewayInstanceResult.java │ │ ├── AdpMcpServerListResult.java │ │ ├── AdpMCPServerResult.java │ │ ├── APIConfigResult.java │ │ ├── APIGMCPServerResult.java │ │ ├── APIResult.java │ │ ├── AuthResult.java │ │ ├── ConsumerCredentialResult.java │ │ ├── ConsumerResult.java │ │ ├── DeveloperResult.java │ │ ├── GatewayMCPServerResult.java │ │ ├── GatewayResult.java │ │ ├── HigressMCPServerResult.java │ │ ├── IdpResult.java │ │ ├── IdpState.java │ │ ├── IdpTokenResult.java │ │ ├── MCPConfigResult.java │ │ ├── MCPServerResult.java │ │ ├── MseNacosResult.java │ │ ├── NacosMCPServerResult.java │ │ ├── NacosNamespaceResult.java │ │ ├── NacosResult.java │ │ ├── PageResult.java │ │ ├── PortalResult.java │ │ ├── ProductPublicationResult.java │ │ ├── ProductRefResult.java │ │ ├── ProductResult.java │ │ └── SubscriptionResult.java │ └── service │ ├── AdministratorService.java │ ├── AdpAIGatewayService.java │ ├── ConsumerService.java │ ├── DeveloperService.java │ ├── gateway │ │ ├── AdpAIGatewayOperator.java │ │ ├── AIGatewayOperator.java │ │ ├── APIGOperator.java │ │ ├── client │ │ │ ├── AdpAIGatewayClient.java │ │ │ ├── APIGClient.java │ │ │ ├── GatewayClient.java │ │ │ ├── HigressClient.java │ │ │ ├── PopGatewayClient.java │ │ │ └── SLSClient.java │ │ ├── factory │ │ │ └── HTTPClientFactory.java │ │ ├── GatewayOperator.java │ │ └── HigressOperator.java │ ├── GatewayService.java │ ├── IdpService.java │ ├── impl │ │ ├── AdministratorServiceImpl.java │ │ ├── ConsumerServiceImpl.java │ │ ├── DeveloperServiceImpl.java │ │ ├── GatewayServiceImpl.java │ │ ├── IdpServiceImpl.java │ │ ├── NacosServiceImpl.java │ │ ├── OAuth2ServiceImpl.java │ │ ├── OidcServiceImpl.java │ │ ├── PortalServiceImpl.java │ │ └── ProductServiceImpl.java │ ├── NacosService.java │ ├── OAuth2Service.java │ ├── OidcService.java │ ├── PortalService.java │ └── ProductService.java ├── portal-web │ ├── api-portal-admin │ │ ├── .env │ │ ├── .gitignore │ │ ├── bin │ │ │ ├── replace_var.py │ │ │ └── start.sh │ │ ├── Dockerfile │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── nginx.conf │ │ ├── package.json │ │ ├── postcss.config.js │ │ ├── proxy.conf │ │ ├── public │ │ │ ├── logo.png │ │ │ └── vite.svg │ │ ├── README.md │ │ ├── src │ │ │ ├── aliyunThemeToken.ts │ │ │ ├── App.css │ │ │ ├── App.tsx │ │ │ ├── assets │ │ │ │ └── react.svg │ │ │ ├── components │ │ │ │ ├── api-product │ │ │ │ │ ├── ApiProductApiDocs.tsx │ │ │ │ │ ├── ApiProductDashboard.tsx │ │ │ │ │ ├── ApiProductFormModal.tsx │ │ │ │ │ ├── ApiProductLinkApi.tsx │ │ │ │ │ ├── ApiProductOverview.tsx │ │ │ │ │ ├── ApiProductPolicy.tsx │ │ │ │ │ ├── ApiProductPortal.tsx │ │ │ │ │ ├── ApiProductUsageGuide.tsx │ │ │ │ │ ├── SwaggerUIWrapper.css │ │ │ │ │ └── SwaggerUIWrapper.tsx │ │ │ │ ├── common │ │ │ │ │ ├── AdvancedSearch.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── console │ │ │ │ │ ├── GatewayTypeSelector.tsx │ │ │ │ │ ├── ImportGatewayModal.tsx │ │ │ │ │ ├── ImportHigressModal.tsx │ │ │ │ │ ├── ImportMseNacosModal.tsx │ │ │ │ │ └── NacosTypeSelector.tsx │ │ │ │ ├── icons │ │ │ │ │ └── McpServerIcon.tsx │ │ │ │ ├── Layout.tsx │ │ │ │ ├── LayoutWrapper.tsx │ │ │ │ ├── portal │ │ │ │ │ ├── PortalConsumers.tsx │ │ │ │ │ ├── PortalDashboard.tsx │ │ │ │ │ ├── PortalDevelopers.tsx │ │ │ │ │ ├── PortalDomain.tsx │ │ │ │ │ ├── PortalFormModal.tsx │ │ │ │ │ ├── PortalOverview.tsx │ │ │ │ │ ├── PortalPublishedApis.tsx │ │ │ │ │ ├── PortalSecurity.tsx │ │ │ │ │ ├── PortalSettings.tsx │ │ │ │ │ ├── PublicKeyManager.tsx │ │ │ │ │ └── ThirdPartyAuthManager.tsx │ │ │ │ └── subscription │ │ │ │ └── SubscriptionListModal.tsx │ │ │ ├── contexts │ │ │ │ └── LoadingContext.tsx │ │ │ ├── index.css │ │ │ ├── lib │ │ │ │ ├── api.ts │ │ │ │ ├── constant.ts │ │ │ │ └── utils.ts │ │ │ ├── main.tsx │ │ │ ├── pages │ │ │ │ ├── ApiProductDetail.tsx │ │ │ │ ├── ApiProducts.tsx │ │ │ │ ├── Dashboard.tsx │ │ │ │ ├── GatewayConsoles.tsx │ │ │ │ ├── Login.tsx │ │ │ │ ├── NacosConsoles.tsx │ │ │ │ ├── PortalDetail.tsx │ │ │ │ ├── Portals.tsx │ │ │ │ └── Register.tsx │ │ │ ├── routes │ │ │ │ └── index.tsx │ │ │ ├── types │ │ │ │ ├── api-product.ts │ │ │ │ ├── consumer.ts │ │ │ │ ├── gateway.ts │ │ │ │ ├── index.ts │ │ │ │ ├── portal.ts │ │ │ │ ├── shims-js-yaml.d.ts │ │ │ │ └── subscription.ts │ │ │ └── vite-env.d.ts │ │ ├── tailwind.config.js │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ └── api-portal-frontend │ ├── .env │ ├── .gitignore │ ├── .husky │ │ └── pre-commit │ ├── bin │ │ ├── replace_var.py │ │ └── start.sh │ ├── Dockerfile │ ├── eslint.config.js │ ├── index.html │ ├── nginx.conf │ ├── package.json │ ├── postcss.config.js │ ├── proxy.conf │ ├── public │ │ ├── favicon.ico │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── MCP.png │ │ ├── MCP.svg │ │ └── vite.svg │ ├── README.md │ ├── src │ │ ├── aliyunThemeToken.ts │ │ ├── App.css │ │ ├── App.tsx │ │ ├── assets │ │ │ ├── aliyun.png │ │ │ ├── github.png │ │ │ ├── google.png │ │ │ └── react.svg │ │ ├── components │ │ │ ├── consumer │ │ │ │ ├── ConsumerBasicInfo.tsx │ │ │ │ ├── CredentialManager.tsx │ │ │ │ ├── index.ts │ │ │ │ └── SubscriptionManager.tsx │ │ │ ├── Layout.tsx │ │ │ ├── Navigation.tsx │ │ │ ├── ProductHeader.tsx │ │ │ ├── SwaggerUIWrapper.css │ │ │ ├── SwaggerUIWrapper.tsx │ │ │ └── UserInfo.tsx │ │ ├── index.css │ │ ├── lib │ │ │ ├── api.ts │ │ │ ├── statusUtils.ts │ │ │ └── utils.ts │ │ ├── main.tsx │ │ ├── pages │ │ │ ├── ApiDetail.tsx │ │ │ ├── Apis.tsx │ │ │ ├── Callback.tsx │ │ │ ├── ConsumerDetail.tsx │ │ │ ├── Consumers.tsx │ │ │ ├── GettingStarted.tsx │ │ │ ├── Home.tsx │ │ │ ├── Login.tsx │ │ │ ├── Mcp.tsx │ │ │ ├── McpDetail.tsx │ │ │ ├── OidcCallback.tsx │ │ │ ├── Profile.tsx │ │ │ ├── Register.tsx │ │ │ └── Test.css │ │ ├── router.tsx │ │ ├── types │ │ │ ├── consumer.ts │ │ │ └── index.ts │ │ └── vite-env.d.ts │ ├── tailwind.config.js │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts └── README.md ``` # Files -------------------------------------------------------------------------------- /portal-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/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, } }) }, // 获取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/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/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-bootstrap/src/test/java/com/alibaba/apiopenplatform/integration/AdministratorAuthIntegrationTest.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.integration; import com.alibaba.apiopenplatform.dto.params.admin.AdminCreateParam; import com.alibaba.apiopenplatform.dto.params.admin.AdminLoginParam; import com.alibaba.apiopenplatform.core.response.Response; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.http.*; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; /** * 管理员初始化、登录、token认证、权限保护、黑名单集成测试 * */ @SpringBootTest(classes = com.alibaba.apiopenplatform.PortalApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class AdministratorAuthIntegrationTest { @Autowired private TestRestTemplate restTemplate; @Test void testAdminRegister() { AdminCreateParam createDto = new AdminCreateParam(); createDto.setUsername("admintest001"); createDto.setPassword("admin123456"); ResponseEntity<Response> registerResp = restTemplate.postForEntity( "/api/admin/init", createDto, Response.class); System.out.println("管理员初始化响应:" + registerResp); assertThat(registerResp.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(registerResp.getBody().getCode()).isEqualTo("Success"); } @Test void testAdminLogin() { AdminLoginParam loginDto = new AdminLoginParam(); loginDto.setUsername("admintest001"); loginDto.setPassword("admin123456"); ResponseEntity<Response> loginResp = restTemplate.postForEntity( "/api/admin/login", loginDto, Response.class); System.out.println("管理员登录响应:" + loginResp); assertThat(loginResp.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(loginResp.getBody().getCode()).isEqualTo("Success"); } @Test void testAdminProtectedApiWithValidToken() { // 登录获取token AdminLoginParam loginDto = new AdminLoginParam(); loginDto.setUsername("admintest001"); loginDto.setPassword("admin123456"); ResponseEntity<Response> loginResp = restTemplate.postForEntity( "/api/admin/login", loginDto, Response.class); assertThat(loginResp.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(loginResp.getBody().getCode()).isEqualTo("Success"); String token = ((Map<String, Object>)loginResp.getBody().getData()).get("token").toString(); // 用token访问受保护接口 HttpHeaders headers = new HttpHeaders(); headers.set("Authorization", "Bearer " + token); HttpEntity<Void> entity = new HttpEntity<>(headers); // 你需要在管理员Controller实现 /api/admin/profile 受保护接口 ResponseEntity<String> protectedResp = restTemplate.exchange( "/api/admin/profile", HttpMethod.GET, entity, String.class); System.out.println("管理员带token访问受保护接口响应:" + protectedResp); assertThat(protectedResp.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(protectedResp.getBody()).contains("管理员受保护信息"); } @Test void testAdminProtectedApiWithoutToken() { // 不带token访问受保护接口 ResponseEntity<String> protectedResp = restTemplate.getForEntity( "/api/admin/profile", String.class); System.out.println("管理员不带token访问受保护接口响应:" + protectedResp); assertThat(protectedResp.getStatusCode().value()).isIn(401, 403); } @Test void testAdminTokenBlacklist() { // 登录获取token AdminLoginParam loginDto = new AdminLoginParam(); loginDto.setUsername("admintest001"); loginDto.setPassword("admin123456"); ResponseEntity<Response> loginResp = restTemplate.postForEntity( "/api/admin/login", loginDto, Response.class); assertThat(loginResp.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(loginResp.getBody().getCode()).isEqualTo("Success"); String token = ((Map<String, Object>)loginResp.getBody().getData()).get("token").toString(); // 调用登出接口,将token加入黑名单 HttpHeaders headers = new HttpHeaders(); headers.set("Authorization", "Bearer " + token); HttpEntity<Void> entity = new HttpEntity<>(headers); // 修正:带上portalId参数 ResponseEntity<Response> logoutResp = restTemplate.postForEntity( "/api/admin/logout?portalId=default", entity, Response.class); System.out.println("管理员登出响应:" + logoutResp); assertThat(logoutResp.getStatusCode()).isEqualTo(HttpStatus.OK); // 再次用该token访问受保护接口,预期401或403 ResponseEntity<String> protectedResp = restTemplate.exchange( "/api/admin/profile", HttpMethod.GET, entity, String.class); System.out.println("管理员黑名单token访问受保护接口响应:" + protectedResp); assertThat(protectedResp.getStatusCode().value()).isIn(401, 403); } @Test void testNeedInitBeforeAndAfterInit() { // 初始化前,need-init 应为 true ResponseEntity<Response> respBefore = restTemplate.getForEntity( "/api/admin/need-init?portalId=default", Response.class); assertThat(respBefore.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(respBefore.getBody()).isNotNull(); assertThat(respBefore.getBody().getCode()).isEqualTo("SUCCESS"); assertThat(respBefore.getBody().getData()).isEqualTo(true); assertThat(respBefore.getBody().getMessage()).isNotNull(); // 初始化 AdminCreateParam createDto = new AdminCreateParam(); createDto.setUsername("admintest002"); createDto.setPassword("admin123456"); ResponseEntity<Response> initResp = restTemplate.postForEntity( "/api/admin/init", createDto, Response.class); assertThat(initResp.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(initResp.getBody()).isNotNull(); assertThat(initResp.getBody().getCode()).isEqualTo("SUCCESS"); assertThat(initResp.getBody().getMessage()).isNotNull(); // 初始化后,need-init 应为 false ResponseEntity<Response<Boolean>> respAfter = restTemplate.exchange( "/api/admin/need-init?portalId=default", HttpMethod.GET, null, new org.springframework.core.ParameterizedTypeReference<Response<Boolean>>() {} ); assertThat(respAfter.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(respAfter.getBody()).isNotNull(); assertThat(respAfter.getBody().getCode()).isEqualTo("SUCCESS"); assertThat(respAfter.getBody().getData()).isEqualTo(false); assertThat(respAfter.getBody().getMessage()).isNotNull(); } @Test void testChangePasswordSuccessAndFail() { // 初始化并登录 AdminCreateParam createDto = new AdminCreateParam(); createDto.setUsername("admintest004"); createDto.setPassword("admin123456"); restTemplate.postForEntity("/api/admin/init", createDto, Response.class); AdminLoginParam loginDto = new AdminLoginParam(); loginDto.setUsername("admintest004"); loginDto.setPassword("admin123456"); ResponseEntity<Response> loginResp = restTemplate.postForEntity( "/api/admin/login", loginDto, Response.class); String token = ((Map<String, Object>)loginResp.getBody().getData()).get("token").toString(); String adminId = ((Map<String, Object>)loginResp.getBody().getData()).get("userId").toString(); // 正确修改密码 HttpHeaders headers = new HttpHeaders(); headers.set("Authorization", "Bearer " + token); headers.setContentType(org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED); MultiValueMap<String, String> emptyBody = new LinkedMultiValueMap<>(); String changeUrl = String.format("/api/admin/change-password?portalId=%s&adminId=%s&oldPassword=%s&newPassword=%s", "default", adminId, "admin123456", "admin654321"); HttpEntity<MultiValueMap<String, String>> changeEntity = new HttpEntity<>(emptyBody, headers); ResponseEntity<Response> changeResp = restTemplate.postForEntity( changeUrl, changeEntity, Response.class); assertThat(changeResp.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(changeResp.getBody()).isNotNull(); assertThat(changeResp.getBody().getCode()).isEqualTo("SUCCESS"); assertThat(changeResp.getBody().getMessage()).isNotNull(); // 原密码错误 String wrongUrl = String.format("/api/admin/change-password?portalId=%s&adminId=%s&oldPassword=%s&newPassword=%s", "default", adminId, "wrongpass", "admin654321"); HttpEntity<MultiValueMap<String, String>> wrongEntity = new HttpEntity<>(emptyBody, headers); ResponseEntity<Response> failResp = restTemplate.postForEntity( wrongUrl, wrongEntity, Response.class); assertThat(failResp.getStatusCode().value()).isIn(401, 400, 409, 403); assertThat(failResp.getBody()).isNotNull(); assertThat(failResp.getBody().getCode()).isIn("ADMIN_PASSWORD_INCORRECT", "INVALID_PARAMETER"); assertThat(failResp.getBody().getMessage()).isNotNull(); } } ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/api-product/ApiProductUsageGuide.tsx: -------------------------------------------------------------------------------- ```typescript import { Card, Button, Space, message } from 'antd' import { SaveOutlined, UploadOutlined, FileMarkdownOutlined, EditOutlined } from '@ant-design/icons' import { useEffect, useState, useRef } from 'react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm'; import MdEditor from 'react-markdown-editor-lite' import 'react-markdown-editor-lite/lib/index.css' import type { ApiProduct } from '@/types/api-product' import { apiProductApi } from '@/lib/api' interface ApiProductUsageGuideProps { apiProduct: ApiProduct handleRefresh: () => void } export function ApiProductUsageGuide({ apiProduct, handleRefresh }: ApiProductUsageGuideProps) { const [content, setContent] = useState(apiProduct.document || '') const [isEditing, setIsEditing] = useState(false) const [originalContent, setOriginalContent] = useState(apiProduct.document || '') const fileInputRef = useRef<HTMLInputElement>(null) useEffect(() => { const doc = apiProduct.document || '' setContent(doc) setOriginalContent(doc) }, [apiProduct.document]) const handleEdit = () => { setIsEditing(true) } const handleSave = () => { apiProductApi.updateApiProduct(apiProduct.productId, { document: content }).then(() => { message.success('保存成功') setIsEditing(false) setOriginalContent(content) handleRefresh(); }) } const handleCancel = () => { setContent(originalContent) setIsEditing(false) } const handleEditorChange = ({ text }: { text: string }) => { setContent(text) } const handleFileImport = (event: React.ChangeEvent<HTMLInputElement>) => { const file = event.target.files?.[0] if (file) { if (file.type !== 'text/markdown' && !file.name.endsWith('.md')) { message.error('请选择 Markdown 文件 (.md)') return } const reader = new FileReader() reader.onload = (e) => { const content = e.target?.result as string setContent(content) setIsEditing(true) message.success('文件导入成功') } reader.readAsText(file) } // 清空 input 值,允许重复选择同一文件 if (event.target) { event.target.value = '' } } const triggerFileInput = () => { fileInputRef.current?.click() } 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">编辑和发布使用指南</p> </div> <Space> {isEditing ? ( <> <Button icon={<UploadOutlined />} onClick={triggerFileInput}> 导入文件 </Button> <Button onClick={handleCancel}> 取消 </Button> <Button type="primary" icon={<SaveOutlined />} onClick={handleSave}> 保存 </Button> </> ) : ( <> <Button icon={<UploadOutlined />} onClick={triggerFileInput}> 导入文件 </Button> <Button type="primary" icon={<EditOutlined />} onClick={handleEdit}> 编辑 </Button> </> )} </Space> </div> <Card> {isEditing ? ( <> <MdEditor value={content} onChange={handleEditorChange} style={{ height: '600px', width: '100%' }} placeholder="请输入使用指南内容..." renderHTML={(text) => <ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>} canView={{ menu: true, md: true, html: true, both: true, fullScreen: false, hideMenu: false }} htmlClass="custom-html-style" markdownClass="custom-markdown-style" /> <div className="mt-4 text-sm text-gray-500"> 💡 支持Markdown格式:代码块、表格、链接、图片等语法 </div> </> ) : ( <div className="min-h-[400px]"> {content ? ( <div className="prose prose-lg max-w-none" style={{ lineHeight: '1.7', color: '#374151', fontSize: '16px', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif' }} > <style>{` .prose h1 { color: #111827; font-weight: 700; font-size: 2.25rem; line-height: 1.2; margin-top: 0; margin-bottom: 1.5rem; border-bottom: 2px solid #e5e7eb; padding-bottom: 0.5rem; } .prose h2 { color: #1f2937; font-weight: 600; font-size: 1.875rem; line-height: 1.3; margin-top: 2rem; margin-bottom: 1rem; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.25rem; } .prose h3 { color: #374151; font-weight: 600; font-size: 1.5rem; margin-top: 1.5rem; margin-bottom: 0.75rem; } .prose p { margin-bottom: 1.25rem; color: #4b5563; line-height: 1.7; font-size: 16px; } .prose code { background-color: #f3f4f6; border: 1px solid #e5e7eb; border-radius: 0.375rem; padding: 0.125rem 0.375rem; font-size: 0.875rem; color: #374151; font-weight: 500; } .prose pre { background-color: #1f2937; border-radius: 0.5rem; padding: 1.25rem; overflow-x: auto; margin: 1.5rem 0; border: 1px solid #374151; } .prose pre code { background-color: transparent; border: none; color: #f9fafb; padding: 0; font-size: 0.875rem; font-weight: normal; } .prose blockquote { border-left: 4px solid #3b82f6; padding-left: 1rem; margin: 1.5rem 0; color: #6b7280; font-style: italic; background-color: #f8fafc; padding: 1rem; border-radius: 0.375rem; font-size: 16px; } .prose ul, .prose ol { margin: 1.25rem 0; padding-left: 1.5rem; } .prose ol { list-style-type: decimal; list-style-position: outside; } .prose ul { list-style-type: disc; list-style-position: outside; } .prose li { margin: 0.5rem 0; color: #4b5563; display: list-item; font-size: 16px; } .prose ol li { padding-left: 0.25rem; } .prose ul li { padding-left: 0.25rem; } .prose table { width: 100%; border-collapse: collapse; margin: 1.5rem 0; font-size: 16px; } .prose th, .prose td { border: 1px solid #d1d5db; padding: 0.75rem; text-align: left; } .prose th { background-color: #f9fafb; font-weight: 600; color: #374151; font-size: 16px; } .prose td { color: #4b5563; font-size: 16px; } .prose a { color: #3b82f6; text-decoration: underline; font-weight: 500; transition: color 0.2s; font-size: inherit; } .prose a:hover { color: #1d4ed8; } .prose strong { color: #111827; font-weight: 600; font-size: inherit; } .prose em { color: #6b7280; font-style: italic; font-size: inherit; } .prose hr { border: none; height: 1px; background-color: #e5e7eb; margin: 2rem 0; } `}</style> <ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown> </div> ) : ( <div className="flex items-center justify-center h-[400px] text-gray-400 border-2 border-dashed border-gray-200 rounded-lg"> <div className="text-center"> <FileMarkdownOutlined className="text-4xl mb-4 text-gray-300" /> <p className="text-lg">暂无使用指南</p> <p className="text-sm">点击编辑按钮开始撰写</p> </div> </div> )} </div> )} </Card> {/* 隐藏的文件输入框 */} <input ref={fileInputRef} type="file" accept=".md,text/markdown" onChange={handleFileImport} style={{ display: 'none' }} /> </div> ) } ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/service/gateway/HigressOperator.java: -------------------------------------------------------------------------------- ```java /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.alibaba.apiopenplatform.service.gateway; import cn.hutool.core.map.MapBuilder; import cn.hutool.json.JSONUtil; import com.alibaba.apiopenplatform.dto.result.*; import com.alibaba.apiopenplatform.entity.Gateway; import com.alibaba.apiopenplatform.entity.Consumer; import com.alibaba.apiopenplatform.entity.ConsumerCredential; import com.alibaba.apiopenplatform.service.gateway.client.HigressClient; import com.alibaba.apiopenplatform.support.consumer.ApiKeyConfig; import com.alibaba.apiopenplatform.support.consumer.ConsumerAuthConfig; import com.alibaba.apiopenplatform.support.consumer.HigressAuthConfig; import com.alibaba.apiopenplatform.support.enums.GatewayType; import com.alibaba.apiopenplatform.support.gateway.GatewayConfig; import com.alibaba.apiopenplatform.support.gateway.HigressConfig; import com.alibaba.apiopenplatform.support.product.HigressRefConfig; import lombok.Builder; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpMethod; import org.springframework.stereotype.Service; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @Service @Slf4j public class HigressOperator extends GatewayOperator<HigressClient> { @Override public PageResult<APIResult> fetchHTTPAPIs(Gateway gateway, int page, int size) { throw new UnsupportedOperationException("Higress gateway does not support HTTP APIs"); } @Override public PageResult<APIResult> fetchRESTAPIs(Gateway gateway, int page, int size) { throw new UnsupportedOperationException("Higress gateway does not support REST APIs"); } @Override public PageResult<? extends GatewayMCPServerResult> fetchMcpServers(Gateway gateway, int page, int size) { HigressClient client = getClient(gateway); Map<String, String> queryParams = MapBuilder.<String, String>create() .put("pageNum", String.valueOf(page)) .put("pageSize", String.valueOf(size)) .build(); HigressPageResponse<HigressMCPConfig> response = client.execute("/v1/mcpServer", HttpMethod.GET, queryParams, null, new ParameterizedTypeReference<HigressPageResponse<HigressMCPConfig>>() { }); List<HigressMCPServerResult> mcpServers = response.getData().stream() .map(s -> new HigressMCPServerResult().convertFrom(s)) .collect(Collectors.toList()); return PageResult.of(mcpServers, page, size, response.getTotal()); } @Override public String fetchAPIConfig(Gateway gateway, Object config) { throw new UnsupportedOperationException("Higress gateway does not support fetching API config"); } @Override public String fetchMcpConfig(Gateway gateway, Object conf) { HigressClient client = getClient(gateway); HigressRefConfig config = (HigressRefConfig) conf; HigressResponse<HigressMCPConfig> response = client.execute("/v1/mcpServer/" + config.getMcpServerName(), HttpMethod.GET, null, null, new ParameterizedTypeReference<HigressResponse<HigressMCPConfig>>() { }); MCPConfigResult m = new MCPConfigResult(); HigressMCPConfig higressMCPConfig = response.getData(); m.setMcpServerName(higressMCPConfig.getName()); // mcpServer config MCPConfigResult.MCPServerConfig c = new MCPConfigResult.MCPServerConfig(); c.setPath("/mcp-servers/" + higressMCPConfig.getName()); c.setDomains(higressMCPConfig.getDomains().stream().map(domain -> MCPConfigResult.Domain.builder() .domain(domain) // 默认HTTP .protocol("http") .build()) .collect(Collectors.toList())); m.setMcpServerConfig(c); // tools m.setTools(higressMCPConfig.getRawConfigurations()); // meta MCPConfigResult.McpMetadata meta = new MCPConfigResult.McpMetadata(); meta.setSource(GatewayType.HIGRESS.name()); meta.setCreateFromType(higressMCPConfig.getType()); m.setMeta(meta); return JSONUtil.toJsonStr(m); } @Override public PageResult<GatewayResult> fetchGateways(Object param, int page, int size) { throw new UnsupportedOperationException("Higress gateway does not support fetching Gateways"); } @Override public String createConsumer(Consumer consumer, ConsumerCredential credential, GatewayConfig config) { HigressConfig higressConfig = config.getHigressConfig(); HigressClient client = new HigressClient(higressConfig); client.execute("/v1/consumers", HttpMethod.POST, null, buildHigressConsumer(consumer.getConsumerId(), credential.getApiKeyConfig()), String.class); return consumer.getConsumerId(); } @Override public void updateConsumer(String consumerId, ConsumerCredential credential, GatewayConfig config) { HigressConfig higressConfig = config.getHigressConfig(); HigressClient client = new HigressClient(higressConfig); client.execute("/v1/consumers/" + consumerId, HttpMethod.PUT, null, buildHigressConsumer(consumerId, credential.getApiKeyConfig()), String.class); } @Override public void deleteConsumer(String consumerId, GatewayConfig config) { HigressConfig higressConfig = config.getHigressConfig(); HigressClient client = new HigressClient(higressConfig); client.execute("/v1/consumers/" + consumerId, HttpMethod.DELETE, null, null, String.class); } @Override public boolean isConsumerExists(String consumerId, GatewayConfig config) { // TODO: 实现Higress网关消费者存在性检查 return true; } @Override public ConsumerAuthConfig authorizeConsumer(Gateway gateway, String consumerId, Object refConfig) { HigressRefConfig config = (HigressRefConfig) refConfig; HigressClient client = getClient(gateway); String mcpServerName = config.getMcpServerName(); client.execute("/v1/mcpServer/consumers/", HttpMethod.PUT, null, buildAuthHigressConsumer(mcpServerName, consumerId), Void.class); HigressAuthConfig higressAuthConfig = HigressAuthConfig.builder() .resourceType("MCP_SERVER") .resourceName(mcpServerName) .build(); return ConsumerAuthConfig.builder() .higressAuthConfig(higressAuthConfig) .build(); } @Override public void revokeConsumerAuthorization(Gateway gateway, String consumerId, ConsumerAuthConfig authConfig) { HigressClient client = getClient(gateway); HigressAuthConfig higressAuthConfig = authConfig.getHigressAuthConfig(); if (higressAuthConfig == null) { return; } client.execute("/v1/mcpServer/consumers/", HttpMethod.DELETE, null, buildAuthHigressConsumer(higressAuthConfig.getResourceName(), consumerId), Void.class); } @Override public APIResult fetchAPI(Gateway gateway, String apiId) { throw new UnsupportedOperationException("Higress gateway does not support fetching API"); } @Override public GatewayType getGatewayType() { return GatewayType.HIGRESS; } @Override public String getDashboard(Gateway gateway, String type) { throw new UnsupportedOperationException("Higress gateway does not support getting dashboard"); } @Data @Builder public static class HigressConsumerConfig { private String name; private List<HigressCredentialConfig> credentials; } @Data @Builder public static class HigressCredentialConfig { private String type; private String source; private String key; private List<String> values; } public HigressConsumerConfig buildHigressConsumer(String consumerId, ApiKeyConfig apiKeyConfig) { String source = mapSource(apiKeyConfig.getSource()); List<String> apiKeys = apiKeyConfig.getCredentials().stream() .map(ApiKeyConfig.ApiKeyCredential::getApiKey) .collect(Collectors.toList()); return HigressConsumerConfig.builder() .name(consumerId) .credentials(Collections.singletonList( HigressCredentialConfig.builder() .type("key-auth") .source(source) .key(apiKeyConfig.getKey()) .values(apiKeys) .build()) ) .build(); } @Data public static class HigressMCPConfig { private String name; private String type; private List<String> domains; private String rawConfigurations; } @Data public static class HigressPageResponse<T> { private List<T> data; private int total; } @Data public static class HigressResponse<T> { private T data; } public HigressAuthConsumerConfig buildAuthHigressConsumer(String gatewayName, String consumerId) { return HigressAuthConsumerConfig.builder() .mcpServerName(gatewayName) .consumers(Collections.singletonList(consumerId)) .build(); } @Data @Builder public static class HigressAuthConsumerConfig { private String mcpServerName; private List<String> consumers; } private String mapSource(String source) { if (StringUtils.isBlank(source)) return null; if ("Default".equalsIgnoreCase(source)) return "BEARER"; if ("HEADER".equalsIgnoreCase(source)) return "HEADER"; if ("QueryString".equalsIgnoreCase(source)) return "QUERY"; return source; } } ```