This is page 6 of 9. Use http://codebase.md/higress-group/himarket?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .cursor │ └── rules │ ├── api-style.mdc │ └── project-architecture.mdc ├── .gitignore ├── build.sh ├── deploy │ ├── docker │ │ ├── docker-compose.yml │ │ └── Docker部署说明.md │ └── helm │ ├── Chart.yaml │ ├── Helm部署说明.md │ ├── templates │ │ ├── _helpers.tpl │ │ ├── himarket-admin-cm.yaml │ │ ├── himarket-admin-deployment.yaml │ │ ├── himarket-admin-service.yaml │ │ ├── himarket-frontend-cm.yaml │ │ ├── himarket-frontend-deployment.yaml │ │ ├── himarket-frontend-service.yaml │ │ ├── himarket-server-cm.yaml │ │ ├── himarket-server-deployment.yaml │ │ ├── himarket-server-service.yaml │ │ ├── mysql.yaml │ │ └── serviceaccount.yaml │ └── values.yaml ├── LICENSE ├── NOTICE ├── pom.xml ├── portal-bootstrap │ ├── Dockerfile │ ├── pom.xml │ └── src │ ├── main │ │ ├── java │ │ │ └── com │ │ │ └── alibaba │ │ │ └── apiopenplatform │ │ │ ├── config │ │ │ │ ├── AsyncConfig.java │ │ │ │ ├── FilterConfig.java │ │ │ │ ├── PageConfig.java │ │ │ │ ├── RestTemplateConfig.java │ │ │ │ ├── SecurityConfig.java │ │ │ │ └── SwaggerConfig.java │ │ │ ├── filter │ │ │ │ └── PortalResolvingFilter.java │ │ │ └── PortalApplication.java │ │ └── resources │ │ └── application.yaml │ └── test │ └── java │ └── com │ └── alibaba │ └── apiopenplatform │ └── integration │ └── AdministratorAuthIntegrationTest.java ├── portal-dal │ ├── pom.xml │ └── src │ └── main │ └── java │ └── com │ └── alibaba │ └── apiopenplatform │ ├── converter │ │ ├── AdpAIGatewayConfigConverter.java │ │ ├── APIGConfigConverter.java │ │ ├── APIGRefConfigConverter.java │ │ ├── ApiKeyConfigConverter.java │ │ ├── ConsumerAuthConfigConverter.java │ │ ├── GatewayConfigConverter.java │ │ ├── HigressConfigConverter.java │ │ ├── HigressRefConfigConverter.java │ │ ├── HmacConfigConverter.java │ │ ├── JsonConverter.java │ │ ├── JwtConfigConverter.java │ │ ├── NacosRefConfigConverter.java │ │ ├── PortalSettingConfigConverter.java │ │ ├── PortalUiConfigConverter.java │ │ └── ProductIconConverter.java │ ├── entity │ │ ├── Administrator.java │ │ ├── BaseEntity.java │ │ ├── Consumer.java │ │ ├── ConsumerCredential.java │ │ ├── ConsumerRef.java │ │ ├── Developer.java │ │ ├── DeveloperExternalIdentity.java │ │ ├── Gateway.java │ │ ├── NacosInstance.java │ │ ├── Portal.java │ │ ├── PortalDomain.java │ │ ├── Product.java │ │ ├── ProductPublication.java │ │ ├── ProductRef.java │ │ └── ProductSubscription.java │ ├── repository │ │ ├── AdministratorRepository.java │ │ ├── BaseRepository.java │ │ ├── ConsumerCredentialRepository.java │ │ ├── ConsumerRefRepository.java │ │ ├── ConsumerRepository.java │ │ ├── DeveloperExternalIdentityRepository.java │ │ ├── DeveloperRepository.java │ │ ├── GatewayRepository.java │ │ ├── NacosInstanceRepository.java │ │ ├── PortalDomainRepository.java │ │ ├── PortalRepository.java │ │ ├── ProductPublicationRepository.java │ │ ├── ProductRefRepository.java │ │ ├── ProductRepository.java │ │ └── SubscriptionRepository.java │ └── support │ ├── common │ │ ├── Encrypted.java │ │ ├── Encryptor.java │ │ └── User.java │ ├── consumer │ │ ├── AdpAIAuthConfig.java │ │ ├── APIGAuthConfig.java │ │ ├── ApiKeyConfig.java │ │ ├── ConsumerAuthConfig.java │ │ ├── HigressAuthConfig.java │ │ ├── HmacConfig.java │ │ └── JwtConfig.java │ ├── enums │ │ ├── APIGAPIType.java │ │ ├── ConsumerAuthType.java │ │ ├── ConsumerStatus.java │ │ ├── CredentialMode.java │ │ ├── DeveloperAuthType.java │ │ ├── DeveloperStatus.java │ │ ├── DomainType.java │ │ ├── GatewayType.java │ │ ├── GrantType.java │ │ ├── HigressAPIType.java │ │ ├── JwtAlgorithm.java │ │ ├── ProductIconType.java │ │ ├── ProductStatus.java │ │ ├── ProductType.java │ │ ├── ProtocolType.java │ │ ├── PublicKeyFormat.java │ │ ├── SourceType.java │ │ ├── SubscriptionStatus.java │ │ └── UserType.java │ ├── gateway │ │ ├── AdpAIGatewayConfig.java │ │ ├── APIGConfig.java │ │ ├── GatewayConfig.java │ │ └── HigressConfig.java │ ├── portal │ │ ├── AuthCodeConfig.java │ │ ├── IdentityMapping.java │ │ ├── JwtBearerConfig.java │ │ ├── OAuth2Config.java │ │ ├── OidcConfig.java │ │ ├── PortalSettingConfig.java │ │ ├── PortalUiConfig.java │ │ └── PublicKeyConfig.java │ └── product │ ├── APIGRefConfig.java │ ├── HigressRefConfig.java │ ├── NacosRefConfig.java │ └── ProductIcon.java ├── portal-server │ ├── pom.xml │ └── src │ └── main │ └── java │ └── com │ └── alibaba │ └── apiopenplatform │ ├── controller │ │ ├── AdministratorController.java │ │ ├── ConsumerController.java │ │ ├── DeveloperController.java │ │ ├── GatewayController.java │ │ ├── NacosController.java │ │ ├── OAuth2Controller.java │ │ ├── OidcController.java │ │ ├── PortalController.java │ │ └── ProductController.java │ ├── core │ │ ├── advice │ │ │ ├── ExceptionAdvice.java │ │ │ └── ResponseAdvice.java │ │ ├── annotation │ │ │ ├── AdminAuth.java │ │ │ ├── AdminOrDeveloperAuth.java │ │ │ └── DeveloperAuth.java │ │ ├── constant │ │ │ ├── CommonConstants.java │ │ │ ├── IdpConstants.java │ │ │ ├── JwtConstants.java │ │ │ └── Resources.java │ │ ├── event │ │ │ ├── DeveloperDeletingEvent.java │ │ │ ├── PortalDeletingEvent.java │ │ │ └── ProductDeletingEvent.java │ │ ├── exception │ │ │ ├── BusinessException.java │ │ │ └── ErrorCode.java │ │ ├── response │ │ │ └── Response.java │ │ ├── security │ │ │ ├── ContextHolder.java │ │ │ ├── DeveloperAuthenticationProvider.java │ │ │ └── JwtAuthenticationFilter.java │ │ └── utils │ │ ├── IdGenerator.java │ │ ├── PasswordHasher.java │ │ └── TokenUtil.java │ ├── dto │ │ ├── converter │ │ │ ├── InputConverter.java │ │ │ ├── NacosToGatewayToolsConverter.java │ │ │ └── OutputConverter.java │ │ ├── params │ │ │ ├── admin │ │ │ │ ├── AdminCreateParam.java │ │ │ │ ├── AdminLoginParam.java │ │ │ │ └── ResetPasswordParam.java │ │ │ ├── consumer │ │ │ │ ├── CreateConsumerParam.java │ │ │ │ ├── CreateCredentialParam.java │ │ │ │ ├── CreateSubscriptionParam.java │ │ │ │ ├── QueryConsumerParam.java │ │ │ │ ├── QuerySubscriptionParam.java │ │ │ │ └── UpdateCredentialParam.java │ │ │ ├── developer │ │ │ │ ├── CreateDeveloperParam.java │ │ │ │ ├── CreateExternalDeveloperParam.java │ │ │ │ ├── DeveloperLoginParam.java │ │ │ │ ├── QueryDeveloperParam.java │ │ │ │ ├── UnbindExternalIdentityParam.java │ │ │ │ ├── UpdateDeveloperParam.java │ │ │ │ └── UpdateDeveloperStatusParam.java │ │ │ ├── gateway │ │ │ │ ├── ImportGatewayParam.java │ │ │ │ ├── QueryAdpAIGatewayParam.java │ │ │ │ ├── QueryAPIGParam.java │ │ │ │ └── QueryGatewayParam.java │ │ │ ├── nacos │ │ │ │ ├── CreateNacosParam.java │ │ │ │ ├── QueryNacosNamespaceParam.java │ │ │ │ ├── QueryNacosParam.java │ │ │ │ └── UpdateNacosParam.java │ │ │ ├── portal │ │ │ │ ├── BindDomainParam.java │ │ │ │ ├── CreatePortalParam.java │ │ │ │ └── UpdatePortalParam.java │ │ │ └── product │ │ │ ├── CreateProductParam.java │ │ │ ├── CreateProductRefParam.java │ │ │ ├── PublishProductParam.java │ │ │ ├── QueryProductParam.java │ │ │ ├── QueryProductSubscriptionParam.java │ │ │ ├── UnPublishProductParam.java │ │ │ └── UpdateProductParam.java │ │ └── result │ │ ├── AdminResult.java │ │ ├── AdpGatewayInstanceResult.java │ │ ├── AdpMcpServerListResult.java │ │ ├── AdpMCPServerResult.java │ │ ├── APIConfigResult.java │ │ ├── APIGMCPServerResult.java │ │ ├── APIResult.java │ │ ├── AuthResult.java │ │ ├── ConsumerCredentialResult.java │ │ ├── ConsumerResult.java │ │ ├── DeveloperResult.java │ │ ├── GatewayMCPServerResult.java │ │ ├── GatewayResult.java │ │ ├── HigressMCPServerResult.java │ │ ├── IdpResult.java │ │ ├── IdpState.java │ │ ├── IdpTokenResult.java │ │ ├── MCPConfigResult.java │ │ ├── MCPServerResult.java │ │ ├── MseNacosResult.java │ │ ├── NacosMCPServerResult.java │ │ ├── NacosNamespaceResult.java │ │ ├── NacosResult.java │ │ ├── PageResult.java │ │ ├── PortalResult.java │ │ ├── ProductPublicationResult.java │ │ ├── ProductRefResult.java │ │ ├── ProductResult.java │ │ └── SubscriptionResult.java │ └── service │ ├── AdministratorService.java │ ├── AdpAIGatewayService.java │ ├── ConsumerService.java │ ├── DeveloperService.java │ ├── gateway │ │ ├── AdpAIGatewayOperator.java │ │ ├── AIGatewayOperator.java │ │ ├── APIGOperator.java │ │ ├── client │ │ │ ├── AdpAIGatewayClient.java │ │ │ ├── APIGClient.java │ │ │ ├── GatewayClient.java │ │ │ ├── HigressClient.java │ │ │ ├── PopGatewayClient.java │ │ │ └── SLSClient.java │ │ ├── factory │ │ │ └── HTTPClientFactory.java │ │ ├── GatewayOperator.java │ │ └── HigressOperator.java │ ├── GatewayService.java │ ├── IdpService.java │ ├── impl │ │ ├── AdministratorServiceImpl.java │ │ ├── ConsumerServiceImpl.java │ │ ├── DeveloperServiceImpl.java │ │ ├── GatewayServiceImpl.java │ │ ├── IdpServiceImpl.java │ │ ├── NacosServiceImpl.java │ │ ├── OAuth2ServiceImpl.java │ │ ├── OidcServiceImpl.java │ │ ├── PortalServiceImpl.java │ │ └── ProductServiceImpl.java │ ├── NacosService.java │ ├── OAuth2Service.java │ ├── OidcService.java │ ├── PortalService.java │ └── ProductService.java ├── portal-web │ ├── api-portal-admin │ │ ├── .env │ │ ├── .gitignore │ │ ├── bin │ │ │ ├── replace_var.py │ │ │ └── start.sh │ │ ├── Dockerfile │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── nginx.conf │ │ ├── package.json │ │ ├── postcss.config.js │ │ ├── proxy.conf │ │ ├── public │ │ │ ├── logo.png │ │ │ └── vite.svg │ │ ├── README.md │ │ ├── src │ │ │ ├── aliyunThemeToken.ts │ │ │ ├── App.css │ │ │ ├── App.tsx │ │ │ ├── assets │ │ │ │ └── react.svg │ │ │ ├── components │ │ │ │ ├── api-product │ │ │ │ │ ├── ApiProductApiDocs.tsx │ │ │ │ │ ├── ApiProductDashboard.tsx │ │ │ │ │ ├── ApiProductFormModal.tsx │ │ │ │ │ ├── ApiProductLinkApi.tsx │ │ │ │ │ ├── ApiProductOverview.tsx │ │ │ │ │ ├── ApiProductPolicy.tsx │ │ │ │ │ ├── ApiProductPortal.tsx │ │ │ │ │ ├── ApiProductUsageGuide.tsx │ │ │ │ │ ├── SwaggerUIWrapper.css │ │ │ │ │ └── SwaggerUIWrapper.tsx │ │ │ │ ├── common │ │ │ │ │ ├── AdvancedSearch.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── console │ │ │ │ │ ├── GatewayTypeSelector.tsx │ │ │ │ │ ├── ImportGatewayModal.tsx │ │ │ │ │ ├── ImportHigressModal.tsx │ │ │ │ │ ├── ImportMseNacosModal.tsx │ │ │ │ │ └── NacosTypeSelector.tsx │ │ │ │ ├── icons │ │ │ │ │ └── McpServerIcon.tsx │ │ │ │ ├── Layout.tsx │ │ │ │ ├── LayoutWrapper.tsx │ │ │ │ ├── portal │ │ │ │ │ ├── PortalConsumers.tsx │ │ │ │ │ ├── PortalDashboard.tsx │ │ │ │ │ ├── PortalDevelopers.tsx │ │ │ │ │ ├── PortalDomain.tsx │ │ │ │ │ ├── PortalFormModal.tsx │ │ │ │ │ ├── PortalOverview.tsx │ │ │ │ │ ├── PortalPublishedApis.tsx │ │ │ │ │ ├── PortalSecurity.tsx │ │ │ │ │ ├── PortalSettings.tsx │ │ │ │ │ ├── PublicKeyManager.tsx │ │ │ │ │ └── ThirdPartyAuthManager.tsx │ │ │ │ └── subscription │ │ │ │ └── SubscriptionListModal.tsx │ │ │ ├── contexts │ │ │ │ └── LoadingContext.tsx │ │ │ ├── index.css │ │ │ ├── lib │ │ │ │ ├── api.ts │ │ │ │ ├── constant.ts │ │ │ │ └── utils.ts │ │ │ ├── main.tsx │ │ │ ├── pages │ │ │ │ ├── ApiProductDetail.tsx │ │ │ │ ├── ApiProducts.tsx │ │ │ │ ├── Dashboard.tsx │ │ │ │ ├── GatewayConsoles.tsx │ │ │ │ ├── Login.tsx │ │ │ │ ├── NacosConsoles.tsx │ │ │ │ ├── PortalDetail.tsx │ │ │ │ ├── Portals.tsx │ │ │ │ └── Register.tsx │ │ │ ├── routes │ │ │ │ └── index.tsx │ │ │ ├── types │ │ │ │ ├── api-product.ts │ │ │ │ ├── consumer.ts │ │ │ │ ├── gateway.ts │ │ │ │ ├── index.ts │ │ │ │ ├── portal.ts │ │ │ │ ├── shims-js-yaml.d.ts │ │ │ │ └── subscription.ts │ │ │ └── vite-env.d.ts │ │ ├── tailwind.config.js │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ └── api-portal-frontend │ ├── .env │ ├── .gitignore │ ├── .husky │ │ └── pre-commit │ ├── bin │ │ ├── replace_var.py │ │ └── start.sh │ ├── Dockerfile │ ├── eslint.config.js │ ├── index.html │ ├── nginx.conf │ ├── package.json │ ├── postcss.config.js │ ├── proxy.conf │ ├── public │ │ ├── favicon.ico │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── MCP.png │ │ ├── MCP.svg │ │ └── vite.svg │ ├── README.md │ ├── src │ │ ├── aliyunThemeToken.ts │ │ ├── App.css │ │ ├── App.tsx │ │ ├── assets │ │ │ ├── aliyun.png │ │ │ ├── github.png │ │ │ ├── google.png │ │ │ └── react.svg │ │ ├── components │ │ │ ├── consumer │ │ │ │ ├── ConsumerBasicInfo.tsx │ │ │ │ ├── CredentialManager.tsx │ │ │ │ ├── index.ts │ │ │ │ └── SubscriptionManager.tsx │ │ │ ├── Layout.tsx │ │ │ ├── Navigation.tsx │ │ │ ├── ProductHeader.tsx │ │ │ ├── SwaggerUIWrapper.css │ │ │ ├── SwaggerUIWrapper.tsx │ │ │ └── UserInfo.tsx │ │ ├── index.css │ │ ├── lib │ │ │ ├── api.ts │ │ │ ├── statusUtils.ts │ │ │ └── utils.ts │ │ ├── main.tsx │ │ ├── pages │ │ │ ├── ApiDetail.tsx │ │ │ ├── Apis.tsx │ │ │ ├── Callback.tsx │ │ │ ├── ConsumerDetail.tsx │ │ │ ├── Consumers.tsx │ │ │ ├── GettingStarted.tsx │ │ │ ├── Home.tsx │ │ │ ├── Login.tsx │ │ │ ├── Mcp.tsx │ │ │ ├── McpDetail.tsx │ │ │ ├── OidcCallback.tsx │ │ │ ├── Profile.tsx │ │ │ ├── Register.tsx │ │ │ └── Test.css │ │ ├── router.tsx │ │ ├── types │ │ │ ├── consumer.ts │ │ │ └── index.ts │ │ └── vite-env.d.ts │ ├── tailwind.config.js │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts └── README.md ``` # Files -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/service/impl/OAuth2ServiceImpl.java: -------------------------------------------------------------------------------- ```java 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one 3 | * or more contributor license agreements. See the NOTICE file 4 | * distributed with this work for additional information 5 | * regarding copyright ownership. The ASF licenses this file 6 | * to you under the Apache License, Version 2.0 (the 7 | * "License"); you may not use this file except in compliance 8 | * with the License. You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | package com.alibaba.apiopenplatform.service.impl; 21 | 22 | import cn.hutool.core.collection.CollUtil; 23 | import cn.hutool.core.convert.Convert; 24 | import cn.hutool.core.util.EnumUtil; 25 | import cn.hutool.core.util.StrUtil; 26 | import cn.hutool.jwt.JWT; 27 | import cn.hutool.jwt.JWTUtil; 28 | import cn.hutool.jwt.signers.JWTSigner; 29 | import cn.hutool.jwt.signers.JWTSignerUtil; 30 | import com.alibaba.apiopenplatform.core.constant.JwtConstants; 31 | import com.alibaba.apiopenplatform.core.constant.Resources; 32 | import com.alibaba.apiopenplatform.core.exception.BusinessException; 33 | import com.alibaba.apiopenplatform.core.exception.ErrorCode; 34 | import com.alibaba.apiopenplatform.core.utils.TokenUtil; 35 | import com.alibaba.apiopenplatform.dto.params.developer.CreateExternalDeveloperParam; 36 | import com.alibaba.apiopenplatform.dto.result.AuthResult; 37 | import com.alibaba.apiopenplatform.dto.result.DeveloperResult; 38 | import com.alibaba.apiopenplatform.dto.result.PortalResult; 39 | import com.alibaba.apiopenplatform.service.DeveloperService; 40 | import com.alibaba.apiopenplatform.service.IdpService; 41 | import com.alibaba.apiopenplatform.service.OAuth2Service; 42 | import com.alibaba.apiopenplatform.service.PortalService; 43 | import com.alibaba.apiopenplatform.support.enums.DeveloperAuthType; 44 | import com.alibaba.apiopenplatform.support.enums.GrantType; 45 | import com.alibaba.apiopenplatform.support.enums.JwtAlgorithm; 46 | import com.alibaba.apiopenplatform.support.portal.*; 47 | import lombok.RequiredArgsConstructor; 48 | import lombok.extern.slf4j.Slf4j; 49 | import org.springframework.stereotype.Service; 50 | 51 | import java.security.PublicKey; 52 | import java.util.*; 53 | 54 | /** 55 | * @author zh 56 | */ 57 | @Service 58 | @RequiredArgsConstructor 59 | @Slf4j 60 | public class OAuth2ServiceImpl implements OAuth2Service { 61 | 62 | private final PortalService portalService; 63 | 64 | private final DeveloperService developerService; 65 | 66 | private final IdpService idpService; 67 | 68 | @Override 69 | public AuthResult authenticate(String grantType, String jwtToken) { 70 | if (!GrantType.JWT_BEARER.getType().equals(grantType)) { 71 | throw new BusinessException(ErrorCode.INVALID_REQUEST, "不支持的授权模式"); 72 | } 73 | 74 | // 解析JWT 75 | JWT jwt = JWTUtil.parseToken(jwtToken); 76 | String kid = (String) jwt.getHeader(JwtConstants.HEADER_KID); 77 | if (StrUtil.isBlank(kid)) { 78 | throw new BusinessException(ErrorCode.INVALID_REQUEST, "JWT header缺少字段kid"); 79 | } 80 | String provider = (String) jwt.getPayload(JwtConstants.PAYLOAD_PROVIDER); 81 | if (StrUtil.isBlank(provider)) { 82 | throw new BusinessException(ErrorCode.INVALID_REQUEST, "JWT payload缺少字段provider"); 83 | } 84 | 85 | String portalId = (String) jwt.getPayload(JwtConstants.PAYLOAD_PORTAL); 86 | if (StrUtil.isBlank(portalId)) { 87 | throw new BusinessException(ErrorCode.INVALID_REQUEST, "JWT payload缺少字段portal"); 88 | } 89 | 90 | // 根据provider确定OAuth2配置 91 | PortalResult portal = portalService.getPortal(portalId); 92 | List<OAuth2Config> oauth2Configs = Optional.ofNullable(portal.getPortalSettingConfig()) 93 | .map(PortalSettingConfig::getOauth2Configs) 94 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.OAUTH2_CONFIG, portalId)); 95 | 96 | OAuth2Config oAuth2Config = oauth2Configs.stream() 97 | // JWT Bearer模式 98 | .filter(config -> config.getGrantType() == GrantType.JWT_BEARER) 99 | .filter(config -> config.getJwtBearerConfig() != null 100 | && CollUtil.isNotEmpty(config.getJwtBearerConfig().getPublicKeys())) 101 | // provider标识 102 | .filter(config -> config.getProvider().equals(provider)) 103 | .findFirst() 104 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.OAUTH2_CONFIG, provider)); 105 | 106 | // 根据kid找到对应公钥 107 | JwtBearerConfig jwtConfig = oAuth2Config.getJwtBearerConfig(); 108 | PublicKeyConfig publicKeyConfig = jwtConfig.getPublicKeys().stream() 109 | .filter(key -> kid.equals(key.getKid())) 110 | .findFirst() 111 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.PUBLIC_KEY, kid)); 112 | 113 | // 验签 114 | if (!verifySignature(jwt, publicKeyConfig)) { 115 | throw new BusinessException(ErrorCode.INVALID_REQUEST, "JWT签名验证失败"); 116 | } 117 | 118 | // 验证Claims 119 | validateJwtClaims(jwt); 120 | 121 | // Developer 122 | String developerId = createOrGetDeveloper(jwt, oAuth2Config); 123 | 124 | // 生成Access Token 125 | String accessToken = TokenUtil.generateDeveloperToken(developerId); 126 | log.info("JWT Bearer认证成功,provider: {}, developer: {}", oAuth2Config.getProvider(), developerId); 127 | return AuthResult.of(accessToken, TokenUtil.getTokenExpiresIn()); 128 | } 129 | 130 | private boolean verifySignature(JWT jwt, PublicKeyConfig keyConfig) { 131 | // 加载公钥 132 | PublicKey publicKey = idpService.loadPublicKey(keyConfig.getFormat(), keyConfig.getValue()); 133 | 134 | // 验证JWT 135 | JWTSigner signer = createJWTSigner(keyConfig.getAlgorithm(), publicKey); 136 | return jwt.setSigner(signer).verify(); 137 | } 138 | 139 | private JWTSigner createJWTSigner(String algorithm, PublicKey publicKey) { 140 | JwtAlgorithm alg = EnumUtil.fromString(JwtAlgorithm.class, algorithm.toUpperCase()); 141 | 142 | switch (alg) { 143 | case RS256: 144 | return JWTSignerUtil.rs256(publicKey); 145 | case RS384: 146 | return JWTSignerUtil.rs384(publicKey); 147 | case RS512: 148 | return JWTSignerUtil.rs512(publicKey); 149 | case ES256: 150 | return JWTSignerUtil.es256(publicKey); 151 | case ES384: 152 | return JWTSignerUtil.es384(publicKey); 153 | case ES512: 154 | return JWTSignerUtil.es512(publicKey); 155 | default: 156 | throw new BusinessException(ErrorCode.INVALID_PARAMETER, "不支持的JWT签名算法"); 157 | } 158 | } 159 | 160 | private void validateJwtClaims(JWT jwt) { 161 | // 过期时间 162 | Object expObj = jwt.getPayload(JwtConstants.PAYLOAD_EXP); 163 | Long exp = Convert.toLong(expObj); 164 | // 签发时间 165 | Object iatObj = jwt.getPayload(JwtConstants.PAYLOAD_IAT); 166 | Long iat = Convert.toLong(iatObj); 167 | 168 | if (iat == null || exp == null || iat > exp) { 169 | throw new BusinessException(ErrorCode.INVALID_REQUEST, "JWT payload中exp或iat不合法"); 170 | } 171 | 172 | long currentTime = System.currentTimeMillis() / 1000; 173 | if (exp <= currentTime) { 174 | throw new BusinessException(ErrorCode.INVALID_REQUEST, "JWT已过期"); 175 | } 176 | } 177 | 178 | private String createOrGetDeveloper(JWT jwt, OAuth2Config config) { 179 | IdentityMapping identityMapping = config.getIdentityMapping(); 180 | // userId & userName 181 | String userIdField = StrUtil.isBlank(identityMapping.getUserIdField()) ? 182 | JwtConstants.PAYLOAD_USER_ID : identityMapping.getUserIdField(); 183 | String userNameField = StrUtil.isBlank(identityMapping.getUserNameField()) ? 184 | JwtConstants.PAYLOAD_USER_NAME : identityMapping.getUserNameField(); 185 | Object userIdObj = jwt.getPayload(userIdField); 186 | Object userNameObj = jwt.getPayload(userNameField); 187 | 188 | String userId = Convert.toStr(userIdObj); 189 | String userName = Convert.toStr(userNameObj); 190 | if (StrUtil.isBlank(userId) || StrUtil.isBlank(userName)) { 191 | throw new BusinessException(ErrorCode.INVALID_REQUEST, "JWT payload中缺少用户ID字段或用户名称"); 192 | } 193 | 194 | // 复用已有的Developer,否则创建 195 | return Optional.ofNullable(developerService.getExternalDeveloper(config.getProvider(), userId)) 196 | .map(DeveloperResult::getDeveloperId) 197 | .orElseGet(() -> { 198 | CreateExternalDeveloperParam param = CreateExternalDeveloperParam.builder() 199 | .provider(config.getProvider()) 200 | .subject(userId) 201 | .displayName(userName) 202 | .authType(DeveloperAuthType.OAUTH2) 203 | .build(); 204 | 205 | return developerService.createExternalDeveloper(param).getDeveloperId(); 206 | }); 207 | } 208 | 209 | } 210 | ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/service/impl/IdpServiceImpl.java: -------------------------------------------------------------------------------- ```java 1 | package com.alibaba.apiopenplatform.service.impl; 2 | 3 | import cn.hutool.core.collection.CollUtil; 4 | import cn.hutool.core.util.StrUtil; 5 | import cn.hutool.json.JSONObject; 6 | import cn.hutool.json.JSONUtil; 7 | import com.alibaba.apiopenplatform.core.constant.IdpConstants; 8 | import com.alibaba.apiopenplatform.core.exception.BusinessException; 9 | import com.alibaba.apiopenplatform.core.exception.ErrorCode; 10 | import com.alibaba.apiopenplatform.service.IdpService; 11 | import com.alibaba.apiopenplatform.support.enums.GrantType; 12 | import com.alibaba.apiopenplatform.support.enums.PublicKeyFormat; 13 | import com.alibaba.apiopenplatform.support.portal.*; 14 | import lombok.RequiredArgsConstructor; 15 | import lombok.extern.slf4j.Slf4j; 16 | import org.springframework.http.HttpMethod; 17 | import org.springframework.stereotype.Service; 18 | import org.springframework.web.client.RestTemplate; 19 | 20 | import java.math.BigInteger; 21 | import java.security.KeyFactory; 22 | import java.security.PublicKey; 23 | import java.security.spec.RSAPublicKeySpec; 24 | import java.security.spec.X509EncodedKeySpec; 25 | import java.util.*; 26 | import java.util.stream.Collectors; 27 | 28 | /** 29 | * @author zh 30 | */ 31 | @Service 32 | @RequiredArgsConstructor 33 | @Slf4j 34 | public class IdpServiceImpl implements IdpService { 35 | 36 | private final RestTemplate restTemplate; 37 | 38 | @Override 39 | public void validateOidcConfigs(List<OidcConfig> oidcConfigs) { 40 | if (CollUtil.isEmpty(oidcConfigs)) { 41 | return; 42 | } 43 | 44 | // provider唯一 45 | Set<String> providers = oidcConfigs.stream() 46 | .map(OidcConfig::getProvider) 47 | .filter(StrUtil::isNotBlank) 48 | .collect(Collectors.toSet()); 49 | if (providers.size() != oidcConfigs.size()) { 50 | throw new BusinessException(ErrorCode.CONFLICT, "OIDC配置中存在空或重复的provider"); 51 | } 52 | 53 | oidcConfigs.forEach(config -> { 54 | AuthCodeConfig authConfig = Optional.ofNullable(config.getAuthCodeConfig()) 55 | .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_PARAMETER, 56 | StrUtil.format("OIDC配置{}缺少授权码配置", config.getProvider()))); 57 | // 基础参数 58 | if (StrUtil.isBlank(authConfig.getClientId()) || 59 | StrUtil.isBlank(authConfig.getClientSecret()) || 60 | StrUtil.isBlank(authConfig.getScopes())) { 61 | throw new BusinessException(ErrorCode.INVALID_PARAMETER, 62 | StrUtil.format("OIDC配置{}缺少必要参数: Client ID, Client Secret 或 Scopes", config.getProvider())); 63 | } 64 | 65 | // 端点配置 66 | if (StrUtil.isNotBlank(authConfig.getIssuer())) { 67 | discoverAndSetEndpoints(config.getProvider(), authConfig); 68 | } else { 69 | if (StrUtil.isBlank(authConfig.getAuthorizationEndpoint()) || 70 | StrUtil.isBlank(authConfig.getTokenEndpoint()) || 71 | StrUtil.isBlank(authConfig.getUserInfoEndpoint())) { 72 | throw new BusinessException(ErrorCode.INVALID_PARAMETER, 73 | StrUtil.format("OIDC配置{}缺少必要端点配置", config.getProvider())); 74 | } 75 | } 76 | }); 77 | } 78 | 79 | @SuppressWarnings("unchecked") 80 | private void discoverAndSetEndpoints(String provider, AuthCodeConfig config) { 81 | String discoveryUrl = config.getIssuer().replaceAll("/$", "") + "/.well-known/openid-configuration"; 82 | try { 83 | Map<String, Object> discovery = restTemplate.exchange( 84 | discoveryUrl, 85 | HttpMethod.GET, 86 | null, 87 | Map.class) 88 | .getBody(); 89 | 90 | // 验证并设置端点 91 | String authEndpoint = getRequiredEndpoint(discovery, IdpConstants.AUTHORIZATION_ENDPOINT); 92 | String tokenEndpoint = getRequiredEndpoint(discovery, IdpConstants.TOKEN_ENDPOINT); 93 | String userInfoEndpoint = getRequiredEndpoint(discovery, IdpConstants.USERINFO_ENDPOINT); 94 | 95 | config.setAuthorizationEndpoint(authEndpoint); 96 | config.setTokenEndpoint(tokenEndpoint); 97 | config.setUserInfoEndpoint(userInfoEndpoint); 98 | } catch (Exception e) { 99 | log.error("Failed to discover OIDC endpoints from discovery URL: {}", discoveryUrl, e); 100 | throw new BusinessException(ErrorCode.INVALID_PARAMETER, StrUtil.format("OIDC配置{}的Issuer无效或无法访问", provider)); 101 | } 102 | } 103 | 104 | private String getRequiredEndpoint(Map<String, Object> discovery, String name) { 105 | return Optional.ofNullable(discovery.get(name)) 106 | .map(Object::toString) 107 | .filter(StrUtil::isNotBlank) 108 | .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_PARAMETER, 109 | "OIDC Discovery配置中缺少端点: " + name)); 110 | } 111 | 112 | @Override 113 | public void validateOAuth2Configs(List<OAuth2Config> oauth2Configs) { 114 | if (CollUtil.isEmpty(oauth2Configs)) { 115 | return; 116 | } 117 | 118 | // provider唯一 119 | Set<String> providers = oauth2Configs.stream() 120 | .map(OAuth2Config::getProvider) 121 | .filter(StrUtil::isNotBlank) 122 | .collect(Collectors.toSet()); 123 | if (providers.size() != oauth2Configs.size()) { 124 | throw new BusinessException(ErrorCode.CONFLICT, "OAuth2配置中存在空或重复的provider"); 125 | } 126 | 127 | oauth2Configs.forEach(config -> { 128 | if (GrantType.JWT_BEARER.equals(config.getGrantType())) { 129 | validateJwtBearerConfig(config); 130 | } 131 | }); 132 | } 133 | 134 | private void validateJwtBearerConfig(OAuth2Config config) { 135 | JwtBearerConfig jwtBearerConfig = config.getJwtBearerConfig(); 136 | if (jwtBearerConfig == null) { 137 | throw new BusinessException(ErrorCode.INVALID_PARAMETER, 138 | StrUtil.format("OAuth2配置{}使用JWT断言模式但缺少JWT断言配置", config.getProvider())); 139 | } 140 | 141 | List<PublicKeyConfig> publicKeys = jwtBearerConfig.getPublicKeys(); 142 | if (CollUtil.isEmpty(publicKeys)) { 143 | throw new BusinessException(ErrorCode.INVALID_PARAMETER, 144 | StrUtil.format("OAuth2配置{}缺少公钥配置", config.getProvider())); 145 | } 146 | 147 | if (publicKeys.stream() 148 | .map(key -> { 149 | // 加载公钥验证有效性 150 | loadPublicKey(key.getFormat(), key.getValue()); 151 | return key.getKid(); 152 | }) 153 | .collect(Collectors.toSet()).size() != publicKeys.size()) { 154 | throw new BusinessException(ErrorCode.CONFLICT, 155 | StrUtil.format("OAuth2配置{}的公钥ID存在重复", config.getProvider())); 156 | } 157 | } 158 | 159 | 160 | @Override 161 | public PublicKey loadPublicKey(PublicKeyFormat format, String publicKey) { 162 | switch (format) { 163 | case PEM: 164 | return loadPublicKeyFromPem(publicKey); 165 | case JWK: 166 | return loadPublicKeyFromJwk(publicKey); 167 | default: 168 | throw new BusinessException(ErrorCode.INVALID_PARAMETER, "公钥格式不支持"); 169 | } 170 | } 171 | 172 | private PublicKey loadPublicKeyFromPem(String pemContent) { 173 | // 清理PEM格式标记和空白字符 174 | String publicKeyPEM = pemContent 175 | .replace("-----BEGIN PUBLIC KEY-----", "") 176 | .replace("-----END PUBLIC KEY-----", "") 177 | .replace("-----BEGIN RSA PUBLIC KEY-----", "") 178 | .replace("-----END RSA PUBLIC KEY-----", "") 179 | .replaceAll("\\s", ""); 180 | 181 | if (StrUtil.isBlank(publicKeyPEM)) { 182 | throw new IllegalArgumentException("PEM内容为空"); 183 | } 184 | 185 | try { 186 | // Base64解码 187 | byte[] decoded = Base64.getDecoder().decode(publicKeyPEM); 188 | 189 | // 公钥对象 190 | X509EncodedKeySpec spec = new X509EncodedKeySpec(decoded); 191 | KeyFactory keyFactory = KeyFactory.getInstance("RSA"); 192 | return keyFactory.generatePublic(spec); 193 | } catch (Exception e) { 194 | log.error("PEM公钥解析失败", e); 195 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "PEM公钥解析失败: " + e.getMessage()); 196 | } 197 | } 198 | 199 | private PublicKey loadPublicKeyFromJwk(String jwkContent) { 200 | JSONObject jwk = JSONUtil.parseObj(jwkContent); 201 | 202 | // 验证必需字段 203 | String kty = getRequiredField(jwk, "kty"); 204 | if (!"RSA".equals(kty)) { 205 | throw new IllegalArgumentException("当前仅支持RSA类型的JWK"); 206 | } 207 | 208 | return loadRSAPublicKeyFromJwk(jwk); 209 | } 210 | 211 | private PublicKey loadRSAPublicKeyFromJwk(JSONObject jwk) { 212 | // 获取必需的RSA参数 213 | String nStr = getRequiredField(jwk, "n"); 214 | String eStr = getRequiredField(jwk, "e"); 215 | 216 | try { 217 | // Base64解码参数 218 | byte[] nBytes = Base64.getUrlDecoder().decode(nStr); 219 | byte[] eBytes = Base64.getUrlDecoder().decode(eStr); 220 | 221 | // 构建RSA公钥 222 | BigInteger modulus = new BigInteger(1, nBytes); 223 | BigInteger exponent = new BigInteger(1, eBytes); 224 | 225 | RSAPublicKeySpec spec = new RSAPublicKeySpec(modulus, exponent); 226 | KeyFactory keyFactory = KeyFactory.getInstance("RSA"); 227 | return keyFactory.generatePublic(spec); 228 | } catch (Exception e) { 229 | log.error("JWK RSA参数解析失败", e); 230 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "JWK RSA参数解析失败: " + e.getMessage()); 231 | } 232 | } 233 | 234 | private String getRequiredField(JSONObject jwk, String fieldName) { 235 | String value = jwk.getStr(fieldName); 236 | if (StrUtil.isBlank(value)) { 237 | throw new BusinessException(ErrorCode.INVALID_REQUEST, "JWK中缺少字段: " + fieldName); 238 | } 239 | return value; 240 | } 241 | } 242 | ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/portal/PublicKeyManager.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import {useState} from 'react' 2 | import {Button, Form, Input, Select, Table, Modal, Space, Tag, message, Card, Row, Col} from 'antd' 3 | import {PlusOutlined, EditOutlined, DeleteOutlined, ExclamationCircleOutlined} from '@ant-design/icons' 4 | import {PublicKeyConfig, PublicKeyFormat} from '@/types' 5 | 6 | interface PublicKeyManagerProps { 7 | provider?: string | null 8 | publicKeys: PublicKeyConfig[] 9 | onSave: (publicKeys: PublicKeyConfig[]) => void 10 | } 11 | 12 | interface PublicKeyFormData { 13 | kid: string 14 | format: PublicKeyFormat 15 | algorithm: string 16 | value: string 17 | } 18 | 19 | export function PublicKeyManager({provider, publicKeys, onSave}: PublicKeyManagerProps) { 20 | const [form] = Form.useForm<PublicKeyFormData>() 21 | const [modalVisible, setModalVisible] = useState(false) 22 | const [editingIndex, setEditingIndex] = useState<number | null>(null) 23 | const [localPublicKeys, setLocalPublicKeys] = useState<PublicKeyConfig[]>(publicKeys) 24 | const [selectedFormat, setSelectedFormat] = useState<PublicKeyFormat>(PublicKeyFormat.PEM) 25 | 26 | const handleAdd = () => { 27 | setEditingIndex(null) 28 | setModalVisible(true) 29 | form.resetFields() 30 | setSelectedFormat(PublicKeyFormat.PEM) 31 | form.setFieldsValue({ 32 | format: PublicKeyFormat.PEM, 33 | algorithm: 'RS256' 34 | }) 35 | } 36 | 37 | const handleEdit = (index: number) => { 38 | setEditingIndex(index) 39 | setModalVisible(true) 40 | const publicKey = localPublicKeys[index] 41 | setSelectedFormat(publicKey.format) 42 | form.setFieldsValue(publicKey) 43 | } 44 | 45 | const handleDelete = (index: number) => { 46 | Modal.confirm({ 47 | title: '确认删除', 48 | icon: <ExclamationCircleOutlined/>, 49 | content: '确定要删除这个公钥配置吗?', 50 | okText: '确认删除', 51 | okType: 'danger', 52 | cancelText: '取消', 53 | onOk() { 54 | const updatedKeys = localPublicKeys.filter((_, i) => i !== index) 55 | setLocalPublicKeys(updatedKeys) 56 | onSave(updatedKeys) 57 | message.success('公钥删除成功') 58 | }, 59 | }) 60 | } 61 | 62 | const handleModalOk = async () => { 63 | try { 64 | const values = await form.validateFields() 65 | 66 | // 验证Kid的唯一性 67 | const isKidExists = localPublicKeys.some((key, index) => 68 | key.kid === values.kid && index !== editingIndex 69 | ) 70 | 71 | if (isKidExists) { 72 | message.error('公钥ID已存在,请使用不同的ID') 73 | return 74 | } 75 | 76 | let updatedKeys 77 | if (editingIndex !== null) { 78 | // 编辑模式 79 | updatedKeys = localPublicKeys.map((key, index) => 80 | index === editingIndex ? values as PublicKeyConfig : key 81 | ) 82 | } else { 83 | // 新增模式 84 | updatedKeys = [...localPublicKeys, values as PublicKeyConfig] 85 | } 86 | 87 | setLocalPublicKeys(updatedKeys) 88 | onSave(updatedKeys) 89 | setModalVisible(false) 90 | message.success(editingIndex !== null ? '公钥更新成功' : '公钥添加成功') 91 | } catch (error) { 92 | message.error('保存公钥失败') 93 | } 94 | } 95 | 96 | const handleModalCancel = () => { 97 | setModalVisible(false) 98 | setEditingIndex(null) 99 | setSelectedFormat(PublicKeyFormat.PEM) 100 | form.resetFields() 101 | } 102 | 103 | // 验证公钥内容格式 104 | const validatePublicKey = (_: any, value: string) => { 105 | if (!value) { 106 | return Promise.reject(new Error('请输入公钥内容')) 107 | } 108 | 109 | if (selectedFormat === PublicKeyFormat.PEM) { 110 | // 简单的PEM格式验证 111 | if (!value.includes('-----BEGIN') || !value.includes('-----END')) { 112 | return Promise.reject(new Error('PEM格式公钥应包含BEGIN和END标记')) 113 | } 114 | } else if (selectedFormat === PublicKeyFormat.JWK) { 115 | // 简单的JWK格式验证 116 | try { 117 | const jwk = JSON.parse(value) 118 | if (!jwk.kty || !jwk.n || !jwk.e) { 119 | return Promise.reject(new Error('JWK格式应包含kty、n、e字段')) 120 | } 121 | } catch { 122 | return Promise.reject(new Error('JWK格式应为有效的JSON')) 123 | } 124 | } 125 | 126 | return Promise.resolve() 127 | } 128 | 129 | const columns = [ 130 | { 131 | title: '公钥ID (kid)', 132 | dataIndex: 'kid', 133 | key: 'kid', 134 | render: (kid: string) => ( 135 | <Tag color="blue">{kid}</Tag> 136 | ) 137 | }, 138 | { 139 | title: '格式', 140 | dataIndex: 'format', 141 | key: 'format', 142 | render: (format: PublicKeyFormat) => ( 143 | <Tag color={format === PublicKeyFormat.PEM ? 'green' : 'orange'}> 144 | {format} 145 | </Tag> 146 | ) 147 | }, 148 | { 149 | title: '算法', 150 | dataIndex: 'algorithm', 151 | key: 'algorithm', 152 | render: (algorithm: string) => ( 153 | <Tag color="purple">{algorithm}</Tag> 154 | ) 155 | }, 156 | { 157 | title: '公钥内容', 158 | key: 'value', 159 | render: (record: PublicKeyConfig) => ( 160 | <span className="font-mono text-xs text-gray-600"> 161 | {record.format === PublicKeyFormat.PEM 162 | ? record.value.substring(0, 50) + '...' 163 | : JSON.stringify(JSON.parse(record.value || '{}')).substring(0, 50) + '...' 164 | } 165 | </span> 166 | ) 167 | }, 168 | { 169 | title: '操作', 170 | key: 'action', 171 | render: (_: any, _record: PublicKeyConfig, index: number) => ( 172 | <Space> 173 | <Button 174 | type="link" 175 | size="small" 176 | icon={<EditOutlined/>} 177 | onClick={() => handleEdit(index)} 178 | > 179 | 编辑 180 | </Button> 181 | <Button 182 | type="link" 183 | danger 184 | size="small" 185 | icon={<DeleteOutlined/>} 186 | onClick={() => handleDelete(index)} 187 | > 188 | 删除 189 | </Button> 190 | </Space> 191 | ) 192 | } 193 | ] 194 | 195 | return ( 196 | <div> 197 | <div className="flex justify-between items-center mb-4"> 198 | <div> 199 | <h4 className="text-lg font-medium"> 200 | {provider && `${provider} - `}JWT签名公钥管理 201 | </h4> 202 | <p className="text-sm text-gray-500"> 203 | 管理用于验证JWT签名的公钥,支持PEM和JWK格式 204 | </p> 205 | </div> 206 | <Button 207 | type="primary" 208 | icon={<PlusOutlined/>} 209 | onClick={handleAdd} 210 | > 211 | 添加公钥 212 | </Button> 213 | </div> 214 | 215 | <Table 216 | columns={columns} 217 | dataSource={localPublicKeys} 218 | rowKey="kid" 219 | pagination={false} 220 | size="small" 221 | locale={{ 222 | emptyText: '暂无公钥配置' 223 | }} 224 | /> 225 | 226 | {/* 公钥配置说明 */} 227 | <Card size="small" className="mt-4 bg-blue-50"> 228 | <Row gutter={16}> 229 | <Col span={12}> 230 | <div className="text-sm"> 231 | <h5 className="font-medium mb-2 text-blue-800">PEM格式示例:</h5> 232 | <div className="bg-white p-2 rounded font-mono text-xs border"> 233 | -----BEGIN PUBLIC KEY-----<br/> 234 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...<br/> 235 | -----END PUBLIC KEY----- 236 | </div> 237 | </div> 238 | </Col> 239 | <Col span={12}> 240 | <div className="text-sm"> 241 | <h5 className="font-medium mb-2 text-blue-800">JWK格式示例:</h5> 242 | <div className="bg-white p-2 rounded font-mono text-xs border"> 243 | {`{ 244 | "kty": "RSA", 245 | "kid": "key1", 246 | "n": "...", 247 | "e": "AQAB" 248 | }`} 249 | </div> 250 | </div> 251 | </Col> 252 | </Row> 253 | </Card> 254 | 255 | {/* 公钥配置模态框 */} 256 | <Modal 257 | title={editingIndex !== null ? '编辑公钥' : '添加公钥'} 258 | open={modalVisible} 259 | onOk={handleModalOk} 260 | onCancel={handleModalCancel} 261 | width={700} 262 | okText={editingIndex !== null ? '更新' : '添加'} 263 | cancelText="取消" 264 | > 265 | <Form 266 | form={form} 267 | layout="vertical" 268 | > 269 | <div className="grid grid-cols-2 gap-4"> 270 | <Form.Item 271 | name="kid" 272 | label="公钥ID (kid)" 273 | rules={[ 274 | {required: true, message: '请输入公钥ID'}, 275 | {pattern: /^[a-zA-Z0-9_-]+$/, message: '公钥ID只能包含字母、数字、下划线和连字符'} 276 | ]} 277 | > 278 | <Input placeholder="如: key1, auth-key-2024"/> 279 | </Form.Item> 280 | <Form.Item 281 | name="algorithm" 282 | label="签名算法" 283 | rules={[{required: true, message: '请选择签名算法'}]} 284 | > 285 | <Select placeholder="选择签名算法"> 286 | <Select.Option value="RS256">RS256</Select.Option> 287 | <Select.Option value="RS384">RS384</Select.Option> 288 | <Select.Option value="RS512">RS512</Select.Option> 289 | <Select.Option value="ES256">ES256</Select.Option> 290 | <Select.Option value="ES384">ES384</Select.Option> 291 | <Select.Option value="ES512">ES512</Select.Option> 292 | </Select> 293 | </Form.Item> 294 | </div> 295 | 296 | <Form.Item 297 | name="format" 298 | label="公钥格式" 299 | rules={[{required: true, message: '请选择公钥格式'}]} 300 | > 301 | <Select 302 | placeholder="选择公钥格式" 303 | onChange={(value) => setSelectedFormat(value as PublicKeyFormat)} 304 | > 305 | <Select.Option value={PublicKeyFormat.PEM}>PEM格式</Select.Option> 306 | <Select.Option value={PublicKeyFormat.JWK}>JWK格式</Select.Option> 307 | </Select> 308 | </Form.Item> 309 | 310 | <Form.Item 311 | name="value" 312 | label="公钥内容" 313 | rules={[ 314 | {required: true, message: '请输入公钥内容'}, 315 | {validator: validatePublicKey} 316 | ]} 317 | > 318 | <Input.TextArea 319 | rows={8} 320 | placeholder={ 321 | selectedFormat === PublicKeyFormat.JWK 322 | ? '请输入JWK格式的公钥,例如:\n{\n "kty": "RSA",\n "kid": "key1",\n "n": "...",\n "e": "AQAB"\n}' 323 | : '请输入PEM格式的公钥,例如:\n-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...\n-----END PUBLIC KEY-----' 324 | } 325 | style={{fontFamily: 'monospace'}} 326 | /> 327 | </Form.Item> 328 | </Form> 329 | </Modal> 330 | </div> 331 | ) 332 | } 333 | ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/api-product/ApiProductPortal.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { useNavigate } from 'react-router-dom' 2 | import { Card, Button, Table, Tag, Space, Switch, Modal, Form, Input, Select, message } from 'antd' 3 | import { PlusOutlined, EyeOutlined, EditOutlined, DeleteOutlined, ExclamationCircleOutlined, GlobalOutlined, CheckCircleFilled, MinusCircleFilled } from '@ant-design/icons' 4 | import { useState, useEffect } from 'react' 5 | import type { ApiProduct } from '@/types/api-product'; 6 | import { apiProductApi, portalApi } from '@/lib/api'; 7 | 8 | interface ApiProductPortalProps { 9 | apiProduct: ApiProduct 10 | } 11 | 12 | interface Portal { 13 | portalId: string 14 | portalName: string 15 | autoApproveSubscription: boolean 16 | createdAt: string 17 | } 18 | 19 | export function ApiProductPortal({ apiProduct }: ApiProductPortalProps) { 20 | const [publishedPortals, setPublishedPortals] = useState<Portal[]>([]) 21 | const [allPortals, setAllPortals] = useState<Portal[]>([]) 22 | const [isModalVisible, setIsModalVisible] = useState(false) 23 | const [selectedPortalIds, setSelectedPortalIds] = useState<string[]>([]) 24 | const [form] = Form.useForm() 25 | const [loading, setLoading] = useState(false) 26 | const [portalLoading, setPortalLoading] = useState(false) 27 | const [modalLoading, setModalLoading] = useState(false) 28 | 29 | // 分页状态 30 | const [currentPage, setCurrentPage] = useState(1) 31 | const [pageSize, setPageSize] = useState(10) 32 | const [total, setTotal] = useState(0) 33 | 34 | const navigate = useNavigate() 35 | 36 | // 获取已发布的门户列表 37 | useEffect(() => { 38 | if (apiProduct.productId) { 39 | fetchPublishedPortals() 40 | } 41 | }, [apiProduct.productId, currentPage, pageSize]) 42 | 43 | // 获取所有门户列表 44 | useEffect(() => { 45 | fetchAllPortals() 46 | }, []) 47 | 48 | const fetchPublishedPortals = async () => { 49 | setLoading(true) 50 | try { 51 | const res = await apiProductApi.getApiProductPublications(apiProduct.productId, { 52 | page: currentPage, 53 | size: pageSize 54 | }) 55 | setPublishedPortals(res.data.content?.map((item: any) => ({ 56 | ...item, 57 | autoApproveSubscription: item.autoApproveSubscriptions || false, 58 | })) || []) 59 | setTotal(res.data.totalElements || 0) 60 | } catch (error) { 61 | console.error('获取已发布门户失败:', error) 62 | // message.error('获取已发布门户失败') 63 | } finally { 64 | setLoading(false) 65 | } 66 | } 67 | 68 | const fetchAllPortals = async () => { 69 | setPortalLoading(true) 70 | try { 71 | const res = await portalApi.getPortals({ 72 | page: 1, 73 | size: 500 // 获取所有门户 74 | }) 75 | setAllPortals(res.data.content?.map((item: any) => ({ 76 | ...item, 77 | portalName: item.name, 78 | autoApproveSubscription: item.portalSettingConfig?.autoApproveSubscriptions || false, 79 | })) || []) 80 | } catch (error) { 81 | console.error('获取门户列表失败:', error) 82 | // message.error('获取门户列表失败') 83 | } finally { 84 | setPortalLoading(false) 85 | } 86 | } 87 | 88 | const handlePageChange = (page: number, size?: number) => { 89 | setCurrentPage(page) 90 | if (size) { 91 | setPageSize(size) 92 | } 93 | } 94 | 95 | const columns = [ 96 | { 97 | title: '门户信息', 98 | key: 'portalInfo', 99 | width: 400, 100 | render: (_: any, record: Portal) => ( 101 | <div> 102 | <div className="text-sm font-medium text-gray-900 truncate"> 103 | {record.portalName} 104 | </div> 105 | <div className="text-xs text-gray-500 truncate"> 106 | {record.portalId} 107 | </div> 108 | </div> 109 | ), 110 | }, 111 | { 112 | title: '订阅自动审批', 113 | key: 'autoApprove', 114 | width: 160, 115 | render: (_: any, record: Portal) => ( 116 | <div className="flex items-center"> 117 | {record.autoApproveSubscription ? ( 118 | <> 119 | <CheckCircleFilled className="text-green-500 mr-1" style={{fontSize: '10px'}} /> 120 | <span className="text-xs text-gray-900">已开启</span> 121 | </> 122 | ) : ( 123 | <> 124 | <MinusCircleFilled className="text-gray-400 mr-1" style={{fontSize: '10px'}} /> 125 | <span className="text-xs text-gray-900">已关闭</span> 126 | </> 127 | )} 128 | </div> 129 | ), 130 | }, 131 | { 132 | title: '操作', 133 | key: 'action', 134 | width: 180, 135 | render: (_: any, record: Portal) => ( 136 | <Space size="middle"> 137 | <Button onClick={() => { 138 | navigate(`/portals/detail?id=${record.portalId}`) 139 | }} type="link" icon={<EyeOutlined />}> 140 | 查看 141 | </Button> 142 | 143 | <Button 144 | type="link" 145 | danger 146 | icon={<DeleteOutlined />} 147 | onClick={() => handleDelete(record.portalId, record.portalName)} 148 | > 149 | 移除 150 | </Button> 151 | </Space> 152 | ), 153 | }, 154 | ] 155 | 156 | const modalColumns = [ 157 | { 158 | title: '门户信息', 159 | key: 'portalInfo', 160 | render: (_: any, record: Portal) => ( 161 | <div> 162 | <div className="text-xs font-normal text-gray-900 truncate"> 163 | {record.portalName} 164 | </div> 165 | <div className="text-xs text-gray-500"> 166 | {record.portalId} 167 | </div> 168 | </div> 169 | ), 170 | }, 171 | { 172 | title: '订阅自动审批', 173 | key: 'autoApprove', 174 | width: 140, 175 | render: (_: any, record: Portal) => ( 176 | <div className="flex items-center"> 177 | {record.autoApproveSubscription ? ( 178 | <> 179 | <CheckCircleFilled className="text-green-500 mr-1" style={{fontSize: '10px'}} /> 180 | <span className="text-xs text-gray-900">已开启</span> 181 | </> 182 | ) : ( 183 | <> 184 | <MinusCircleFilled className="text-gray-400 mr-1" style={{fontSize: '10px'}} /> 185 | <span className="text-xs text-gray-900">已关闭</span> 186 | </> 187 | )} 188 | </div> 189 | ), 190 | }, 191 | ] 192 | 193 | const handleAdd = () => { 194 | setIsModalVisible(true) 195 | } 196 | 197 | const handleDelete = (portalId: string, portalName: string) => { 198 | Modal.confirm({ 199 | title: '确认移除', 200 | icon: <ExclamationCircleOutlined />, 201 | content: `确定要从API产品中移除门户 "${portalName}" 吗?此操作不可恢复。`, 202 | okText: '确认移除', 203 | okType: 'danger', 204 | cancelText: '取消', 205 | onOk() { 206 | apiProductApi.cancelPublishToPortal(apiProduct.productId, portalId).then((res) => { 207 | message.success('移除成功') 208 | fetchPublishedPortals() 209 | }).catch((error) => { 210 | console.error('移除失败:', error) 211 | // message.error('移除失败') 212 | }) 213 | }, 214 | }) 215 | } 216 | 217 | const handleModalOk = async () => { 218 | if (selectedPortalIds.length === 0) { 219 | message.warning('请至少选择一个门户') 220 | return 221 | } 222 | 223 | setModalLoading(true) 224 | try { 225 | // 批量发布到选中的门户 226 | for (const portalId of selectedPortalIds) { 227 | await apiProductApi.publishToPortal(apiProduct.productId, portalId) 228 | } 229 | message.success(`成功发布到 ${selectedPortalIds.length} 个门户`) 230 | setSelectedPortalIds([]) 231 | setIsModalVisible(false) 232 | // 重新获取已发布的门户列表 233 | fetchPublishedPortals() 234 | } catch (error) { 235 | console.error('发布失败:', error) 236 | // message.error('发布失败') 237 | } finally { 238 | setModalLoading(false) 239 | } 240 | } 241 | 242 | const handleModalCancel = () => { 243 | setIsModalVisible(false) 244 | setSelectedPortalIds([]) 245 | } 246 | 247 | return ( 248 | <div className="p-6 space-y-6"> 249 | <div className="flex justify-between items-center"> 250 | <div> 251 | <h1 className="text-2xl font-bold mb-2">发布门户</h1> 252 | <p className="text-gray-600">管理API产品发布的门户</p> 253 | </div> 254 | <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}> 255 | 发布到门户 256 | </Button> 257 | </div> 258 | 259 | <Card> 260 | {publishedPortals.length === 0 && !loading ? ( 261 | <div className="text-center py-8 text-gray-500"> 262 | <p>暂未发布到任何门户</p> 263 | </div> 264 | ) : ( 265 | <Table 266 | columns={columns} 267 | dataSource={publishedPortals} 268 | rowKey="portalId" 269 | loading={loading} 270 | pagination={{ 271 | current: currentPage, 272 | pageSize: pageSize, 273 | total: total, 274 | showSizeChanger: true, 275 | showQuickJumper: true, 276 | showTotal: (total) => `共 ${total} 条`, 277 | onChange: handlePageChange, 278 | onShowSizeChange: handlePageChange, 279 | }} 280 | /> 281 | )} 282 | </Card> 283 | 284 | <Modal 285 | title="发布到门户" 286 | open={isModalVisible} 287 | onOk={handleModalOk} 288 | onCancel={handleModalCancel} 289 | okText="发布" 290 | cancelText="取消" 291 | width={700} 292 | confirmLoading={modalLoading} 293 | destroyOnClose 294 | > 295 | <div className="border border-gray-200 rounded-lg overflow-hidden"> 296 | <Table 297 | columns={modalColumns} 298 | dataSource={allPortals.filter(portal => 299 | !publishedPortals.some(published => published.portalId === portal.portalId) 300 | )} 301 | rowKey="portalId" 302 | loading={portalLoading} 303 | pagination={false} 304 | scroll={{ y: 350 }} 305 | size="middle" 306 | className="portal-selection-table" 307 | rowSelection={{ 308 | type: 'checkbox', 309 | selectedRowKeys: selectedPortalIds, 310 | onChange: (selectedRowKeys) => { 311 | setSelectedPortalIds(selectedRowKeys as string[]) 312 | }, 313 | columnWidth: 50, 314 | }} 315 | rowClassName={(record) => 316 | selectedPortalIds.includes(record.portalId) 317 | ? 'bg-blue-50 hover:bg-blue-100' 318 | : 'hover:bg-gray-50' 319 | } 320 | locale={{ 321 | emptyText: ( 322 | <div className="py-8"> 323 | <div className="text-gray-400 mb-2"> 324 | <GlobalOutlined style={{ fontSize: '24px' }} /> 325 | </div> 326 | <div className="text-gray-500 text-sm">暂无可发布的门户</div> 327 | </div> 328 | ) 329 | }} 330 | /> 331 | </div> 332 | </Modal> 333 | </div> 334 | ) 335 | } ``` -------------------------------------------------------------------------------- /portal-bootstrap/src/test/java/com/alibaba/apiopenplatform/integration/AdministratorAuthIntegrationTest.java: -------------------------------------------------------------------------------- ```java 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one 3 | * or more contributor license agreements. See the NOTICE file 4 | * distributed with this work for additional information 5 | * regarding copyright ownership. The ASF licenses this file 6 | * to you under the Apache License, Version 2.0 (the 7 | * "License"); you may not use this file except in compliance 8 | * with the License. You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | package com.alibaba.apiopenplatform.integration; 21 | 22 | import com.alibaba.apiopenplatform.dto.params.admin.AdminCreateParam; 23 | import com.alibaba.apiopenplatform.dto.params.admin.AdminLoginParam; 24 | import com.alibaba.apiopenplatform.core.response.Response; 25 | import org.junit.jupiter.api.Test; 26 | import org.springframework.beans.factory.annotation.Autowired; 27 | import org.springframework.boot.test.context.SpringBootTest; 28 | import org.springframework.boot.test.web.client.TestRestTemplate; 29 | import org.springframework.http.*; 30 | import java.util.Map; 31 | import static org.assertj.core.api.Assertions.assertThat; 32 | import org.springframework.util.LinkedMultiValueMap; 33 | import org.springframework.util.MultiValueMap; 34 | 35 | /** 36 | * 管理员初始化、登录、token认证、权限保护、黑名单集成测试 37 | * 38 | */ 39 | @SpringBootTest(classes = com.alibaba.apiopenplatform.PortalApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) 40 | public class AdministratorAuthIntegrationTest { 41 | 42 | @Autowired 43 | private TestRestTemplate restTemplate; 44 | 45 | @Test 46 | void testAdminRegister() { 47 | AdminCreateParam createDto = new AdminCreateParam(); 48 | createDto.setUsername("admintest001"); 49 | createDto.setPassword("admin123456"); 50 | ResponseEntity<Response> registerResp = restTemplate.postForEntity( 51 | "/api/admin/init", createDto, Response.class); 52 | System.out.println("管理员初始化响应:" + registerResp); 53 | assertThat(registerResp.getStatusCode()).isEqualTo(HttpStatus.OK); 54 | assertThat(registerResp.getBody().getCode()).isEqualTo("Success"); 55 | } 56 | 57 | @Test 58 | void testAdminLogin() { 59 | AdminLoginParam loginDto = new AdminLoginParam(); 60 | loginDto.setUsername("admintest001"); 61 | loginDto.setPassword("admin123456"); 62 | ResponseEntity<Response> loginResp = restTemplate.postForEntity( 63 | "/api/admin/login", loginDto, Response.class); 64 | System.out.println("管理员登录响应:" + loginResp); 65 | assertThat(loginResp.getStatusCode()).isEqualTo(HttpStatus.OK); 66 | assertThat(loginResp.getBody().getCode()).isEqualTo("Success"); 67 | } 68 | 69 | @Test 70 | void testAdminProtectedApiWithValidToken() { 71 | // 登录获取token 72 | AdminLoginParam loginDto = new AdminLoginParam(); 73 | loginDto.setUsername("admintest001"); 74 | loginDto.setPassword("admin123456"); 75 | ResponseEntity<Response> loginResp = restTemplate.postForEntity( 76 | "/api/admin/login", loginDto, Response.class); 77 | assertThat(loginResp.getStatusCode()).isEqualTo(HttpStatus.OK); 78 | assertThat(loginResp.getBody().getCode()).isEqualTo("Success"); 79 | String token = ((Map<String, Object>)loginResp.getBody().getData()).get("token").toString(); 80 | 81 | // 用token访问受保护接口 82 | HttpHeaders headers = new HttpHeaders(); 83 | headers.set("Authorization", "Bearer " + token); 84 | HttpEntity<Void> entity = new HttpEntity<>(headers); 85 | // 你需要在管理员Controller实现 /api/admin/profile 受保护接口 86 | ResponseEntity<String> protectedResp = restTemplate.exchange( 87 | "/api/admin/profile", HttpMethod.GET, entity, String.class); 88 | System.out.println("管理员带token访问受保护接口响应:" + protectedResp); 89 | assertThat(protectedResp.getStatusCode()).isEqualTo(HttpStatus.OK); 90 | assertThat(protectedResp.getBody()).contains("管理员受保护信息"); 91 | } 92 | 93 | @Test 94 | void testAdminProtectedApiWithoutToken() { 95 | // 不带token访问受保护接口 96 | ResponseEntity<String> protectedResp = restTemplate.getForEntity( 97 | "/api/admin/profile", String.class); 98 | System.out.println("管理员不带token访问受保护接口响应:" + protectedResp); 99 | assertThat(protectedResp.getStatusCode().value()).isIn(401, 403); 100 | } 101 | 102 | @Test 103 | void testAdminTokenBlacklist() { 104 | // 登录获取token 105 | AdminLoginParam loginDto = new AdminLoginParam(); 106 | loginDto.setUsername("admintest001"); 107 | loginDto.setPassword("admin123456"); 108 | ResponseEntity<Response> loginResp = restTemplate.postForEntity( 109 | "/api/admin/login", loginDto, Response.class); 110 | assertThat(loginResp.getStatusCode()).isEqualTo(HttpStatus.OK); 111 | assertThat(loginResp.getBody().getCode()).isEqualTo("Success"); 112 | String token = ((Map<String, Object>)loginResp.getBody().getData()).get("token").toString(); 113 | 114 | // 调用登出接口,将token加入黑名单 115 | HttpHeaders headers = new HttpHeaders(); 116 | headers.set("Authorization", "Bearer " + token); 117 | HttpEntity<Void> entity = new HttpEntity<>(headers); 118 | // 修正:带上portalId参数 119 | ResponseEntity<Response> logoutResp = restTemplate.postForEntity( 120 | "/api/admin/logout?portalId=default", entity, Response.class); 121 | System.out.println("管理员登出响应:" + logoutResp); 122 | assertThat(logoutResp.getStatusCode()).isEqualTo(HttpStatus.OK); 123 | 124 | // 再次用该token访问受保护接口,预期401或403 125 | ResponseEntity<String> protectedResp = restTemplate.exchange( 126 | "/api/admin/profile", HttpMethod.GET, entity, String.class); 127 | System.out.println("管理员黑名单token访问受保护接口响应:" + protectedResp); 128 | assertThat(protectedResp.getStatusCode().value()).isIn(401, 403); 129 | } 130 | 131 | @Test 132 | void testNeedInitBeforeAndAfterInit() { 133 | // 初始化前,need-init 应为 true 134 | ResponseEntity<Response> respBefore = restTemplate.getForEntity( 135 | "/api/admin/need-init?portalId=default", Response.class); 136 | assertThat(respBefore.getStatusCode()).isEqualTo(HttpStatus.OK); 137 | assertThat(respBefore.getBody()).isNotNull(); 138 | assertThat(respBefore.getBody().getCode()).isEqualTo("SUCCESS"); 139 | assertThat(respBefore.getBody().getData()).isEqualTo(true); 140 | assertThat(respBefore.getBody().getMessage()).isNotNull(); 141 | 142 | // 初始化 143 | AdminCreateParam createDto = new AdminCreateParam(); 144 | createDto.setUsername("admintest002"); 145 | createDto.setPassword("admin123456"); 146 | ResponseEntity<Response> initResp = restTemplate.postForEntity( 147 | "/api/admin/init", createDto, Response.class); 148 | assertThat(initResp.getStatusCode()).isEqualTo(HttpStatus.OK); 149 | assertThat(initResp.getBody()).isNotNull(); 150 | assertThat(initResp.getBody().getCode()).isEqualTo("SUCCESS"); 151 | assertThat(initResp.getBody().getMessage()).isNotNull(); 152 | 153 | // 初始化后,need-init 应为 false 154 | ResponseEntity<Response<Boolean>> respAfter = restTemplate.exchange( 155 | "/api/admin/need-init?portalId=default", 156 | HttpMethod.GET, 157 | null, 158 | new org.springframework.core.ParameterizedTypeReference<Response<Boolean>>() {} 159 | ); 160 | assertThat(respAfter.getStatusCode()).isEqualTo(HttpStatus.OK); 161 | assertThat(respAfter.getBody()).isNotNull(); 162 | assertThat(respAfter.getBody().getCode()).isEqualTo("SUCCESS"); 163 | assertThat(respAfter.getBody().getData()).isEqualTo(false); 164 | assertThat(respAfter.getBody().getMessage()).isNotNull(); 165 | 166 | } 167 | 168 | @Test 169 | void testChangePasswordSuccessAndFail() { 170 | // 初始化并登录 171 | AdminCreateParam createDto = new AdminCreateParam(); 172 | createDto.setUsername("admintest004"); 173 | createDto.setPassword("admin123456"); 174 | restTemplate.postForEntity("/api/admin/init", createDto, Response.class); 175 | AdminLoginParam loginDto = new AdminLoginParam(); 176 | loginDto.setUsername("admintest004"); 177 | loginDto.setPassword("admin123456"); 178 | ResponseEntity<Response> loginResp = restTemplate.postForEntity( 179 | "/api/admin/login", loginDto, Response.class); 180 | String token = ((Map<String, Object>)loginResp.getBody().getData()).get("token").toString(); 181 | String adminId = ((Map<String, Object>)loginResp.getBody().getData()).get("userId").toString(); 182 | 183 | // 正确修改密码 184 | HttpHeaders headers = new HttpHeaders(); 185 | headers.set("Authorization", "Bearer " + token); 186 | headers.setContentType(org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED); 187 | MultiValueMap<String, String> emptyBody = new LinkedMultiValueMap<>(); 188 | String changeUrl = String.format("/api/admin/change-password?portalId=%s&adminId=%s&oldPassword=%s&newPassword=%s", 189 | "default", adminId, "admin123456", "admin654321"); 190 | HttpEntity<MultiValueMap<String, String>> changeEntity = new HttpEntity<>(emptyBody, headers); 191 | ResponseEntity<Response> changeResp = restTemplate.postForEntity( 192 | changeUrl, changeEntity, Response.class); 193 | assertThat(changeResp.getStatusCode()).isEqualTo(HttpStatus.OK); 194 | assertThat(changeResp.getBody()).isNotNull(); 195 | assertThat(changeResp.getBody().getCode()).isEqualTo("SUCCESS"); 196 | assertThat(changeResp.getBody().getMessage()).isNotNull(); 197 | 198 | // 原密码错误 199 | String wrongUrl = String.format("/api/admin/change-password?portalId=%s&adminId=%s&oldPassword=%s&newPassword=%s", 200 | "default", adminId, "wrongpass", "admin654321"); 201 | HttpEntity<MultiValueMap<String, String>> wrongEntity = new HttpEntity<>(emptyBody, headers); 202 | ResponseEntity<Response> failResp = restTemplate.postForEntity( 203 | wrongUrl, wrongEntity, Response.class); 204 | assertThat(failResp.getStatusCode().value()).isIn(401, 400, 409, 403); 205 | assertThat(failResp.getBody()).isNotNull(); 206 | assertThat(failResp.getBody().getCode()).isIn("ADMIN_PASSWORD_INCORRECT", "INVALID_PARAMETER"); 207 | assertThat(failResp.getBody().getMessage()).isNotNull(); 208 | } 209 | } ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/api-product/ApiProductUsageGuide.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { Card, Button, Space, message } from 'antd' 2 | import { SaveOutlined, UploadOutlined, FileMarkdownOutlined, EditOutlined } from '@ant-design/icons' 3 | import { useEffect, useState, useRef } from 'react' 4 | import ReactMarkdown from 'react-markdown' 5 | import remarkGfm from 'remark-gfm'; 6 | import MdEditor from 'react-markdown-editor-lite' 7 | import 'react-markdown-editor-lite/lib/index.css' 8 | import type { ApiProduct } from '@/types/api-product' 9 | import { apiProductApi } from '@/lib/api' 10 | 11 | interface ApiProductUsageGuideProps { 12 | apiProduct: ApiProduct 13 | handleRefresh: () => void 14 | } 15 | 16 | export function ApiProductUsageGuide({ apiProduct, handleRefresh }: ApiProductUsageGuideProps) { 17 | const [content, setContent] = useState(apiProduct.document || '') 18 | const [isEditing, setIsEditing] = useState(false) 19 | const [originalContent, setOriginalContent] = useState(apiProduct.document || '') 20 | const fileInputRef = useRef<HTMLInputElement>(null) 21 | 22 | useEffect(() => { 23 | const doc = apiProduct.document || '' 24 | setContent(doc) 25 | setOriginalContent(doc) 26 | }, [apiProduct.document]) 27 | 28 | const handleEdit = () => { 29 | setIsEditing(true) 30 | } 31 | 32 | const handleSave = () => { 33 | apiProductApi.updateApiProduct(apiProduct.productId, { 34 | document: content 35 | }).then(() => { 36 | message.success('保存成功') 37 | setIsEditing(false) 38 | setOriginalContent(content) 39 | handleRefresh(); 40 | }) 41 | } 42 | 43 | const handleCancel = () => { 44 | setContent(originalContent) 45 | setIsEditing(false) 46 | } 47 | 48 | const handleEditorChange = ({ text }: { text: string }) => { 49 | setContent(text) 50 | } 51 | 52 | const handleFileImport = (event: React.ChangeEvent<HTMLInputElement>) => { 53 | const file = event.target.files?.[0] 54 | if (file) { 55 | if (file.type !== 'text/markdown' && !file.name.endsWith('.md')) { 56 | message.error('请选择 Markdown 文件 (.md)') 57 | return 58 | } 59 | 60 | const reader = new FileReader() 61 | reader.onload = (e) => { 62 | const content = e.target?.result as string 63 | setContent(content) 64 | setIsEditing(true) 65 | message.success('文件导入成功') 66 | } 67 | reader.readAsText(file) 68 | } 69 | // 清空 input 值,允许重复选择同一文件 70 | if (event.target) { 71 | event.target.value = '' 72 | } 73 | } 74 | 75 | const triggerFileInput = () => { 76 | fileInputRef.current?.click() 77 | } 78 | 79 | return ( 80 | <div className="p-6 space-y-6"> 81 | <div className="flex justify-between items-center"> 82 | <div> 83 | <h1 className="text-2xl font-bold mb-2">使用指南</h1> 84 | <p className="text-gray-600">编辑和发布使用指南</p> 85 | </div> 86 | <Space> 87 | {isEditing ? ( 88 | <> 89 | <Button icon={<UploadOutlined />} onClick={triggerFileInput}> 90 | 导入文件 91 | </Button> 92 | <Button onClick={handleCancel}> 93 | 取消 94 | </Button> 95 | <Button type="primary" icon={<SaveOutlined />} onClick={handleSave}> 96 | 保存 97 | </Button> 98 | </> 99 | ) : ( 100 | <> 101 | <Button icon={<UploadOutlined />} onClick={triggerFileInput}> 102 | 导入文件 103 | </Button> 104 | <Button type="primary" icon={<EditOutlined />} onClick={handleEdit}> 105 | 编辑 106 | </Button> 107 | </> 108 | )} 109 | </Space> 110 | </div> 111 | 112 | <Card> 113 | {isEditing ? ( 114 | <> 115 | <MdEditor 116 | value={content} 117 | onChange={handleEditorChange} 118 | style={{ height: '600px', width: '100%' }} 119 | placeholder="请输入使用指南内容..." 120 | renderHTML={(text) => <ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>} 121 | canView={{ menu: true, md: true, html: true, both: true, fullScreen: false, hideMenu: false }} 122 | htmlClass="custom-html-style" 123 | markdownClass="custom-markdown-style" 124 | /> 125 | <div className="mt-4 text-sm text-gray-500"> 126 | 💡 支持Markdown格式:代码块、表格、链接、图片等语法 127 | </div> 128 | </> 129 | ) : ( 130 | <div className="min-h-[400px]"> 131 | {content ? ( 132 | <div 133 | className="prose prose-lg max-w-none" 134 | style={{ 135 | lineHeight: '1.7', 136 | color: '#374151', 137 | fontSize: '16px', 138 | fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif' 139 | }} 140 | > 141 | <style>{` 142 | .prose h1 { 143 | color: #111827; 144 | font-weight: 700; 145 | font-size: 2.25rem; 146 | line-height: 1.2; 147 | margin-top: 0; 148 | margin-bottom: 1.5rem; 149 | border-bottom: 2px solid #e5e7eb; 150 | padding-bottom: 0.5rem; 151 | } 152 | .prose h2 { 153 | color: #1f2937; 154 | font-weight: 600; 155 | font-size: 1.875rem; 156 | line-height: 1.3; 157 | margin-top: 2rem; 158 | margin-bottom: 1rem; 159 | border-bottom: 1px solid #e5e7eb; 160 | padding-bottom: 0.25rem; 161 | } 162 | .prose h3 { 163 | color: #374151; 164 | font-weight: 600; 165 | font-size: 1.5rem; 166 | margin-top: 1.5rem; 167 | margin-bottom: 0.75rem; 168 | } 169 | .prose p { 170 | margin-bottom: 1.25rem; 171 | color: #4b5563; 172 | line-height: 1.7; 173 | font-size: 16px; 174 | } 175 | .prose code { 176 | background-color: #f3f4f6; 177 | border: 1px solid #e5e7eb; 178 | border-radius: 0.375rem; 179 | padding: 0.125rem 0.375rem; 180 | font-size: 0.875rem; 181 | color: #374151; 182 | font-weight: 500; 183 | } 184 | .prose pre { 185 | background-color: #1f2937; 186 | border-radius: 0.5rem; 187 | padding: 1.25rem; 188 | overflow-x: auto; 189 | margin: 1.5rem 0; 190 | border: 1px solid #374151; 191 | } 192 | .prose pre code { 193 | background-color: transparent; 194 | border: none; 195 | color: #f9fafb; 196 | padding: 0; 197 | font-size: 0.875rem; 198 | font-weight: normal; 199 | } 200 | .prose blockquote { 201 | border-left: 4px solid #3b82f6; 202 | padding-left: 1rem; 203 | margin: 1.5rem 0; 204 | color: #6b7280; 205 | font-style: italic; 206 | background-color: #f8fafc; 207 | padding: 1rem; 208 | border-radius: 0.375rem; 209 | font-size: 16px; 210 | } 211 | .prose ul, .prose ol { 212 | margin: 1.25rem 0; 213 | padding-left: 1.5rem; 214 | } 215 | .prose ol { 216 | list-style-type: decimal; 217 | list-style-position: outside; 218 | } 219 | .prose ul { 220 | list-style-type: disc; 221 | list-style-position: outside; 222 | } 223 | .prose li { 224 | margin: 0.5rem 0; 225 | color: #4b5563; 226 | display: list-item; 227 | font-size: 16px; 228 | } 229 | .prose ol li { 230 | padding-left: 0.25rem; 231 | } 232 | .prose ul li { 233 | padding-left: 0.25rem; 234 | } 235 | .prose table { 236 | width: 100%; 237 | border-collapse: collapse; 238 | margin: 1.5rem 0; 239 | font-size: 16px; 240 | } 241 | .prose th, .prose td { 242 | border: 1px solid #d1d5db; 243 | padding: 0.75rem; 244 | text-align: left; 245 | } 246 | .prose th { 247 | background-color: #f9fafb; 248 | font-weight: 600; 249 | color: #374151; 250 | font-size: 16px; 251 | } 252 | .prose td { 253 | color: #4b5563; 254 | font-size: 16px; 255 | } 256 | .prose a { 257 | color: #3b82f6; 258 | text-decoration: underline; 259 | font-weight: 500; 260 | transition: color 0.2s; 261 | font-size: inherit; 262 | } 263 | .prose a:hover { 264 | color: #1d4ed8; 265 | } 266 | .prose strong { 267 | color: #111827; 268 | font-weight: 600; 269 | font-size: inherit; 270 | } 271 | .prose em { 272 | color: #6b7280; 273 | font-style: italic; 274 | font-size: inherit; 275 | } 276 | .prose hr { 277 | border: none; 278 | height: 1px; 279 | background-color: #e5e7eb; 280 | margin: 2rem 0; 281 | } 282 | `}</style> 283 | <ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown> 284 | </div> 285 | ) : ( 286 | <div className="flex items-center justify-center h-[400px] text-gray-400 border-2 border-dashed border-gray-200 rounded-lg"> 287 | <div className="text-center"> 288 | <FileMarkdownOutlined className="text-4xl mb-4 text-gray-300" /> 289 | <p className="text-lg">暂无使用指南</p> 290 | <p className="text-sm">点击编辑按钮开始撰写</p> 291 | </div> 292 | </div> 293 | )} 294 | </div> 295 | )} 296 | </Card> 297 | 298 | {/* 隐藏的文件输入框 */} 299 | <input 300 | ref={fileInputRef} 301 | type="file" 302 | accept=".md,text/markdown" 303 | onChange={handleFileImport} 304 | style={{ display: 'none' }} 305 | /> 306 | </div> 307 | ) 308 | } ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/service/gateway/HigressOperator.java: -------------------------------------------------------------------------------- ```java 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one 3 | * or more contributor license agreements. See the NOTICE file 4 | * distributed with this work for additional information 5 | * regarding copyright ownership. The ASF licenses this file 6 | * to you under the Apache License, Version 2.0 (the 7 | * "License"); you may not use this file except in compliance 8 | * with the License. You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | package com.alibaba.apiopenplatform.service.gateway; 21 | 22 | import cn.hutool.core.map.MapBuilder; 23 | import cn.hutool.json.JSONUtil; 24 | import com.alibaba.apiopenplatform.dto.result.*; 25 | import com.alibaba.apiopenplatform.entity.Gateway; 26 | import com.alibaba.apiopenplatform.entity.Consumer; 27 | import com.alibaba.apiopenplatform.entity.ConsumerCredential; 28 | import com.alibaba.apiopenplatform.service.gateway.client.HigressClient; 29 | import com.alibaba.apiopenplatform.support.consumer.ApiKeyConfig; 30 | import com.alibaba.apiopenplatform.support.consumer.ConsumerAuthConfig; 31 | import com.alibaba.apiopenplatform.support.consumer.HigressAuthConfig; 32 | import com.alibaba.apiopenplatform.support.enums.GatewayType; 33 | import com.alibaba.apiopenplatform.support.gateway.GatewayConfig; 34 | import com.alibaba.apiopenplatform.support.gateway.HigressConfig; 35 | import com.alibaba.apiopenplatform.support.product.HigressRefConfig; 36 | 37 | import lombok.Builder; 38 | import lombok.Data; 39 | import lombok.extern.slf4j.Slf4j; 40 | import org.apache.commons.lang3.StringUtils; 41 | import org.springframework.core.ParameterizedTypeReference; 42 | import org.springframework.http.HttpMethod; 43 | import org.springframework.stereotype.Service; 44 | 45 | import java.util.Collections; 46 | import java.util.List; 47 | import java.util.Map; 48 | import java.util.stream.Collectors; 49 | 50 | @Service 51 | @Slf4j 52 | public class HigressOperator extends GatewayOperator<HigressClient> { 53 | 54 | @Override 55 | public PageResult<APIResult> fetchHTTPAPIs(Gateway gateway, int page, int size) { 56 | throw new UnsupportedOperationException("Higress gateway does not support HTTP APIs"); 57 | } 58 | 59 | @Override 60 | public PageResult<APIResult> fetchRESTAPIs(Gateway gateway, int page, int size) { 61 | throw new UnsupportedOperationException("Higress gateway does not support REST APIs"); 62 | } 63 | 64 | @Override 65 | public PageResult<? extends GatewayMCPServerResult> fetchMcpServers(Gateway gateway, int page, int size) { 66 | HigressClient client = getClient(gateway); 67 | 68 | Map<String, String> queryParams = MapBuilder.<String, String>create() 69 | .put("pageNum", String.valueOf(page)) 70 | .put("pageSize", String.valueOf(size)) 71 | .build(); 72 | 73 | HigressPageResponse<HigressMCPConfig> response = client.execute("/v1/mcpServer", 74 | HttpMethod.GET, 75 | queryParams, 76 | null, 77 | new ParameterizedTypeReference<HigressPageResponse<HigressMCPConfig>>() { 78 | }); 79 | 80 | List<HigressMCPServerResult> mcpServers = response.getData().stream() 81 | .map(s -> new HigressMCPServerResult().convertFrom(s)) 82 | .collect(Collectors.toList()); 83 | 84 | return PageResult.of(mcpServers, page, size, response.getTotal()); 85 | } 86 | 87 | @Override 88 | public String fetchAPIConfig(Gateway gateway, Object config) { 89 | throw new UnsupportedOperationException("Higress gateway does not support fetching API config"); 90 | } 91 | 92 | @Override 93 | public String fetchMcpConfig(Gateway gateway, Object conf) { 94 | HigressClient client = getClient(gateway); 95 | HigressRefConfig config = (HigressRefConfig) conf; 96 | 97 | HigressResponse<HigressMCPConfig> response = client.execute("/v1/mcpServer/" + config.getMcpServerName(), 98 | HttpMethod.GET, 99 | null, 100 | null, 101 | new ParameterizedTypeReference<HigressResponse<HigressMCPConfig>>() { 102 | }); 103 | 104 | MCPConfigResult m = new MCPConfigResult(); 105 | HigressMCPConfig higressMCPConfig = response.getData(); 106 | m.setMcpServerName(higressMCPConfig.getName()); 107 | 108 | // mcpServer config 109 | MCPConfigResult.MCPServerConfig c = new MCPConfigResult.MCPServerConfig(); 110 | c.setPath("/mcp-servers/" + higressMCPConfig.getName()); 111 | c.setDomains(higressMCPConfig.getDomains().stream().map(domain -> MCPConfigResult.Domain.builder() 112 | .domain(domain) 113 | // 默认HTTP 114 | .protocol("http") 115 | .build()) 116 | .collect(Collectors.toList())); 117 | m.setMcpServerConfig(c); 118 | 119 | // tools 120 | m.setTools(higressMCPConfig.getRawConfigurations()); 121 | 122 | // meta 123 | MCPConfigResult.McpMetadata meta = new MCPConfigResult.McpMetadata(); 124 | meta.setSource(GatewayType.HIGRESS.name()); 125 | meta.setCreateFromType(higressMCPConfig.getType()); 126 | m.setMeta(meta); 127 | 128 | return JSONUtil.toJsonStr(m); 129 | } 130 | 131 | @Override 132 | public PageResult<GatewayResult> fetchGateways(Object param, int page, int size) { 133 | throw new UnsupportedOperationException("Higress gateway does not support fetching Gateways"); 134 | } 135 | 136 | @Override 137 | public String createConsumer(Consumer consumer, ConsumerCredential credential, GatewayConfig config) { 138 | HigressConfig higressConfig = config.getHigressConfig(); 139 | HigressClient client = new HigressClient(higressConfig); 140 | 141 | client.execute("/v1/consumers", 142 | HttpMethod.POST, 143 | null, 144 | buildHigressConsumer(consumer.getConsumerId(), credential.getApiKeyConfig()), 145 | String.class); 146 | 147 | return consumer.getConsumerId(); 148 | } 149 | 150 | @Override 151 | public void updateConsumer(String consumerId, ConsumerCredential credential, GatewayConfig config) { 152 | HigressConfig higressConfig = config.getHigressConfig(); 153 | HigressClient client = new HigressClient(higressConfig); 154 | 155 | client.execute("/v1/consumers/" + consumerId, 156 | HttpMethod.PUT, 157 | null, 158 | buildHigressConsumer(consumerId, credential.getApiKeyConfig()), 159 | String.class); 160 | } 161 | 162 | @Override 163 | public void deleteConsumer(String consumerId, GatewayConfig config) { 164 | HigressConfig higressConfig = config.getHigressConfig(); 165 | HigressClient client = new HigressClient(higressConfig); 166 | 167 | client.execute("/v1/consumers/" + consumerId, 168 | HttpMethod.DELETE, 169 | null, 170 | null, 171 | String.class); 172 | } 173 | 174 | @Override 175 | public boolean isConsumerExists(String consumerId, GatewayConfig config) { 176 | // TODO: 实现Higress网关消费者存在性检查 177 | return true; 178 | } 179 | 180 | @Override 181 | public ConsumerAuthConfig authorizeConsumer(Gateway gateway, String consumerId, Object refConfig) { 182 | HigressRefConfig config = (HigressRefConfig) refConfig; 183 | HigressClient client = getClient(gateway); 184 | 185 | String mcpServerName = config.getMcpServerName(); 186 | client.execute("/v1/mcpServer/consumers/", 187 | HttpMethod.PUT, 188 | null, 189 | buildAuthHigressConsumer(mcpServerName, consumerId), 190 | Void.class); 191 | 192 | HigressAuthConfig higressAuthConfig = HigressAuthConfig.builder() 193 | .resourceType("MCP_SERVER") 194 | .resourceName(mcpServerName) 195 | .build(); 196 | 197 | return ConsumerAuthConfig.builder() 198 | .higressAuthConfig(higressAuthConfig) 199 | .build(); 200 | } 201 | 202 | @Override 203 | public void revokeConsumerAuthorization(Gateway gateway, String consumerId, ConsumerAuthConfig authConfig) { 204 | HigressClient client = getClient(gateway); 205 | 206 | HigressAuthConfig higressAuthConfig = authConfig.getHigressAuthConfig(); 207 | if (higressAuthConfig == null) { 208 | return; 209 | } 210 | 211 | client.execute("/v1/mcpServer/consumers/", 212 | HttpMethod.DELETE, 213 | null, 214 | buildAuthHigressConsumer(higressAuthConfig.getResourceName(), consumerId), 215 | Void.class); 216 | } 217 | 218 | @Override 219 | public APIResult fetchAPI(Gateway gateway, String apiId) { 220 | throw new UnsupportedOperationException("Higress gateway does not support fetching API"); 221 | } 222 | 223 | @Override 224 | public GatewayType getGatewayType() { 225 | return GatewayType.HIGRESS; 226 | } 227 | 228 | @Override 229 | public String getDashboard(Gateway gateway, String type) { 230 | throw new UnsupportedOperationException("Higress gateway does not support getting dashboard"); 231 | } 232 | 233 | @Data 234 | @Builder 235 | public static class HigressConsumerConfig { 236 | private String name; 237 | private List<HigressCredentialConfig> credentials; 238 | } 239 | 240 | @Data 241 | @Builder 242 | public static class HigressCredentialConfig { 243 | private String type; 244 | private String source; 245 | private String key; 246 | private List<String> values; 247 | } 248 | 249 | public HigressConsumerConfig buildHigressConsumer(String consumerId, ApiKeyConfig apiKeyConfig) { 250 | 251 | String source = mapSource(apiKeyConfig.getSource()); 252 | 253 | List<String> apiKeys = apiKeyConfig.getCredentials().stream() 254 | .map(ApiKeyConfig.ApiKeyCredential::getApiKey) 255 | .collect(Collectors.toList()); 256 | 257 | return HigressConsumerConfig.builder() 258 | .name(consumerId) 259 | .credentials(Collections.singletonList( 260 | HigressCredentialConfig.builder() 261 | .type("key-auth") 262 | .source(source) 263 | .key(apiKeyConfig.getKey()) 264 | .values(apiKeys) 265 | .build()) 266 | ) 267 | .build(); 268 | } 269 | 270 | @Data 271 | public static class HigressMCPConfig { 272 | private String name; 273 | private String type; 274 | private List<String> domains; 275 | private String rawConfigurations; 276 | } 277 | 278 | @Data 279 | public static class HigressPageResponse<T> { 280 | private List<T> data; 281 | private int total; 282 | } 283 | 284 | @Data 285 | public static class HigressResponse<T> { 286 | private T data; 287 | } 288 | 289 | public HigressAuthConsumerConfig buildAuthHigressConsumer(String gatewayName, String consumerId) { 290 | return HigressAuthConsumerConfig.builder() 291 | .mcpServerName(gatewayName) 292 | .consumers(Collections.singletonList(consumerId)) 293 | .build(); 294 | } 295 | 296 | @Data 297 | @Builder 298 | public static class HigressAuthConsumerConfig { 299 | private String mcpServerName; 300 | private List<String> consumers; 301 | } 302 | 303 | private String mapSource(String source) { 304 | if (StringUtils.isBlank(source)) return null; 305 | if ("Default".equalsIgnoreCase(source)) return "BEARER"; 306 | if ("HEADER".equalsIgnoreCase(source)) return "HEADER"; 307 | if ("QueryString".equalsIgnoreCase(source)) return "QUERY"; 308 | return source; 309 | } 310 | 311 | } 312 | ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/pages/GatewayConsoles.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { useState, useEffect, useCallback } from 'react' 2 | import { Button, Table, message, Modal, Tabs } from 'antd' 3 | import { PlusOutlined } from '@ant-design/icons' 4 | import { gatewayApi } from '@/lib/api' 5 | import ImportGatewayModal from '@/components/console/ImportGatewayModal' 6 | import ImportHigressModal from '@/components/console/ImportHigressModal' 7 | import GatewayTypeSelector from '@/components/console/GatewayTypeSelector' 8 | import { formatDateTime } from '@/lib/utils' 9 | import { Gateway, GatewayType } from '@/types' 10 | 11 | export default function Consoles() { 12 | const [gateways, setGateways] = useState<Gateway[]>([]) 13 | const [typeSelectorVisible, setTypeSelectorVisible] = useState(false) 14 | const [importVisible, setImportVisible] = useState(false) 15 | const [higressImportVisible, setHigressImportVisible] = useState(false) 16 | const [selectedGatewayType, setSelectedGatewayType] = useState<GatewayType>('APIG_API') 17 | const [loading, setLoading] = useState(false) 18 | const [activeTab, setActiveTab] = useState<GatewayType>('HIGRESS') 19 | const [pagination, setPagination] = useState({ 20 | current: 1, 21 | pageSize: 10, 22 | total: 0, 23 | }) 24 | 25 | const fetchGatewaysByType = useCallback(async (gatewayType: GatewayType, page = 1, size = 10) => { 26 | setLoading(true) 27 | try { 28 | const res = await gatewayApi.getGateways({ gatewayType, page, size }) 29 | setGateways(res.data?.content || []) 30 | setPagination({ 31 | current: page, 32 | pageSize: size, 33 | total: res.data?.totalElements || 0, 34 | }) 35 | } catch (error) { 36 | // message.error('获取网关列表失败') 37 | } finally { 38 | setLoading(false) 39 | } 40 | }, []) 41 | 42 | useEffect(() => { 43 | fetchGatewaysByType(activeTab, 1, 10) 44 | }, [fetchGatewaysByType, activeTab]) 45 | 46 | // 处理导入成功 47 | const handleImportSuccess = () => { 48 | fetchGatewaysByType(activeTab, pagination.current, pagination.pageSize) 49 | } 50 | 51 | // 处理网关类型选择 52 | const handleGatewayTypeSelect = (type: GatewayType) => { 53 | setSelectedGatewayType(type) 54 | setTypeSelectorVisible(false) 55 | if (type === 'HIGRESS') { 56 | setHigressImportVisible(true) 57 | } else { 58 | setImportVisible(true) 59 | } 60 | } 61 | 62 | // 处理分页变化 63 | const handlePaginationChange = (page: number, pageSize: number) => { 64 | fetchGatewaysByType(activeTab, page, pageSize) 65 | } 66 | 67 | // 处理Tab切换 68 | const handleTabChange = (tabKey: string) => { 69 | const gatewayType = tabKey as GatewayType 70 | setActiveTab(gatewayType) 71 | // Tab切换时重置到第一页 72 | setPagination(prev => ({ ...prev, current: 1 })) 73 | } 74 | 75 | const handleDeleteGateway = async (gatewayId: string) => { 76 | Modal.confirm({ 77 | title: '确认删除', 78 | content: '确定要删除该网关吗?', 79 | onOk: async () => { 80 | try { 81 | await gatewayApi.deleteGateway(gatewayId) 82 | message.success('删除成功') 83 | fetchGatewaysByType(activeTab, pagination.current, pagination.pageSize) 84 | } catch (error) { 85 | // message.error('删除失败') 86 | } 87 | }, 88 | }) 89 | } 90 | 91 | // APIG 网关的列定义 92 | const apigColumns = [ 93 | { 94 | title: '网关名称/ID', 95 | key: 'nameAndId', 96 | width: 280, 97 | render: (_: any, record: Gateway) => ( 98 | <div> 99 | <div className="text-sm font-medium text-gray-900 truncate"> 100 | {record.gatewayName} 101 | </div> 102 | <div className="text-xs text-gray-500 truncate"> 103 | {record.gatewayId} 104 | </div> 105 | </div> 106 | ), 107 | }, 108 | { 109 | title: '区域', 110 | dataIndex: 'region', 111 | key: 'region', 112 | render: (_: any, record: Gateway) => { 113 | return record.apigConfig?.region || '-' 114 | } 115 | }, 116 | { 117 | title: '创建时间', 118 | dataIndex: 'createAt', 119 | key: 'createAt', 120 | render: (date: string) => formatDateTime(date) 121 | }, 122 | { 123 | title: '操作', 124 | key: 'action', 125 | render: (_: any, record: Gateway) => ( 126 | <Button type="link" danger onClick={() => handleDeleteGateway(record.gatewayId)}>删除</Button> 127 | ), 128 | }, 129 | ] 130 | 131 | // 专有云 AI 网关的列定义 132 | const adpAiColumns = [ 133 | { 134 | title: '网关名称/ID', 135 | key: 'nameAndId', 136 | width: 280, 137 | render: (_: any, record: Gateway) => ( 138 | <div> 139 | <div className="text-sm font-medium text-gray-900 truncate"> 140 | {record.gatewayName} 141 | </div> 142 | <div className="text-xs text-gray-500 truncate"> 143 | {record.gatewayId} 144 | </div> 145 | </div> 146 | ), 147 | }, 148 | { 149 | title: '创建时间', 150 | dataIndex: 'createAt', 151 | key: 'createAt', 152 | render: (date: string) => formatDateTime(date) 153 | }, 154 | { 155 | title: '操作', 156 | key: 'action', 157 | render: (_: any, record: Gateway) => ( 158 | <Button type="link" danger onClick={() => handleDeleteGateway(record.gatewayId)}>删除</Button> 159 | ), 160 | } 161 | ] 162 | 163 | // Higress 网关的列定义 164 | const higressColumns = [ 165 | { 166 | title: '网关名称/ID', 167 | key: 'nameAndId', 168 | width: 280, 169 | render: (_: any, record: Gateway) => ( 170 | <div> 171 | <div className="text-sm font-medium text-gray-900 truncate"> 172 | {record.gatewayName} 173 | </div> 174 | <div className="text-xs text-gray-500 truncate"> 175 | {record.gatewayId} 176 | </div> 177 | </div> 178 | ), 179 | }, 180 | { 181 | title: '服务地址', 182 | dataIndex: 'address', 183 | key: 'address', 184 | render: (_: any, record: Gateway) => { 185 | return record.higressConfig?.address || '-' 186 | } 187 | }, 188 | { 189 | title: '用户名', 190 | dataIndex: 'username', 191 | key: 'username', 192 | render: (_: any, record: Gateway) => { 193 | return record.higressConfig?.username || '-' 194 | } 195 | }, 196 | { 197 | title: '创建时间', 198 | dataIndex: 'createAt', 199 | key: 'createAt', 200 | render: (date: string) => formatDateTime(date) 201 | }, 202 | { 203 | title: '操作', 204 | key: 'action', 205 | render: (_: any, record: Gateway) => ( 206 | <Button type="link" danger onClick={() => handleDeleteGateway(record.gatewayId)}>删除</Button> 207 | ), 208 | }, 209 | ] 210 | 211 | return ( 212 | <div className="space-y-6"> 213 | <div className="flex items-center justify-between"> 214 | <div> 215 | <h1 className="text-3xl font-bold tracking-tight">网关实例</h1> 216 | <p className="text-gray-500 mt-2"> 217 | 管理和配置您的网关实例 218 | </p> 219 | </div> 220 | <Button type="primary" icon={<PlusOutlined />} onClick={() => setTypeSelectorVisible(true)}> 221 | 导入网关实例 222 | </Button> 223 | </div> 224 | 225 | <Tabs 226 | activeKey={activeTab} 227 | onChange={handleTabChange} 228 | items={[ 229 | { 230 | key: 'HIGRESS', 231 | label: 'Higress 网关', 232 | children: ( 233 | <div className="bg-white rounded-lg"> 234 | <div className="py-4 pl-4 border-b border-gray-200"> 235 | <h3 className="text-lg font-medium text-gray-900">Higress 网关</h3> 236 | <p className="text-sm text-gray-500 mt-1">Higress 云原生网关</p> 237 | </div> 238 | <Table 239 | columns={higressColumns} 240 | dataSource={gateways} 241 | rowKey="gatewayId" 242 | loading={loading} 243 | pagination={{ 244 | current: pagination.current, 245 | pageSize: pagination.pageSize, 246 | total: pagination.total, 247 | showSizeChanger: true, 248 | showQuickJumper: true, 249 | showTotal: (total) => `共 ${total} 条`, 250 | onChange: handlePaginationChange, 251 | onShowSizeChange: handlePaginationChange, 252 | }} 253 | /> 254 | </div> 255 | ), 256 | }, 257 | { 258 | key: 'APIG_API', 259 | label: 'API 网关', 260 | children: ( 261 | <div className="bg-white rounded-lg"> 262 | <div className="py-4 pl-4 border-b border-gray-200"> 263 | <h3 className="text-lg font-medium text-gray-900">API 网关</h3> 264 | <p className="text-sm text-gray-500 mt-1">阿里云 API 网关服务</p> 265 | </div> 266 | <Table 267 | columns={apigColumns} 268 | dataSource={gateways} 269 | rowKey="gatewayId" 270 | loading={loading} 271 | pagination={{ 272 | current: pagination.current, 273 | pageSize: pagination.pageSize, 274 | total: pagination.total, 275 | showSizeChanger: true, 276 | showQuickJumper: true, 277 | showTotal: (total) => `共 ${total} 条`, 278 | onChange: handlePaginationChange, 279 | onShowSizeChange: handlePaginationChange, 280 | }} 281 | /> 282 | </div> 283 | ), 284 | }, 285 | { 286 | key: 'APIG_AI', 287 | label: 'AI 网关', 288 | children: ( 289 | <div className="bg-white rounded-lg"> 290 | <div className="py-4 pl-4 border-b border-gray-200"> 291 | <h3 className="text-lg font-medium text-gray-900">AI 网关</h3> 292 | <p className="text-sm text-gray-500 mt-1">阿里云 AI 网关服务</p> 293 | </div> 294 | <Table 295 | columns={apigColumns} 296 | dataSource={gateways} 297 | rowKey="gatewayId" 298 | loading={loading} 299 | pagination={{ 300 | current: pagination.current, 301 | pageSize: pagination.pageSize, 302 | total: pagination.total, 303 | showSizeChanger: true, 304 | showQuickJumper: true, 305 | showTotal: (total) => `共 ${total} 条`, 306 | onChange: handlePaginationChange, 307 | onShowSizeChange: handlePaginationChange, 308 | }} 309 | /> 310 | </div> 311 | ), 312 | }, 313 | { 314 | key: 'ADP_AI_GATEWAY', 315 | label: '专有云 AI 网关', 316 | children: ( 317 | <div className="bg-white rounded-lg"> 318 | <div className="py-4 pl-4 border-b border-gray-200"> 319 | <h3 className="text-lg font-medium text-gray-900">AI 网关</h3> 320 | <p className="text-sm text-gray-500 mt-1">专有云 AI 网关服务</p> 321 | </div> 322 | <Table 323 | columns={adpAiColumns} 324 | dataSource={gateways} 325 | rowKey="gatewayId" 326 | loading={loading} 327 | pagination={{ 328 | current: pagination.current, 329 | pageSize: pagination.pageSize, 330 | total: pagination.total, 331 | showSizeChanger: true, 332 | showQuickJumper: true, 333 | showTotal: (total) => `共 ${total} 条`, 334 | onChange: handlePaginationChange, 335 | onShowSizeChange: handlePaginationChange, 336 | }} 337 | /> 338 | </div> 339 | ), 340 | }, 341 | ]} 342 | /> 343 | 344 | <ImportGatewayModal 345 | visible={importVisible} 346 | gatewayType={selectedGatewayType as 'APIG_API' | 'APIG_AI' | 'ADP_AI_GATEWAY'} 347 | onCancel={() => setImportVisible(false)} 348 | onSuccess={handleImportSuccess} 349 | /> 350 | 351 | <ImportHigressModal 352 | visible={higressImportVisible} 353 | onCancel={() => setHigressImportVisible(false)} 354 | onSuccess={handleImportSuccess} 355 | /> 356 | 357 | <GatewayTypeSelector 358 | visible={typeSelectorVisible} 359 | onCancel={() => setTypeSelectorVisible(false)} 360 | onSelect={handleGatewayTypeSelect} 361 | /> 362 | </div> 363 | ) 364 | } 365 | ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/service/impl/GatewayServiceImpl.java: -------------------------------------------------------------------------------- ```java 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one 3 | * or more contributor license agreements. See the NOTICE file 4 | * distributed with this work for additional information 5 | * regarding copyright ownership. The ASF licenses this file 6 | * to you under the Apache License, Version 2.0 (the 7 | * "License"); you may not use this file except in compliance 8 | * with the License. You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | package com.alibaba.apiopenplatform.service.impl; 21 | 22 | import cn.hutool.core.util.EnumUtil; 23 | import cn.hutool.core.util.StrUtil; 24 | import com.alibaba.apiopenplatform.core.constant.Resources; 25 | import com.alibaba.apiopenplatform.core.exception.BusinessException; 26 | import com.alibaba.apiopenplatform.core.exception.ErrorCode; 27 | import com.alibaba.apiopenplatform.core.security.ContextHolder; 28 | import com.alibaba.apiopenplatform.core.utils.IdGenerator; 29 | import com.alibaba.apiopenplatform.dto.params.gateway.ImportGatewayParam; 30 | import com.alibaba.apiopenplatform.dto.params.gateway.QueryAPIGParam; 31 | import com.alibaba.apiopenplatform.dto.params.gateway.QueryAdpAIGatewayParam; 32 | import com.alibaba.apiopenplatform.dto.params.gateway.QueryGatewayParam; 33 | import com.alibaba.apiopenplatform.dto.result.*; 34 | import com.alibaba.apiopenplatform.entity.*; 35 | import com.alibaba.apiopenplatform.repository.GatewayRepository; 36 | import com.alibaba.apiopenplatform.repository.ProductRefRepository; 37 | import com.alibaba.apiopenplatform.service.AdpAIGatewayService; 38 | import com.alibaba.apiopenplatform.service.GatewayService; 39 | import com.alibaba.apiopenplatform.service.gateway.GatewayOperator; 40 | import com.alibaba.apiopenplatform.support.consumer.ConsumerAuthConfig; 41 | import com.alibaba.apiopenplatform.support.enums.APIGAPIType; 42 | import com.alibaba.apiopenplatform.support.enums.GatewayType; 43 | import com.alibaba.apiopenplatform.support.gateway.GatewayConfig; 44 | import lombok.RequiredArgsConstructor; 45 | import lombok.extern.slf4j.Slf4j; 46 | import org.springframework.beans.BeansException; 47 | import org.springframework.context.ApplicationContext; 48 | import org.springframework.context.ApplicationContextAware; 49 | import org.springframework.data.domain.Page; 50 | import org.springframework.data.domain.Pageable; 51 | import org.springframework.data.jpa.domain.Specification; 52 | import org.springframework.stereotype.Service; 53 | 54 | import javax.persistence.criteria.Predicate; 55 | import java.util.ArrayList; 56 | import java.util.List; 57 | import java.util.Map; 58 | import java.util.stream.Collectors; 59 | 60 | @Service 61 | @RequiredArgsConstructor 62 | @SuppressWarnings("unchecked") 63 | @Slf4j 64 | public class GatewayServiceImpl implements GatewayService, ApplicationContextAware, AdpAIGatewayService { 65 | 66 | private final GatewayRepository gatewayRepository; 67 | private final ProductRefRepository productRefRepository; 68 | 69 | private Map<GatewayType, GatewayOperator> gatewayOperators; 70 | 71 | private final ContextHolder contextHolder; 72 | 73 | @Override 74 | public PageResult<GatewayResult> fetchGateways(QueryAPIGParam param, int page, int size) { 75 | return gatewayOperators.get(param.getGatewayType()).fetchGateways(param, page, size); 76 | } 77 | 78 | @Override 79 | public PageResult<GatewayResult> fetchGateways(QueryAdpAIGatewayParam param, int page, int size) { 80 | return gatewayOperators.get(GatewayType.ADP_AI_GATEWAY).fetchGateways(param, page, size); 81 | } 82 | 83 | @Override 84 | public void importGateway(ImportGatewayParam param) { 85 | gatewayRepository.findByGatewayId(param.getGatewayId()) 86 | .ifPresent(gateway -> { 87 | throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.GATEWAY, param.getGatewayId())); 88 | }); 89 | 90 | Gateway gateway = param.convertTo(); 91 | if (gateway.getGatewayType().isHigress()) { 92 | gateway.setGatewayId(IdGenerator.genHigressGatewayId()); 93 | } 94 | gateway.setAdminId(contextHolder.getUser()); 95 | gatewayRepository.save(gateway); 96 | } 97 | 98 | @Override 99 | public GatewayResult getGateway(String gatewayId) { 100 | Gateway gateway = findGateway(gatewayId); 101 | 102 | return new GatewayResult().convertFrom(gateway); 103 | } 104 | 105 | @Override 106 | public PageResult<GatewayResult> listGateways(QueryGatewayParam param, Pageable pageable) { 107 | Page<Gateway> gateways = gatewayRepository.findAll(buildGatewaySpec(param), pageable); 108 | 109 | return new PageResult<GatewayResult>().convertFrom(gateways, gateway -> new GatewayResult().convertFrom(gateway)); 110 | } 111 | 112 | @Override 113 | public void deleteGateway(String gatewayId) { 114 | Gateway gateway = findGateway(gatewayId); 115 | // 已有Product引用时不允许删除 116 | if (productRefRepository.existsByGatewayId(gatewayId)) { 117 | throw new BusinessException(ErrorCode.INVALID_REQUEST, "网关已被Product引用"); 118 | } 119 | 120 | gatewayRepository.delete(gateway); 121 | } 122 | 123 | @Override 124 | public PageResult<APIResult> fetchAPIs(String gatewayId, String apiType, int page, int size) { 125 | Gateway gateway = findGateway(gatewayId); 126 | GatewayType gatewayType = gateway.getGatewayType(); 127 | 128 | if (gatewayType.isAPIG()) { 129 | APIGAPIType type = EnumUtil.fromString(APIGAPIType.class, apiType); 130 | switch (type) { 131 | case REST: 132 | return fetchRESTAPIs(gatewayId, page, size); 133 | case HTTP: 134 | return fetchHTTPAPIs(gatewayId, page, size); 135 | default: 136 | } 137 | } 138 | 139 | if (gatewayType.isHigress()) { 140 | return fetchRoutes(gatewayId, page, size); 141 | } 142 | 143 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, 144 | String.format("Gateway type %s does not support API type %s", gatewayType, apiType)); 145 | } 146 | 147 | @Override 148 | public PageResult<APIResult> fetchHTTPAPIs(String gatewayId, int page, int size) { 149 | Gateway gateway = findGateway(gatewayId); 150 | return getOperator(gateway).fetchHTTPAPIs(gateway, page, size); 151 | } 152 | 153 | @Override 154 | public PageResult<APIResult> fetchRESTAPIs(String gatewayId, int page, int size) { 155 | Gateway gateway = findGateway(gatewayId); 156 | return getOperator(gateway).fetchRESTAPIs(gateway, page, size); 157 | } 158 | 159 | @Override 160 | public PageResult<APIResult> fetchRoutes(String gatewayId, int page, int size) { 161 | return null; 162 | } 163 | 164 | @Override 165 | public PageResult<GatewayMCPServerResult> fetchMcpServers(String gatewayId, int page, int size) { 166 | Gateway gateway = findGateway(gatewayId); 167 | return getOperator(gateway).fetchMcpServers(gateway, page, size); 168 | } 169 | 170 | @Override 171 | public String fetchAPIConfig(String gatewayId, Object config) { 172 | Gateway gateway = findGateway(gatewayId); 173 | return getOperator(gateway).fetchAPIConfig(gateway, config); 174 | } 175 | 176 | @Override 177 | public String fetchMcpConfig(String gatewayId, Object conf) { 178 | Gateway gateway = findGateway(gatewayId); 179 | return getOperator(gateway).fetchMcpConfig(gateway, conf); 180 | } 181 | 182 | @Override 183 | public String createConsumer(Consumer consumer, ConsumerCredential credential, GatewayConfig config) { 184 | return gatewayOperators.get(config.getGatewayType()).createConsumer(consumer, credential, config); 185 | } 186 | 187 | @Override 188 | public void updateConsumer(String gwConsumerId, ConsumerCredential credential, GatewayConfig config) { 189 | gatewayOperators.get(config.getGatewayType()).updateConsumer(gwConsumerId, credential, config); 190 | } 191 | 192 | @Override 193 | public void deleteConsumer(String gwConsumerId, GatewayConfig config) { 194 | gatewayOperators.get(config.getGatewayType()).deleteConsumer(gwConsumerId, config); 195 | } 196 | 197 | @Override 198 | public boolean isConsumerExists(String gwConsumerId, GatewayConfig config) { 199 | return gatewayOperators.get(config.getGatewayType()).isConsumerExists(gwConsumerId, config); 200 | } 201 | 202 | @Override 203 | public ConsumerAuthConfig authorizeConsumer(String gatewayId, String gwConsumerId, ProductRefResult productRef) { 204 | Gateway gateway = findGateway(gatewayId); 205 | Object refConfig; 206 | if (gateway.getGatewayType().isHigress()) { 207 | refConfig = productRef.getHigressRefConfig(); 208 | } else if (gateway.getGatewayType().isAdpAIGateway()) { 209 | refConfig = productRef.getAdpAIGatewayRefConfig(); 210 | } else { 211 | refConfig = productRef.getApigRefConfig(); 212 | } 213 | return getOperator(gateway).authorizeConsumer(gateway, gwConsumerId, refConfig); 214 | } 215 | 216 | @Override 217 | public void revokeConsumerAuthorization(String gatewayId, String gwConsumerId, ConsumerAuthConfig config) { 218 | Gateway gateway = findGateway(gatewayId); 219 | 220 | getOperator(gateway).revokeConsumerAuthorization(gateway, gwConsumerId, config); 221 | } 222 | 223 | @Override 224 | public GatewayConfig getGatewayConfig(String gatewayId) { 225 | Gateway gateway = findGateway(gatewayId); 226 | 227 | return GatewayConfig.builder() 228 | .gatewayType(gateway.getGatewayType()) 229 | .apigConfig(gateway.getApigConfig()) 230 | .higressConfig(gateway.getHigressConfig()) 231 | .adpAIGatewayConfig(gateway.getAdpAIGatewayConfig()) 232 | .gateway(gateway) // 添加Gateway实体引用 233 | .build(); 234 | } 235 | 236 | @Override 237 | public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { 238 | Map<String, GatewayOperator> operators = applicationContext.getBeansOfType(GatewayOperator.class); 239 | 240 | gatewayOperators = operators.values().stream() 241 | .collect(Collectors.toMap( 242 | operator -> operator.getGatewayType(), 243 | operator -> operator, 244 | (existing, replacement) -> existing)); 245 | } 246 | 247 | private Gateway findGateway(String gatewayId) { 248 | return gatewayRepository.findByGatewayId(gatewayId) 249 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.GATEWAY, gatewayId)); 250 | } 251 | 252 | private GatewayOperator getOperator(Gateway gateway) { 253 | GatewayOperator gatewayOperator = gatewayOperators.get(gateway.getGatewayType()); 254 | if (gatewayOperator == null) { 255 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, 256 | "No gateway operator found for gateway type: " + gateway.getGatewayType()); 257 | } 258 | return gatewayOperator; 259 | } 260 | 261 | @Override 262 | public String getDashboard(String gatewayId,String type) { 263 | Gateway gateway = findGateway(gatewayId); 264 | return getOperator(gateway).getDashboard(gateway,type); //type: Portal,MCP,API 265 | } 266 | 267 | private Specification<Gateway> buildGatewaySpec(QueryGatewayParam param) { 268 | return (root, query, cb) -> { 269 | List<Predicate> predicates = new ArrayList<>(); 270 | 271 | if (param != null && param.getGatewayType() != null) { 272 | predicates.add(cb.equal(root.get("gatewayType"), param.getGatewayType())); 273 | } 274 | 275 | String adminId = contextHolder.getUser(); 276 | if (StrUtil.isNotBlank(adminId)) { 277 | predicates.add(cb.equal(root.get("adminId"), adminId)); 278 | } 279 | 280 | return cb.and(predicates.toArray(new Predicate[0])); 281 | }; 282 | } 283 | } ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/console/ImportGatewayModal.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { useState } from 'react' 2 | import { Button, Table, Modal, Form, Input, message, Select } from 'antd' 3 | import { PlusOutlined } from '@ant-design/icons' 4 | import { gatewayApi } from '@/lib/api' 5 | import { Gateway, ApigConfig } from '@/types' 6 | import { getGatewayTypeLabel } from '@/lib/constant' 7 | 8 | interface ImportGatewayModalProps { 9 | visible: boolean 10 | gatewayType: 'APIG_API' | 'APIG_AI' | 'ADP_AI_GATEWAY' 11 | onCancel: () => void 12 | onSuccess: () => void 13 | } 14 | 15 | export default function ImportGatewayModal({ visible, gatewayType, onCancel, onSuccess }: ImportGatewayModalProps) { 16 | const [importForm] = Form.useForm() 17 | 18 | const [gatewayLoading, setGatewayLoading] = useState(false) 19 | const [gatewayList, setGatewayList] = useState<Gateway[]>([]) 20 | const [selectedGateway, setSelectedGateway] = useState<Gateway | null>(null) 21 | 22 | const [apigConfig, setApigConfig] = useState<ApigConfig>({ 23 | region: '', 24 | accessKey: '', 25 | secretKey: '', 26 | }) 27 | 28 | const [gatewayPagination, setGatewayPagination] = useState({ 29 | current: 1, 30 | pageSize: 10, 31 | total: 0, 32 | }) 33 | 34 | // 监听表单中的认证方式,确保切换时联动渲染 35 | const authType = Form.useWatch('authType', importForm) 36 | 37 | // 获取网关列表 38 | const fetchGateways = async (values: any, page = 1, size = 10) => { 39 | setGatewayLoading(true) 40 | try { 41 | const res = await gatewayApi.getApigGateway({ 42 | ...values, 43 | page, 44 | size, 45 | }) 46 | 47 | setGatewayList(res.data?.content || []) 48 | setGatewayPagination({ 49 | current: page, 50 | pageSize: size, 51 | total: res.data?.totalElements || 0, 52 | }) 53 | } catch { 54 | // message.error('获取网关列表失败') 55 | } finally { 56 | setGatewayLoading(false) 57 | } 58 | } 59 | 60 | const fetchAdpGateways = async (values: any, page = 1, size = 50) => { 61 | setGatewayLoading(true) 62 | try { 63 | const res = await gatewayApi.getAdpGateways({...values, page, size}) 64 | setGatewayList(res.data?.content || []) 65 | setGatewayPagination({ 66 | current: page, 67 | pageSize: size, 68 | total: res.data?.totalElements || 0, 69 | }) 70 | } catch { 71 | // message.error('获取网关列表失败') 72 | } finally { 73 | setGatewayLoading(false) 74 | } 75 | } 76 | 77 | // 处理网关选择 78 | const handleGatewaySelect = (gateway: Gateway) => { 79 | setSelectedGateway(gateway) 80 | } 81 | 82 | // 处理网关分页变化 83 | const handleGatewayPaginationChange = (page: number, pageSize: number) => { 84 | const values = importForm.getFieldsValue() 85 | const data = JSON.parse(sessionStorage.getItem('importFormConfig') || ''); 86 | if (JSON.stringify(values) === '{}') { 87 | fetchGateways({...data, gatewayType: gatewayType}, page, pageSize) 88 | } else { 89 | fetchGateways({...values, gatewayType: gatewayType}, page, pageSize) 90 | } 91 | } 92 | 93 | // 处理导入 94 | const handleImport = () => { 95 | if (!selectedGateway) { 96 | message.warning('请选择一个Gateway') 97 | return 98 | } 99 | const payload: any = { 100 | ...selectedGateway, 101 | gatewayType: gatewayType, 102 | } 103 | if (gatewayType === 'ADP_AI_GATEWAY') { 104 | payload.adpAIGatewayConfig = apigConfig 105 | } else { 106 | payload.apigConfig = apigConfig 107 | } 108 | gatewayApi.importGateway(payload).then(() => { 109 | message.success('导入成功!') 110 | handleCancel() 111 | onSuccess() 112 | }).catch(() => { 113 | // message.error(error.response?.data?.message || '导入失败!') 114 | }) 115 | } 116 | 117 | // 重置弹窗状态 118 | const handleCancel = () => { 119 | setSelectedGateway(null) 120 | setGatewayList([]) 121 | setGatewayPagination({ current: 1, pageSize: 10, total: 0 }) 122 | importForm.resetFields() 123 | onCancel() 124 | } 125 | 126 | return ( 127 | <Modal 128 | title="导入网关实例" 129 | open={visible} 130 | onCancel={handleCancel} 131 | footer={null} 132 | width={800} 133 | > 134 | <Form form={importForm} layout="vertical" preserve={false}> 135 | {gatewayList.length === 0 && ['APIG_API', 'APIG_AI'].includes(gatewayType) && ( 136 | <div className="mb-4"> 137 | <h3 className="text-lg font-medium mb-3">认证信息</h3> 138 | <Form.Item label="Region" name="region" rules={[{ required: true, message: '请输入region' }]}> 139 | <Input /> 140 | </Form.Item> 141 | <Form.Item label="Access Key" name="accessKey" rules={[{ required: true, message: '请输入accessKey' }]}> 142 | <Input /> 143 | </Form.Item> 144 | <Form.Item label="Secret Key" name="secretKey" rules={[{ required: true, message: '请输入secretKey' }]}> 145 | <Input.Password /> 146 | </Form.Item> 147 | <Button 148 | type="primary" 149 | onClick={() => { 150 | importForm.validateFields().then((values) => { 151 | setApigConfig(values) 152 | sessionStorage.setItem('importFormConfig', JSON.stringify(values)) 153 | fetchGateways({...values, gatewayType: gatewayType}) 154 | }) 155 | }} 156 | loading={gatewayLoading} 157 | > 158 | 获取网关列表 159 | </Button> 160 | </div> 161 | )} 162 | 163 | {['ADP_AI_GATEWAY'].includes(gatewayType) && gatewayList.length === 0 && ( 164 | <div className="mb-4"> 165 | <h3 className="text-lg font-medium mb-3">认证信息</h3> 166 | <Form.Item label="服务地址" name="baseUrl" rules={[{ required: true, message: '请输入服务地址' }, { pattern: /^https?:\/\//i, message: '必须以 http:// 或 https:// 开头' }]}> 167 | <Input placeholder="如:http://apigateway.example.com 或者 http://10.236.6.144" /> 168 | </Form.Item> 169 | <Form.Item 170 | label="端口" 171 | name="port" 172 | initialValue={80} 173 | rules={[ 174 | { required: true, message: '请输入端口号' }, 175 | { 176 | validator: (_, value) => { 177 | if (value === undefined || value === null || value === '') return Promise.resolve() 178 | const n = Number(value) 179 | return n >= 1 && n <= 65535 ? Promise.resolve() : Promise.reject(new Error('端口范围需在 1-65535')) 180 | } 181 | } 182 | ]} 183 | > 184 | <Input type="text" placeholder="如:8080" /> 185 | </Form.Item> 186 | <Form.Item 187 | label="认证方式" 188 | name="authType" 189 | initialValue="Seed" 190 | rules={[{ required: true, message: '请选择认证方式' }]} 191 | > 192 | <Select> 193 | <Select.Option value="Seed">Seed</Select.Option> 194 | <Select.Option value="Header">固定Header</Select.Option> 195 | </Select> 196 | </Form.Item> 197 | {authType === 'Seed' && ( 198 | <Form.Item label="Seed" name="authSeed" rules={[{ required: true, message: '请输入Seed' }]}> 199 | <Input placeholder="通过configmap获取" /> 200 | </Form.Item> 201 | )} 202 | {authType === 'Header' && ( 203 | <Form.Item label="Headers"> 204 | <Form.List name="authHeaders" initialValue={[{ key: '', value: '' }]}> 205 | {(fields, { add, remove }) => ( 206 | <> 207 | {fields.map(({ key, name, ...restField }) => ( 208 | <div key={key} style={{ display: 'flex', marginBottom: 8, alignItems: 'center' }}> 209 | <Form.Item 210 | {...restField} 211 | name={[name, 'key']} 212 | rules={[{ required: true, message: '请输入Header名称' }]} 213 | style={{ flex: 1, marginRight: 8, marginBottom: 0 }} 214 | > 215 | <Input placeholder="Header名称,如:X-Auth-Token" /> 216 | </Form.Item> 217 | <Form.Item 218 | {...restField} 219 | name={[name, 'value']} 220 | rules={[{ required: true, message: '请输入Header值' }]} 221 | style={{ flex: 1, marginRight: 8, marginBottom: 0 }} 222 | > 223 | <Input placeholder="Header值" /> 224 | </Form.Item> 225 | {fields.length > 1 && ( 226 | <Button 227 | type="text" 228 | danger 229 | onClick={() => remove(name)} 230 | style={{ marginBottom: 0 }} 231 | > 232 | 删除 233 | </Button> 234 | )} 235 | </div> 236 | ))} 237 | <Form.Item style={{ marginBottom: 0 }}> 238 | <Button 239 | type="dashed" 240 | onClick={() => add({ key: '', value: '' })} 241 | block 242 | icon={<PlusOutlined />} 243 | > 244 | 添加Header 245 | </Button> 246 | </Form.Item> 247 | </> 248 | )} 249 | </Form.List> 250 | </Form.Item> 251 | )} 252 | <Button 253 | type="primary" 254 | onClick={() => { 255 | importForm.validateFields().then((values) => { 256 | // 处理认证参数 257 | const processedValues = { ...values }; 258 | 259 | // 根据认证方式设置相应的参数 260 | if (values.authType === 'Seed') { 261 | processedValues.authSeed = values.authSeed; 262 | delete processedValues.authHeaders; 263 | } else if (values.authType === 'Header') { 264 | processedValues.authHeaders = values.authHeaders; 265 | delete processedValues.authSeed; 266 | } 267 | 268 | setApigConfig(processedValues) 269 | sessionStorage.setItem('importFormConfig', JSON.stringify(processedValues)) 270 | fetchAdpGateways({...processedValues, gatewayType: gatewayType}) 271 | }) 272 | }} 273 | loading={gatewayLoading} 274 | > 275 | 获取网关列表 276 | </Button> 277 | </div> 278 | )} 279 | 280 | {gatewayList.length > 0 && ( 281 | <div className="mb-4"> 282 | <h3 className="text-lg font-medium mb-3">选择网关实例</h3> 283 | <Table 284 | rowKey="gatewayId" 285 | columns={[ 286 | { title: 'ID', dataIndex: 'gatewayId' }, 287 | { 288 | title: '类型', 289 | dataIndex: 'gatewayType', 290 | render: (gatewayType: string) => getGatewayTypeLabel(gatewayType as any) 291 | }, 292 | { title: '名称', dataIndex: 'gatewayName' }, 293 | ]} 294 | dataSource={gatewayList} 295 | rowSelection={{ 296 | type: 'radio', 297 | selectedRowKeys: selectedGateway ? [selectedGateway.gatewayId] : [], 298 | onChange: (_selectedRowKeys, selectedRows) => { 299 | handleGatewaySelect(selectedRows[0]) 300 | }, 301 | }} 302 | pagination={{ 303 | current: gatewayPagination.current, 304 | pageSize: gatewayPagination.pageSize, 305 | total: gatewayPagination.total, 306 | onChange: handleGatewayPaginationChange, 307 | showSizeChanger: true, 308 | showQuickJumper: true, 309 | showTotal: (total) => `共 ${total} 条`, 310 | }} 311 | size="small" 312 | /> 313 | </div> 314 | )} 315 | 316 | {selectedGateway && ( 317 | <div className="text-right"> 318 | <Button type="primary" onClick={handleImport}> 319 | 完成导入 320 | </Button> 321 | </div> 322 | )} 323 | </Form> 324 | </Modal> 325 | ) 326 | } ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/service/impl/PortalServiceImpl.java: -------------------------------------------------------------------------------- ```java 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one 3 | * or more contributor license agreements. See the NOTICE file 4 | * distributed with this work for additional information 5 | * regarding copyright ownership. The ASF licenses this file 6 | * to you under the Apache License, Version 2.0 (the 7 | * "License"); you may not use this file except in compliance 8 | * with the License. You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | package com.alibaba.apiopenplatform.service.impl; 21 | 22 | import cn.hutool.core.collection.CollUtil; 23 | import cn.hutool.core.util.BooleanUtil; 24 | import cn.hutool.core.util.StrUtil; 25 | import com.alibaba.apiopenplatform.core.constant.Resources; 26 | import com.alibaba.apiopenplatform.core.event.PortalDeletingEvent; 27 | import com.alibaba.apiopenplatform.core.exception.BusinessException; 28 | import com.alibaba.apiopenplatform.core.exception.ErrorCode; 29 | import com.alibaba.apiopenplatform.core.security.ContextHolder; 30 | import com.alibaba.apiopenplatform.core.utils.IdGenerator; 31 | import com.alibaba.apiopenplatform.dto.params.consumer.QuerySubscriptionParam; 32 | import com.alibaba.apiopenplatform.dto.params.portal.*; 33 | import com.alibaba.apiopenplatform.dto.result.PageResult; 34 | import com.alibaba.apiopenplatform.dto.result.PortalResult; 35 | import com.alibaba.apiopenplatform.dto.result.SubscriptionResult; 36 | import com.alibaba.apiopenplatform.entity.Portal; 37 | import com.alibaba.apiopenplatform.entity.PortalDomain; 38 | import com.alibaba.apiopenplatform.entity.ProductSubscription; 39 | import com.alibaba.apiopenplatform.repository.PortalDomainRepository; 40 | import com.alibaba.apiopenplatform.repository.PortalRepository; 41 | import com.alibaba.apiopenplatform.repository.SubscriptionRepository; 42 | import com.alibaba.apiopenplatform.repository.ProductPublicationRepository; 43 | import com.alibaba.apiopenplatform.repository.ProductRefRepository; 44 | import com.alibaba.apiopenplatform.service.GatewayService; 45 | import com.alibaba.apiopenplatform.entity.ProductPublication; 46 | import com.alibaba.apiopenplatform.entity.ProductRef; 47 | import org.springframework.data.domain.PageRequest; 48 | import com.alibaba.apiopenplatform.service.IdpService; 49 | import com.alibaba.apiopenplatform.service.PortalService; 50 | import com.alibaba.apiopenplatform.support.enums.DomainType; 51 | import com.alibaba.apiopenplatform.support.portal.OidcConfig; 52 | import com.alibaba.apiopenplatform.support.portal.PortalSettingConfig; 53 | import com.alibaba.apiopenplatform.support.portal.PortalUiConfig; 54 | import lombok.RequiredArgsConstructor; 55 | import lombok.extern.slf4j.Slf4j; 56 | import org.springframework.context.ApplicationEventPublisher; 57 | import org.springframework.data.domain.Page; 58 | import org.springframework.data.domain.Pageable; 59 | import org.springframework.data.jpa.domain.Specification; 60 | import org.springframework.stereotype.Service; 61 | 62 | import javax.persistence.criteria.Predicate; 63 | import javax.transaction.Transactional; 64 | import java.util.ArrayList; 65 | import java.util.List; 66 | import java.util.Map; 67 | import java.util.Optional; 68 | import java.util.stream.Collectors; 69 | 70 | @Service 71 | @Slf4j 72 | @RequiredArgsConstructor 73 | @Transactional 74 | public class PortalServiceImpl implements PortalService { 75 | 76 | private final PortalRepository portalRepository; 77 | 78 | private final PortalDomainRepository portalDomainRepository; 79 | 80 | private final ApplicationEventPublisher eventPublisher; 81 | 82 | private final SubscriptionRepository subscriptionRepository; 83 | 84 | private final ContextHolder contextHolder; 85 | 86 | private final IdpService idpService; 87 | 88 | private final String domainFormat = "%s.api.portal.local"; 89 | 90 | private final ProductPublicationRepository publicationRepository; 91 | 92 | private final ProductRefRepository productRefRepository; 93 | 94 | private final GatewayService gatewayService; 95 | 96 | public PortalResult createPortal(CreatePortalParam param) { 97 | portalRepository.findByName(param.getName()) 98 | .ifPresent(portal -> { 99 | throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.PORTAL, portal.getName())); 100 | }); 101 | 102 | String portalId = IdGenerator.genPortalId(); 103 | Portal portal = param.convertTo(); 104 | portal.setPortalId(portalId); 105 | portal.setAdminId(contextHolder.getUser()); 106 | 107 | // Setting & Ui 108 | portal.setPortalSettingConfig(new PortalSettingConfig()); 109 | portal.setPortalUiConfig(new PortalUiConfig()); 110 | 111 | // Domain 112 | PortalDomain portalDomain = new PortalDomain(); 113 | portalDomain.setDomain(String.format(domainFormat, portalId)); 114 | portalDomain.setPortalId(portalId); 115 | 116 | portalDomainRepository.save(portalDomain); 117 | portalRepository.save(portal); 118 | 119 | return getPortal(portalId); 120 | } 121 | 122 | @Override 123 | public PortalResult getPortal(String portalId) { 124 | Portal portal = findPortal(portalId); 125 | List<PortalDomain> domains = portalDomainRepository.findAllByPortalId(portalId); 126 | portal.setPortalDomains(domains); 127 | 128 | return new PortalResult().convertFrom(portal); 129 | } 130 | 131 | @Override 132 | public void existsPortal(String portalId) { 133 | portalRepository.findByPortalId(portalId) 134 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.PORTAL, portalId)); 135 | } 136 | 137 | @Override 138 | public PageResult<PortalResult> listPortals(Pageable pageable) { 139 | Page<Portal> portals = portalRepository.findAll(pageable); 140 | 141 | // 填充Domain 142 | if (portals.hasContent()) { 143 | List<String> portalIds = portals.getContent().stream() 144 | .map(Portal::getPortalId) 145 | .collect(Collectors.toList()); 146 | 147 | List<PortalDomain> allDomains = portalDomainRepository.findAllByPortalIdIn(portalIds); 148 | Map<String, List<PortalDomain>> portalDomains = allDomains.stream() 149 | .collect(Collectors.groupingBy(PortalDomain::getPortalId)); 150 | 151 | portals.getContent().forEach(portal -> { 152 | List<PortalDomain> domains = portalDomains.getOrDefault(portal.getPortalId(), new ArrayList<>()); 153 | portal.setPortalDomains(domains); 154 | }); 155 | } 156 | 157 | return new PageResult<PortalResult>().convertFrom(portals, portal -> new PortalResult().convertFrom(portal)); 158 | } 159 | 160 | @Override 161 | public PortalResult updatePortal(String portalId, UpdatePortalParam param) { 162 | Portal portal = findPortal(portalId); 163 | 164 | Optional.ofNullable(param.getName()) 165 | .filter(name -> !name.equals(portal.getName())) 166 | .flatMap(portalRepository::findByName) 167 | .ifPresent(p -> { 168 | throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.PORTAL, portal.getName())); 169 | }); 170 | 171 | param.update(portal); 172 | // 验证OIDC配置 173 | PortalSettingConfig setting = portal.getPortalSettingConfig(); 174 | if (CollUtil.isNotEmpty(setting.getOidcConfigs())) { 175 | idpService.validateOidcConfigs(setting.getOidcConfigs()); 176 | } 177 | 178 | if (CollUtil.isNotEmpty(setting.getOauth2Configs())) { 179 | idpService.validateOAuth2Configs(setting.getOauth2Configs()); 180 | } 181 | 182 | // 至少保留一种认证方式 183 | if (BooleanUtil.isFalse(setting.getBuiltinAuthEnabled())) { 184 | boolean enabledOidc = Optional.ofNullable(setting.getOidcConfigs()) 185 | .filter(CollUtil::isNotEmpty) 186 | .map(configs -> configs.stream().anyMatch(OidcConfig::isEnabled)) 187 | .orElse(false); 188 | 189 | if (!enabledOidc) { 190 | throw new BusinessException(ErrorCode.INVALID_REQUEST, "至少配置一种认证方式"); 191 | } 192 | } 193 | portalRepository.saveAndFlush(portal); 194 | 195 | return getPortal(portal.getPortalId()); 196 | } 197 | 198 | @Override 199 | public void deletePortal(String portalId) { 200 | Portal portal = findPortal(portalId); 201 | 202 | // 清理Domain 203 | portalDomainRepository.deleteAllByPortalId(portalId); 204 | 205 | // 异步清理门户资源 206 | eventPublisher.publishEvent(new PortalDeletingEvent(portalId)); 207 | portalRepository.delete(portal); 208 | } 209 | 210 | @Override 211 | public String resolvePortal(String domain) { 212 | return portalDomainRepository.findByDomain(domain) 213 | .map(PortalDomain::getPortalId) 214 | .orElse(null); 215 | } 216 | 217 | @Override 218 | public PortalResult bindDomain(String portalId, BindDomainParam param) { 219 | existsPortal(portalId); 220 | portalDomainRepository.findByPortalIdAndDomain(portalId, param.getDomain()) 221 | .ifPresent(portalDomain -> { 222 | throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.PORTAL_DOMAIN, portalDomain.getDomain())); 223 | }); 224 | 225 | PortalDomain portalDomain = param.convertTo(); 226 | portalDomain.setPortalId(portalId); 227 | 228 | portalDomainRepository.save(portalDomain); 229 | return getPortal(portalId); 230 | } 231 | 232 | @Override 233 | public PortalResult unbindDomain(String portalId, String domain) { 234 | portalDomainRepository.findByPortalIdAndDomain(portalId, domain) 235 | .ifPresent(portalDomain -> { 236 | // 默认域名不允许解绑 237 | if (portalDomain.getType() == DomainType.DEFAULT) { 238 | throw new BusinessException(ErrorCode.INVALID_REQUEST, "默认域名不允许解绑"); 239 | } 240 | portalDomainRepository.delete(portalDomain); 241 | }); 242 | return getPortal(portalId); 243 | } 244 | 245 | @Override 246 | public PageResult<SubscriptionResult> listSubscriptions(String portalId, QuerySubscriptionParam param, Pageable pageable) { 247 | // Ensure portal exists 248 | existsPortal(portalId); 249 | 250 | Specification<ProductSubscription> spec = (root, query, cb) -> { 251 | List<Predicate> predicates = new ArrayList<>(); 252 | predicates.add(cb.equal(root.get("portalId"), portalId)); 253 | if (param != null && param.getStatus() != null) { 254 | predicates.add(cb.equal(root.get("status"), param.getStatus())); 255 | } 256 | return cb.and(predicates.toArray(new Predicate[0])); 257 | }; 258 | 259 | Page<ProductSubscription> page = subscriptionRepository.findAll(spec, pageable); 260 | return new PageResult<SubscriptionResult>().convertFrom(page, s -> new SubscriptionResult().convertFrom(s)); 261 | } 262 | 263 | @Override 264 | public String getDefaultPortal() { 265 | Portal portal = portalRepository.findFirstByOrderByIdAsc().orElse(null); 266 | if (portal == null) { 267 | return null; 268 | } 269 | return portal.getPortalId(); 270 | } 271 | 272 | @Override 273 | public String getDashboard(String portalId) { 274 | existsPortal(portalId); 275 | 276 | // 找到该门户下任一已发布产品(取第一页第一条) 277 | ProductPublication pub = publicationRepository.findByPortalId(portalId, PageRequest.of(0, 1)) 278 | .stream() 279 | .findFirst() 280 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.PORTAL, portalId)); 281 | 282 | // 取产品的网关引用 283 | ProductRef productRef = productRefRepository.findFirstByProductId(pub.getProductId()) 284 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.PRODUCT, pub.getProductId())); 285 | 286 | if (productRef.getGatewayId() == null) { 287 | throw new BusinessException(ErrorCode.NOT_FOUND, "网关", "该门户下的产品尚未关联网关服务"); 288 | } 289 | 290 | // 复用网关的Dashboard能力 291 | return gatewayService.getDashboard(productRef.getGatewayId(),"Portal"); 292 | } 293 | 294 | private Portal findPortal(String portalId) { 295 | return portalRepository.findByPortalId(portalId) 296 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.PORTAL, portalId)); 297 | } 298 | } 299 | ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/portal/PortalDevelopers.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import {Card, Table, Badge, Button, Space, message, Modal} from 'antd' 2 | import { 3 | EditOutlined, 4 | DeleteOutlined, 5 | ExclamationCircleOutlined, 6 | EyeOutlined, 7 | UnorderedListOutlined, 8 | CheckCircleFilled, 9 | ClockCircleOutlined 10 | } from '@ant-design/icons' 11 | import {useEffect, useState} from 'react' 12 | import {Portal, Developer, Consumer} from '@/types' 13 | import {portalApi} from '@/lib/api' 14 | import {formatDateTime} from '@/lib/utils' 15 | import {SubscriptionListModal} from '@/components/subscription/SubscriptionListModal' 16 | 17 | interface PortalDevelopersProps { 18 | portal: Portal 19 | } 20 | 21 | export function PortalDevelopers({portal}: PortalDevelopersProps) { 22 | const [developers, setDevelopers] = useState<Developer[]>([]) 23 | const [pagination, setPagination] = useState({ 24 | current: 1, 25 | pageSize: 10, 26 | total: 0, 27 | showSizeChanger: true, 28 | showQuickJumper: true, 29 | showTotal: (total: number, range: [number, number]) => 30 | `共 ${total} 条` 31 | }) 32 | 33 | // Consumer相关状态 34 | const [consumers, setConsumers] = useState<Consumer[]>([]) 35 | const [consumerModalVisible, setConsumerModalVisible] = useState(false) 36 | const [currentDeveloper, setCurrentDeveloper] = useState<Developer | null>(null) 37 | const [consumerPagination, setConsumerPagination] = useState({ 38 | current: 1, 39 | pageSize: 10, 40 | total: 0, 41 | showSizeChanger: true, 42 | showQuickJumper: true, 43 | showTotal: (total: number, range: [number, number]) => 44 | `共 ${total} 条` 45 | }) 46 | 47 | // 订阅列表相关状态 48 | const [subscriptionModalVisible, setSubscriptionModalVisible] = useState(false) 49 | const [currentConsumer, setCurrentConsumer] = useState<Consumer | null>(null) 50 | 51 | useEffect(() => { 52 | fetchDevelopers() 53 | }, [portal.portalId, pagination.current, pagination.pageSize]) 54 | 55 | const fetchDevelopers = () => { 56 | portalApi.getDeveloperList(portal.portalId, { 57 | page: pagination.current, // 后端从0开始 58 | size: pagination.pageSize 59 | }).then((res) => { 60 | setDevelopers(res.data.content) 61 | setPagination(prev => ({ 62 | ...prev, 63 | total: res.data.totalElements || 0 64 | })) 65 | }) 66 | } 67 | 68 | const handleUpdateDeveloperStatus = (developerId: string, status: string) => { 69 | portalApi.updateDeveloperStatus(portal.portalId, developerId, status).then(() => { 70 | if (status === 'PENDING') { 71 | message.success('取消授权成功') 72 | } else { 73 | message.success('审批成功') 74 | } 75 | fetchDevelopers() 76 | }).catch(() => { 77 | message.error('审批失败') 78 | }) 79 | } 80 | 81 | const handleTableChange = (paginationInfo: any) => { 82 | setPagination(prev => ({ 83 | ...prev, 84 | current: paginationInfo.current, 85 | pageSize: paginationInfo.pageSize 86 | })) 87 | } 88 | 89 | const handleDeleteDeveloper = (developerId: string, username: string) => { 90 | Modal.confirm({ 91 | title: '确认删除', 92 | icon: <ExclamationCircleOutlined/>, 93 | content: `确定要删除开发者 "${username}" 吗?此操作不可恢复。`, 94 | okText: '确认删除', 95 | okType: 'danger', 96 | cancelText: '取消', 97 | onOk() { 98 | portalApi.deleteDeveloper(developerId).then(() => { 99 | message.success('删除成功') 100 | fetchDevelopers() 101 | }).catch(() => { 102 | message.error('删除失败') 103 | }) 104 | }, 105 | }) 106 | } 107 | 108 | // Consumer相关函数 109 | const handleViewConsumers = (developer: Developer) => { 110 | setCurrentDeveloper(developer) 111 | setConsumerModalVisible(true) 112 | setConsumerPagination(prev => ({...prev, current: 1})) 113 | fetchConsumers(developer.developerId, 1, consumerPagination.pageSize) 114 | } 115 | 116 | const fetchConsumers = (developerId: string, page: number, size: number) => { 117 | portalApi.getConsumerList(portal.portalId, developerId, {page: page, size}).then((res) => { 118 | setConsumers(res.data.content || []) 119 | setConsumerPagination(prev => ({ 120 | ...prev, 121 | total: res.data.totalElements || 0 122 | })) 123 | }).then((res: any) => { 124 | setConsumers(res.data.content || []) 125 | setConsumerPagination(prev => ({ 126 | ...prev, 127 | total: res.data.totalElements || 0 128 | })) 129 | }) 130 | } 131 | 132 | const handleConsumerTableChange = (paginationInfo: any) => { 133 | if (currentDeveloper) { 134 | setConsumerPagination(prev => ({ 135 | ...prev, 136 | current: paginationInfo.current, 137 | pageSize: paginationInfo.pageSize 138 | })) 139 | fetchConsumers(currentDeveloper.developerId, paginationInfo.current, paginationInfo.pageSize) 140 | } 141 | } 142 | 143 | const handleConsumerStatusUpdate = (consumerId: string) => { 144 | if (currentDeveloper) { 145 | portalApi.approveConsumer(consumerId).then((res) => { 146 | message.success('审批成功') 147 | fetchConsumers(currentDeveloper.developerId, consumerPagination.current, consumerPagination.pageSize) 148 | }).catch((err) => { 149 | // message.error('审批失败') 150 | }) 151 | } 152 | } 153 | 154 | // 查看订阅列表 155 | const handleViewSubscriptions = (consumer: Consumer) => { 156 | setCurrentConsumer(consumer) 157 | setSubscriptionModalVisible(true) 158 | } 159 | 160 | // 关闭订阅列表模态框 161 | const handleSubscriptionModalCancel = () => { 162 | setSubscriptionModalVisible(false) 163 | setCurrentConsumer(null) 164 | } 165 | 166 | 167 | const columns = [ 168 | { 169 | title: '开发者名称/ID', 170 | dataIndex: 'username', 171 | key: 'username', 172 | fixed: 'left' as const, 173 | width: 280, 174 | render: (username: string, record: Developer) => ( 175 | <div className="ml-2"> 176 | <div className="font-medium">{username}</div> 177 | <div className="text-sm text-gray-500">{record.developerId}</div> 178 | </div> 179 | ), 180 | }, 181 | { 182 | title: '状态', 183 | dataIndex: 'status', 184 | key: 'status', 185 | width: 120, 186 | render: (status: string) => ( 187 | <div className="flex items-center"> 188 | {status === 'APPROVED' ? ( 189 | <> 190 | <CheckCircleFilled className="text-green-500 mr-2" style={{fontSize: '10px'}} /> 191 | <span className="text-xs text-gray-900">可用</span> 192 | </> 193 | ) : ( 194 | <> 195 | <ClockCircleOutlined className="text-orange-500 mr-2" style={{fontSize: '10px'}} /> 196 | <span className="text-xs text-gray-900">待审核</span> 197 | </> 198 | )} 199 | </div> 200 | ) 201 | }, 202 | 203 | { 204 | title: '创建时间', 205 | dataIndex: 'createAt', 206 | key: 'createAt', 207 | width: 160, 208 | render: (date: string) => formatDateTime(date) 209 | }, 210 | 211 | { 212 | title: '操作', 213 | key: 'action', 214 | fixed: 'right' as const, 215 | width: 250, 216 | render: (_: any, record: Developer) => ( 217 | <Space size="middle"> 218 | <Button onClick={() => handleViewConsumers(record)} type="link" icon={<EyeOutlined/>}> 219 | 查看Consumer 220 | </Button> 221 | { 222 | !portal.portalSettingConfig.autoApproveDevelopers && ( 223 | record.status === 'APPROVED' ? ( 224 | <Button onClick={() => handleUpdateDeveloperStatus(record.developerId, 'PENDING')} 225 | type="link" icon={<EditOutlined/>}> 226 | 取消授权 227 | </Button> 228 | ) : ( 229 | <Button onClick={() => handleUpdateDeveloperStatus(record.developerId, 'APPROVED')} 230 | type="link" icon={<EditOutlined/>}> 231 | 审批通过 232 | </Button> 233 | ) 234 | ) 235 | } 236 | <Button onClick={() => handleDeleteDeveloper(record.developerId, record.username)} type="link" 237 | danger icon={<DeleteOutlined/>}> 238 | 删除 239 | </Button> 240 | </Space> 241 | ), 242 | }, 243 | ] 244 | 245 | // Consumer表格列定义 246 | const consumerColumns = [ 247 | { 248 | title: 'Consumer名称', 249 | dataIndex: 'name', 250 | key: 'name', 251 | width: 200, 252 | }, 253 | { 254 | title: 'Consumer ID', 255 | dataIndex: 'consumerId', 256 | key: 'consumerId', 257 | width: 200, 258 | }, 259 | { 260 | title: '描述', 261 | dataIndex: 'description', 262 | key: 'description', 263 | ellipsis: true, 264 | width: 200, 265 | }, 266 | // { 267 | // title: '状态', 268 | // dataIndex: 'status', 269 | // key: 'status', 270 | // width: 120, 271 | // render: (status: string) => ( 272 | // <Badge status={status === 'APPROVED' ? 'success' : 'default'} text={status === 'APPROVED' ? '可用' : '待审核'} /> 273 | // ) 274 | // }, 275 | { 276 | title: '创建时间', 277 | dataIndex: 'createAt', 278 | key: 'createAt', 279 | width: 150, 280 | render: (date: string) => formatDateTime(date) 281 | }, 282 | { 283 | title: '操作', 284 | key: 'action', 285 | width: 120, 286 | render: (_: any, record: Consumer) => ( 287 | <Button 288 | onClick={() => handleViewSubscriptions(record)} 289 | type="link" 290 | icon={<UnorderedListOutlined/>} 291 | > 292 | 订阅列表 293 | </Button> 294 | ), 295 | }, 296 | ] 297 | 298 | return ( 299 | <div className="p-6 space-y-6"> 300 | <div className="flex justify-between items-center"> 301 | <div> 302 | <h1 className="text-2xl font-bold mb-2">开发者</h1> 303 | <p className="text-gray-600">管理Portal的开发者用户</p> 304 | </div> 305 | </div> 306 | 307 | <Card> 308 | {/* <div className="mb-4"> 309 | <Input 310 | placeholder="搜索开发者..." 311 | prefix={<SearchOutlined />} 312 | value={searchText} 313 | onChange={(e) => setSearchText(e.target.value)} 314 | style={{ width: 300 }} 315 | /> 316 | </div> */} 317 | <Table 318 | columns={columns} 319 | dataSource={developers} 320 | rowKey="developerId" 321 | pagination={pagination} 322 | onChange={handleTableChange} 323 | scroll={{ 324 | y: 'calc(100vh - 400px)', 325 | x: 'max-content' 326 | }} 327 | /> 328 | </Card> 329 | 330 | {/* Consumer弹窗 */} 331 | <Modal 332 | title={`查看Consumer - ${currentDeveloper?.username || ''}`} 333 | open={consumerModalVisible} 334 | onCancel={() => setConsumerModalVisible(false)} 335 | footer={null} 336 | width={1000} 337 | destroyOnClose 338 | > 339 | <Table 340 | columns={consumerColumns} 341 | dataSource={consumers} 342 | rowKey="consumerId" 343 | pagination={consumerPagination} 344 | onChange={handleConsumerTableChange} 345 | scroll={{y: 'calc(100vh - 400px)'}} 346 | /> 347 | </Modal> 348 | 349 | {/* 订阅列表弹窗 */} 350 | {currentConsumer && ( 351 | <SubscriptionListModal 352 | visible={subscriptionModalVisible} 353 | consumerId={currentConsumer.consumerId} 354 | consumerName={currentConsumer.name} 355 | onCancel={handleSubscriptionModalCancel} 356 | /> 357 | )} 358 | 359 | </div> 360 | ) 361 | } ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/api-product/ApiProductFormModal.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { useState, useEffect } from "react"; 2 | import { 3 | Modal, 4 | Form, 5 | Input, 6 | Select, 7 | Image, 8 | message, 9 | UploadFile, 10 | Switch, 11 | Radio, 12 | Space, 13 | } from "antd"; 14 | import { CameraOutlined } from "@ant-design/icons"; 15 | import { apiProductApi } from "@/lib/api"; 16 | import type { ApiProduct } from "@/types/api-product"; 17 | 18 | interface ApiProductFormModalProps { 19 | visible: boolean; 20 | onCancel: () => void; 21 | onSuccess: () => void; 22 | productId?: string; 23 | initialData?: Partial<ApiProduct>; 24 | } 25 | 26 | export default function ApiProductFormModal({ 27 | visible, 28 | onCancel, 29 | onSuccess, 30 | productId, 31 | initialData, 32 | }: ApiProductFormModalProps) { 33 | const [form] = Form.useForm(); 34 | const [loading, setLoading] = useState(false); 35 | const [previewOpen, setPreviewOpen] = useState(false); 36 | const [previewImage, setPreviewImage] = useState(""); 37 | const [fileList, setFileList] = useState<UploadFile[]>([]); 38 | const [iconMode, setIconMode] = useState<'BASE64' | 'URL'>('URL'); 39 | const isEditMode = !!productId; 40 | 41 | // 初始化时加载已有数据 42 | useEffect(() => { 43 | if (visible && isEditMode && initialData && initialData.name) { 44 | setTimeout(() => { 45 | // 1. 先设置所有字段 46 | form.setFieldsValue({ 47 | name: initialData.name, 48 | description: initialData.description, 49 | type: initialData.type, 50 | autoApprove: initialData.autoApprove, 51 | }); 52 | }, 100); 53 | 54 | // 2. 处理 icon 字段 55 | if (initialData.icon) { 56 | if (typeof initialData.icon === 'object' && initialData.icon.type && initialData.icon.value) { 57 | // 新格式:{ type: 'BASE64' | 'URL', value: string } 58 | const iconType = initialData.icon.type as 'BASE64' | 'URL'; 59 | const iconValue = initialData.icon.value; 60 | 61 | setIconMode(iconType); 62 | 63 | if (iconType === 'BASE64') { 64 | setFileList([ 65 | { 66 | uid: "-1", 67 | name: "头像.png", 68 | status: "done", 69 | url: iconValue, 70 | }, 71 | ]); 72 | form.setFieldsValue({ icon: iconValue }); 73 | } else { 74 | form.setFieldsValue({ iconUrl: iconValue }); 75 | } 76 | } else { 77 | // 兼容旧格式(字符串格式) 78 | const iconStr = initialData.icon as unknown as string; 79 | if (iconStr && typeof iconStr === 'string' && iconStr.includes("value=")) { 80 | const startIndex = iconStr.indexOf("value=") + 6; 81 | const endIndex = iconStr.length - 1; 82 | const base64Data = iconStr.substring(startIndex, endIndex).trim(); 83 | 84 | setIconMode('BASE64'); 85 | setFileList([ 86 | { 87 | uid: "-1", 88 | name: "头像.png", 89 | status: "done", 90 | url: base64Data, 91 | }, 92 | ]); 93 | form.setFieldsValue({ icon: base64Data }); 94 | } 95 | } 96 | } 97 | } else if (visible && !isEditMode) { 98 | // 新建模式下清空表单 99 | form.resetFields(); 100 | setFileList([]); 101 | setIconMode('URL'); 102 | } 103 | }, [visible, isEditMode, initialData, form]); 104 | 105 | // 将文件转为 Base64 106 | const getBase64 = (file: File): Promise<string> => 107 | new Promise((resolve, reject) => { 108 | const reader = new FileReader(); 109 | reader.readAsDataURL(file); 110 | reader.onload = () => resolve(reader.result as string); 111 | reader.onerror = (error) => reject(error); 112 | }); 113 | 114 | 115 | const uploadButton = ( 116 | <div style={{ 117 | display: 'flex', 118 | flexDirection: 'column', 119 | alignItems: 'center', 120 | justifyContent: 'center', 121 | color: '#999' 122 | }}> 123 | <CameraOutlined style={{ fontSize: '16px', marginBottom: '6px' }} /> 124 | <span style={{ fontSize: '12px', color: '#999' }}>上传图片</span> 125 | </div> 126 | ); 127 | 128 | // 处理Icon模式切换 129 | const handleIconModeChange = (mode: 'BASE64' | 'URL') => { 130 | setIconMode(mode); 131 | // 清空相关字段 132 | if (mode === 'URL') { 133 | form.setFieldsValue({ icon: undefined }); 134 | setFileList([]); 135 | } else { 136 | form.setFieldsValue({ iconUrl: undefined }); 137 | } 138 | }; 139 | 140 | const resetForm = () => { 141 | form.resetFields(); 142 | setFileList([]); 143 | setPreviewImage(""); 144 | setPreviewOpen(false); 145 | setIconMode('URL'); 146 | }; 147 | 148 | const handleCancel = () => { 149 | resetForm(); 150 | onCancel(); 151 | }; 152 | 153 | const handleSubmit = async () => { 154 | try { 155 | const values = await form.validateFields(); 156 | setLoading(true); 157 | 158 | const { icon, iconUrl, ...otherValues } = values; 159 | 160 | if (isEditMode) { 161 | let params = { ...otherValues }; 162 | 163 | // 处理icon字段 164 | if (iconMode === 'BASE64' && icon) { 165 | params.icon = { 166 | type: "BASE64", 167 | value: icon, 168 | }; 169 | } else if (iconMode === 'URL' && iconUrl) { 170 | params.icon = { 171 | type: "URL", 172 | value: iconUrl, 173 | }; 174 | } else if (!icon && !iconUrl) { 175 | // 如果两种模式都没有提供icon,保持原有icon不变 176 | delete params.icon; 177 | } 178 | 179 | await apiProductApi.updateApiProduct(productId!, params); 180 | message.success("API Product 更新成功"); 181 | } else { 182 | let params = { ...otherValues }; 183 | 184 | // 处理icon字段 185 | if (iconMode === 'BASE64' && icon) { 186 | params.icon = { 187 | type: "BASE64", 188 | value: icon, 189 | }; 190 | } else if (iconMode === 'URL' && iconUrl) { 191 | params.icon = { 192 | type: "URL", 193 | value: iconUrl, 194 | }; 195 | } 196 | 197 | await apiProductApi.createApiProduct(params); 198 | message.success("API Product 创建成功"); 199 | } 200 | 201 | resetForm(); 202 | onSuccess(); 203 | } catch (error: any) { 204 | if (error?.errorFields) return; 205 | message.error("操作失败"); 206 | } finally { 207 | setLoading(false); 208 | } 209 | }; 210 | 211 | return ( 212 | <Modal 213 | title={isEditMode ? "编辑 API Product" : "创建 API Product"} 214 | open={visible} 215 | onOk={handleSubmit} 216 | onCancel={handleCancel} 217 | confirmLoading={loading} 218 | width={600} 219 | > 220 | <Form form={form} layout="vertical" preserve={false}> 221 | <Form.Item 222 | label="名称" 223 | name="name" 224 | rules={[{ required: true, message: "请输入API Product名称" }]} 225 | > 226 | <Input placeholder="请输入API Product名称" /> 227 | </Form.Item> 228 | 229 | <Form.Item 230 | label="描述" 231 | name="description" 232 | rules={[{ required: true, message: "请输入描述" }]} 233 | > 234 | <Input.TextArea placeholder="请输入描述" rows={3} /> 235 | </Form.Item> 236 | 237 | <Form.Item 238 | label="类型" 239 | name="type" 240 | rules={[{ required: true, message: "请选择类型" }]} 241 | > 242 | <Select placeholder="请选择类型"> 243 | <Select.Option value="REST_API">REST API</Select.Option> 244 | <Select.Option value="MCP_SERVER">MCP Server</Select.Option> 245 | </Select> 246 | </Form.Item> 247 | 248 | <Form.Item 249 | label="自动审批订阅" 250 | name="autoApprove" 251 | tooltip={{ 252 | title: ( 253 | <div style={{ 254 | color: '#000000', 255 | backgroundColor: '#ffffff', 256 | fontSize: '13px', 257 | lineHeight: '1.4', 258 | padding: '4px 0' 259 | }}> 260 | 启用后,该产品的订阅申请将自动审批通过,否则使用Portal的消费者订阅审批设置。 261 | </div> 262 | ), 263 | placement: "topLeft", 264 | overlayInnerStyle: { 265 | backgroundColor: '#ffffff', 266 | color: '#000000', 267 | border: '1px solid #d9d9d9', 268 | borderRadius: '6px', 269 | boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', 270 | }, 271 | overlayStyle: { 272 | maxWidth: '300px' 273 | } 274 | }} 275 | valuePropName="checked" 276 | > 277 | <Switch /> 278 | </Form.Item> 279 | 280 | <Form.Item label="Icon设置" style={{ marginBottom: '16px' }}> 281 | <Space direction="vertical" style={{ width: '100%' }}> 282 | <Radio.Group 283 | value={iconMode} 284 | onChange={(e) => handleIconModeChange(e.target.value)} 285 | > 286 | <Radio value="URL">图片链接</Radio> 287 | <Radio value="BASE64">本地上传</Radio> 288 | </Radio.Group> 289 | 290 | {iconMode === 'URL' ? ( 291 | <Form.Item 292 | name="iconUrl" 293 | style={{ marginBottom: 0 }} 294 | rules={[ 295 | { 296 | type: 'url', 297 | message: '请输入有效的图片链接' 298 | } 299 | ]} 300 | > 301 | <Input placeholder="请输入图片链接地址" /> 302 | </Form.Item> 303 | ) : ( 304 | <Form.Item name="icon" style={{ marginBottom: 0 }}> 305 | <div 306 | style={{ 307 | width: '80px', 308 | height: '80px', 309 | border: '1px dashed #d9d9d9', 310 | borderRadius: '8px', 311 | display: 'flex', 312 | alignItems: 'center', 313 | justifyContent: 'center', 314 | cursor: 'pointer', 315 | transition: 'border-color 0.3s', 316 | position: 'relative' 317 | }} 318 | onClick={() => { 319 | // 触发文件选择 320 | const input = document.createElement('input'); 321 | input.type = 'file'; 322 | input.accept = 'image/*'; 323 | input.onchange = (e) => { 324 | const file = (e.target as HTMLInputElement).files?.[0]; 325 | if (file) { 326 | // 验证文件大小,限制为16KB 327 | const maxSize = 16 * 1024; // 16KB 328 | if (file.size > maxSize) { 329 | message.error(`图片大小不能超过 16KB,当前图片大小为 ${Math.round(file.size / 1024)}KB`); 330 | return; 331 | } 332 | 333 | const newFileList: UploadFile[] = [{ 334 | uid: Date.now().toString(), 335 | name: file.name, 336 | status: 'done' as const, 337 | url: URL.createObjectURL(file) 338 | }]; 339 | setFileList(newFileList); 340 | getBase64(file).then((base64) => { 341 | form.setFieldsValue({ icon: base64 }); 342 | }); 343 | } 344 | }; 345 | input.click(); 346 | }} 347 | onMouseEnter={(e) => { 348 | e.currentTarget.style.borderColor = '#1890ff'; 349 | }} 350 | onMouseLeave={(e) => { 351 | e.currentTarget.style.borderColor = '#d9d9d9'; 352 | }} 353 | > 354 | {fileList.length >= 1 ? ( 355 | <img 356 | src={fileList[0].url} 357 | alt="uploaded" 358 | style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '6px' }} 359 | onClick={(e) => { 360 | e.stopPropagation(); 361 | // 预览图片 362 | setPreviewImage(fileList[0].url || ''); 363 | setPreviewOpen(true); 364 | }} 365 | /> 366 | ) : ( 367 | uploadButton 368 | )} 369 | {fileList.length >= 1 && ( 370 | <div 371 | style={{ 372 | position: 'absolute', 373 | top: '4px', 374 | right: '4px', 375 | background: 'rgba(0, 0, 0, 0.5)', 376 | borderRadius: '50%', 377 | width: '16px', 378 | height: '16px', 379 | display: 'flex', 380 | alignItems: 'center', 381 | justifyContent: 'center', 382 | cursor: 'pointer', 383 | color: 'white', 384 | fontSize: '10px' 385 | }} 386 | onClick={(e) => { 387 | e.stopPropagation(); 388 | setFileList([]); 389 | form.setFieldsValue({ icon: null }); 390 | }} 391 | > 392 | × 393 | </div> 394 | )} 395 | </div> 396 | </Form.Item> 397 | )} 398 | </Space> 399 | </Form.Item> 400 | 401 | {/* 图片预览弹窗 */} 402 | {previewImage && ( 403 | <Image 404 | wrapperStyle={{ display: "none" }} 405 | preview={{ 406 | visible: previewOpen, 407 | onVisibleChange: (visible) => setPreviewOpen(visible), 408 | afterOpenChange: (visible) => { 409 | if (!visible) setPreviewImage(""); 410 | }, 411 | }} 412 | src={previewImage} 413 | /> 414 | )} 415 | </Form> 416 | </Modal> 417 | ); 418 | } 419 | ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/service/impl/OidcServiceImpl.java: -------------------------------------------------------------------------------- ```java 1 | package com.alibaba.apiopenplatform.service.impl; 2 | 3 | import cn.hutool.core.codec.Base64; 4 | import cn.hutool.core.convert.Convert; 5 | import cn.hutool.core.map.MapUtil; 6 | import cn.hutool.core.util.IdUtil; 7 | import cn.hutool.core.util.StrUtil; 8 | import cn.hutool.json.JSONUtil; 9 | import cn.hutool.jwt.JWT; 10 | import cn.hutool.jwt.JWTUtil; 11 | import com.alibaba.apiopenplatform.core.constant.CommonConstants; 12 | import com.alibaba.apiopenplatform.core.constant.IdpConstants; 13 | import com.alibaba.apiopenplatform.core.constant.Resources; 14 | import com.alibaba.apiopenplatform.core.exception.BusinessException; 15 | import com.alibaba.apiopenplatform.core.exception.ErrorCode; 16 | import com.alibaba.apiopenplatform.core.security.ContextHolder; 17 | import com.alibaba.apiopenplatform.core.utils.TokenUtil; 18 | import com.alibaba.apiopenplatform.dto.params.developer.CreateExternalDeveloperParam; 19 | import com.alibaba.apiopenplatform.dto.result.*; 20 | import com.alibaba.apiopenplatform.service.OidcService; 21 | import com.alibaba.apiopenplatform.service.DeveloperService; 22 | import com.alibaba.apiopenplatform.service.PortalService; 23 | import com.alibaba.apiopenplatform.support.enums.DeveloperAuthType; 24 | import com.alibaba.apiopenplatform.support.enums.GrantType; 25 | import com.alibaba.apiopenplatform.support.portal.AuthCodeConfig; 26 | import com.alibaba.apiopenplatform.support.portal.IdentityMapping; 27 | import com.alibaba.apiopenplatform.support.portal.OidcConfig; 28 | import lombok.RequiredArgsConstructor; 29 | import lombok.extern.slf4j.Slf4j; 30 | import org.springframework.http.HttpEntity; 31 | import org.springframework.http.HttpHeaders; 32 | import org.springframework.http.HttpMethod; 33 | import org.springframework.http.ResponseEntity; 34 | import org.springframework.stereotype.Service; 35 | import org.springframework.util.LinkedMultiValueMap; 36 | import org.springframework.util.MultiValueMap; 37 | import org.springframework.web.client.RestTemplate; 38 | import org.springframework.web.util.UriComponentsBuilder; 39 | 40 | import javax.servlet.http.HttpServletRequest; 41 | import javax.servlet.http.HttpServletResponse; 42 | import java.util.*; 43 | import java.util.stream.Collectors; 44 | 45 | @Slf4j 46 | @Service 47 | @RequiredArgsConstructor 48 | public class OidcServiceImpl implements OidcService { 49 | 50 | private final PortalService portalService; 51 | 52 | private final DeveloperService developerService; 53 | 54 | private final RestTemplate restTemplate; 55 | 56 | private final ContextHolder contextHolder; 57 | 58 | @Override 59 | public String buildAuthorizationUrl(String provider, String apiPrefix, HttpServletRequest request) { 60 | OidcConfig oidcConfig = findOidcConfig(provider); 61 | AuthCodeConfig authCodeConfig = oidcConfig.getAuthCodeConfig(); 62 | 63 | // state保存上下文信息 64 | String state = buildState(provider, apiPrefix); 65 | String redirectUri = buildRedirectUri(request); 66 | 67 | // 重定向URL 68 | String authUrl = UriComponentsBuilder 69 | .fromUriString(authCodeConfig.getAuthorizationEndpoint()) 70 | // 授权码模式 71 | .queryParam(IdpConstants.RESPONSE_TYPE, IdpConstants.CODE) 72 | .queryParam(IdpConstants.CLIENT_ID, authCodeConfig.getClientId()) 73 | .queryParam(IdpConstants.REDIRECT_URI, redirectUri) 74 | .queryParam(IdpConstants.SCOPE, authCodeConfig.getScopes()) 75 | .queryParam(IdpConstants.STATE, state) 76 | .build() 77 | .toUriString(); 78 | 79 | log.info("Generated OIDC authorization URL: {}", authUrl); 80 | return authUrl; 81 | } 82 | 83 | @Override 84 | public AuthResult handleCallback(String code, String state, HttpServletRequest request, HttpServletResponse response) { 85 | log.info("Processing OIDC callback with code: {}, state: {}", code, state); 86 | 87 | // 解析state获取provider信息 88 | IdpState idpState = parseState(state); 89 | String provider = idpState.getProvider(); 90 | 91 | if (StrUtil.isBlank(provider)) { 92 | throw new BusinessException(ErrorCode.INVALID_REQUEST, "缺少OIDC provider"); 93 | } 94 | 95 | OidcConfig oidcConfig = findOidcConfig(provider); 96 | 97 | // 使用授权码获取Token 98 | IdpTokenResult tokenResult = requestToken(code, oidcConfig, request); 99 | 100 | // 获取用户信息,优先使用ID Token,降级到UserInfo端点 101 | Map<String, Object> userInfo = getUserInfo(tokenResult, oidcConfig); 102 | log.info("Get OIDC user info: {}", userInfo); 103 | 104 | // 处理用户认证逻辑 105 | String developerId = createOrGetDeveloper(userInfo, oidcConfig); 106 | String accessToken = TokenUtil.generateDeveloperToken(developerId); 107 | 108 | return AuthResult.of(accessToken, TokenUtil.getTokenExpiresIn()); 109 | } 110 | 111 | @Override 112 | public List<IdpResult> getAvailableProviders() { 113 | return Optional.ofNullable(portalService.getPortal(contextHolder.getPortal())) 114 | .filter(portal -> portal.getPortalSettingConfig() != null) 115 | .filter(portal -> portal.getPortalSettingConfig().getOidcConfigs() != null) 116 | .map(portal -> portal.getPortalSettingConfig().getOidcConfigs()) 117 | // 确定当前Portal下启用的OIDC配置,返回Idp信息 118 | .map(configs -> configs.stream() 119 | .filter(OidcConfig::isEnabled) 120 | .map(config -> IdpResult.builder() 121 | .provider(config.getProvider()) 122 | .displayName(config.getName()) 123 | .build()) 124 | .collect(Collectors.toList())) 125 | .orElse(Collections.emptyList()); 126 | } 127 | 128 | private String buildRedirectUri(HttpServletRequest request) { 129 | String scheme = request.getScheme(); 130 | // String serverName = "localhost"; 131 | // int serverPort = 5173; 132 | String serverName = request.getServerName(); 133 | int serverPort = request.getServerPort(); 134 | 135 | String baseUrl = scheme + "://" + serverName; 136 | if (serverPort != CommonConstants.HTTP_PORT && serverPort != CommonConstants.HTTPS_PORT) { 137 | baseUrl += ":" + serverPort; 138 | } 139 | 140 | // 重定向到前端的Callback接口 141 | return baseUrl + "/oidc/callback"; 142 | } 143 | 144 | private OidcConfig findOidcConfig(String provider) { 145 | return Optional.ofNullable(portalService.getPortal(contextHolder.getPortal())) 146 | .filter(portal -> portal.getPortalSettingConfig() != null) 147 | .filter(portal -> portal.getPortalSettingConfig().getOidcConfigs() != null) 148 | // 根据provider字段过滤 149 | .flatMap(portal -> portal.getPortalSettingConfig() 150 | .getOidcConfigs() 151 | .stream() 152 | .filter(config -> provider.equals(config.getProvider()) && config.isEnabled()) 153 | .findFirst()) 154 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.OIDC_CONFIG, provider)); 155 | } 156 | 157 | private String buildState(String provider, String apiPrefix) { 158 | IdpState state = IdpState.builder() 159 | .provider(provider) 160 | .timestamp(System.currentTimeMillis()) 161 | .nonce(IdUtil.fastSimpleUUID()) 162 | .apiPrefix(apiPrefix) 163 | .build(); 164 | return Base64.encode(JSONUtil.toJsonStr(state)); 165 | } 166 | 167 | private IdpState parseState(String encodedState) { 168 | String stateJson = Base64.decodeStr(encodedState); 169 | IdpState idpState = JSONUtil.toBean(stateJson, IdpState.class); 170 | 171 | // 验证时间戳,10分钟有效期 172 | if (idpState.getTimestamp() != null) { 173 | long currentTime = System.currentTimeMillis(); 174 | if (currentTime - idpState.getTimestamp() > 10 * 60 * 1000) { 175 | throw new BusinessException(ErrorCode.INVALID_REQUEST, "请求已过期"); 176 | } 177 | } 178 | 179 | return idpState; 180 | } 181 | 182 | private IdpTokenResult requestToken(String code, OidcConfig oidcConfig, HttpServletRequest request) { 183 | AuthCodeConfig authCodeConfig = oidcConfig.getAuthCodeConfig(); 184 | String redirectUri = buildRedirectUri(request); 185 | 186 | MultiValueMap<String, String> params = new LinkedMultiValueMap<>(); 187 | params.add(IdpConstants.GRANT_TYPE, GrantType.AUTHORIZATION_CODE.getType()); 188 | params.add(IdpConstants.CODE, code); 189 | params.add(IdpConstants.REDIRECT_URI, redirectUri); 190 | params.add(IdpConstants.CLIENT_ID, authCodeConfig.getClientId()); 191 | params.add(IdpConstants.CLIENT_SECRET, authCodeConfig.getClientSecret()); 192 | 193 | log.info("Request tokens at: {}, params: {}", authCodeConfig.getTokenEndpoint(), params); 194 | return executeRequest(authCodeConfig.getTokenEndpoint(), HttpMethod.POST, null, params, IdpTokenResult.class); 195 | } 196 | 197 | private Map<String, Object> getUserInfo(IdpTokenResult tokenResult, OidcConfig oidcConfig) { 198 | // 优先使用ID Token 199 | if (StrUtil.isNotBlank(tokenResult.getIdToken())) { 200 | log.info("Get user info form id token: {}", tokenResult.getIdToken()); 201 | return parseUserInfo(tokenResult.getIdToken(), oidcConfig); 202 | } 203 | 204 | // 降级策略:使用UserInfo端点 205 | log.warn("ID Token not available, falling back to UserInfo endpoint"); 206 | if (StrUtil.isBlank(tokenResult.getAccessToken())) { 207 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "OIDC获取用户信息失败"); 208 | } 209 | 210 | AuthCodeConfig authCodeConfig = oidcConfig.getAuthCodeConfig(); 211 | if (StrUtil.isBlank(authCodeConfig.getUserInfoEndpoint())) { 212 | throw new BusinessException(ErrorCode.INVALID_PARAMETER, "OIDC配置缺少用户信息端点"); 213 | } 214 | 215 | return requestUserInfo(tokenResult.getAccessToken(), authCodeConfig, oidcConfig); 216 | } 217 | 218 | private Map<String, Object> parseUserInfo(String idToken, OidcConfig oidcConfig) { 219 | JWT jwt = JWTUtil.parseToken(idToken); 220 | 221 | // 验证过期时间 222 | Object exp = jwt.getPayload("exp"); 223 | if (exp != null) { 224 | long expTime = Convert.toLong(exp); 225 | long currentTime = System.currentTimeMillis() / 1000; 226 | if (expTime <= currentTime) { 227 | throw new BusinessException(ErrorCode.INVALID_REQUEST, "ID Token已过期"); 228 | } 229 | } 230 | // TODO 验签 231 | 232 | Map<String, Object> userInfo = jwt.getPayload().getClaimsJson(); 233 | 234 | log.info("Successfully extracted user info from ID Token, sub: {}", userInfo); 235 | return userInfo; 236 | } 237 | 238 | @SuppressWarnings("unchecked") 239 | private Map<String, Object> requestUserInfo(String accessToken, AuthCodeConfig authCodeConfig, OidcConfig oidcConfig) { 240 | try { 241 | HttpHeaders headers = new HttpHeaders(); 242 | headers.setBearerAuth(accessToken); 243 | 244 | log.info("Fetching user info from endpoint: {}", authCodeConfig.getUserInfoEndpoint()); 245 | Map<String, Object> userInfo = executeRequest(authCodeConfig.getUserInfoEndpoint(), HttpMethod.GET, headers, null, Map.class); 246 | 247 | log.info("Successfully fetched user info from endpoint, sub: {}", userInfo); 248 | return userInfo; 249 | } catch (Exception e) { 250 | log.error("Failed to fetch user info from endpoint: {}", authCodeConfig.getUserInfoEndpoint(), e); 251 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "获取用户信息失败"); 252 | } 253 | } 254 | 255 | private String createOrGetDeveloper(Map<String, Object> userInfo, OidcConfig config) { 256 | IdentityMapping identityMapping = config.getIdentityMapping(); 257 | // userId & userName & email 258 | String userIdField = StrUtil.isBlank(identityMapping.getUserIdField()) ? 259 | IdpConstants.SUBJECT : identityMapping.getUserIdField(); 260 | String userNameField = StrUtil.isBlank(identityMapping.getUserNameField()) ? 261 | IdpConstants.NAME : identityMapping.getUserNameField(); 262 | String emailField = StrUtil.isBlank(identityMapping.getEmailField()) ? 263 | IdpConstants.EMAIL : identityMapping.getEmailField(); 264 | 265 | Object userIdObj = userInfo.get(userIdField); 266 | Object userNameObj = userInfo.get(userNameField); 267 | Object emailObj = userInfo.get(emailField); 268 | 269 | String userId = Convert.toStr(userIdObj); 270 | String userName = Convert.toStr(userNameObj); 271 | String email = Convert.toStr(emailObj); 272 | if (StrUtil.isBlank(userId) || StrUtil.isBlank(userName)) { 273 | throw new BusinessException(ErrorCode.INVALID_REQUEST, "Id Token中缺少用户ID字段或用户名称"); 274 | } 275 | 276 | // 复用已有的Developer,否则创建 277 | return Optional.ofNullable(developerService.getExternalDeveloper(config.getProvider(), userId)) 278 | .map(DeveloperResult::getDeveloperId) 279 | .orElseGet(() -> { 280 | CreateExternalDeveloperParam param = CreateExternalDeveloperParam.builder() 281 | .provider(config.getProvider()) 282 | .subject(userId) 283 | .displayName(userName) 284 | .email(email) 285 | .authType(DeveloperAuthType.OIDC) 286 | .build(); 287 | 288 | return developerService.createExternalDeveloper(param).getDeveloperId(); 289 | }); 290 | } 291 | 292 | private <T> T executeRequest(String url, HttpMethod method, HttpHeaders headers, Object body, Class<T> responseType) { 293 | HttpEntity<?> requestEntity = new HttpEntity<>(body, headers); 294 | log.info("Executing HTTP request to: {}", url); 295 | ResponseEntity<String> response = restTemplate.exchange( 296 | url, 297 | method, 298 | requestEntity, 299 | String.class 300 | ); 301 | 302 | log.info("Received HTTP response from: {}, status: {}, body: {}", url, response.getStatusCode(), response.getBody()); 303 | 304 | return JSONUtil.toBean(response.getBody(), responseType); 305 | } 306 | } 307 | ```