This is page 5 of 7. Use http://codebase.md/higress-group/himarket?page={x} to view the full context.
# Directory Structure
```
├── .cursor
│ └── rules
│ ├── api-style.mdc
│ └── project-architecture.mdc
├── .gitignore
├── build.sh
├── deploy
│ ├── docker
│ │ ├── docker-compose.yml
│ │ └── Docker部署说明.md
│ └── helm
│ ├── Chart.yaml
│ ├── Helm部署说明.md
│ ├── templates
│ │ ├── _helpers.tpl
│ │ ├── himarket-admin-cm.yaml
│ │ ├── himarket-admin-deployment.yaml
│ │ ├── himarket-admin-service.yaml
│ │ ├── himarket-frontend-cm.yaml
│ │ ├── himarket-frontend-deployment.yaml
│ │ ├── himarket-frontend-service.yaml
│ │ ├── himarket-server-cm.yaml
│ │ ├── himarket-server-deployment.yaml
│ │ ├── himarket-server-service.yaml
│ │ ├── mysql.yaml
│ │ └── serviceaccount.yaml
│ └── values.yaml
├── LICENSE
├── NOTICE
├── pom.xml
├── portal-bootstrap
│ ├── Dockerfile
│ ├── pom.xml
│ └── src
│ ├── main
│ │ ├── java
│ │ │ └── com
│ │ │ └── alibaba
│ │ │ └── apiopenplatform
│ │ │ ├── config
│ │ │ │ ├── AsyncConfig.java
│ │ │ │ ├── FilterConfig.java
│ │ │ │ ├── PageConfig.java
│ │ │ │ ├── RestTemplateConfig.java
│ │ │ │ ├── SecurityConfig.java
│ │ │ │ └── SwaggerConfig.java
│ │ │ ├── filter
│ │ │ │ └── PortalResolvingFilter.java
│ │ │ └── PortalApplication.java
│ │ └── resources
│ │ └── application.yaml
│ └── test
│ └── java
│ └── com
│ └── alibaba
│ └── apiopenplatform
│ └── integration
│ └── AdministratorAuthIntegrationTest.java
├── portal-dal
│ ├── pom.xml
│ └── src
│ └── main
│ └── java
│ └── com
│ └── alibaba
│ └── apiopenplatform
│ ├── converter
│ │ ├── AdpAIGatewayConfigConverter.java
│ │ ├── APIGConfigConverter.java
│ │ ├── APIGRefConfigConverter.java
│ │ ├── ApiKeyConfigConverter.java
│ │ ├── ApsaraGatewayConfigConverter.java
│ │ ├── ConsumerAuthConfigConverter.java
│ │ ├── GatewayConfigConverter.java
│ │ ├── HigressConfigConverter.java
│ │ ├── HigressRefConfigConverter.java
│ │ ├── HmacConfigConverter.java
│ │ ├── JsonConverter.java
│ │ ├── JwtConfigConverter.java
│ │ ├── NacosRefConfigConverter.java
│ │ ├── PortalSettingConfigConverter.java
│ │ ├── PortalUiConfigConverter.java
│ │ └── ProductIconConverter.java
│ ├── entity
│ │ ├── Administrator.java
│ │ ├── BaseEntity.java
│ │ ├── Consumer.java
│ │ ├── ConsumerCredential.java
│ │ ├── ConsumerRef.java
│ │ ├── Developer.java
│ │ ├── DeveloperExternalIdentity.java
│ │ ├── Gateway.java
│ │ ├── NacosInstance.java
│ │ ├── Portal.java
│ │ ├── PortalDomain.java
│ │ ├── Product.java
│ │ ├── ProductPublication.java
│ │ ├── ProductRef.java
│ │ └── ProductSubscription.java
│ ├── repository
│ │ ├── AdministratorRepository.java
│ │ ├── BaseRepository.java
│ │ ├── ConsumerCredentialRepository.java
│ │ ├── ConsumerRefRepository.java
│ │ ├── ConsumerRepository.java
│ │ ├── DeveloperExternalIdentityRepository.java
│ │ ├── DeveloperRepository.java
│ │ ├── GatewayRepository.java
│ │ ├── NacosInstanceRepository.java
│ │ ├── PortalDomainRepository.java
│ │ ├── PortalRepository.java
│ │ ├── ProductPublicationRepository.java
│ │ ├── ProductRefRepository.java
│ │ ├── ProductRepository.java
│ │ └── SubscriptionRepository.java
│ └── support
│ ├── common
│ │ ├── Encrypted.java
│ │ ├── Encryptor.java
│ │ └── User.java
│ ├── consumer
│ │ ├── AdpAIAuthConfig.java
│ │ ├── APIGAuthConfig.java
│ │ ├── ApiKeyConfig.java
│ │ ├── ConsumerAuthConfig.java
│ │ ├── HigressAuthConfig.java
│ │ ├── HmacConfig.java
│ │ └── JwtConfig.java
│ ├── enums
│ │ ├── APIGAPIType.java
│ │ ├── ConsumerAuthType.java
│ │ ├── ConsumerStatus.java
│ │ ├── CredentialMode.java
│ │ ├── DeveloperAuthType.java
│ │ ├── DeveloperStatus.java
│ │ ├── DomainType.java
│ │ ├── GatewayType.java
│ │ ├── GrantType.java
│ │ ├── HigressAPIType.java
│ │ ├── JwtAlgorithm.java
│ │ ├── ProductIconType.java
│ │ ├── ProductStatus.java
│ │ ├── ProductType.java
│ │ ├── ProtocolType.java
│ │ ├── PublicKeyFormat.java
│ │ ├── SourceType.java
│ │ ├── SubscriptionStatus.java
│ │ └── UserType.java
│ ├── gateway
│ │ ├── AdpAIGatewayConfig.java
│ │ ├── APIGConfig.java
│ │ ├── ApsaraGatewayConfig.java
│ │ ├── GatewayConfig.java
│ │ └── HigressConfig.java
│ ├── portal
│ │ ├── AuthCodeConfig.java
│ │ ├── IdentityMapping.java
│ │ ├── JwtBearerConfig.java
│ │ ├── OAuth2Config.java
│ │ ├── OidcConfig.java
│ │ ├── PortalSettingConfig.java
│ │ ├── PortalUiConfig.java
│ │ └── PublicKeyConfig.java
│ └── product
│ ├── APIGRefConfig.java
│ ├── HigressRefConfig.java
│ ├── NacosRefConfig.java
│ └── ProductIcon.java
├── portal-server
│ ├── lib
│ │ └── csb220230206-1.5.3.jar
│ ├── pom.xml
│ └── src
│ └── main
│ └── java
│ └── com
│ └── alibaba
│ └── apiopenplatform
│ ├── controller
│ │ ├── AdministratorController.java
│ │ ├── ConsumerController.java
│ │ ├── DeveloperController.java
│ │ ├── GatewayController.java
│ │ ├── NacosController.java
│ │ ├── OAuth2Controller.java
│ │ ├── OidcController.java
│ │ ├── PortalController.java
│ │ └── ProductController.java
│ ├── core
│ │ ├── advice
│ │ │ ├── ExceptionAdvice.java
│ │ │ └── ResponseAdvice.java
│ │ ├── annotation
│ │ │ ├── AdminAuth.java
│ │ │ ├── AdminOrDeveloperAuth.java
│ │ │ └── DeveloperAuth.java
│ │ ├── constant
│ │ │ ├── CommonConstants.java
│ │ │ ├── IdpConstants.java
│ │ │ ├── JwtConstants.java
│ │ │ └── Resources.java
│ │ ├── event
│ │ │ ├── DeveloperDeletingEvent.java
│ │ │ ├── PortalDeletingEvent.java
│ │ │ └── ProductDeletingEvent.java
│ │ ├── exception
│ │ │ ├── BusinessException.java
│ │ │ └── ErrorCode.java
│ │ ├── response
│ │ │ └── Response.java
│ │ ├── security
│ │ │ ├── ContextHolder.java
│ │ │ ├── DeveloperAuthenticationProvider.java
│ │ │ └── JwtAuthenticationFilter.java
│ │ └── utils
│ │ ├── IdGenerator.java
│ │ ├── PasswordHasher.java
│ │ └── TokenUtil.java
│ ├── dto
│ │ ├── converter
│ │ │ ├── InputConverter.java
│ │ │ ├── NacosToGatewayToolsConverter.java
│ │ │ └── OutputConverter.java
│ │ ├── params
│ │ │ ├── admin
│ │ │ │ ├── AdminCreateParam.java
│ │ │ │ ├── AdminLoginParam.java
│ │ │ │ └── ResetPasswordParam.java
│ │ │ ├── consumer
│ │ │ │ ├── CreateConsumerParam.java
│ │ │ │ ├── CreateCredentialParam.java
│ │ │ │ ├── CreateSubscriptionParam.java
│ │ │ │ ├── QueryConsumerParam.java
│ │ │ │ ├── QuerySubscriptionParam.java
│ │ │ │ └── UpdateCredentialParam.java
│ │ │ ├── developer
│ │ │ │ ├── CreateDeveloperParam.java
│ │ │ │ ├── CreateExternalDeveloperParam.java
│ │ │ │ ├── DeveloperLoginParam.java
│ │ │ │ ├── QueryDeveloperParam.java
│ │ │ │ ├── UnbindExternalIdentityParam.java
│ │ │ │ ├── UpdateDeveloperParam.java
│ │ │ │ └── UpdateDeveloperStatusParam.java
│ │ │ ├── gateway
│ │ │ │ ├── ImportGatewayParam.java
│ │ │ │ ├── QueryAdpAIGatewayParam.java
│ │ │ │ ├── QueryAPIGParam.java
│ │ │ │ ├── QueryApsaraGatewayParam.java
│ │ │ │ └── QueryGatewayParam.java
│ │ │ ├── nacos
│ │ │ │ ├── CreateNacosParam.java
│ │ │ │ ├── QueryNacosNamespaceParam.java
│ │ │ │ ├── QueryNacosParam.java
│ │ │ │ └── UpdateNacosParam.java
│ │ │ ├── portal
│ │ │ │ ├── BindDomainParam.java
│ │ │ │ ├── CreatePortalParam.java
│ │ │ │ └── UpdatePortalParam.java
│ │ │ └── product
│ │ │ ├── CreateProductParam.java
│ │ │ ├── CreateProductRefParam.java
│ │ │ ├── PublishProductParam.java
│ │ │ ├── QueryProductParam.java
│ │ │ ├── QueryProductSubscriptionParam.java
│ │ │ ├── UnPublishProductParam.java
│ │ │ └── UpdateProductParam.java
│ │ └── result
│ │ ├── AdminResult.java
│ │ ├── AdpGatewayInstanceResult.java
│ │ ├── AdpMcpServerListResult.java
│ │ ├── AdpMCPServerResult.java
│ │ ├── APIConfigResult.java
│ │ ├── APIGMCPServerResult.java
│ │ ├── APIResult.java
│ │ ├── AuthResult.java
│ │ ├── ConsumerCredentialResult.java
│ │ ├── ConsumerResult.java
│ │ ├── DeveloperResult.java
│ │ ├── GatewayMCPServerResult.java
│ │ ├── GatewayResult.java
│ │ ├── HigressMCPServerResult.java
│ │ ├── IdpResult.java
│ │ ├── IdpState.java
│ │ ├── IdpTokenResult.java
│ │ ├── MCPConfigResult.java
│ │ ├── MCPServerResult.java
│ │ ├── MseNacosResult.java
│ │ ├── NacosMCPServerResult.java
│ │ ├── NacosNamespaceResult.java
│ │ ├── NacosResult.java
│ │ ├── PageResult.java
│ │ ├── PortalResult.java
│ │ ├── ProductPublicationResult.java
│ │ ├── ProductRefResult.java
│ │ ├── ProductResult.java
│ │ └── SubscriptionResult.java
│ └── service
│ ├── AdministratorService.java
│ ├── AdpAIGatewayService.java
│ ├── ApsaraGatewayService.java
│ ├── ConsumerService.java
│ ├── DeveloperService.java
│ ├── gateway
│ │ ├── AdpAIGatewayOperator.java
│ │ ├── AIGatewayOperator.java
│ │ ├── APIGOperator.java
│ │ ├── ApsaraGatewayOperator.java
│ │ ├── client
│ │ │ ├── AdpAIGatewayClient.java
│ │ │ ├── APIGClient.java
│ │ │ ├── ApsaraGatewayClient.java
│ │ │ ├── ApsaraStackGatewayClient.java
│ │ │ ├── GatewayClient.java
│ │ │ ├── HigressClient.java
│ │ │ ├── PopGatewayClient.java
│ │ │ └── SLSClient.java
│ │ ├── factory
│ │ │ └── HTTPClientFactory.java
│ │ ├── GatewayOperator.java
│ │ └── HigressOperator.java
│ ├── GatewayService.java
│ ├── IdpService.java
│ ├── impl
│ │ ├── AdministratorServiceImpl.java
│ │ ├── ConsumerServiceImpl.java
│ │ ├── DeveloperServiceImpl.java
│ │ ├── GatewayServiceImpl.java
│ │ ├── IdpServiceImpl.java
│ │ ├── NacosServiceImpl.java
│ │ ├── OAuth2ServiceImpl.java
│ │ ├── OidcServiceImpl.java
│ │ ├── PortalServiceImpl.java
│ │ └── ProductServiceImpl.java
│ ├── NacosService.java
│ ├── OAuth2Service.java
│ ├── OidcService.java
│ ├── PortalService.java
│ └── ProductService.java
├── portal-web
│ ├── api-portal-admin
│ │ ├── .env
│ │ ├── .gitignore
│ │ ├── bin
│ │ │ ├── replace_var.py
│ │ │ └── start.sh
│ │ ├── Dockerfile
│ │ ├── eslint.config.js
│ │ ├── index.html
│ │ ├── nginx.conf
│ │ ├── package.json
│ │ ├── postcss.config.js
│ │ ├── proxy.conf
│ │ ├── public
│ │ │ ├── logo.png
│ │ │ └── vite.svg
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── aliyunThemeToken.ts
│ │ │ ├── App.css
│ │ │ ├── App.tsx
│ │ │ ├── assets
│ │ │ │ └── react.svg
│ │ │ ├── components
│ │ │ │ ├── api-product
│ │ │ │ │ ├── ApiProductApiDocs.tsx
│ │ │ │ │ ├── ApiProductDashboard.tsx
│ │ │ │ │ ├── ApiProductFormModal.tsx
│ │ │ │ │ ├── ApiProductLinkApi.tsx
│ │ │ │ │ ├── ApiProductOverview.tsx
│ │ │ │ │ ├── ApiProductPolicy.tsx
│ │ │ │ │ ├── ApiProductPortal.tsx
│ │ │ │ │ ├── ApiProductUsageGuide.tsx
│ │ │ │ │ ├── SwaggerUIWrapper.css
│ │ │ │ │ └── SwaggerUIWrapper.tsx
│ │ │ │ ├── common
│ │ │ │ │ ├── AdvancedSearch.tsx
│ │ │ │ │ └── index.ts
│ │ │ │ ├── console
│ │ │ │ │ ├── GatewayTypeSelector.tsx
│ │ │ │ │ ├── ImportGatewayModal.tsx
│ │ │ │ │ ├── ImportHigressModal.tsx
│ │ │ │ │ ├── ImportMseNacosModal.tsx
│ │ │ │ │ └── NacosTypeSelector.tsx
│ │ │ │ ├── icons
│ │ │ │ │ └── McpServerIcon.tsx
│ │ │ │ ├── Layout.tsx
│ │ │ │ ├── LayoutWrapper.tsx
│ │ │ │ ├── portal
│ │ │ │ │ ├── PortalConsumers.tsx
│ │ │ │ │ ├── PortalDashboard.tsx
│ │ │ │ │ ├── PortalDevelopers.tsx
│ │ │ │ │ ├── PortalDomain.tsx
│ │ │ │ │ ├── PortalFormModal.tsx
│ │ │ │ │ ├── PortalOverview.tsx
│ │ │ │ │ ├── PortalPublishedApis.tsx
│ │ │ │ │ ├── PortalSecurity.tsx
│ │ │ │ │ ├── PortalSettings.tsx
│ │ │ │ │ ├── PublicKeyManager.tsx
│ │ │ │ │ └── ThirdPartyAuthManager.tsx
│ │ │ │ └── subscription
│ │ │ │ └── SubscriptionListModal.tsx
│ │ │ ├── contexts
│ │ │ │ └── LoadingContext.tsx
│ │ │ ├── index.css
│ │ │ ├── lib
│ │ │ │ ├── api.ts
│ │ │ │ ├── constant.ts
│ │ │ │ └── utils.ts
│ │ │ ├── main.tsx
│ │ │ ├── pages
│ │ │ │ ├── ApiProductDetail.tsx
│ │ │ │ ├── ApiProducts.tsx
│ │ │ │ ├── Dashboard.tsx
│ │ │ │ ├── GatewayConsoles.tsx
│ │ │ │ ├── Login.tsx
│ │ │ │ ├── NacosConsoles.tsx
│ │ │ │ ├── PortalDetail.tsx
│ │ │ │ ├── Portals.tsx
│ │ │ │ └── Register.tsx
│ │ │ ├── routes
│ │ │ │ └── index.tsx
│ │ │ ├── types
│ │ │ │ ├── api-product.ts
│ │ │ │ ├── consumer.ts
│ │ │ │ ├── gateway.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── portal.ts
│ │ │ │ ├── shims-js-yaml.d.ts
│ │ │ │ └── subscription.ts
│ │ │ └── vite-env.d.ts
│ │ ├── tailwind.config.js
│ │ ├── tsconfig.json
│ │ ├── tsconfig.node.json
│ │ └── vite.config.ts
│ └── api-portal-frontend
│ ├── .env
│ ├── .gitignore
│ ├── .husky
│ │ └── pre-commit
│ ├── bin
│ │ ├── replace_var.py
│ │ └── start.sh
│ ├── Dockerfile
│ ├── eslint.config.js
│ ├── index.html
│ ├── nginx.conf
│ ├── package.json
│ ├── postcss.config.js
│ ├── proxy.conf
│ ├── public
│ │ ├── favicon.ico
│ │ ├── logo.png
│ │ ├── logo.svg
│ │ ├── MCP.png
│ │ ├── MCP.svg
│ │ └── vite.svg
│ ├── README.md
│ ├── src
│ │ ├── aliyunThemeToken.ts
│ │ ├── App.css
│ │ ├── App.tsx
│ │ ├── assets
│ │ │ ├── aliyun.png
│ │ │ ├── github.png
│ │ │ ├── google.png
│ │ │ └── react.svg
│ │ ├── components
│ │ │ ├── consumer
│ │ │ │ ├── ConsumerBasicInfo.tsx
│ │ │ │ ├── CredentialManager.tsx
│ │ │ │ ├── index.ts
│ │ │ │ └── SubscriptionManager.tsx
│ │ │ ├── Layout.tsx
│ │ │ ├── Navigation.tsx
│ │ │ ├── ProductHeader.tsx
│ │ │ ├── SwaggerUIWrapper.css
│ │ │ ├── SwaggerUIWrapper.tsx
│ │ │ └── UserInfo.tsx
│ │ ├── index.css
│ │ ├── lib
│ │ │ ├── api.ts
│ │ │ ├── statusUtils.ts
│ │ │ └── utils.ts
│ │ ├── main.tsx
│ │ ├── pages
│ │ │ ├── ApiDetail.tsx
│ │ │ ├── Apis.tsx
│ │ │ ├── Callback.tsx
│ │ │ ├── ConsumerDetail.tsx
│ │ │ ├── Consumers.tsx
│ │ │ ├── GettingStarted.tsx
│ │ │ ├── Home.tsx
│ │ │ ├── Login.tsx
│ │ │ ├── Mcp.tsx
│ │ │ ├── McpDetail.tsx
│ │ │ ├── OidcCallback.tsx
│ │ │ ├── Profile.tsx
│ │ │ ├── Register.tsx
│ │ │ └── Test.css
│ │ ├── router.tsx
│ │ ├── types
│ │ │ ├── consumer.ts
│ │ │ └── index.ts
│ │ └── vite-env.d.ts
│ ├── tailwind.config.js
│ ├── tsconfig.app.json
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
└── README.md
```
# Files
--------------------------------------------------------------------------------
/portal-bootstrap/src/test/java/com/alibaba/apiopenplatform/integration/AdministratorAuthIntegrationTest.java:
--------------------------------------------------------------------------------
```java
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.alibaba.apiopenplatform.integration;
import com.alibaba.apiopenplatform.dto.params.admin.AdminCreateParam;
import com.alibaba.apiopenplatform.dto.params.admin.AdminLoginParam;
import com.alibaba.apiopenplatform.core.response.Response;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
/**
* 管理员初始化、登录、token认证、权限保护、黑名单集成测试
*
*/
@SpringBootTest(classes = com.alibaba.apiopenplatform.PortalApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class AdministratorAuthIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void testAdminRegister() {
AdminCreateParam createDto = new AdminCreateParam();
createDto.setUsername("admintest001");
createDto.setPassword("admin123456");
ResponseEntity<Response> registerResp = restTemplate.postForEntity(
"/api/admin/init", createDto, Response.class);
System.out.println("管理员初始化响应:" + registerResp);
assertThat(registerResp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(registerResp.getBody().getCode()).isEqualTo("Success");
}
@Test
void testAdminLogin() {
AdminLoginParam loginDto = new AdminLoginParam();
loginDto.setUsername("admintest001");
loginDto.setPassword("admin123456");
ResponseEntity<Response> loginResp = restTemplate.postForEntity(
"/api/admin/login", loginDto, Response.class);
System.out.println("管理员登录响应:" + loginResp);
assertThat(loginResp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(loginResp.getBody().getCode()).isEqualTo("Success");
}
@Test
void testAdminProtectedApiWithValidToken() {
// 登录获取token
AdminLoginParam loginDto = new AdminLoginParam();
loginDto.setUsername("admintest001");
loginDto.setPassword("admin123456");
ResponseEntity<Response> loginResp = restTemplate.postForEntity(
"/api/admin/login", loginDto, Response.class);
assertThat(loginResp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(loginResp.getBody().getCode()).isEqualTo("Success");
String token = ((Map<String, Object>)loginResp.getBody().getData()).get("token").toString();
// 用token访问受保护接口
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + token);
HttpEntity<Void> entity = new HttpEntity<>(headers);
// 你需要在管理员Controller实现 /api/admin/profile 受保护接口
ResponseEntity<String> protectedResp = restTemplate.exchange(
"/api/admin/profile", HttpMethod.GET, entity, String.class);
System.out.println("管理员带token访问受保护接口响应:" + protectedResp);
assertThat(protectedResp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(protectedResp.getBody()).contains("管理员受保护信息");
}
@Test
void testAdminProtectedApiWithoutToken() {
// 不带token访问受保护接口
ResponseEntity<String> protectedResp = restTemplate.getForEntity(
"/api/admin/profile", String.class);
System.out.println("管理员不带token访问受保护接口响应:" + protectedResp);
assertThat(protectedResp.getStatusCode().value()).isIn(401, 403);
}
@Test
void testAdminTokenBlacklist() {
// 登录获取token
AdminLoginParam loginDto = new AdminLoginParam();
loginDto.setUsername("admintest001");
loginDto.setPassword("admin123456");
ResponseEntity<Response> loginResp = restTemplate.postForEntity(
"/api/admin/login", loginDto, Response.class);
assertThat(loginResp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(loginResp.getBody().getCode()).isEqualTo("Success");
String token = ((Map<String, Object>)loginResp.getBody().getData()).get("token").toString();
// 调用登出接口,将token加入黑名单
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + token);
HttpEntity<Void> entity = new HttpEntity<>(headers);
// 修正:带上portalId参数
ResponseEntity<Response> logoutResp = restTemplate.postForEntity(
"/api/admin/logout?portalId=default", entity, Response.class);
System.out.println("管理员登出响应:" + logoutResp);
assertThat(logoutResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 再次用该token访问受保护接口,预期401或403
ResponseEntity<String> protectedResp = restTemplate.exchange(
"/api/admin/profile", HttpMethod.GET, entity, String.class);
System.out.println("管理员黑名单token访问受保护接口响应:" + protectedResp);
assertThat(protectedResp.getStatusCode().value()).isIn(401, 403);
}
@Test
void testNeedInitBeforeAndAfterInit() {
// 初始化前,need-init 应为 true
ResponseEntity<Response> respBefore = restTemplate.getForEntity(
"/api/admin/need-init?portalId=default", Response.class);
assertThat(respBefore.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(respBefore.getBody()).isNotNull();
assertThat(respBefore.getBody().getCode()).isEqualTo("SUCCESS");
assertThat(respBefore.getBody().getData()).isEqualTo(true);
assertThat(respBefore.getBody().getMessage()).isNotNull();
// 初始化
AdminCreateParam createDto = new AdminCreateParam();
createDto.setUsername("admintest002");
createDto.setPassword("admin123456");
ResponseEntity<Response> initResp = restTemplate.postForEntity(
"/api/admin/init", createDto, Response.class);
assertThat(initResp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(initResp.getBody()).isNotNull();
assertThat(initResp.getBody().getCode()).isEqualTo("SUCCESS");
assertThat(initResp.getBody().getMessage()).isNotNull();
// 初始化后,need-init 应为 false
ResponseEntity<Response<Boolean>> respAfter = restTemplate.exchange(
"/api/admin/need-init?portalId=default",
HttpMethod.GET,
null,
new org.springframework.core.ParameterizedTypeReference<Response<Boolean>>() {}
);
assertThat(respAfter.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(respAfter.getBody()).isNotNull();
assertThat(respAfter.getBody().getCode()).isEqualTo("SUCCESS");
assertThat(respAfter.getBody().getData()).isEqualTo(false);
assertThat(respAfter.getBody().getMessage()).isNotNull();
}
@Test
void testChangePasswordSuccessAndFail() {
// 初始化并登录
AdminCreateParam createDto = new AdminCreateParam();
createDto.setUsername("admintest004");
createDto.setPassword("admin123456");
restTemplate.postForEntity("/api/admin/init", createDto, Response.class);
AdminLoginParam loginDto = new AdminLoginParam();
loginDto.setUsername("admintest004");
loginDto.setPassword("admin123456");
ResponseEntity<Response> loginResp = restTemplate.postForEntity(
"/api/admin/login", loginDto, Response.class);
String token = ((Map<String, Object>)loginResp.getBody().getData()).get("token").toString();
String adminId = ((Map<String, Object>)loginResp.getBody().getData()).get("userId").toString();
// 正确修改密码
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + token);
headers.setContentType(org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> emptyBody = new LinkedMultiValueMap<>();
String changeUrl = String.format("/api/admin/change-password?portalId=%s&adminId=%s&oldPassword=%s&newPassword=%s",
"default", adminId, "admin123456", "admin654321");
HttpEntity<MultiValueMap<String, String>> changeEntity = new HttpEntity<>(emptyBody, headers);
ResponseEntity<Response> changeResp = restTemplate.postForEntity(
changeUrl, changeEntity, Response.class);
assertThat(changeResp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(changeResp.getBody()).isNotNull();
assertThat(changeResp.getBody().getCode()).isEqualTo("SUCCESS");
assertThat(changeResp.getBody().getMessage()).isNotNull();
// 原密码错误
String wrongUrl = String.format("/api/admin/change-password?portalId=%s&adminId=%s&oldPassword=%s&newPassword=%s",
"default", adminId, "wrongpass", "admin654321");
HttpEntity<MultiValueMap<String, String>> wrongEntity = new HttpEntity<>(emptyBody, headers);
ResponseEntity<Response> failResp = restTemplate.postForEntity(
wrongUrl, wrongEntity, Response.class);
assertThat(failResp.getStatusCode().value()).isIn(401, 400, 409, 403);
assertThat(failResp.getBody()).isNotNull();
assertThat(failResp.getBody().getCode()).isIn("ADMIN_PASSWORD_INCORRECT", "INVALID_PARAMETER");
assertThat(failResp.getBody().getMessage()).isNotNull();
}
}
```
--------------------------------------------------------------------------------
/portal-web/api-portal-admin/src/components/api-product/ApiProductUsageGuide.tsx:
--------------------------------------------------------------------------------
```typescript
import { Card, Button, Space, message } from 'antd'
import { SaveOutlined, UploadOutlined, FileMarkdownOutlined, EditOutlined } from '@ant-design/icons'
import { useEffect, useState, useRef } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm';
import MdEditor from 'react-markdown-editor-lite'
import 'react-markdown-editor-lite/lib/index.css'
import type { ApiProduct } from '@/types/api-product'
import { apiProductApi } from '@/lib/api'
interface ApiProductUsageGuideProps {
apiProduct: ApiProduct
handleRefresh: () => void
}
export function ApiProductUsageGuide({ apiProduct, handleRefresh }: ApiProductUsageGuideProps) {
const [content, setContent] = useState(apiProduct.document || '')
const [isEditing, setIsEditing] = useState(false)
const [originalContent, setOriginalContent] = useState(apiProduct.document || '')
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
const doc = apiProduct.document || ''
setContent(doc)
setOriginalContent(doc)
}, [apiProduct.document])
const handleEdit = () => {
setIsEditing(true)
}
const handleSave = () => {
apiProductApi.updateApiProduct(apiProduct.productId, {
document: content
}).then(() => {
message.success('保存成功')
setIsEditing(false)
setOriginalContent(content)
handleRefresh();
})
}
const handleCancel = () => {
setContent(originalContent)
setIsEditing(false)
}
const handleEditorChange = ({ text }: { text: string }) => {
setContent(text)
}
const handleFileImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file) {
if (file.type !== 'text/markdown' && !file.name.endsWith('.md')) {
message.error('请选择 Markdown 文件 (.md)')
return
}
const reader = new FileReader()
reader.onload = (e) => {
const content = e.target?.result as string
setContent(content)
setIsEditing(true)
message.success('文件导入成功')
}
reader.readAsText(file)
}
// 清空 input 值,允许重复选择同一文件
if (event.target) {
event.target.value = ''
}
}
const triggerFileInput = () => {
fileInputRef.current?.click()
}
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold mb-2">使用指南</h1>
<p className="text-gray-600">编辑和发布使用指南</p>
</div>
<Space>
{isEditing ? (
<>
<Button icon={<UploadOutlined />} onClick={triggerFileInput}>
导入文件
</Button>
<Button onClick={handleCancel}>
取消
</Button>
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave}>
保存
</Button>
</>
) : (
<>
<Button icon={<UploadOutlined />} onClick={triggerFileInput}>
导入文件
</Button>
<Button type="primary" icon={<EditOutlined />} onClick={handleEdit}>
编辑
</Button>
</>
)}
</Space>
</div>
<Card>
{isEditing ? (
<>
<MdEditor
value={content}
onChange={handleEditorChange}
style={{ height: '600px', width: '100%' }}
placeholder="请输入使用指南内容..."
renderHTML={(text) => <ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>}
canView={{ menu: true, md: true, html: true, both: true, fullScreen: false, hideMenu: false }}
htmlClass="custom-html-style"
markdownClass="custom-markdown-style"
/>
<div className="mt-4 text-sm text-gray-500">
💡 支持Markdown格式:代码块、表格、链接、图片等语法
</div>
</>
) : (
<div className="min-h-[400px]">
{content ? (
<div
className="prose prose-lg max-w-none"
style={{
lineHeight: '1.7',
color: '#374151',
fontSize: '16px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
}}
>
<style>{`
.prose h1 {
color: #111827;
font-weight: 700;
font-size: 2.25rem;
line-height: 1.2;
margin-top: 0;
margin-bottom: 1.5rem;
border-bottom: 2px solid #e5e7eb;
padding-bottom: 0.5rem;
}
.prose h2 {
color: #1f2937;
font-weight: 600;
font-size: 1.875rem;
line-height: 1.3;
margin-top: 2rem;
margin-bottom: 1rem;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 0.25rem;
}
.prose h3 {
color: #374151;
font-weight: 600;
font-size: 1.5rem;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.prose p {
margin-bottom: 1.25rem;
color: #4b5563;
line-height: 1.7;
font-size: 16px;
}
.prose code {
background-color: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
padding: 0.125rem 0.375rem;
font-size: 0.875rem;
color: #374151;
font-weight: 500;
}
.prose pre {
background-color: #1f2937;
border-radius: 0.5rem;
padding: 1.25rem;
overflow-x: auto;
margin: 1.5rem 0;
border: 1px solid #374151;
}
.prose pre code {
background-color: transparent;
border: none;
color: #f9fafb;
padding: 0;
font-size: 0.875rem;
font-weight: normal;
}
.prose blockquote {
border-left: 4px solid #3b82f6;
padding-left: 1rem;
margin: 1.5rem 0;
color: #6b7280;
font-style: italic;
background-color: #f8fafc;
padding: 1rem;
border-radius: 0.375rem;
font-size: 16px;
}
.prose ul, .prose ol {
margin: 1.25rem 0;
padding-left: 1.5rem;
}
.prose ol {
list-style-type: decimal;
list-style-position: outside;
}
.prose ul {
list-style-type: disc;
list-style-position: outside;
}
.prose li {
margin: 0.5rem 0;
color: #4b5563;
display: list-item;
font-size: 16px;
}
.prose ol li {
padding-left: 0.25rem;
}
.prose ul li {
padding-left: 0.25rem;
}
.prose table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
font-size: 16px;
}
.prose th, .prose td {
border: 1px solid #d1d5db;
padding: 0.75rem;
text-align: left;
}
.prose th {
background-color: #f9fafb;
font-weight: 600;
color: #374151;
font-size: 16px;
}
.prose td {
color: #4b5563;
font-size: 16px;
}
.prose a {
color: #3b82f6;
text-decoration: underline;
font-weight: 500;
transition: color 0.2s;
font-size: inherit;
}
.prose a:hover {
color: #1d4ed8;
}
.prose strong {
color: #111827;
font-weight: 600;
font-size: inherit;
}
.prose em {
color: #6b7280;
font-style: italic;
font-size: inherit;
}
.prose hr {
border: none;
height: 1px;
background-color: #e5e7eb;
margin: 2rem 0;
}
`}</style>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
) : (
<div className="flex items-center justify-center h-[400px] text-gray-400 border-2 border-dashed border-gray-200 rounded-lg">
<div className="text-center">
<FileMarkdownOutlined className="text-4xl mb-4 text-gray-300" />
<p className="text-lg">暂无使用指南</p>
<p className="text-sm">点击编辑按钮开始撰写</p>
</div>
</div>
)}
</div>
)}
</Card>
{/* 隐藏的文件输入框 */}
<input
ref={fileInputRef}
type="file"
accept=".md,text/markdown"
onChange={handleFileImport}
style={{ display: 'none' }}
/>
</div>
)
}
```
--------------------------------------------------------------------------------
/portal-server/src/main/java/com/alibaba/apiopenplatform/service/gateway/HigressOperator.java:
--------------------------------------------------------------------------------
```java
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.alibaba.apiopenplatform.service.gateway;
import cn.hutool.core.map.MapBuilder;
import cn.hutool.json.JSONUtil;
import com.alibaba.apiopenplatform.dto.result.*;
import com.alibaba.apiopenplatform.entity.Gateway;
import com.alibaba.apiopenplatform.entity.Consumer;
import com.alibaba.apiopenplatform.entity.ConsumerCredential;
import com.alibaba.apiopenplatform.service.gateway.client.HigressClient;
import com.alibaba.apiopenplatform.support.consumer.ApiKeyConfig;
import com.alibaba.apiopenplatform.support.consumer.ConsumerAuthConfig;
import com.alibaba.apiopenplatform.support.consumer.HigressAuthConfig;
import com.alibaba.apiopenplatform.support.enums.GatewayType;
import com.alibaba.apiopenplatform.support.gateway.GatewayConfig;
import com.alibaba.apiopenplatform.support.gateway.HigressConfig;
import com.alibaba.apiopenplatform.support.product.HigressRefConfig;
import lombok.Builder;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@Slf4j
public class HigressOperator extends GatewayOperator<HigressClient> {
@Override
public PageResult<APIResult> fetchHTTPAPIs(Gateway gateway, int page, int size) {
throw new UnsupportedOperationException("Higress gateway does not support HTTP APIs");
}
@Override
public PageResult<APIResult> fetchRESTAPIs(Gateway gateway, int page, int size) {
throw new UnsupportedOperationException("Higress gateway does not support REST APIs");
}
@Override
public PageResult<? extends GatewayMCPServerResult> fetchMcpServers(Gateway gateway, int page, int size) {
HigressClient client = getClient(gateway);
Map<String, String> queryParams = MapBuilder.<String, String>create()
.put("pageNum", String.valueOf(page))
.put("pageSize", String.valueOf(size))
.build();
HigressPageResponse<HigressMCPConfig> response = client.execute("/v1/mcpServer",
HttpMethod.GET,
queryParams,
null,
new ParameterizedTypeReference<HigressPageResponse<HigressMCPConfig>>() {
});
List<HigressMCPServerResult> mcpServers = response.getData().stream()
.map(s -> new HigressMCPServerResult().convertFrom(s))
.collect(Collectors.toList());
return PageResult.of(mcpServers, page, size, response.getTotal());
}
@Override
public String fetchAPIConfig(Gateway gateway, Object config) {
throw new UnsupportedOperationException("Higress gateway does not support fetching API config");
}
@Override
public String fetchMcpConfig(Gateway gateway, Object conf) {
HigressClient client = getClient(gateway);
HigressRefConfig config = (HigressRefConfig) conf;
HigressResponse<HigressMCPConfig> response = client.execute("/v1/mcpServer/" + config.getMcpServerName(),
HttpMethod.GET,
null,
null,
new ParameterizedTypeReference<HigressResponse<HigressMCPConfig>>() {
});
MCPConfigResult m = new MCPConfigResult();
HigressMCPConfig higressMCPConfig = response.getData();
m.setMcpServerName(higressMCPConfig.getName());
// mcpServer config
MCPConfigResult.MCPServerConfig c = new MCPConfigResult.MCPServerConfig();
c.setPath("/mcp-servers/" + higressMCPConfig.getName());
c.setDomains(higressMCPConfig.getDomains().stream().map(domain -> MCPConfigResult.Domain.builder()
.domain(domain)
// 默认HTTP
.protocol("http")
.build())
.collect(Collectors.toList()));
m.setMcpServerConfig(c);
// tools
m.setTools(higressMCPConfig.getRawConfigurations());
// meta
MCPConfigResult.McpMetadata meta = new MCPConfigResult.McpMetadata();
meta.setSource(GatewayType.HIGRESS.name());
meta.setCreateFromType(higressMCPConfig.getType());
m.setMeta(meta);
return JSONUtil.toJsonStr(m);
}
@Override
public PageResult<GatewayResult> fetchGateways(Object param, int page, int size) {
throw new UnsupportedOperationException("Higress gateway does not support fetching Gateways");
}
@Override
public String createConsumer(Consumer consumer, ConsumerCredential credential, GatewayConfig config) {
HigressConfig higressConfig = config.getHigressConfig();
HigressClient client = new HigressClient(higressConfig);
client.execute("/v1/consumers",
HttpMethod.POST,
null,
buildHigressConsumer(consumer.getConsumerId(), credential.getApiKeyConfig()),
String.class);
return consumer.getConsumerId();
}
@Override
public void updateConsumer(String consumerId, ConsumerCredential credential, GatewayConfig config) {
HigressConfig higressConfig = config.getHigressConfig();
HigressClient client = new HigressClient(higressConfig);
client.execute("/v1/consumers/" + consumerId,
HttpMethod.PUT,
null,
buildHigressConsumer(consumerId, credential.getApiKeyConfig()),
String.class);
}
@Override
public void deleteConsumer(String consumerId, GatewayConfig config) {
HigressConfig higressConfig = config.getHigressConfig();
HigressClient client = new HigressClient(higressConfig);
client.execute("/v1/consumers/" + consumerId,
HttpMethod.DELETE,
null,
null,
String.class);
}
@Override
public boolean isConsumerExists(String consumerId, GatewayConfig config) {
// TODO: 实现Higress网关消费者存在性检查
return true;
}
@Override
public ConsumerAuthConfig authorizeConsumer(Gateway gateway, String consumerId, Object refConfig) {
HigressRefConfig config = (HigressRefConfig) refConfig;
HigressClient client = getClient(gateway);
String mcpServerName = config.getMcpServerName();
client.execute("/v1/mcpServer/consumers/",
HttpMethod.PUT,
null,
buildAuthHigressConsumer(mcpServerName, consumerId),
Void.class);
HigressAuthConfig higressAuthConfig = HigressAuthConfig.builder()
.resourceType("MCP_SERVER")
.resourceName(mcpServerName)
.build();
return ConsumerAuthConfig.builder()
.higressAuthConfig(higressAuthConfig)
.build();
}
@Override
public void revokeConsumerAuthorization(Gateway gateway, String consumerId, ConsumerAuthConfig authConfig) {
HigressClient client = getClient(gateway);
HigressAuthConfig higressAuthConfig = authConfig.getHigressAuthConfig();
if (higressAuthConfig == null) {
return;
}
client.execute("/v1/mcpServer/consumers/",
HttpMethod.DELETE,
null,
buildAuthHigressConsumer(higressAuthConfig.getResourceName(), consumerId),
Void.class);
}
@Override
public APIResult fetchAPI(Gateway gateway, String apiId) {
throw new UnsupportedOperationException("Higress gateway does not support fetching API");
}
@Override
public GatewayType getGatewayType() {
return GatewayType.HIGRESS;
}
@Override
public String getDashboard(Gateway gateway, String type) {
throw new UnsupportedOperationException("Higress gateway does not support getting dashboard");
}
@Data
@Builder
public static class HigressConsumerConfig {
private String name;
private List<HigressCredentialConfig> credentials;
}
@Data
@Builder
public static class HigressCredentialConfig {
private String type;
private String source;
private String key;
private List<String> values;
}
public HigressConsumerConfig buildHigressConsumer(String consumerId, ApiKeyConfig apiKeyConfig) {
String source = mapSource(apiKeyConfig.getSource());
List<String> apiKeys = apiKeyConfig.getCredentials().stream()
.map(ApiKeyConfig.ApiKeyCredential::getApiKey)
.collect(Collectors.toList());
return HigressConsumerConfig.builder()
.name(consumerId)
.credentials(Collections.singletonList(
HigressCredentialConfig.builder()
.type("key-auth")
.source(source)
.key(apiKeyConfig.getKey())
.values(apiKeys)
.build())
)
.build();
}
@Data
public static class HigressMCPConfig {
private String name;
private String type;
private List<String> domains;
private String rawConfigurations;
}
@Data
public static class HigressPageResponse<T> {
private List<T> data;
private int total;
}
@Data
public static class HigressResponse<T> {
private T data;
}
public HigressAuthConsumerConfig buildAuthHigressConsumer(String gatewayName, String consumerId) {
return HigressAuthConsumerConfig.builder()
.mcpServerName(gatewayName)
.consumers(Collections.singletonList(consumerId))
.build();
}
@Data
@Builder
public static class HigressAuthConsumerConfig {
private String mcpServerName;
private List<String> consumers;
}
private String mapSource(String source) {
if (StringUtils.isBlank(source)) return null;
if ("Default".equalsIgnoreCase(source)) return "BEARER";
if ("HEADER".equalsIgnoreCase(source)) return "HEADER";
if ("QueryString".equalsIgnoreCase(source)) return "QUERY";
return source;
}
}
```
--------------------------------------------------------------------------------
/portal-server/src/main/java/com/alibaba/apiopenplatform/service/impl/PortalServiceImpl.java:
--------------------------------------------------------------------------------
```java
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.alibaba.apiopenplatform.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.apiopenplatform.core.constant.Resources;
import com.alibaba.apiopenplatform.core.event.PortalDeletingEvent;
import com.alibaba.apiopenplatform.core.exception.BusinessException;
import com.alibaba.apiopenplatform.core.exception.ErrorCode;
import com.alibaba.apiopenplatform.core.security.ContextHolder;
import com.alibaba.apiopenplatform.core.utils.IdGenerator;
import com.alibaba.apiopenplatform.dto.params.consumer.QuerySubscriptionParam;
import com.alibaba.apiopenplatform.dto.params.portal.*;
import com.alibaba.apiopenplatform.dto.result.PageResult;
import com.alibaba.apiopenplatform.dto.result.PortalResult;
import com.alibaba.apiopenplatform.dto.result.SubscriptionResult;
import com.alibaba.apiopenplatform.entity.Portal;
import com.alibaba.apiopenplatform.entity.PortalDomain;
import com.alibaba.apiopenplatform.entity.ProductSubscription;
import com.alibaba.apiopenplatform.repository.PortalDomainRepository;
import com.alibaba.apiopenplatform.repository.PortalRepository;
import com.alibaba.apiopenplatform.repository.SubscriptionRepository;
import com.alibaba.apiopenplatform.repository.ProductPublicationRepository;
import com.alibaba.apiopenplatform.repository.ProductRefRepository;
import com.alibaba.apiopenplatform.service.GatewayService;
import com.alibaba.apiopenplatform.entity.ProductPublication;
import com.alibaba.apiopenplatform.entity.ProductRef;
import org.springframework.data.domain.PageRequest;
import com.alibaba.apiopenplatform.service.IdpService;
import com.alibaba.apiopenplatform.service.PortalService;
import com.alibaba.apiopenplatform.support.enums.DomainType;
import com.alibaba.apiopenplatform.support.portal.OidcConfig;
import com.alibaba.apiopenplatform.support.portal.PortalSettingConfig;
import com.alibaba.apiopenplatform.support.portal.PortalUiConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import javax.persistence.criteria.Predicate;
import javax.transaction.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@Service
@Slf4j
@RequiredArgsConstructor
@Transactional
public class PortalServiceImpl implements PortalService {
private final PortalRepository portalRepository;
private final PortalDomainRepository portalDomainRepository;
private final ApplicationEventPublisher eventPublisher;
private final SubscriptionRepository subscriptionRepository;
private final ContextHolder contextHolder;
private final IdpService idpService;
private final String domainFormat = "%s.api.portal.local";
private final ProductPublicationRepository publicationRepository;
private final ProductRefRepository productRefRepository;
private final GatewayService gatewayService;
public PortalResult createPortal(CreatePortalParam param) {
portalRepository.findByName(param.getName())
.ifPresent(portal -> {
throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.PORTAL, portal.getName()));
});
String portalId = IdGenerator.genPortalId();
Portal portal = param.convertTo();
portal.setPortalId(portalId);
portal.setAdminId(contextHolder.getUser());
// Setting & Ui
portal.setPortalSettingConfig(new PortalSettingConfig());
portal.setPortalUiConfig(new PortalUiConfig());
// Domain
PortalDomain portalDomain = new PortalDomain();
portalDomain.setDomain(String.format(domainFormat, portalId));
portalDomain.setPortalId(portalId);
portalDomainRepository.save(portalDomain);
portalRepository.save(portal);
return getPortal(portalId);
}
@Override
public PortalResult getPortal(String portalId) {
Portal portal = findPortal(portalId);
List<PortalDomain> domains = portalDomainRepository.findAllByPortalId(portalId);
portal.setPortalDomains(domains);
return new PortalResult().convertFrom(portal);
}
@Override
public void existsPortal(String portalId) {
portalRepository.findByPortalId(portalId)
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.PORTAL, portalId));
}
@Override
public PageResult<PortalResult> listPortals(Pageable pageable) {
Page<Portal> portals = portalRepository.findAll(pageable);
// 填充Domain
if (portals.hasContent()) {
List<String> portalIds = portals.getContent().stream()
.map(Portal::getPortalId)
.collect(Collectors.toList());
List<PortalDomain> allDomains = portalDomainRepository.findAllByPortalIdIn(portalIds);
Map<String, List<PortalDomain>> portalDomains = allDomains.stream()
.collect(Collectors.groupingBy(PortalDomain::getPortalId));
portals.getContent().forEach(portal -> {
List<PortalDomain> domains = portalDomains.getOrDefault(portal.getPortalId(), new ArrayList<>());
portal.setPortalDomains(domains);
});
}
return new PageResult<PortalResult>().convertFrom(portals, portal -> new PortalResult().convertFrom(portal));
}
@Override
public PortalResult updatePortal(String portalId, UpdatePortalParam param) {
Portal portal = findPortal(portalId);
Optional.ofNullable(param.getName())
.filter(name -> !name.equals(portal.getName()))
.flatMap(portalRepository::findByName)
.ifPresent(p -> {
throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.PORTAL, portal.getName()));
});
param.update(portal);
// 验证OIDC配置
PortalSettingConfig setting = portal.getPortalSettingConfig();
if (CollUtil.isNotEmpty(setting.getOidcConfigs())) {
idpService.validateOidcConfigs(setting.getOidcConfigs());
}
if (CollUtil.isNotEmpty(setting.getOauth2Configs())) {
idpService.validateOAuth2Configs(setting.getOauth2Configs());
}
// 至少保留一种认证方式
if (BooleanUtil.isFalse(setting.getBuiltinAuthEnabled())) {
boolean enabledOidc = Optional.ofNullable(setting.getOidcConfigs())
.filter(CollUtil::isNotEmpty)
.map(configs -> configs.stream().anyMatch(OidcConfig::isEnabled))
.orElse(false);
if (!enabledOidc) {
throw new BusinessException(ErrorCode.INVALID_REQUEST, "至少配置一种认证方式");
}
}
portalRepository.saveAndFlush(portal);
return getPortal(portal.getPortalId());
}
@Override
public void deletePortal(String portalId) {
Portal portal = findPortal(portalId);
// 清理Domain
portalDomainRepository.deleteAllByPortalId(portalId);
// 异步清理门户资源
eventPublisher.publishEvent(new PortalDeletingEvent(portalId));
portalRepository.delete(portal);
}
@Override
public String resolvePortal(String domain) {
return portalDomainRepository.findByDomain(domain)
.map(PortalDomain::getPortalId)
.orElse(null);
}
@Override
public PortalResult bindDomain(String portalId, BindDomainParam param) {
existsPortal(portalId);
portalDomainRepository.findByPortalIdAndDomain(portalId, param.getDomain())
.ifPresent(portalDomain -> {
throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.PORTAL_DOMAIN, portalDomain.getDomain()));
});
PortalDomain portalDomain = param.convertTo();
portalDomain.setPortalId(portalId);
portalDomainRepository.save(portalDomain);
return getPortal(portalId);
}
@Override
public PortalResult unbindDomain(String portalId, String domain) {
portalDomainRepository.findByPortalIdAndDomain(portalId, domain)
.ifPresent(portalDomain -> {
// 默认域名不允许解绑
if (portalDomain.getType() == DomainType.DEFAULT) {
throw new BusinessException(ErrorCode.INVALID_REQUEST, "默认域名不允许解绑");
}
portalDomainRepository.delete(portalDomain);
});
return getPortal(portalId);
}
@Override
public PageResult<SubscriptionResult> listSubscriptions(String portalId, QuerySubscriptionParam param, Pageable pageable) {
// Ensure portal exists
existsPortal(portalId);
Specification<ProductSubscription> spec = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
predicates.add(cb.equal(root.get("portalId"), portalId));
if (param != null && param.getStatus() != null) {
predicates.add(cb.equal(root.get("status"), param.getStatus()));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
Page<ProductSubscription> page = subscriptionRepository.findAll(spec, pageable);
return new PageResult<SubscriptionResult>().convertFrom(page, s -> new SubscriptionResult().convertFrom(s));
}
@Override
public String getDefaultPortal() {
Portal portal = portalRepository.findFirstByOrderByIdAsc().orElse(null);
if (portal == null) {
return null;
}
return portal.getPortalId();
}
@Override
public String getDashboard(String portalId) {
existsPortal(portalId);
// 找到该门户下任一已发布产品(取第一页第一条)
ProductPublication pub = publicationRepository.findByPortalId(portalId, PageRequest.of(0, 1))
.stream()
.findFirst()
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.PORTAL, portalId));
// 取产品的网关引用
ProductRef productRef = productRefRepository.findFirstByProductId(pub.getProductId())
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.PRODUCT, pub.getProductId()));
if (productRef.getGatewayId() == null) {
throw new BusinessException(ErrorCode.NOT_FOUND, "网关", "该门户下的产品尚未关联网关服务");
}
// 复用网关的Dashboard能力
return gatewayService.getDashboard(productRef.getGatewayId(),"Portal");
}
private Portal findPortal(String portalId) {
return portalRepository.findByPortalId(portalId)
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.PORTAL, portalId));
}
}
```
--------------------------------------------------------------------------------
/portal-server/src/main/java/com/alibaba/apiopenplatform/service/impl/GatewayServiceImpl.java:
--------------------------------------------------------------------------------
```java
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.alibaba.apiopenplatform.service.impl;
import cn.hutool.core.util.EnumUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.apiopenplatform.core.constant.Resources;
import com.alibaba.apiopenplatform.core.exception.BusinessException;
import com.alibaba.apiopenplatform.core.exception.ErrorCode;
import com.alibaba.apiopenplatform.core.security.ContextHolder;
import com.alibaba.apiopenplatform.core.utils.IdGenerator;
import com.alibaba.apiopenplatform.dto.params.gateway.ImportGatewayParam;
import com.alibaba.apiopenplatform.dto.params.gateway.QueryAPIGParam;
import com.alibaba.apiopenplatform.dto.params.gateway.QueryAdpAIGatewayParam;
import com.alibaba.apiopenplatform.dto.params.gateway.QueryGatewayParam;
import com.alibaba.apiopenplatform.dto.params.gateway.QueryApsaraGatewayParam;
import com.alibaba.apiopenplatform.dto.result.*;
import com.alibaba.apiopenplatform.entity.*;
import com.alibaba.apiopenplatform.repository.GatewayRepository;
import com.alibaba.apiopenplatform.repository.ProductRefRepository;
import com.alibaba.apiopenplatform.service.AdpAIGatewayService;
import com.alibaba.apiopenplatform.service.GatewayService;
import com.alibaba.apiopenplatform.service.gateway.GatewayOperator;
import com.alibaba.apiopenplatform.support.consumer.ConsumerAuthConfig;
import com.alibaba.apiopenplatform.support.enums.APIGAPIType;
import com.alibaba.apiopenplatform.support.enums.GatewayType;
import com.alibaba.apiopenplatform.support.gateway.GatewayConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import javax.persistence.criteria.Predicate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@SuppressWarnings("unchecked")
@Slf4j
public class GatewayServiceImpl implements GatewayService, ApplicationContextAware, AdpAIGatewayService, com.alibaba.apiopenplatform.service.ApsaraGatewayService {
private final GatewayRepository gatewayRepository;
private final ProductRefRepository productRefRepository;
private Map<GatewayType, GatewayOperator> gatewayOperators;
private final ContextHolder contextHolder;
@Override
public PageResult<GatewayResult> fetchGateways(QueryAPIGParam param, int page, int size) {
return gatewayOperators.get(param.getGatewayType()).fetchGateways(param, page, size);
}
@Override
public PageResult<GatewayResult> fetchGateways(QueryAdpAIGatewayParam param, int page, int size) {
return gatewayOperators.get(GatewayType.ADP_AI_GATEWAY).fetchGateways(param, page, size);
}
@Override
public PageResult<GatewayResult> fetchGateways(QueryApsaraGatewayParam param, int page, int size) {
return gatewayOperators.get(GatewayType.APSARA_GATEWAY).fetchGateways(param, page, size);
}
public void importGateway(ImportGatewayParam param) {
gatewayRepository.findByGatewayId(param.getGatewayId())
.ifPresent(gateway -> {
throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.GATEWAY, param.getGatewayId()));
});
Gateway gateway = param.convertTo();
if (gateway.getGatewayType().isHigress()) {
gateway.setGatewayId(IdGenerator.genHigressGatewayId());
}
gateway.setAdminId(contextHolder.getUser());
gatewayRepository.save(gateway);
}
@Override
public GatewayResult getGateway(String gatewayId) {
Gateway gateway = findGateway(gatewayId);
return new GatewayResult().convertFrom(gateway);
}
@Override
public PageResult<GatewayResult> listGateways(QueryGatewayParam param, Pageable pageable) {
Page<Gateway> gateways = gatewayRepository.findAll(buildGatewaySpec(param), pageable);
return new PageResult<GatewayResult>().convertFrom(gateways, gateway -> new GatewayResult().convertFrom(gateway));
}
@Override
public void deleteGateway(String gatewayId) {
Gateway gateway = findGateway(gatewayId);
// 已有Product引用时不允许删除
if (productRefRepository.existsByGatewayId(gatewayId)) {
throw new BusinessException(ErrorCode.INVALID_REQUEST, "网关已被Product引用");
}
gatewayRepository.delete(gateway);
}
@Override
public PageResult<APIResult> fetchAPIs(String gatewayId, String apiType, int page, int size) {
Gateway gateway = findGateway(gatewayId);
GatewayType gatewayType = gateway.getGatewayType();
if (gatewayType.isAPIG()) {
APIGAPIType type = EnumUtil.fromString(APIGAPIType.class, apiType);
switch (type) {
case REST:
return fetchRESTAPIs(gatewayId, page, size);
case HTTP:
return fetchHTTPAPIs(gatewayId, page, size);
default:
}
}
if (gatewayType.isHigress()) {
return fetchRoutes(gatewayId, page, size);
}
throw new BusinessException(ErrorCode.INTERNAL_ERROR,
String.format("Gateway type %s does not support API type %s", gatewayType, apiType));
}
@Override
public PageResult<APIResult> fetchHTTPAPIs(String gatewayId, int page, int size) {
Gateway gateway = findGateway(gatewayId);
return getOperator(gateway).fetchHTTPAPIs(gateway, page, size);
}
@Override
public PageResult<APIResult> fetchRESTAPIs(String gatewayId, int page, int size) {
Gateway gateway = findGateway(gatewayId);
return getOperator(gateway).fetchRESTAPIs(gateway, page, size);
}
@Override
public PageResult<APIResult> fetchRoutes(String gatewayId, int page, int size) {
return null;
}
@Override
public PageResult<GatewayMCPServerResult> fetchMcpServers(String gatewayId, int page, int size) {
Gateway gateway = findGateway(gatewayId);
return getOperator(gateway).fetchMcpServers(gateway, page, size);
}
@Override
public String fetchAPIConfig(String gatewayId, Object config) {
Gateway gateway = findGateway(gatewayId);
return getOperator(gateway).fetchAPIConfig(gateway, config);
}
@Override
public String fetchMcpConfig(String gatewayId, Object conf) {
Gateway gateway = findGateway(gatewayId);
return getOperator(gateway).fetchMcpConfig(gateway, conf);
}
@Override
public String createConsumer(Consumer consumer, ConsumerCredential credential, GatewayConfig config) {
return gatewayOperators.get(config.getGatewayType()).createConsumer(consumer, credential, config);
}
@Override
public void updateConsumer(String gwConsumerId, ConsumerCredential credential, GatewayConfig config) {
gatewayOperators.get(config.getGatewayType()).updateConsumer(gwConsumerId, credential, config);
}
@Override
public void deleteConsumer(String gwConsumerId, GatewayConfig config) {
gatewayOperators.get(config.getGatewayType()).deleteConsumer(gwConsumerId, config);
}
@Override
public boolean isConsumerExists(String gwConsumerId, GatewayConfig config) {
return gatewayOperators.get(config.getGatewayType()).isConsumerExists(gwConsumerId, config);
}
@Override
public ConsumerAuthConfig authorizeConsumer(String gatewayId, String gwConsumerId, ProductRefResult productRef) {
Gateway gateway = findGateway(gatewayId);
Object refConfig;
if (gateway.getGatewayType().isHigress()) {
refConfig = productRef.getHigressRefConfig();
} else if (gateway.getGatewayType().isAdpAIGateway()) {
refConfig = productRef.getAdpAIGatewayRefConfig();
} else if (gateway.getGatewayType().isApsaraGateway()) {
refConfig = productRef.getApsaraGatewayRefConfig();
} else {
refConfig = productRef.getApigRefConfig();
}
return getOperator(gateway).authorizeConsumer(gateway, gwConsumerId, refConfig);
}
@Override
public void revokeConsumerAuthorization(String gatewayId, String gwConsumerId, ConsumerAuthConfig config) {
Gateway gateway = findGateway(gatewayId);
getOperator(gateway).revokeConsumerAuthorization(gateway, gwConsumerId, config);
}
@Override
public GatewayConfig getGatewayConfig(String gatewayId) {
Gateway gateway = findGateway(gatewayId);
return GatewayConfig.builder()
.gatewayType(gateway.getGatewayType())
.apigConfig(gateway.getApigConfig())
.higressConfig(gateway.getHigressConfig())
.adpAIGatewayConfig(gateway.getAdpAIGatewayConfig())
.apsaraGatewayConfig(gateway.getApsaraGatewayConfig())
.gateway(gateway) // 添加Gateway实体引用
.build();
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
Map<String, GatewayOperator> operators = applicationContext.getBeansOfType(GatewayOperator.class);
gatewayOperators = operators.values().stream()
.collect(Collectors.toMap(
operator -> operator.getGatewayType(),
operator -> operator,
(existing, replacement) -> existing));
}
private Gateway findGateway(String gatewayId) {
return gatewayRepository.findByGatewayId(gatewayId)
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.GATEWAY, gatewayId));
}
private GatewayOperator getOperator(Gateway gateway) {
GatewayOperator gatewayOperator = gatewayOperators.get(gateway.getGatewayType());
if (gatewayOperator == null) {
throw new BusinessException(ErrorCode.INTERNAL_ERROR,
"No gateway operator found for gateway type: " + gateway.getGatewayType());
}
return gatewayOperator;
}
@Override
public String getDashboard(String gatewayId,String type) {
Gateway gateway = findGateway(gatewayId);
return getOperator(gateway).getDashboard(gateway,type); //type: Portal,MCP,API
}
private Specification<Gateway> buildGatewaySpec(QueryGatewayParam param) {
return (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (param != null && param.getGatewayType() != null) {
predicates.add(cb.equal(root.get("gatewayType"), param.getGatewayType()));
}
String adminId = contextHolder.getUser();
if (StrUtil.isNotBlank(adminId)) {
predicates.add(cb.equal(root.get("adminId"), adminId));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
}
}
```
--------------------------------------------------------------------------------
/portal-web/api-portal-admin/src/components/portal/PortalDevelopers.tsx:
--------------------------------------------------------------------------------
```typescript
import {Card, Table, Badge, Button, Space, message, Modal} from 'antd'
import {
EditOutlined,
DeleteOutlined,
ExclamationCircleOutlined,
EyeOutlined,
UnorderedListOutlined,
CheckCircleFilled,
ClockCircleOutlined
} from '@ant-design/icons'
import {useEffect, useState} from 'react'
import {Portal, Developer, Consumer} from '@/types'
import {portalApi} from '@/lib/api'
import {formatDateTime} from '@/lib/utils'
import {SubscriptionListModal} from '@/components/subscription/SubscriptionListModal'
interface PortalDevelopersProps {
portal: Portal
}
export function PortalDevelopers({portal}: PortalDevelopersProps) {
const [developers, setDevelopers] = useState<Developer[]>([])
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total: number, range: [number, number]) =>
`共 ${total} 条`
})
// Consumer相关状态
const [consumers, setConsumers] = useState<Consumer[]>([])
const [consumerModalVisible, setConsumerModalVisible] = useState(false)
const [currentDeveloper, setCurrentDeveloper] = useState<Developer | null>(null)
const [consumerPagination, setConsumerPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total: number, range: [number, number]) =>
`共 ${total} 条`
})
// 订阅列表相关状态
const [subscriptionModalVisible, setSubscriptionModalVisible] = useState(false)
const [currentConsumer, setCurrentConsumer] = useState<Consumer | null>(null)
useEffect(() => {
fetchDevelopers()
}, [portal.portalId, pagination.current, pagination.pageSize])
const fetchDevelopers = () => {
portalApi.getDeveloperList(portal.portalId, {
page: pagination.current, // 后端从0开始
size: pagination.pageSize
}).then((res) => {
setDevelopers(res.data.content)
setPagination(prev => ({
...prev,
total: res.data.totalElements || 0
}))
})
}
const handleUpdateDeveloperStatus = (developerId: string, status: string) => {
portalApi.updateDeveloperStatus(portal.portalId, developerId, status).then(() => {
if (status === 'PENDING') {
message.success('取消授权成功')
} else {
message.success('审批成功')
}
fetchDevelopers()
}).catch(() => {
message.error('审批失败')
})
}
const handleTableChange = (paginationInfo: any) => {
setPagination(prev => ({
...prev,
current: paginationInfo.current,
pageSize: paginationInfo.pageSize
}))
}
const handleDeleteDeveloper = (developerId: string, username: string) => {
Modal.confirm({
title: '确认删除',
icon: <ExclamationCircleOutlined/>,
content: `确定要删除开发者 "${username}" 吗?此操作不可恢复。`,
okText: '确认删除',
okType: 'danger',
cancelText: '取消',
onOk() {
portalApi.deleteDeveloper(developerId).then(() => {
message.success('删除成功')
fetchDevelopers()
}).catch(() => {
message.error('删除失败')
})
},
})
}
// Consumer相关函数
const handleViewConsumers = (developer: Developer) => {
setCurrentDeveloper(developer)
setConsumerModalVisible(true)
setConsumerPagination(prev => ({...prev, current: 1}))
fetchConsumers(developer.developerId, 1, consumerPagination.pageSize)
}
const fetchConsumers = (developerId: string, page: number, size: number) => {
portalApi.getConsumerList(portal.portalId, developerId, {page: page, size}).then((res) => {
setConsumers(res.data.content || [])
setConsumerPagination(prev => ({
...prev,
total: res.data.totalElements || 0
}))
}).then((res: any) => {
setConsumers(res.data.content || [])
setConsumerPagination(prev => ({
...prev,
total: res.data.totalElements || 0
}))
})
}
const handleConsumerTableChange = (paginationInfo: any) => {
if (currentDeveloper) {
setConsumerPagination(prev => ({
...prev,
current: paginationInfo.current,
pageSize: paginationInfo.pageSize
}))
fetchConsumers(currentDeveloper.developerId, paginationInfo.current, paginationInfo.pageSize)
}
}
const handleConsumerStatusUpdate = (consumerId: string) => {
if (currentDeveloper) {
portalApi.approveConsumer(consumerId).then((res) => {
message.success('审批成功')
fetchConsumers(currentDeveloper.developerId, consumerPagination.current, consumerPagination.pageSize)
}).catch((err) => {
// message.error('审批失败')
})
}
}
// 查看订阅列表
const handleViewSubscriptions = (consumer: Consumer) => {
setCurrentConsumer(consumer)
setSubscriptionModalVisible(true)
}
// 关闭订阅列表模态框
const handleSubscriptionModalCancel = () => {
setSubscriptionModalVisible(false)
setCurrentConsumer(null)
}
const columns = [
{
title: '开发者名称/ID',
dataIndex: 'username',
key: 'username',
fixed: 'left' as const,
width: 280,
render: (username: string, record: Developer) => (
<div className="ml-2">
<div className="font-medium">{username}</div>
<div className="text-sm text-gray-500">{record.developerId}</div>
</div>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 120,
render: (status: string) => (
<div className="flex items-center">
{status === 'APPROVED' ? (
<>
<CheckCircleFilled className="text-green-500 mr-2" style={{fontSize: '10px'}} />
<span className="text-xs text-gray-900">可用</span>
</>
) : (
<>
<ClockCircleOutlined className="text-orange-500 mr-2" style={{fontSize: '10px'}} />
<span className="text-xs text-gray-900">待审核</span>
</>
)}
</div>
)
},
{
title: '创建时间',
dataIndex: 'createAt',
key: 'createAt',
width: 160,
render: (date: string) => formatDateTime(date)
},
{
title: '操作',
key: 'action',
fixed: 'right' as const,
width: 250,
render: (_: any, record: Developer) => (
<Space size="middle">
<Button onClick={() => handleViewConsumers(record)} type="link" icon={<EyeOutlined/>}>
查看Consumer
</Button>
{
!portal.portalSettingConfig.autoApproveDevelopers && (
record.status === 'APPROVED' ? (
<Button onClick={() => handleUpdateDeveloperStatus(record.developerId, 'PENDING')}
type="link" icon={<EditOutlined/>}>
取消授权
</Button>
) : (
<Button onClick={() => handleUpdateDeveloperStatus(record.developerId, 'APPROVED')}
type="link" icon={<EditOutlined/>}>
审批通过
</Button>
)
)
}
<Button onClick={() => handleDeleteDeveloper(record.developerId, record.username)} type="link"
danger icon={<DeleteOutlined/>}>
删除
</Button>
</Space>
),
},
]
// Consumer表格列定义
const consumerColumns = [
{
title: 'Consumer名称',
dataIndex: 'name',
key: 'name',
width: 200,
},
{
title: 'Consumer ID',
dataIndex: 'consumerId',
key: 'consumerId',
width: 200,
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
ellipsis: true,
width: 200,
},
// {
// title: '状态',
// dataIndex: 'status',
// key: 'status',
// width: 120,
// render: (status: string) => (
// <Badge status={status === 'APPROVED' ? 'success' : 'default'} text={status === 'APPROVED' ? '可用' : '待审核'} />
// )
// },
{
title: '创建时间',
dataIndex: 'createAt',
key: 'createAt',
width: 150,
render: (date: string) => formatDateTime(date)
},
{
title: '操作',
key: 'action',
width: 120,
render: (_: any, record: Consumer) => (
<Button
onClick={() => handleViewSubscriptions(record)}
type="link"
icon={<UnorderedListOutlined/>}
>
订阅列表
</Button>
),
},
]
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold mb-2">开发者</h1>
<p className="text-gray-600">管理Portal的开发者用户</p>
</div>
</div>
<Card>
{/* <div className="mb-4">
<Input
placeholder="搜索开发者..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 300 }}
/>
</div> */}
<Table
columns={columns}
dataSource={developers}
rowKey="developerId"
pagination={pagination}
onChange={handleTableChange}
scroll={{
y: 'calc(100vh - 400px)',
x: 'max-content'
}}
/>
</Card>
{/* Consumer弹窗 */}
<Modal
title={`查看Consumer - ${currentDeveloper?.username || ''}`}
open={consumerModalVisible}
onCancel={() => setConsumerModalVisible(false)}
footer={null}
width={1000}
destroyOnClose
>
<Table
columns={consumerColumns}
dataSource={consumers}
rowKey="consumerId"
pagination={consumerPagination}
onChange={handleConsumerTableChange}
scroll={{y: 'calc(100vh - 400px)'}}
/>
</Modal>
{/* 订阅列表弹窗 */}
{currentConsumer && (
<SubscriptionListModal
visible={subscriptionModalVisible}
consumerId={currentConsumer.consumerId}
consumerName={currentConsumer.name}
onCancel={handleSubscriptionModalCancel}
/>
)}
</div>
)
}
```
--------------------------------------------------------------------------------
/portal-web/api-portal-admin/src/components/api-product/ApiProductFormModal.tsx:
--------------------------------------------------------------------------------
```typescript
import { useState, useEffect } from "react";
import {
Modal,
Form,
Input,
Select,
Image,
message,
UploadFile,
Switch,
Radio,
Space,
} from "antd";
import { CameraOutlined } from "@ant-design/icons";
import { apiProductApi } from "@/lib/api";
import type { ApiProduct } from "@/types/api-product";
interface ApiProductFormModalProps {
visible: boolean;
onCancel: () => void;
onSuccess: () => void;
productId?: string;
initialData?: Partial<ApiProduct>;
}
export default function ApiProductFormModal({
visible,
onCancel,
onSuccess,
productId,
initialData,
}: ApiProductFormModalProps) {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const [previewImage, setPreviewImage] = useState("");
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [iconMode, setIconMode] = useState<'BASE64' | 'URL'>('URL');
const isEditMode = !!productId;
// 初始化时加载已有数据
useEffect(() => {
if (visible && isEditMode && initialData && initialData.name) {
setTimeout(() => {
// 1. 先设置所有字段
form.setFieldsValue({
name: initialData.name,
description: initialData.description,
type: initialData.type,
autoApprove: initialData.autoApprove,
});
}, 100);
// 2. 处理 icon 字段
if (initialData.icon) {
if (typeof initialData.icon === 'object' && initialData.icon.type && initialData.icon.value) {
// 新格式:{ type: 'BASE64' | 'URL', value: string }
const iconType = initialData.icon.type as 'BASE64' | 'URL';
const iconValue = initialData.icon.value;
setIconMode(iconType);
if (iconType === 'BASE64') {
setFileList([
{
uid: "-1",
name: "头像.png",
status: "done",
url: iconValue,
},
]);
form.setFieldsValue({ icon: iconValue });
} else {
form.setFieldsValue({ iconUrl: iconValue });
}
} else {
// 兼容旧格式(字符串格式)
const iconStr = initialData.icon as unknown as string;
if (iconStr && typeof iconStr === 'string' && iconStr.includes("value=")) {
const startIndex = iconStr.indexOf("value=") + 6;
const endIndex = iconStr.length - 1;
const base64Data = iconStr.substring(startIndex, endIndex).trim();
setIconMode('BASE64');
setFileList([
{
uid: "-1",
name: "头像.png",
status: "done",
url: base64Data,
},
]);
form.setFieldsValue({ icon: base64Data });
}
}
}
} else if (visible && !isEditMode) {
// 新建模式下清空表单
form.resetFields();
setFileList([]);
setIconMode('URL');
}
}, [visible, isEditMode, initialData, form]);
// 将文件转为 Base64
const getBase64 = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
const uploadButton = (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: '#999'
}}>
<CameraOutlined style={{ fontSize: '16px', marginBottom: '6px' }} />
<span style={{ fontSize: '12px', color: '#999' }}>上传图片</span>
</div>
);
// 处理Icon模式切换
const handleIconModeChange = (mode: 'BASE64' | 'URL') => {
setIconMode(mode);
// 清空相关字段
if (mode === 'URL') {
form.setFieldsValue({ icon: undefined });
setFileList([]);
} else {
form.setFieldsValue({ iconUrl: undefined });
}
};
const resetForm = () => {
form.resetFields();
setFileList([]);
setPreviewImage("");
setPreviewOpen(false);
setIconMode('URL');
};
const handleCancel = () => {
resetForm();
onCancel();
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setLoading(true);
const { icon, iconUrl, ...otherValues } = values;
if (isEditMode) {
let params = { ...otherValues };
// 处理icon字段
if (iconMode === 'BASE64' && icon) {
params.icon = {
type: "BASE64",
value: icon,
};
} else if (iconMode === 'URL' && iconUrl) {
params.icon = {
type: "URL",
value: iconUrl,
};
} else if (!icon && !iconUrl) {
// 如果两种模式都没有提供icon,保持原有icon不变
delete params.icon;
}
await apiProductApi.updateApiProduct(productId!, params);
message.success("API Product 更新成功");
} else {
let params = { ...otherValues };
// 处理icon字段
if (iconMode === 'BASE64' && icon) {
params.icon = {
type: "BASE64",
value: icon,
};
} else if (iconMode === 'URL' && iconUrl) {
params.icon = {
type: "URL",
value: iconUrl,
};
}
await apiProductApi.createApiProduct(params);
message.success("API Product 创建成功");
}
resetForm();
onSuccess();
} catch (error: any) {
if (error?.errorFields) return;
message.error("操作失败");
} finally {
setLoading(false);
}
};
return (
<Modal
title={isEditMode ? "编辑 API Product" : "创建 API Product"}
open={visible}
onOk={handleSubmit}
onCancel={handleCancel}
confirmLoading={loading}
width={600}
>
<Form form={form} layout="vertical" preserve={false}>
<Form.Item
label="名称"
name="name"
rules={[{ required: true, message: "请输入API Product名称" }]}
>
<Input placeholder="请输入API Product名称" />
</Form.Item>
<Form.Item
label="描述"
name="description"
rules={[{ required: true, message: "请输入描述" }]}
>
<Input.TextArea placeholder="请输入描述" rows={3} />
</Form.Item>
<Form.Item
label="类型"
name="type"
rules={[{ required: true, message: "请选择类型" }]}
>
<Select placeholder="请选择类型">
<Select.Option value="REST_API">REST API</Select.Option>
<Select.Option value="MCP_SERVER">MCP Server</Select.Option>
</Select>
</Form.Item>
<Form.Item
label="自动审批订阅"
name="autoApprove"
tooltip={{
title: (
<div style={{
color: '#000000',
backgroundColor: '#ffffff',
fontSize: '13px',
lineHeight: '1.4',
padding: '4px 0'
}}>
启用后,该产品的订阅申请将自动审批通过,否则使用Portal的消费者订阅审批设置。
</div>
),
placement: "topLeft",
overlayInnerStyle: {
backgroundColor: '#ffffff',
color: '#000000',
border: '1px solid #d9d9d9',
borderRadius: '6px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
},
overlayStyle: {
maxWidth: '300px'
}
}}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item label="Icon设置" style={{ marginBottom: '16px' }}>
<Space direction="vertical" style={{ width: '100%' }}>
<Radio.Group
value={iconMode}
onChange={(e) => handleIconModeChange(e.target.value)}
>
<Radio value="URL">图片链接</Radio>
<Radio value="BASE64">本地上传</Radio>
</Radio.Group>
{iconMode === 'URL' ? (
<Form.Item
name="iconUrl"
style={{ marginBottom: 0 }}
rules={[
{
type: 'url',
message: '请输入有效的图片链接'
}
]}
>
<Input placeholder="请输入图片链接地址" />
</Form.Item>
) : (
<Form.Item name="icon" style={{ marginBottom: 0 }}>
<div
style={{
width: '80px',
height: '80px',
border: '1px dashed #d9d9d9',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'border-color 0.3s',
position: 'relative'
}}
onClick={() => {
// 触发文件选择
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
// 验证文件大小,限制为16KB
const maxSize = 16 * 1024; // 16KB
if (file.size > maxSize) {
message.error(`图片大小不能超过 16KB,当前图片大小为 ${Math.round(file.size / 1024)}KB`);
return;
}
const newFileList: UploadFile[] = [{
uid: Date.now().toString(),
name: file.name,
status: 'done' as const,
url: URL.createObjectURL(file)
}];
setFileList(newFileList);
getBase64(file).then((base64) => {
form.setFieldsValue({ icon: base64 });
});
}
};
input.click();
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#1890ff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#d9d9d9';
}}
>
{fileList.length >= 1 ? (
<img
src={fileList[0].url}
alt="uploaded"
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '6px' }}
onClick={(e) => {
e.stopPropagation();
// 预览图片
setPreviewImage(fileList[0].url || '');
setPreviewOpen(true);
}}
/>
) : (
uploadButton
)}
{fileList.length >= 1 && (
<div
style={{
position: 'absolute',
top: '4px',
right: '4px',
background: 'rgba(0, 0, 0, 0.5)',
borderRadius: '50%',
width: '16px',
height: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: 'white',
fontSize: '10px'
}}
onClick={(e) => {
e.stopPropagation();
setFileList([]);
form.setFieldsValue({ icon: null });
}}
>
×
</div>
)}
</div>
</Form.Item>
)}
</Space>
</Form.Item>
{/* 图片预览弹窗 */}
{previewImage && (
<Image
wrapperStyle={{ display: "none" }}
preview={{
visible: previewOpen,
onVisibleChange: (visible) => setPreviewOpen(visible),
afterOpenChange: (visible) => {
if (!visible) setPreviewImage("");
},
}}
src={previewImage}
/>
)}
</Form>
</Modal>
);
}
```
--------------------------------------------------------------------------------
/portal-web/api-portal-admin/src/pages/GatewayConsoles.tsx:
--------------------------------------------------------------------------------
```typescript
import { useState, useEffect, useCallback } from 'react'
import { Button, Table, message, Modal, Tabs } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import { gatewayApi } from '@/lib/api'
import ImportGatewayModal from '@/components/console/ImportGatewayModal'
import ImportHigressModal from '@/components/console/ImportHigressModal'
import GatewayTypeSelector from '@/components/console/GatewayTypeSelector'
import { formatDateTime } from '@/lib/utils'
import { Gateway, GatewayType } from '@/types'
export default function Consoles() {
const [gateways, setGateways] = useState<Gateway[]>([])
const [typeSelectorVisible, setTypeSelectorVisible] = useState(false)
const [importVisible, setImportVisible] = useState(false)
const [higressImportVisible, setHigressImportVisible] = useState(false)
const [selectedGatewayType, setSelectedGatewayType] = useState<GatewayType>('APIG_API')
const [loading, setLoading] = useState(false)
const [activeTab, setActiveTab] = useState<GatewayType>('HIGRESS')
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
})
const fetchGatewaysByType = useCallback(async (gatewayType: GatewayType, page = 1, size = 10) => {
setLoading(true)
try {
const res = await gatewayApi.getGateways({ gatewayType, page, size })
setGateways(res.data?.content || [])
setPagination({
current: page,
pageSize: size,
total: res.data?.totalElements || 0,
})
} catch (error) {
// message.error('获取网关列表失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchGatewaysByType(activeTab, 1, 10)
}, [fetchGatewaysByType, activeTab])
// 处理导入成功
const handleImportSuccess = () => {
fetchGatewaysByType(activeTab, pagination.current, pagination.pageSize)
}
// 处理网关类型选择
const handleGatewayTypeSelect = (type: GatewayType) => {
setSelectedGatewayType(type)
setTypeSelectorVisible(false)
if (type === 'HIGRESS') {
setHigressImportVisible(true)
} else {
setImportVisible(true)
}
}
// 处理分页变化
const handlePaginationChange = (page: number, pageSize: number) => {
fetchGatewaysByType(activeTab, page, pageSize)
}
// 处理Tab切换
const handleTabChange = (tabKey: string) => {
const gatewayType = tabKey as GatewayType
setActiveTab(gatewayType)
// Tab切换时重置到第一页
setPagination(prev => ({ ...prev, current: 1 }))
}
const handleDeleteGateway = async (gatewayId: string) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除该网关吗?',
onOk: async () => {
try {
await gatewayApi.deleteGateway(gatewayId)
message.success('删除成功')
fetchGatewaysByType(activeTab, pagination.current, pagination.pageSize)
} catch (error) {
// message.error('删除失败')
}
},
})
}
// APIG 网关的列定义
const apigColumns = [
{
title: '网关名称/ID',
key: 'nameAndId',
width: 280,
render: (_: any, record: Gateway) => (
<div>
<div className="text-sm font-medium text-gray-900 truncate">
{record.gatewayName}
</div>
<div className="text-xs text-gray-500 truncate">
{record.gatewayId}
</div>
</div>
),
},
{
title: '区域',
dataIndex: 'region',
key: 'region',
render: (_: any, record: Gateway) => {
return record.apigConfig?.region || '-'
}
},
{
title: '创建时间',
dataIndex: 'createAt',
key: 'createAt',
render: (date: string) => formatDateTime(date)
},
{
title: '操作',
key: 'action',
render: (_: any, record: Gateway) => (
<Button type="link" danger onClick={() => handleDeleteGateway(record.gatewayId)}>删除</Button>
),
},
]
// 专有云 AI 网关的列定义
const adpAiColumns = [
{
title: '网关名称/ID',
key: 'nameAndId',
width: 280,
render: (_: any, record: Gateway) => (
<div>
<div className="text-sm font-medium text-gray-900 truncate">
{record.gatewayName}
</div>
<div className="text-xs text-gray-500 truncate">
{record.gatewayId}
</div>
</div>
),
},
{
title: '创建时间',
dataIndex: 'createAt',
key: 'createAt',
render: (date: string) => formatDateTime(date)
},
{
title: '操作',
key: 'action',
render: (_: any, record: Gateway) => (
<Button type="link" danger onClick={() => handleDeleteGateway(record.gatewayId)}>删除</Button>
),
}
]
// 飞天企业版 AI 网关的列定义
const apsaraGatewayColumns = [
{
title: '网关名称/ID',
key: 'nameAndId',
width: 280,
render: (_: any, record: Gateway) => (
<div>
<div className="text-sm font-medium text-gray-900 truncate">
{record.gatewayName}
</div>
<div className="text-xs text-gray-500 truncate">
{record.gatewayId}
</div>
</div>
),
},
{
title: '创建时间',
dataIndex: 'createAt',
key: 'createAt',
render: (date: string) => formatDateTime(date)
},
{
title: '操作',
key: 'action',
render: (_: any, record: Gateway) => (
<Button type="link" danger onClick={() => handleDeleteGateway(record.gatewayId)}>删除</Button>
),
},
]
// Higress 网关的列定义
const higressColumns = [
{
title: '网关名称/ID',
key: 'nameAndId',
width: 280,
render: (_: any, record: Gateway) => (
<div>
<div className="text-sm font-medium text-gray-900 truncate">
{record.gatewayName}
</div>
<div className="text-xs text-gray-500 truncate">
{record.gatewayId}
</div>
</div>
),
},
{
title: '服务地址',
dataIndex: 'address',
key: 'address',
render: (_: any, record: Gateway) => {
return record.higressConfig?.address || '-'
}
},
{
title: '用户名',
dataIndex: 'username',
key: 'username',
render: (_: any, record: Gateway) => {
return record.higressConfig?.username || '-'
}
},
{
title: '创建时间',
dataIndex: 'createAt',
key: 'createAt',
render: (date: string) => formatDateTime(date)
},
{
title: '操作',
key: 'action',
render: (_: any, record: Gateway) => (
<Button type="link" danger onClick={() => handleDeleteGateway(record.gatewayId)}>删除</Button>
),
},
]
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">网关实例</h1>
<p className="text-gray-500 mt-2">
管理和配置您的网关实例
</p>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setTypeSelectorVisible(true)}>
导入网关实例
</Button>
</div>
<Tabs
activeKey={activeTab}
onChange={handleTabChange}
items={[
{
key: 'HIGRESS',
label: 'Higress 网关',
children: (
<div className="bg-white rounded-lg">
<div className="py-4 pl-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">Higress 网关</h3>
<p className="text-sm text-gray-500 mt-1">Higress 云原生网关</p>
</div>
<Table
columns={higressColumns}
dataSource={gateways}
rowKey="gatewayId"
loading={loading}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `共 ${total} 条`,
onChange: handlePaginationChange,
onShowSizeChange: handlePaginationChange,
}}
/>
</div>
),
},
{
key: 'APIG_API',
label: 'API 网关',
children: (
<div className="bg-white rounded-lg">
<div className="py-4 pl-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">API 网关</h3>
<p className="text-sm text-gray-500 mt-1">阿里云 API 网关服务</p>
</div>
<Table
columns={apigColumns}
dataSource={gateways}
rowKey="gatewayId"
loading={loading}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `共 ${total} 条`,
onChange: handlePaginationChange,
onShowSizeChange: handlePaginationChange,
}}
/>
</div>
),
},
{
key: 'APIG_AI',
label: 'AI 网关',
children: (
<div className="bg-white rounded-lg">
<div className="py-4 pl-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">AI 网关</h3>
<p className="text-sm text-gray-500 mt-1">阿里云 AI 网关服务</p>
</div>
<Table
columns={apigColumns}
dataSource={gateways}
rowKey="gatewayId"
loading={loading}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `共 ${total} 条`,
onChange: handlePaginationChange,
onShowSizeChange: handlePaginationChange,
}}
/>
</div>
),
},
{
key: 'ADP_AI_GATEWAY',
label: '专有云 AI 网关',
children: (
<div className="bg-white rounded-lg">
<div className="py-4 pl-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">AI 网关</h3>
<p className="text-sm text-gray-500 mt-1">专有云 AI 网关服务</p>
</div>
<Table
columns={adpAiColumns}
dataSource={gateways}
rowKey="gatewayId"
loading={loading}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `共 ${total} 条`,
onChange: handlePaginationChange,
onShowSizeChange: handlePaginationChange,
}}
/>
</div>
),
},
{
key: 'APSARA_GATEWAY',
label: '飞天企业版 AI 网关',
children: (
<div className="bg-white rounded-lg">
<div className="py-4 pl-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">飞天企业版 AI 网关</h3>
<p className="text-sm text-gray-500 mt-1">阿里云飞天企业版 AI 网关服务</p>
</div>
<Table
columns={apsaraGatewayColumns}
dataSource={gateways}
rowKey="gatewayId"
loading={loading}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `共 ${total} 条`,
onChange: handlePaginationChange,
onShowSizeChange: handlePaginationChange,
}}
/>
</div>
),
},
]}
/>
<ImportGatewayModal
visible={importVisible}
gatewayType={selectedGatewayType as 'APIG_API' | 'APIG_AI' | 'ADP_AI_GATEWAY' | 'APSARA_GATEWAY'}
onCancel={() => setImportVisible(false)}
onSuccess={handleImportSuccess}
/>
<ImportHigressModal
visible={higressImportVisible}
onCancel={() => setHigressImportVisible(false)}
onSuccess={handleImportSuccess}
/>
<GatewayTypeSelector
visible={typeSelectorVisible}
onCancel={() => setTypeSelectorVisible(false)}
onSelect={handleGatewayTypeSelect}
/>
</div>
)
}
```
--------------------------------------------------------------------------------
/portal-server/src/main/java/com/alibaba/apiopenplatform/service/impl/OidcServiceImpl.java:
--------------------------------------------------------------------------------
```java
package com.alibaba.apiopenplatform.service.impl;
import cn.hutool.core.codec.Base64;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import cn.hutool.jwt.JWT;
import cn.hutool.jwt.JWTUtil;
import com.alibaba.apiopenplatform.core.constant.CommonConstants;
import com.alibaba.apiopenplatform.core.constant.IdpConstants;
import com.alibaba.apiopenplatform.core.constant.Resources;
import com.alibaba.apiopenplatform.core.exception.BusinessException;
import com.alibaba.apiopenplatform.core.exception.ErrorCode;
import com.alibaba.apiopenplatform.core.security.ContextHolder;
import com.alibaba.apiopenplatform.core.utils.TokenUtil;
import com.alibaba.apiopenplatform.dto.params.developer.CreateExternalDeveloperParam;
import com.alibaba.apiopenplatform.dto.result.*;
import com.alibaba.apiopenplatform.service.OidcService;
import com.alibaba.apiopenplatform.service.DeveloperService;
import com.alibaba.apiopenplatform.service.PortalService;
import com.alibaba.apiopenplatform.support.enums.DeveloperAuthType;
import com.alibaba.apiopenplatform.support.enums.GrantType;
import com.alibaba.apiopenplatform.support.portal.AuthCodeConfig;
import com.alibaba.apiopenplatform.support.portal.IdentityMapping;
import com.alibaba.apiopenplatform.support.portal.OidcConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class OidcServiceImpl implements OidcService {
private final PortalService portalService;
private final DeveloperService developerService;
private final RestTemplate restTemplate;
private final ContextHolder contextHolder;
@Override
public String buildAuthorizationUrl(String provider, String apiPrefix, HttpServletRequest request) {
OidcConfig oidcConfig = findOidcConfig(provider);
AuthCodeConfig authCodeConfig = oidcConfig.getAuthCodeConfig();
// state保存上下文信息
String state = buildState(provider, apiPrefix);
String redirectUri = buildRedirectUri(request);
// 重定向URL
String authUrl = UriComponentsBuilder
.fromUriString(authCodeConfig.getAuthorizationEndpoint())
// 授权码模式
.queryParam(IdpConstants.RESPONSE_TYPE, IdpConstants.CODE)
.queryParam(IdpConstants.CLIENT_ID, authCodeConfig.getClientId())
.queryParam(IdpConstants.REDIRECT_URI, redirectUri)
.queryParam(IdpConstants.SCOPE, authCodeConfig.getScopes())
.queryParam(IdpConstants.STATE, state)
.build()
.toUriString();
log.info("Generated OIDC authorization URL: {}", authUrl);
return authUrl;
}
@Override
public AuthResult handleCallback(String code, String state, HttpServletRequest request, HttpServletResponse response) {
log.info("Processing OIDC callback with code: {}, state: {}", code, state);
// 解析state获取provider信息
IdpState idpState = parseState(state);
String provider = idpState.getProvider();
if (StrUtil.isBlank(provider)) {
throw new BusinessException(ErrorCode.INVALID_REQUEST, "缺少OIDC provider");
}
OidcConfig oidcConfig = findOidcConfig(provider);
// 使用授权码获取Token
IdpTokenResult tokenResult = requestToken(code, oidcConfig, request);
// 获取用户信息,优先使用ID Token,降级到UserInfo端点
Map<String, Object> userInfo = getUserInfo(tokenResult, oidcConfig);
log.info("Get OIDC user info: {}", userInfo);
// 处理用户认证逻辑
String developerId = createOrGetDeveloper(userInfo, oidcConfig);
String accessToken = TokenUtil.generateDeveloperToken(developerId);
return AuthResult.of(accessToken, TokenUtil.getTokenExpiresIn());
}
@Override
public List<IdpResult> getAvailableProviders() {
return Optional.ofNullable(portalService.getPortal(contextHolder.getPortal()))
.filter(portal -> portal.getPortalSettingConfig() != null)
.filter(portal -> portal.getPortalSettingConfig().getOidcConfigs() != null)
.map(portal -> portal.getPortalSettingConfig().getOidcConfigs())
// 确定当前Portal下启用的OIDC配置,返回Idp信息
.map(configs -> configs.stream()
.filter(OidcConfig::isEnabled)
.map(config -> IdpResult.builder()
.provider(config.getProvider())
.displayName(config.getName())
.build())
.collect(Collectors.toList()))
.orElse(Collections.emptyList());
}
private String buildRedirectUri(HttpServletRequest request) {
String scheme = request.getScheme();
// String serverName = "localhost";
// int serverPort = 5173;
String serverName = request.getServerName();
int serverPort = request.getServerPort();
String baseUrl = scheme + "://" + serverName;
if (serverPort != CommonConstants.HTTP_PORT && serverPort != CommonConstants.HTTPS_PORT) {
baseUrl += ":" + serverPort;
}
// 重定向到前端的Callback接口
return baseUrl + "/oidc/callback";
}
private OidcConfig findOidcConfig(String provider) {
return Optional.ofNullable(portalService.getPortal(contextHolder.getPortal()))
.filter(portal -> portal.getPortalSettingConfig() != null)
.filter(portal -> portal.getPortalSettingConfig().getOidcConfigs() != null)
// 根据provider字段过滤
.flatMap(portal -> portal.getPortalSettingConfig()
.getOidcConfigs()
.stream()
.filter(config -> provider.equals(config.getProvider()) && config.isEnabled())
.findFirst())
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.OIDC_CONFIG, provider));
}
private String buildState(String provider, String apiPrefix) {
IdpState state = IdpState.builder()
.provider(provider)
.timestamp(System.currentTimeMillis())
.nonce(IdUtil.fastSimpleUUID())
.apiPrefix(apiPrefix)
.build();
return Base64.encode(JSONUtil.toJsonStr(state));
}
private IdpState parseState(String encodedState) {
String stateJson = Base64.decodeStr(encodedState);
IdpState idpState = JSONUtil.toBean(stateJson, IdpState.class);
// 验证时间戳,10分钟有效期
if (idpState.getTimestamp() != null) {
long currentTime = System.currentTimeMillis();
if (currentTime - idpState.getTimestamp() > 10 * 60 * 1000) {
throw new BusinessException(ErrorCode.INVALID_REQUEST, "请求已过期");
}
}
return idpState;
}
private IdpTokenResult requestToken(String code, OidcConfig oidcConfig, HttpServletRequest request) {
AuthCodeConfig authCodeConfig = oidcConfig.getAuthCodeConfig();
String redirectUri = buildRedirectUri(request);
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add(IdpConstants.GRANT_TYPE, GrantType.AUTHORIZATION_CODE.getType());
params.add(IdpConstants.CODE, code);
params.add(IdpConstants.REDIRECT_URI, redirectUri);
params.add(IdpConstants.CLIENT_ID, authCodeConfig.getClientId());
params.add(IdpConstants.CLIENT_SECRET, authCodeConfig.getClientSecret());
log.info("Request tokens at: {}, params: {}", authCodeConfig.getTokenEndpoint(), params);
return executeRequest(authCodeConfig.getTokenEndpoint(), HttpMethod.POST, null, params, IdpTokenResult.class);
}
private Map<String, Object> getUserInfo(IdpTokenResult tokenResult, OidcConfig oidcConfig) {
// 优先使用ID Token
if (StrUtil.isNotBlank(tokenResult.getIdToken())) {
log.info("Get user info form id token: {}", tokenResult.getIdToken());
return parseUserInfo(tokenResult.getIdToken(), oidcConfig);
}
// 降级策略:使用UserInfo端点
log.warn("ID Token not available, falling back to UserInfo endpoint");
if (StrUtil.isBlank(tokenResult.getAccessToken())) {
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "OIDC获取用户信息失败");
}
AuthCodeConfig authCodeConfig = oidcConfig.getAuthCodeConfig();
if (StrUtil.isBlank(authCodeConfig.getUserInfoEndpoint())) {
throw new BusinessException(ErrorCode.INVALID_PARAMETER, "OIDC配置缺少用户信息端点");
}
return requestUserInfo(tokenResult.getAccessToken(), authCodeConfig, oidcConfig);
}
private Map<String, Object> parseUserInfo(String idToken, OidcConfig oidcConfig) {
JWT jwt = JWTUtil.parseToken(idToken);
// 验证过期时间
Object exp = jwt.getPayload("exp");
if (exp != null) {
long expTime = Convert.toLong(exp);
long currentTime = System.currentTimeMillis() / 1000;
if (expTime <= currentTime) {
throw new BusinessException(ErrorCode.INVALID_REQUEST, "ID Token已过期");
}
}
// TODO 验签
Map<String, Object> userInfo = jwt.getPayload().getClaimsJson();
log.info("Successfully extracted user info from ID Token, sub: {}", userInfo);
return userInfo;
}
@SuppressWarnings("unchecked")
private Map<String, Object> requestUserInfo(String accessToken, AuthCodeConfig authCodeConfig, OidcConfig oidcConfig) {
try {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
log.info("Fetching user info from endpoint: {}", authCodeConfig.getUserInfoEndpoint());
Map<String, Object> userInfo = executeRequest(authCodeConfig.getUserInfoEndpoint(), HttpMethod.GET, headers, null, Map.class);
log.info("Successfully fetched user info from endpoint, sub: {}", userInfo);
return userInfo;
} catch (Exception e) {
log.error("Failed to fetch user info from endpoint: {}", authCodeConfig.getUserInfoEndpoint(), e);
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "获取用户信息失败");
}
}
private String createOrGetDeveloper(Map<String, Object> userInfo, OidcConfig config) {
IdentityMapping identityMapping = config.getIdentityMapping();
// userId & userName & email
String userIdField = StrUtil.isBlank(identityMapping.getUserIdField()) ?
IdpConstants.SUBJECT : identityMapping.getUserIdField();
String userNameField = StrUtil.isBlank(identityMapping.getUserNameField()) ?
IdpConstants.NAME : identityMapping.getUserNameField();
String emailField = StrUtil.isBlank(identityMapping.getEmailField()) ?
IdpConstants.EMAIL : identityMapping.getEmailField();
Object userIdObj = userInfo.get(userIdField);
Object userNameObj = userInfo.get(userNameField);
Object emailObj = userInfo.get(emailField);
String userId = Convert.toStr(userIdObj);
String userName = Convert.toStr(userNameObj);
String email = Convert.toStr(emailObj);
if (StrUtil.isBlank(userId) || StrUtil.isBlank(userName)) {
throw new BusinessException(ErrorCode.INVALID_REQUEST, "Id Token中缺少用户ID字段或用户名称");
}
// 复用已有的Developer,否则创建
return Optional.ofNullable(developerService.getExternalDeveloper(config.getProvider(), userId))
.map(DeveloperResult::getDeveloperId)
.orElseGet(() -> {
CreateExternalDeveloperParam param = CreateExternalDeveloperParam.builder()
.provider(config.getProvider())
.subject(userId)
.displayName(userName)
.email(email)
.authType(DeveloperAuthType.OIDC)
.build();
return developerService.createExternalDeveloper(param).getDeveloperId();
});
}
private <T> T executeRequest(String url, HttpMethod method, HttpHeaders headers, Object body, Class<T> responseType) {
HttpEntity<?> requestEntity = new HttpEntity<>(body, headers);
log.info("Executing HTTP request to: {}", url);
ResponseEntity<String> response = restTemplate.exchange(
url,
method,
requestEntity,
String.class
);
log.info("Received HTTP response from: {}, status: {}, body: {}", url, response.getStatusCode(), response.getBody());
return JSONUtil.toBean(response.getBody(), responseType);
}
}
```
--------------------------------------------------------------------------------
/portal-web/api-portal-admin/src/components/portal/PortalSettings.tsx:
--------------------------------------------------------------------------------
```typescript
import {Card, Form, Input, Select, Switch, Button, Divider, Space, Tag, Table, Modal, message, Tabs} from 'antd'
import {SaveOutlined, PlusOutlined, DeleteOutlined, ExclamationCircleOutlined} from '@ant-design/icons'
import {useState, useMemo} from 'react'
import {Portal, ThirdPartyAuthConfig, AuthenticationType, OidcConfig, OAuth2Config} from '@/types'
import {portalApi} from '@/lib/api'
import {ThirdPartyAuthManager} from './ThirdPartyAuthManager'
interface PortalSettingsProps {
portal: Portal
onRefresh?: () => void
}
export function PortalSettings({portal, onRefresh}: PortalSettingsProps) {
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [domainModalVisible, setDomainModalVisible] = useState(false)
const [domainForm] = Form.useForm()
const [domainLoading, setDomainLoading] = useState(false)
// 本地OIDC配置状态,避免频繁刷新
// local的有点问题,一切tab就坏了
const handleSave = async () => {
try {
setLoading(true)
const values = await form.validateFields()
await portalApi.updatePortal(portal.portalId, {
name: portal.name, // 保持现有名称不变
description: portal.description, // 保持现有描述不变
portalSettingConfig: {
...portal.portalSettingConfig,
builtinAuthEnabled: values.builtinAuthEnabled,
oidcAuthEnabled: values.oidcAuthEnabled,
autoApproveDevelopers: values.autoApproveDevelopers,
autoApproveSubscriptions: values.autoApproveSubscriptions,
frontendRedirectUrl: values.frontendRedirectUrl,
},
portalDomainConfig: portal.portalDomainConfig,
portalUiConfig: portal.portalUiConfig,
})
message.success('Portal设置保存成功')
onRefresh?.()
} catch (error) {
message.error('保存Portal设置失败')
} finally {
setLoading(false)
}
}
const handleSettingUpdate = async (key: string, value: any) => {
try {
await portalApi.updatePortal(portal.portalId, {
...portal,
portalSettingConfig: {
...portal.portalSettingConfig,
[key]: value
}
})
message.success('设置已更新')
onRefresh?.()
} catch (error) {
message.error('设置更新失败')
}
}
const handleAddDomain = () => {
setDomainModalVisible(true)
domainForm.resetFields()
}
const handleDomainModalOk = async () => {
try {
setDomainLoading(true)
const values = await domainForm.validateFields()
const newDomain = {
domain: values.domain,
protocol: values.protocol,
type: 'CUSTOM'
}
await portalApi.bindDomain(portal.portalId, newDomain)
message.success('域名绑定成功')
onRefresh?.()
setDomainModalVisible(false)
} catch (error) {
message.error('绑定域名失败')
} finally {
setDomainLoading(false)
}
}
const handleDomainModalCancel = () => {
setDomainModalVisible(false)
domainForm.resetFields()
}
const handleDeleteDomain = async (domain: string) => {
Modal.confirm({
title: '确认解绑',
icon: <ExclamationCircleOutlined/>,
content: `确定要解绑域名 "${domain}" 吗?此操作不可恢复。`,
okText: '确认解绑',
okType: 'danger',
cancelText: '取消',
async onOk() {
try {
await portalApi.unbindDomain(portal.portalId, domain)
message.success('域名解绑成功')
onRefresh?.()
} catch (error) {
message.error('解绑域名失败')
}
},
})
}
// 合并OIDC和OAuth2配置用于统一显示
const thirdPartyAuthConfigs = useMemo((): ThirdPartyAuthConfig[] => {
const configs: ThirdPartyAuthConfig[] = []
// 添加OIDC配置
if (portal.portalSettingConfig?.oidcConfigs) {
portal.portalSettingConfig.oidcConfigs.forEach(oidcConfig => {
configs.push({
...oidcConfig,
type: AuthenticationType.OIDC
})
})
}
// 添加OAuth2配置
if (portal.portalSettingConfig?.oauth2Configs) {
portal.portalSettingConfig.oauth2Configs.forEach(oauth2Config => {
configs.push({
...oauth2Config,
type: AuthenticationType.OAUTH2
})
})
}
return configs
}, [portal.portalSettingConfig?.oidcConfigs, portal.portalSettingConfig?.oauth2Configs])
// 第三方认证配置保存函数
const handleSaveThirdPartyAuth = async (configs: ThirdPartyAuthConfig[]) => {
try {
// 分离OIDC和OAuth2配置,去掉type字段
const oidcConfigs = configs
.filter(config => config.type === AuthenticationType.OIDC)
.map(config => {
const { type, ...oidcConfig } = config as (OidcConfig & { type: AuthenticationType.OIDC })
return oidcConfig
})
const oauth2Configs = configs
.filter(config => config.type === AuthenticationType.OAUTH2)
.map(config => {
const { type, ...oauth2Config } = config as (OAuth2Config & { type: AuthenticationType.OAUTH2 })
return oauth2Config
})
const updateData = {
...portal,
portalSettingConfig: {
...portal.portalSettingConfig,
// 直接保存分离的配置数组
oidcConfigs: oidcConfigs,
oauth2Configs: oauth2Configs
}
}
await portalApi.updatePortal(portal.portalId, updateData)
onRefresh?.()
} catch (error) {
throw error
}
}
// 域名表格列定义
const domainColumns = [
{
title: '域名',
dataIndex: 'domain',
key: 'domain',
},
{
title: '协议',
dataIndex: 'protocol',
key: 'protocol',
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
render: (type: string) => (
<Tag color={type === 'DEFAULT' ? 'blue' : 'green'}>
{type === 'DEFAULT' ? '默认域名' : '自定义域名'}
</Tag>
)
},
{
title: '操作',
key: 'action',
render: (_: any, record: any) => (
<Space>
{record.type === 'CUSTOM' && (
<Button
type="link"
danger
icon={<DeleteOutlined/>}
onClick={() => handleDeleteDomain(record.domain)}
>
解绑
</Button>
)}
</Space>
)
}
]
const tabItems = [
{
key: 'auth',
label: '安全设置',
children: (
<div className="space-y-6">
{/* 基本安全设置 */}
<div className="grid grid-cols-2 gap-6">
<Form.Item
name="builtinAuthEnabled"
label="账号密码登录"
valuePropName="checked"
>
<Switch
onChange={(checked) => handleSettingUpdate('builtinAuthEnabled', checked)}
/>
</Form.Item>
{/* <Form.Item
name="oidcAuthEnabled"
label="OIDC认证"
valuePropName="checked"
>
<Switch
onChange={(checked) => handleSettingUpdate('oidcAuthEnabled', checked)}
/>
</Form.Item> */}
<Form.Item
name="autoApproveDevelopers"
label="开发者自动审批"
valuePropName="checked"
>
<Switch
onChange={(checked) => handleSettingUpdate('autoApproveDevelopers', checked)}
/>
</Form.Item>
<Form.Item
name="autoApproveSubscriptions"
label="订阅自动审批"
valuePropName="checked"
>
<Switch
onChange={(checked) => handleSettingUpdate('autoApproveSubscriptions', checked)}
/>
</Form.Item>
</div>
{/* 第三方认证管理 */}
<Divider/>
<ThirdPartyAuthManager
configs={thirdPartyAuthConfigs}
onSave={handleSaveThirdPartyAuth}
/>
</div>
)
},
{
key: 'domain',
label: '域名管理',
children: (
<div>
<div className="flex justify-between items-center mb-4">
<div>
<h3 className="text-lg font-medium">域名列表</h3>
<p className="text-sm text-gray-500">管理Portal的域名配置</p>
</div>
<Button
type="primary"
icon={<PlusOutlined/>}
onClick={handleAddDomain}
>
绑定域名
</Button>
</div>
<Table
columns={domainColumns}
dataSource={portal.portalDomainConfig || []}
rowKey="domain"
pagination={false}
size="small"
/>
</div>
)
}
]
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold mb-2">Portal设置</h1>
<p className="text-gray-600">配置Portal的基本设置和高级选项</p>
</div>
<Space>
<Button type="primary" icon={<SaveOutlined/>} loading={loading} onClick={handleSave}>
保存设置
</Button>
</Space>
</div>
<Form
form={form}
layout="vertical"
initialValues={{
portalSettingConfig: portal.portalSettingConfig,
builtinAuthEnabled: portal.portalSettingConfig?.builtinAuthEnabled,
oidcAuthEnabled: portal.portalSettingConfig?.oidcAuthEnabled,
autoApproveDevelopers: portal.portalSettingConfig?.autoApproveDevelopers,
autoApproveSubscriptions: portal.portalSettingConfig?.autoApproveSubscriptions,
frontendRedirectUrl: portal.portalSettingConfig?.frontendRedirectUrl,
portalDomainConfig: portal.portalDomainConfig,
}}
>
<Card>
<Tabs
items={tabItems}
defaultActiveKey="auth"
type="card"
/>
</Card>
</Form>
{/* 域名绑定模态框 */}
<Modal
title="绑定域名"
open={domainModalVisible}
onOk={handleDomainModalOk}
onCancel={handleDomainModalCancel}
confirmLoading={domainLoading}
okText="绑定"
cancelText="取消"
>
<Form
form={domainForm}
layout="vertical"
>
<Form.Item
name="domain"
label="域名"
rules={[
{required: true, message: '请输入域名'},
{
pattern: /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/,
message: '请输入有效的域名格式'
}
]}
>
<Input placeholder="example.com"/>
</Form.Item>
<Form.Item
name="protocol"
label="协议"
rules={[{required: true, message: '请选择协议'}]}
>
<Select placeholder="请选择协议">
<Select.Option value="HTTP">HTTP</Select.Option>
<Select.Option value="HTTPS">HTTPS</Select.Option>
</Select>
</Form.Item>
</Form>
</Modal>
</div>
)
}
```
--------------------------------------------------------------------------------
/portal-web/api-portal-admin/src/pages/Portals.tsx:
--------------------------------------------------------------------------------
```typescript
import { useState, useCallback, memo, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import {
Button,
Card,
Avatar,
Dropdown,
Modal,
Form,
Input,
message,
Tooltip,
Pagination,
Skeleton,
} from "antd";
import { PlusOutlined, MoreOutlined, LinkOutlined } from "@ant-design/icons";
import type { MenuProps } from "antd";
import { portalApi } from "../lib/api";
import { Portal } from '@/types'
// 优化的Portal卡片组件
const PortalCard = memo(
({
portal,
onNavigate,
fetchPortals,
}: {
portal: Portal;
onNavigate: (id: string) => void;
fetchPortals: () => void;
}) => {
const handleCardClick = useCallback(() => {
onNavigate(portal.portalId);
}, [portal.portalId, onNavigate]);
const handleLinkClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
}, []);
const dropdownItems: MenuProps["items"] = [
{
key: "delete",
label: "删除",
danger: true,
onClick: (e) => {
e?.domEvent?.stopPropagation(); // 阻止事件冒泡
Modal.confirm({
title: "删除Portal",
content: "确定要删除该Portal吗?",
onOk: () => {
return handleDeletePortal(portal.portalId);
},
});
},
},
];
const handleDeletePortal = useCallback((portalId: string) => {
return portalApi.deletePortal(portalId).then(() => {
message.success("Portal删除成功");
fetchPortals();
}).catch((error) => {
message.error(error?.response?.data?.message || "删除失败,请稍后重试");
throw error;
});
}, [fetchPortals]);
return (
<Card
className="cursor-pointer hover:shadow-xl transition-all duration-300 hover:scale-[1.02] border border-gray-100 hover:border-blue-300 bg-gradient-to-br from-white to-gray-50/30"
onClick={handleCardClick}
bodyStyle={{ padding: "20px" }}
>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-4">
<div className="relative">
<Avatar
size={48}
className="bg-gradient-to-br from-blue-500 to-blue-600 shadow-lg"
style={{ fontSize: "18px", fontWeight: "600" }}
>
{portal.title.charAt(0).toUpperCase()}
</Avatar>
<div className="absolute -bottom-1 -right-1 w-4 h-4 bg-green-400 rounded-full border-2 border-white"></div>
</div>
<div>
<h3 className="text-xl font-bold text-gray-800 mb-1">
{portal.title}
</h3>
<p className="text-sm text-gray-500">{portal.description}</p>
</div>
</div>
<Dropdown menu={{ items: dropdownItems }} trigger={["click"]}>
<Button
type="text"
icon={<MoreOutlined />}
onClick={(e) => e.stopPropagation()}
className="hover:bg-gray-100 rounded-full"
/>
</Dropdown>
</div>
<div className="space-y-6">
<div className="flex items-center space-x-3 p-3 bg-blue-50 rounded-lg border border-blue-100">
<LinkOutlined className="h-4 w-4 text-blue-500" />
<Tooltip
title={portal.portalDomainConfig?.[0].domain}
placement="top"
color="#000"
>
<a
href={`http://${portal.portalDomainConfig?.[0].domain}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-700 font-medium text-sm"
onClick={handleLinkClick}
style={{
display: "inline-block",
maxWidth: 200,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
verticalAlign: "bottom",
cursor: "pointer",
}}
>
{portal.portalDomainConfig?.[0].domain}
</a>
</Tooltip>
</div>
<div className="space-y-3">
{/* 第一行:账号密码登录 + 开发者自动审批 */}
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center justify-between p-2 bg-gray-50 rounded-md">
<span className="text-xs font-medium text-gray-600">
账号密码登录
</span>
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
portal.portalSettingConfig?.builtinAuthEnabled
? "bg-green-100 text-green-700"
: "bg-red-100 text-red-700"
}`}
>
{portal.portalSettingConfig?.builtinAuthEnabled
? "支持"
: "不支持"}
</span>
</div>
<div className="flex items-center justify-between p-2 bg-gray-50 rounded-md">
<span className="text-xs font-medium text-gray-600">
开发者自动审批
</span>
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
portal.portalSettingConfig?.autoApproveDevelopers
? "bg-green-100 text-green-700"
: "bg-yellow-100 text-yellow-700"
}`}
>
{portal.portalSettingConfig?.autoApproveDevelopers
? "是"
: "否"}
</span>
</div>
</div>
{/* 第二行:订阅自动审批 + 域名配置 */}
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center justify-between p-2 bg-gray-50 rounded-md">
<span className="text-xs font-medium text-gray-600">
订阅自动审批
</span>
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
portal.portalSettingConfig?.autoApproveSubscriptions
? "bg-green-100 text-green-700"
: "bg-yellow-100 text-yellow-700"
}`}
>
{portal.portalSettingConfig?.autoApproveSubscriptions
? "是"
: "否"}
</span>
</div>
<div className="flex items-center justify-between p-2 bg-gray-50 rounded-md">
<span className="text-xs font-medium text-gray-600">
域名配置
</span>
<span className="px-2 py-1 bg-purple-100 text-purple-700 rounded-full text-xs font-medium">
{portal.portalDomainConfig?.length || 0}个
</span>
</div>
</div>
</div>
<div className="text-center pt-4 border-t border-gray-100">
<div className="inline-flex items-center space-x-2 text-sm text-gray-500 hover:text-blue-600 transition-colors">
<span onClick={handleCardClick}>点击查看详情</span>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
</div>
</Card>
);
}
);
PortalCard.displayName = "PortalCard";
export default function Portals() {
const navigate = useNavigate();
const [portals, setPortals] = useState<Portal[]>([]);
const [loading, setLoading] = useState<boolean>(true); // 初始状态为 loading
const [error, setError] = useState<string | null>(null);
const [isModalVisible, setIsModalVisible] = useState<boolean>(false);
const [form] = Form.useForm();
const [pagination, setPagination] = useState({
current: 1,
pageSize: 12,
total: 0,
});
const fetchPortals = useCallback((page = 1, size = 12) => {
setLoading(true);
portalApi.getPortals({ page, size }).then((res: any) => {
const list = res?.data?.content || [];
const portals: Portal[] = list.map((item: any) => ({
portalId: item.portalId,
name: item.name,
title: item.name,
description: item.description,
adminId: item.adminId,
portalSettingConfig: item.portalSettingConfig,
portalUiConfig: item.portalUiConfig,
portalDomainConfig: item.portalDomainConfig || [],
}));
setPortals(portals);
setPagination({
current: page,
pageSize: size,
total: res?.data?.totalElements || 0,
});
}).catch((err: any) => {
setError(err?.message || "加载失败");
}).finally(() => {
setLoading(false);
});
}, []);
useEffect(() => {
setError(null);
fetchPortals(1, 12);
}, [fetchPortals]);
// 处理分页变化
const handlePaginationChange = (page: number, pageSize: number) => {
fetchPortals(page, pageSize);
};
const handleCreatePortal = useCallback(() => {
setIsModalVisible(true);
}, []);
const handleModalOk = useCallback(async () => {
try {
const values = await form.validateFields();
setLoading(true);
const newPortal = {
name: values.name,
title: values.title,
description: values.description,
};
await portalApi.createPortal(newPortal);
message.success("Portal创建成功");
setIsModalVisible(false);
form.resetFields();
fetchPortals()
} catch (error: any) {
// message.error(error?.message || "创建失败");
} finally {
setLoading(false);
}
}, [form]);
const handleModalCancel = useCallback(() => {
setIsModalVisible(false);
form.resetFields();
}, [form]);
const handlePortalClick = useCallback(
(portalId: string) => {
navigate(`/portals/detail?id=${portalId}`);
},
[navigate]
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Portal</h1>
<p className="text-gray-500 mt-2">管理和配置您的开发者门户</p>
</div>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleCreatePortal}
>
创建 Portal
</Button>
</div>
{error && <div className="text-red-500">{error}</div>}
{loading ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: pagination.pageSize || 12 }).map((_, index) => (
<div key={index} className="h-full rounded-lg shadow-lg bg-white p-4">
<div className="flex items-start space-x-4">
<Skeleton.Avatar size={48} active />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
<Skeleton.Input active size="small" style={{ width: 120 }} />
<Skeleton.Input active size="small" style={{ width: 60 }} />
</div>
<Skeleton.Input active size="small" style={{ width: '100%', marginBottom: 12 }} />
<Skeleton.Input active size="small" style={{ width: '80%', marginBottom: 8 }} />
<div className="flex items-center justify-between">
<Skeleton.Input active size="small" style={{ width: 60 }} />
<Skeleton.Input active size="small" style={{ width: 80 }} />
</div>
</div>
</div>
</div>
))}
</div>
) : (
<>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{portals.map((portal) => (
<PortalCard
key={portal.portalId}
portal={portal}
onNavigate={handlePortalClick}
fetchPortals={() => fetchPortals(pagination.current, pagination.pageSize)}
/>
))}
</div>
{pagination.total > 0 && (
<div className="flex justify-center mt-6">
<Pagination
current={pagination.current}
pageSize={pagination.pageSize}
total={pagination.total}
onChange={handlePaginationChange}
showSizeChanger
showQuickJumper
showTotal={(total) => `共 ${total} 条`}
pageSizeOptions={['6', '12', '24', '48']}
/>
</div>
)}
</>
)}
<Modal
title="创建Portal"
open={isModalVisible}
onOk={handleModalOk}
onCancel={handleModalCancel}
confirmLoading={loading}
width={600}
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="名称"
rules={[{ required: true, message: "请输入Portal名称" }]}
>
<Input placeholder="请输入Portal名称" />
</Form.Item>
{/* <Form.Item
name="title"
label="标题"
rules={[{ required: true, message: "请输入Portal标题" }]}
>
<Input placeholder="请输入Portal标题" />
</Form.Item> */}
<Form.Item
name="description"
label="描述"
rules={[{ message: "请输入描述" }]}
>
<Input.TextArea rows={3} placeholder="请输入Portal描述" />
</Form.Item>
</Form>
</Modal>
</div>
);
}
```
--------------------------------------------------------------------------------
/portal-server/src/main/java/com/alibaba/apiopenplatform/service/impl/DeveloperServiceImpl.java:
--------------------------------------------------------------------------------
```java
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.alibaba.apiopenplatform.service.impl;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.apiopenplatform.core.constant.Resources;
import com.alibaba.apiopenplatform.core.event.DeveloperDeletingEvent;
import com.alibaba.apiopenplatform.core.event.PortalDeletingEvent;
import com.alibaba.apiopenplatform.core.utils.TokenUtil;
import com.alibaba.apiopenplatform.dto.params.developer.CreateDeveloperParam;
import com.alibaba.apiopenplatform.dto.params.developer.CreateExternalDeveloperParam;
import com.alibaba.apiopenplatform.dto.params.developer.QueryDeveloperParam;
import com.alibaba.apiopenplatform.dto.params.developer.UpdateDeveloperParam;
import com.alibaba.apiopenplatform.dto.result.AuthResult;
import com.alibaba.apiopenplatform.dto.result.DeveloperResult;
import com.alibaba.apiopenplatform.dto.result.PageResult;
import com.alibaba.apiopenplatform.entity.Developer;
import com.alibaba.apiopenplatform.entity.Portal;
import com.alibaba.apiopenplatform.repository.DeveloperRepository;
import com.alibaba.apiopenplatform.repository.PortalRepository;
import com.alibaba.apiopenplatform.service.DeveloperService;
import com.alibaba.apiopenplatform.core.utils.PasswordHasher;
import com.alibaba.apiopenplatform.core.utils.IdGenerator;
import com.alibaba.apiopenplatform.repository.DeveloperExternalIdentityRepository;
import com.alibaba.apiopenplatform.entity.DeveloperExternalIdentity;
import com.alibaba.apiopenplatform.support.enums.DeveloperAuthType;
import com.alibaba.apiopenplatform.support.enums.DeveloperStatus;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.event.EventListener;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.alibaba.apiopenplatform.core.exception.BusinessException;
import com.alibaba.apiopenplatform.core.exception.ErrorCode;
import com.alibaba.apiopenplatform.core.security.ContextHolder;
import javax.persistence.criteria.Predicate;
import java.util.*;
import javax.servlet.http.HttpServletRequest;
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class DeveloperServiceImpl implements DeveloperService {
private final DeveloperRepository developerRepository;
private final DeveloperExternalIdentityRepository externalRepository;
private final PortalRepository portalRepository;
private final ContextHolder contextHolder;
private final ApplicationEventPublisher eventPublisher;
@Override
public AuthResult registerDeveloper(CreateDeveloperParam param) {
DeveloperResult developer = createDeveloper(param);
// 检查是否自动审批
String portalId = contextHolder.getPortal();
Portal portal = findPortal(portalId);
boolean autoApprove = portal.getPortalSettingConfig() != null
&& BooleanUtil.isTrue(portal.getPortalSettingConfig().getAutoApproveDevelopers());
if (autoApprove) {
String token = generateToken(developer.getDeveloperId());
return AuthResult.of(token, TokenUtil.getTokenExpiresIn());
}
return null;
}
@Override
public DeveloperResult createDeveloper(CreateDeveloperParam param) {
String portalId = contextHolder.getPortal();
developerRepository.findByPortalIdAndUsername(portalId, param.getUsername()).ifPresent(developer -> {
throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.DEVELOPER, param.getUsername()));
});
Developer developer = param.convertTo();
developer.setDeveloperId(generateDeveloperId());
developer.setPortalId(portalId);
developer.setPasswordHash(PasswordHasher.hash(param.getPassword()));
Portal portal = findPortal(portalId);
boolean autoApprove = portal.getPortalSettingConfig() != null
&& BooleanUtil.isTrue(portal.getPortalSettingConfig().getAutoApproveDevelopers());
developer.setStatus(autoApprove ? DeveloperStatus.APPROVED : DeveloperStatus.PENDING);
developer.setAuthType(DeveloperAuthType.BUILTIN);
developerRepository.save(developer);
return new DeveloperResult().convertFrom(developer);
}
@Override
public AuthResult login(String username, String password) {
String portalId = contextHolder.getPortal();
Developer developer = developerRepository.findByPortalIdAndUsername(portalId, username)
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.DEVELOPER, username));
if (!DeveloperStatus.APPROVED.equals(developer.getStatus())) {
throw new BusinessException(ErrorCode.INVALID_REQUEST, "账号审批中");
}
if (!PasswordHasher.verify(password, developer.getPasswordHash())) {
throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户名或密码错误");
}
String token = generateToken(developer.getDeveloperId());
return AuthResult.builder()
.accessToken(token)
.expiresIn(TokenUtil.getTokenExpiresIn())
.build();
}
@Override
public void existsDeveloper(String developerId) {
developerRepository.findByDeveloperId(developerId)
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.DEVELOPER, developerId));
}
@Override
public DeveloperResult createExternalDeveloper(CreateExternalDeveloperParam param) {
Developer developer = Developer.builder()
.developerId(IdGenerator.genDeveloperId())
.portalId(contextHolder.getPortal())
.username(buildExternalName(param.getProvider(), param.getDisplayName()))
.email(param.getEmail())
// 默认APPROVED
.status(DeveloperStatus.APPROVED)
.build();
DeveloperExternalIdentity externalIdentity = DeveloperExternalIdentity.builder()
.provider(param.getProvider())
.subject(param.getSubject())
.displayName(param.getDisplayName())
.authType(param.getAuthType())
.developer(developer)
.build();
developerRepository.save(developer);
externalRepository.save(externalIdentity);
return new DeveloperResult().convertFrom(developer);
}
@Override
public DeveloperResult getExternalDeveloper(String provider, String subject) {
return externalRepository.findByProviderAndSubject(provider, subject)
.map(o -> new DeveloperResult().convertFrom(o.getDeveloper()))
.orElse(null);
}
private String buildExternalName(String provider, String subject) {
return StrUtil.format("{}_{}", provider, subject);
}
@Override
public void deleteDeveloper(String developerId) {
eventPublisher.publishEvent(new DeveloperDeletingEvent(developerId));
externalRepository.deleteByDeveloper_DeveloperId(developerId);
developerRepository.findByDeveloperId(developerId).ifPresent(developerRepository::delete);
}
@Override
public DeveloperResult getDeveloper(String developerId) {
Developer developer = findDeveloper(developerId);
return new DeveloperResult().convertFrom(developer);
}
@Override
public PageResult<DeveloperResult> listDevelopers(QueryDeveloperParam param, Pageable pageable) {
if (contextHolder.isDeveloper()) {
param.setPortalId(contextHolder.getPortal());
}
Page<Developer> developers = developerRepository.findAll(buildSpecification(param), pageable);
return new PageResult<DeveloperResult>().convertFrom(developers, developer -> new DeveloperResult().convertFrom(developer));
}
@Override
public void setDeveloperStatus(String developerId, DeveloperStatus status) {
Developer developer = findDeveloper(developerId);
developer.setStatus(status);
developerRepository.save(developer);
}
@Override
@Transactional
public boolean resetPassword(String developerId, String oldPassword, String newPassword) {
Developer developer = findDeveloper(developerId);
if (!PasswordHasher.verify(oldPassword, developer.getPasswordHash())) {
throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户名或密码错误");
}
developer.setPasswordHash(PasswordHasher.hash(newPassword));
developerRepository.save(developer);
return true;
}
@Override
public boolean updateProfile(UpdateDeveloperParam param) {
Developer developer = findDeveloper(contextHolder.getUser());
String username = param.getUsername();
if (username != null && !username.equals(developer.getUsername())) {
if (developerRepository.findByPortalIdAndUsername(developer.getPortalId(), username).isPresent()) {
throw new BusinessException(ErrorCode.CONFLICT, StrUtil.format("{}:{}已存在", Resources.DEVELOPER, username));
}
}
param.update(developer);
developerRepository.save(developer);
return true;
}
@EventListener
@Async("taskExecutor")
public void handlePortalDeletion(PortalDeletingEvent event) {
String portalId = event.getPortalId();
List<Developer> developers = developerRepository.findByPortalId(portalId);
developers.forEach(developer -> deleteDeveloper(developer.getDeveloperId()));
}
private String generateToken(String developerId) {
return TokenUtil.generateDeveloperToken(developerId);
}
private Developer createExternalDeveloper(String providerName, String providerSubject, String email, String displayName, String rawInfoJson) {
String portalId = contextHolder.getPortal();
String username = generateUniqueUsername(portalId, displayName, providerName, providerSubject);
Developer developer = Developer.builder()
.developerId(generateDeveloperId())
.portalId(portalId)
.username(username)
.email(email)
.status(DeveloperStatus.APPROVED)
.authType(DeveloperAuthType.OIDC)
.build();
developer = developerRepository.save(developer);
DeveloperExternalIdentity ext = DeveloperExternalIdentity.builder()
.provider(providerName)
.subject(providerSubject)
.displayName(displayName)
.rawInfoJson(rawInfoJson)
.developer(developer)
.build();
externalRepository.save(ext);
return developer;
}
private String generateUniqueUsername(String portalId, String displayName, String providerName, String providerSubject) {
String username = displayName != null ? displayName : providerName + "_" + providerSubject;
String originalUsername = username;
int suffix = 1;
while (developerRepository.findByPortalIdAndUsername(portalId, username).isPresent()) {
username = originalUsername + "_" + suffix;
suffix++;
}
return username;
}
private String generateDeveloperId() {
return IdGenerator.genDeveloperId();
}
private Developer findDeveloper(String developerId) {
return developerRepository.findByDeveloperId(developerId)
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.DEVELOPER, developerId));
}
private Portal findPortal(String portalId) {
return portalRepository.findByPortalId(portalId)
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.PORTAL, portalId));
}
private Specification<Developer> buildSpecification(QueryDeveloperParam param) {
return (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (StrUtil.isNotBlank(param.getPortalId())) {
predicates.add(cb.equal(root.get("portalId"), param.getPortalId()));
}
if (StrUtil.isNotBlank(param.getUsername())) {
String likePattern = "%" + param.getUsername() + "%";
predicates.add(cb.like(root.get("username"), likePattern));
}
if (param.getStatus() != null) {
predicates.add(cb.equal(root.get("status"), param.getStatus()));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
}
@Override
public void logout(HttpServletRequest request) {
// 使用TokenUtil处理登出逻辑
com.alibaba.apiopenplatform.core.utils.TokenUtil.revokeToken(request);
}
@Override
public DeveloperResult getCurrentDeveloperInfo() {
String currentUserId = contextHolder.getUser();
Developer developer = findDeveloper(currentUserId);
return new DeveloperResult().convertFrom(developer);
}
@Override
public boolean changeCurrentDeveloperPassword(String oldPassword, String newPassword) {
String currentUserId = contextHolder.getUser();
return resetPassword(currentUserId, oldPassword, newPassword);
}
}
```
--------------------------------------------------------------------------------
/portal-web/api-portal-admin/src/components/console/ImportGatewayModal.tsx:
--------------------------------------------------------------------------------
```typescript
import { useState } from 'react'
import { Button, Table, Modal, Form, Input, message, Select } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import { gatewayApi } from '@/lib/api'
import { Gateway, ApigConfig } from '@/types'
import { getGatewayTypeLabel } from '@/lib/constant'
interface ImportGatewayModalProps {
visible: boolean
gatewayType: 'APIG_API' | 'APIG_AI' | 'ADP_AI_GATEWAY' | 'APSARA_GATEWAY'
onCancel: () => void
onSuccess: () => void
}
export default function ImportGatewayModal({ visible, gatewayType, onCancel, onSuccess }: ImportGatewayModalProps) {
const [importForm] = Form.useForm()
const [gatewayLoading, setGatewayLoading] = useState(false)
const [gatewayList, setGatewayList] = useState<Gateway[]>([])
const [selectedGateway, setSelectedGateway] = useState<Gateway | null>(null)
const [apigConfig, setApigConfig] = useState<ApigConfig>({
region: '',
accessKey: '',
secretKey: '',
})
const [gatewayPagination, setGatewayPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
})
// 监听表单中的认证方式,确保切换时联动渲染
const authType = Form.useWatch('authType', importForm)
// 获取网关列表
const fetchGateways = async (values: any, page = 1, size = 10) => {
setGatewayLoading(true)
try {
const res = await gatewayApi.getApigGateway({
...values,
page,
size,
})
setGatewayList(res.data?.content || [])
setGatewayPagination({
current: page,
pageSize: size,
total: res.data?.totalElements || 0,
})
} catch {
// message.error('获取网关列表失败')
} finally {
setGatewayLoading(false)
}
}
const fetchAdpGateways = async (values: any, page = 1, size = 50) => {
setGatewayLoading(true)
try {
const res = await gatewayApi.getAdpGateways({...values, page, size})
setGatewayList(res.data?.content || [])
setGatewayPagination({
current: page,
pageSize: size,
total: res.data?.totalElements || 0,
})
} catch {
// message.error('获取网关列表失败')
} finally {
setGatewayLoading(false)
}
}
const fetchApsaraGateways = async (values: any, page = 1, size = 50) => {
setGatewayLoading(true)
try {
const res = await gatewayApi.getApsaraGateways({...values, page, size})
setGatewayList(res.data?.content || [])
setGatewayPagination({
current: page,
pageSize: size,
total: res.data?.totalElements || 0,
})
} finally {
setGatewayLoading(false)
}
}
// 处理网关选择
const handleGatewaySelect = (gateway: Gateway) => {
setSelectedGateway(gateway)
}
// 处理网关分页变化
const handleGatewayPaginationChange = (page: number, pageSize: number) => {
const values = importForm.getFieldsValue()
const data = JSON.parse(sessionStorage.getItem('importFormConfig') || '');
if (JSON.stringify(values) === '{}') {
fetchGateways({...data, gatewayType: gatewayType}, page, pageSize)
} else {
fetchGateways({...values, gatewayType: gatewayType}, page, pageSize)
}
}
// 处理导入
const handleImport = () => {
if (!selectedGateway) {
message.warning('请选择一个Gateway')
return
}
const payload: any = {
...selectedGateway,
gatewayType: gatewayType,
}
if (gatewayType === 'ADP_AI_GATEWAY') {
payload.adpAIGatewayConfig = apigConfig
} else if (gatewayType === 'APSARA_GATEWAY') {
payload.apsaraGatewayConfig = apigConfig
} else {
payload.apigConfig = apigConfig
}
gatewayApi.importGateway(payload).then(() => {
message.success('导入成功!')
handleCancel()
onSuccess()
}).catch(() => {
// message.error(error.response?.data?.message || '导入失败!')
})
}
// 重置弹窗状态
const handleCancel = () => {
setSelectedGateway(null)
setGatewayList([])
setGatewayPagination({ current: 1, pageSize: 10, total: 0 })
importForm.resetFields()
onCancel()
}
return (
<Modal
title="导入网关实例"
open={visible}
onCancel={handleCancel}
footer={null}
width={800}
>
<Form form={importForm} layout="vertical" preserve={false}>
{gatewayList.length === 0 && ['APIG_API', 'APIG_AI'].includes(gatewayType) && (
<div className="mb-4">
<h3 className="text-lg font-medium mb-3">认证信息</h3>
<Form.Item label="Region" name="region" rules={[{ required: true, message: '请输入region' }]}>
<Input />
</Form.Item>
<Form.Item label="Access Key" name="accessKey" rules={[{ required: true, message: '请输入accessKey' }]}>
<Input />
</Form.Item>
<Form.Item label="Secret Key" name="secretKey" rules={[{ required: true, message: '请输入secretKey' }]}>
<Input.Password />
</Form.Item>
<Button
type="primary"
onClick={() => {
importForm.validateFields().then((values) => {
setApigConfig(values)
sessionStorage.setItem('importFormConfig', JSON.stringify(values))
fetchGateways({...values, gatewayType: gatewayType})
})
}}
loading={gatewayLoading}
>
获取网关列表
</Button>
</div>
)}
{['ADP_AI_GATEWAY'].includes(gatewayType) && gatewayList.length === 0 && (
<div className="mb-4">
<h3 className="text-lg font-medium mb-3">认证信息</h3>
<Form.Item label="服务地址" name="baseUrl" rules={[{ required: true, message: '请输入服务地址' }, { pattern: /^https?:\/\//i, message: '必须以 http:// 或 https:// 开头' }]}>
<Input placeholder="如:http://apigateway.example.com 或者 http://10.236.6.144" />
</Form.Item>
<Form.Item
label="端口"
name="port"
initialValue={80}
rules={[
{ required: true, message: '请输入端口号' },
{
validator: (_, value) => {
if (value === undefined || value === null || value === '') return Promise.resolve()
const n = Number(value)
return n >= 1 && n <= 65535 ? Promise.resolve() : Promise.reject(new Error('端口范围需在 1-65535'))
}
}
]}
>
<Input type="text" placeholder="如:8080" />
</Form.Item>
<Form.Item
label="认证方式"
name="authType"
initialValue="Seed"
rules={[{ required: true, message: '请选择认证方式' }]}
>
<Select>
<Select.Option value="Seed">Seed</Select.Option>
<Select.Option value="Header">固定Header</Select.Option>
</Select>
</Form.Item>
{authType === 'Seed' && (
<Form.Item label="Seed" name="authSeed" rules={[{ required: true, message: '请输入Seed' }]}>
<Input placeholder="通过configmap获取" />
</Form.Item>
)}
{authType === 'Header' && (
<Form.Item label="Headers">
<Form.List name="authHeaders" initialValue={[{ key: '', value: '' }]}>
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<div key={key} style={{ display: 'flex', marginBottom: 8, alignItems: 'center' }}>
<Form.Item
{...restField}
name={[name, 'key']}
rules={[{ required: true, message: '请输入Header名称' }]}
style={{ flex: 1, marginRight: 8, marginBottom: 0 }}
>
<Input placeholder="Header名称,如:X-Auth-Token" />
</Form.Item>
<Form.Item
{...restField}
name={[name, 'value']}
rules={[{ required: true, message: '请输入Header值' }]}
style={{ flex: 1, marginRight: 8, marginBottom: 0 }}
>
<Input placeholder="Header值" />
</Form.Item>
{fields.length > 1 && (
<Button
type="text"
danger
onClick={() => remove(name)}
style={{ marginBottom: 0 }}
>
删除
</Button>
)}
</div>
))}
<Form.Item style={{ marginBottom: 0 }}>
<Button
type="dashed"
onClick={() => add({ key: '', value: '' })}
block
icon={<PlusOutlined />}
>
添加Header
</Button>
</Form.Item>
</>
)}
</Form.List>
</Form.Item>
)}
<Button
type="primary"
onClick={() => {
importForm.validateFields().then((values) => {
// 处理认证参数
const processedValues = { ...values };
// 根据认证方式设置相应的参数
if (values.authType === 'Seed') {
processedValues.authSeed = values.authSeed;
delete processedValues.authHeaders;
} else if (values.authType === 'Header') {
processedValues.authHeaders = values.authHeaders;
delete processedValues.authSeed;
}
setApigConfig(processedValues)
sessionStorage.setItem('importFormConfig', JSON.stringify(processedValues))
fetchAdpGateways({...processedValues, gatewayType: gatewayType})
})
}}
loading={gatewayLoading}
>
获取网关列表
</Button>
</div>
)}
{gatewayList.length === 0 && gatewayType === 'APSARA_GATEWAY' && (
<div className="mb-4">
<h3 className="text-lg font-medium mb-3">Apsara 认证与路由</h3>
<Form.Item label="RegionId" name="regionId" rules={[{ required: true, message: '请输入RegionId' }]}>
<Input />
</Form.Item>
<Form.Item label="AccessKeyId" name="accessKeyId" rules={[{ required: true, message: '请输入AccessKeyId' }]}>
<Input />
</Form.Item>
<Form.Item label="AccessKeySecret" name="accessKeySecret" rules={[{ required: true, message: '请输入AccessKeySecret' }]}>
<Input.Password />
</Form.Item>
<Form.Item label="SecurityToken(可选)" name="securityToken">
<Input />
</Form.Item>
<Form.Item label="Domain" name="domain" rules={[{ required: true, message: '请输入Domain' }]}>
<Input placeholder="csb-cop-api-biz.inter.envXX.example.com" />
</Form.Item>
<Form.Item label="Product" name="product" rules={[{ required: true, message: '请输入Product' }]} initialValue="csb2">
<Input />
</Form.Item>
<Form.Item label="Version" name="version" rules={[{ required: true, message: '请输入Version' }]} initialValue="2023-02-06">
<Input />
</Form.Item>
<Form.Item label="x-acs-organizationid" name="xAcsOrganizationId" rules={[{ required: true, message: '请输入组织ID' }]}>
<Input />
</Form.Item>
<Form.Item label="x-acs-caller-sdk-source" name="xAcsCallerSdkSource">
<Input />
</Form.Item>
<Form.Item label="x-acs-resourcegroupid(可选)" name="xAcsResourceGroupId">
<Input />
</Form.Item>
<Form.Item label="x-acs-caller-type(可选)" name="xAcsCallerType">
<Input />
</Form.Item>
<div className="flex gap-2">
<Button
onClick={() => {
try {
const raw = localStorage.getItem('apsaraImportConfig')
if (!raw) {
message.info('暂无历史参数')
return
}
const data = JSON.parse(raw)
importForm.setFieldsValue(data)
message.success('已填充上次参数')
} catch {
message.error('读取历史参数失败')
}
}}
>
填充上次参数
</Button>
<Button
type="primary"
onClick={() => {
importForm.validateFields().then((values) => {
setApigConfig(values)
sessionStorage.setItem('importFormConfig', JSON.stringify(values))
localStorage.setItem('apsaraImportConfig', JSON.stringify(values))
// APSARA 需要远程拉取列表
fetchApsaraGateways({...values, gatewayType: 'APSARA_GATEWAY'})
})
}}
>
使用以上配置继续
</Button>
</div>
</div>
)}
{gatewayList.length > 0 && (
<div className="mb-4">
<h3 className="text-lg font-medium mb-3">选择网关实例</h3>
<Table
rowKey="gatewayId"
columns={[
{ title: 'ID', dataIndex: 'gatewayId' },
{
title: '类型',
dataIndex: 'gatewayType',
render: (gatewayType: string) => getGatewayTypeLabel(gatewayType as any)
},
{ title: '名称', dataIndex: 'gatewayName' },
]}
dataSource={gatewayList}
rowSelection={{
type: 'radio',
selectedRowKeys: selectedGateway ? [selectedGateway.gatewayId] : [],
onChange: (_selectedRowKeys, selectedRows) => {
handleGatewaySelect(selectedRows[0])
},
}}
pagination={{
current: gatewayPagination.current,
pageSize: gatewayPagination.pageSize,
total: gatewayPagination.total,
onChange: handleGatewayPaginationChange,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `共 ${total} 条`,
}}
size="small"
/>
</div>
)}
{selectedGateway && (
<div className="text-right">
<Button type="primary" onClick={handleImport}>
完成导入
</Button>
</div>
)}
</Form>
</Modal>
)
}
```
--------------------------------------------------------------------------------
/portal-web/api-portal-frontend/src/pages/ApiDetail.tsx:
--------------------------------------------------------------------------------
```typescript
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { Card, Alert, Row, Col, Tabs } from "antd";
import { Layout } from "../components/Layout";
import { ProductHeader } from "../components/ProductHeader";
import { SwaggerUIWrapper } from "../components/SwaggerUIWrapper";
import api from "../lib/api";
import type { Product, ApiResponse } from "../types";
import ReactMarkdown from "react-markdown";
import remarkGfm from 'remark-gfm';
import 'react-markdown-editor-lite/lib/index.css';
import * as yaml from 'js-yaml';
import { Button, Typography, Space, Divider, message } from "antd";
import { CopyOutlined, RocketOutlined, DownloadOutlined } from "@ant-design/icons";
const { Title, Paragraph } = Typography;
interface UpdatedProduct extends Omit<Product, 'apiSpec'> {
apiConfig?: {
spec: string;
meta: {
source: string;
type: string;
};
};
createAt: string;
enabled: boolean;
}
function ApiDetailPage() {
const { id } = useParams();
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [apiData, setApiData] = useState<UpdatedProduct | null>(null);
const [baseUrl, setBaseUrl] = useState<string>('');
const [examplePath, setExamplePath] = useState<string>('/{path}');
const [exampleMethod, setExampleMethod] = useState<string>('GET');
useEffect(() => {
if (!id) return;
fetchApiDetail();
}, [id]);
const fetchApiDetail = async () => {
setLoading(true);
setError('');
try {
const response: ApiResponse<UpdatedProduct> = await api.get(`/products/${id}`);
if (response.code === "SUCCESS" && response.data) {
setApiData(response.data);
// 提取基础URL和示例路径用于curl示例
if (response.data.apiConfig?.spec) {
try {
let openApiDoc: any;
try {
openApiDoc = yaml.load(response.data.apiConfig.spec);
} catch {
openApiDoc = JSON.parse(response.data.apiConfig.spec);
}
// 提取服务器URL并处理尾部斜杠
let serverUrl = openApiDoc?.servers?.[0]?.url || '';
if (serverUrl && serverUrl.endsWith('/')) {
serverUrl = serverUrl.slice(0, -1); // 移除末尾的斜杠
}
setBaseUrl(serverUrl);
// 提取第一个可用的路径和方法作为示例
const paths = openApiDoc?.paths;
if (paths && typeof paths === 'object') {
const pathEntries = Object.entries(paths);
if (pathEntries.length > 0) {
const [firstPath, pathMethods] = pathEntries[0] as [string, any];
if (pathMethods && typeof pathMethods === 'object') {
const methods = Object.keys(pathMethods);
if (methods.length > 0) {
const firstMethod = methods[0].toUpperCase();
setExamplePath(firstPath);
setExampleMethod(firstMethod);
}
}
}
}
} catch (error) {
console.error('解析OpenAPI规范失败:', error);
}
}
}
} catch (error) {
console.error('获取API详情失败:', error);
setError('加载失败,请稍后重试');
} finally {
setLoading(false);
}
};
if (error) {
return (
<Layout loading={loading}>
<Alert message={error} type="error" showIcon className="my-8" />
</Layout>
);
}
if (!apiData) {
return (
<Layout loading={loading}>
<Alert message="未找到API信息" type="warning" showIcon className="my-8" />
</Layout>
);
}
return (
<Layout loading={loading}>
<div className="mb-6">
<ProductHeader
name={apiData.name}
description={apiData.description}
icon={apiData.icon}
defaultIcon="/logo.svg"
updatedAt={apiData.updatedAt}
productType="REST_API"
/>
<hr className="border-gray-200 mt-4" />
</div>
{/* 主要内容区域 - 左右布局 */}
<Row gutter={24}>
{/* 左侧内容 */}
<Col span={15}>
<Card className="mb-6 rounded-lg border-gray-200">
<Tabs
defaultActiveKey="overview"
items={[
{
key: "overview",
label: "Overview",
children: apiData.document ? (
<div className="min-h-[400px]">
<div
className="prose prose-lg max-w-none"
style={{
lineHeight: '1.7',
color: '#374151',
fontSize: '16px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
}}
>
<style>{`
.prose h1 {
color: #111827;
font-weight: 700;
font-size: 2.25rem;
line-height: 1.2;
margin-top: 0;
margin-bottom: 1.5rem;
border-bottom: 2px solid #e5e7eb;
padding-bottom: 0.5rem;
}
.prose h2 {
color: #1f2937;
font-weight: 600;
font-size: 1.875rem;
line-height: 1.3;
margin-top: 2rem;
margin-bottom: 1rem;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 0.25rem;
}
.prose h3 {
color: #374151;
font-weight: 600;
font-size: 1.5rem;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.prose p {
margin-bottom: 1.25rem;
color: #4b5563;
line-height: 1.7;
font-size: 16px;
}
.prose code {
background-color: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
padding: 0.125rem 0.375rem;
font-size: 0.875rem;
color: #374151;
font-weight: 500;
}
.prose pre {
background-color: #1f2937;
border-radius: 0.5rem;
padding: 1.25rem;
overflow-x: auto;
margin: 1.5rem 0;
border: 1px solid #374151;
}
.prose pre code {
background-color: transparent;
border: none;
color: #f9fafb;
padding: 0;
font-size: 0.875rem;
font-weight: normal;
}
.prose blockquote {
border-left: 4px solid #3b82f6;
padding-left: 1rem;
margin: 1.5rem 0;
color: #6b7280;
font-style: italic;
background-color: #f8fafc;
padding: 1rem;
border-radius: 0.375rem;
font-size: 16px;
}
.prose ul, .prose ol {
margin: 1.25rem 0;
padding-left: 1.5rem;
}
.prose ol {
list-style-type: decimal;
list-style-position: outside;
}
.prose ul {
list-style-type: disc;
list-style-position: outside;
}
.prose li {
margin: 0.5rem 0;
color: #4b5563;
display: list-item;
font-size: 16px;
}
.prose ol li {
padding-left: 0.25rem;
}
.prose ul li {
padding-left: 0.25rem;
}
.prose table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
font-size: 16px;
}
.prose th, .prose td {
border: 1px solid #d1d5db;
padding: 0.75rem;
text-align: left;
}
.prose th {
background-color: #f9fafb;
font-weight: 600;
color: #374151;
font-size: 16px;
}
.prose td {
color: #4b5563;
font-size: 16px;
}
.prose a {
color: #3b82f6;
text-decoration: underline;
font-weight: 500;
transition: color 0.2s;
font-size: inherit;
}
.prose a:hover {
color: #1d4ed8;
}
.prose strong {
color: #111827;
font-weight: 600;
font-size: inherit;
}
.prose em {
color: #6b7280;
font-style: italic;
font-size: inherit;
}
.prose hr {
border: none;
height: 1px;
background-color: #e5e7eb;
margin: 2rem 0;
}
`}</style>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{apiData.document}</ReactMarkdown>
</div>
</div>
) : (
<div className="text-gray-500 text-center py-8">
暂无文档内容
</div>
),
},
{
key: "openapi-spec",
label: "OpenAPI Specification",
children: (
<div>
{apiData.apiConfig && apiData.apiConfig.spec ? (
<SwaggerUIWrapper apiSpec={apiData.apiConfig.spec} />
) : (
<div className="text-gray-500 text-center py-8">
暂无OpenAPI规范
</div>
)}
</div>
),
},
]}
/>
</Card>
</Col>
{/* 右侧内容 */}
<Col span={9}>
<Card
className="rounded-lg border-gray-200"
title={
<Space>
<RocketOutlined />
<span>快速开始</span>
</Space>
}>
<Space direction="vertical" className="w-full" size="middle">
{/* cURL示例 */}
<div>
<Title level={5}>cURL调用示例</Title>
<div className="bg-gray-50 p-3 rounded border relative">
<pre className="text-sm mb-0">
{`curl -X ${exampleMethod} \\
'${baseUrl || 'https://api.example.com'}${examplePath}' \\
-H 'Accept: application/json' \\
-H 'Content-Type: application/json'`}
</pre>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
className="absolute top-2 right-2"
onClick={() => {
const curlCommand = `curl -X ${exampleMethod} \\\n '${baseUrl || 'https://api.example.com'}${examplePath}' \\\n -H 'Accept: application/json' \\\n -H 'Content-Type: application/json'`;
navigator.clipboard.writeText(curlCommand);
message.success('cURL命令已复制到剪贴板', 1);
}}
/>
</div>
</div>
<Divider />
{/* 下载OAS文件 */}
<div>
<Title level={5}>OpenAPI规范文件</Title>
<Paragraph type="secondary">
下载完整的OpenAPI规范文件,用于代码生成、API测试等场景
</Paragraph>
<Space>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={() => {
if (apiData?.apiConfig?.spec) {
const blob = new Blob([apiData.apiConfig.spec], { type: 'text/yaml' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${apiData.name || 'api'}-openapi.yaml`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
message.success('OpenAPI规范文件下载成功', 1);
}
}}
>
下载YAML
</Button>
<Button
icon={<DownloadOutlined />}
onClick={() => {
if (apiData?.apiConfig?.spec) {
try {
const yamlDoc = yaml.load(apiData.apiConfig.spec);
const jsonSpec = JSON.stringify(yamlDoc, null, 2);
const blob = new Blob([jsonSpec], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${apiData.name || 'api'}-openapi.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
message.success('OpenAPI规范文件下载成功', 1);
} catch (error) {
message.error('转换JSON格式失败');
}
}
}}
>
下载JSON
</Button>
</Space>
</div>
</Space>
</Card>
</Col>
</Row>
</Layout>
);
}
export default ApiDetailPage;
```
--------------------------------------------------------------------------------
/portal-web/api-portal-admin/src/pages/ApiProducts.tsx:
--------------------------------------------------------------------------------
```typescript
import { memo, useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import type { MenuProps } from 'antd';
import { Badge, Button, Card, Dropdown, Modal, message, Pagination, Skeleton, Input, Select, Tag, Space } from 'antd';
import type { ApiProduct, ProductIcon } from '@/types/api-product';
import { ApiOutlined, MoreOutlined, PlusOutlined, ExclamationCircleOutlined, ExclamationCircleFilled, ClockCircleFilled, CheckCircleFilled, SearchOutlined } from '@ant-design/icons';
import McpServerIcon from '@/components/icons/McpServerIcon';
import { apiProductApi } from '@/lib/api';
import ApiProductFormModal from '@/components/api-product/ApiProductFormModal';
// 优化的产品卡片组件
const ProductCard = memo(({ product, onNavigate, handleRefresh, onEdit }: {
product: ApiProduct;
onNavigate: (productId: string) => void;
handleRefresh: () => void;
onEdit: (product: ApiProduct) => void;
}) => {
// 处理产品图标的函数
const getTypeIcon = (icon: ProductIcon | null | undefined, type: string) => {
if (icon) {
switch (icon.type) {
case "URL":
return <img src={icon.value} alt="icon" style={{ borderRadius: '8px', minHeight: '40px', width: '40px', height: '40px', objectFit: 'cover' }} />
case "BASE64":
// 如果value已经包含data URL前缀,直接使用;否则添加前缀
const src = icon.value.startsWith('data:') ? icon.value : `data:image/png;base64,${icon.value}`;
return <img src={src} alt="icon" style={{ borderRadius: '8px', minHeight: '40px', width: '40px', height: '40px', objectFit: 'cover' }} />
default:
return type === "REST_API" ? <ApiOutlined style={{ fontSize: '16px', width: '16px', height: '16px' }} /> : <McpServerIcon style={{ fontSize: '16px', width: '16px', height: '16px' }} />
}
} else {
return type === "REST_API" ? <ApiOutlined style={{ fontSize: '16px', width: '16px', height: '16px' }} /> : <McpServerIcon style={{ fontSize: '16px', width: '16px', height: '16px' }} />
}
}
const handleClick = useCallback(() => {
onNavigate(product.productId)
}, [product.productId, onNavigate]);
const handleDelete = useCallback((productId: string, productName: string, e?: React.MouseEvent | any) => {
if (e && e.stopPropagation) e.stopPropagation();
Modal.confirm({
title: '确认删除',
icon: <ExclamationCircleOutlined />,
content: `确定要删除API产品 "${productName}" 吗?此操作不可恢复。`,
okText: '确认删除',
okType: 'danger',
cancelText: '取消',
onOk() {
apiProductApi.deleteApiProduct(productId).then(() => {
message.success('API Product 删除成功');
handleRefresh();
});
},
});
}, [handleRefresh]);
const handleEdit = useCallback((e?: React.MouseEvent | any) => {
if (e && e?.domEvent?.stopPropagation) e.domEvent.stopPropagation();
onEdit(product);
}, [product, onEdit]);
const dropdownItems: MenuProps['items'] = [
{
key: 'edit',
label: '编辑',
onClick: handleEdit,
},
{
type: 'divider',
},
{
key: 'delete',
label: '删除',
danger: true,
onClick: (info: any) => handleDelete(product.productId, product.name, info?.domEvent),
},
]
return (
<Card
className="hover:shadow-lg transition-shadow cursor-pointer rounded-xl border border-gray-200 shadow-sm hover:border-blue-300"
onClick={handleClick}
bodyStyle={{ padding: '16px' }}
>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-100">
{getTypeIcon(product.icon, product.type)}
</div>
<div>
<h3 className="text-lg font-semibold">{product.name}</h3>
<div className="flex items-center gap-3 mt-1 flex-wrap">
{product.category && <Badge color="green" text={product.category} />}
<div className="flex items-center">
{product.type === "REST_API" ? (
<ApiOutlined className="text-blue-500 mr-1" style={{fontSize: '12px', width: '12px', height: '12px'}} />
) : (
<McpServerIcon className="text-black mr-1" style={{fontSize: '12px', width: '12px', height: '12px'}} />
)}
<span className="text-xs text-gray-700">
{product.type === "REST_API" ? "REST API" : "MCP Server"}
</span>
</div>
<div className="flex items-center">
{product.status === "PENDING" ? (
<ExclamationCircleFilled className="text-yellow-500 mr-1" style={{fontSize: '12px', width: '12px', height: '12px'}} />
) : product.status === "READY" ? (
<ClockCircleFilled className="text-blue-500 mr-1" style={{fontSize: '12px', width: '12px', height: '12px'}} />
) : (
<CheckCircleFilled className="text-green-500 mr-1" style={{fontSize: '12px', width: '12px', height: '12px'}} />
)}
<span className="text-xs text-gray-700">
{product.status === "PENDING" ? "待配置" : product.status === "READY" ? "待发布" : "已发布"}
</span>
</div>
</div>
</div>
</div>
<Dropdown menu={{ items: dropdownItems }} trigger={['click']}>
<Button
type="text"
icon={<MoreOutlined />}
onClick={(e) => e.stopPropagation()}
/>
</Dropdown>
</div>
<div className="space-y-4">
{product.description && (
<p className="text-sm text-gray-600">{product.description}</p>
)}
</div>
</Card>
)
})
ProductCard.displayName = 'ProductCard'
export default function ApiProducts() {
const navigate = useNavigate();
const [apiProducts, setApiProducts] = useState<ApiProduct[]>([]);
const [filters, setFilters] = useState<{ type?: string, name?: string }>({});
const [loading, setLoading] = useState(true); // 初始状态为 loading
const [pagination, setPagination] = useState({
current: 1,
pageSize: 12,
total: 0,
});
const [modalVisible, setModalVisible] = useState(false);
const [editingProduct, setEditingProduct] = useState<ApiProduct | null>(null);
// 搜索状态
const [searchValue, setSearchValue] = useState('');
const [searchType, setSearchType] = useState<'name' | 'type'>('name');
const [activeFilters, setActiveFilters] = useState<Array<{ type: string; value: string; label: string }>>([]);
const fetchApiProducts = useCallback((page = 1, size = 12, queryFilters?: { type?: string, name?: string }) => {
setLoading(true);
const params = { page, size, ...(queryFilters || {}) };
apiProductApi.getApiProducts(params).then((res: any) => {
const products = res.data.content;
setApiProducts(products);
setPagination({
current: page,
pageSize: size,
total: res.data.totalElements || 0,
});
}).finally(() => {
setLoading(false);
});
}, []); // 不依赖任何状态,避免无限循环
useEffect(() => {
fetchApiProducts(1, 12);
}, []); // 只在组件初始化时执行一次
// 产品类型选项
const typeOptions = [
{ label: 'REST API', value: 'REST_API' },
{ label: 'MCP Server', value: 'MCP_SERVER' },
];
// 搜索类型选项
const searchTypeOptions = [
{ label: '产品名称', value: 'name' as const },
{ label: '产品类型', value: 'type' as const },
];
// 搜索处理函数
const handleSearch = () => {
if (searchValue.trim()) {
let labelText = '';
let filterValue = searchValue.trim();
if (searchType === 'name') {
labelText = `产品名称:${searchValue.trim()}`;
} else {
const typeLabel = typeOptions.find(opt => opt.value === searchValue.trim())?.label || searchValue.trim();
labelText = `产品类型:${typeLabel}`;
}
const newFilter = { type: searchType, value: filterValue, label: labelText };
const updatedFilters = activeFilters.filter(f => f.type !== searchType);
updatedFilters.push(newFilter);
setActiveFilters(updatedFilters);
const filters: { type?: string, name?: string } = {};
updatedFilters.forEach(filter => {
if (filter.type === 'type' || filter.type === 'name') {
filters[filter.type] = filter.value;
}
});
setFilters(filters);
fetchApiProducts(1, pagination.pageSize, filters);
setSearchValue('');
}
};
// 移除单个筛选条件
const removeFilter = (filterType: string) => {
const updatedFilters = activeFilters.filter(f => f.type !== filterType);
setActiveFilters(updatedFilters);
const newFilters: { type?: string, name?: string } = {};
updatedFilters.forEach(filter => {
if (filter.type === 'type' || filter.type === 'name') {
newFilters[filter.type] = filter.value;
}
});
setFilters(newFilters);
fetchApiProducts(1, pagination.pageSize, newFilters);
};
// 清空所有筛选条件
const clearAllFilters = () => {
setActiveFilters([]);
setFilters({});
fetchApiProducts(1, pagination.pageSize, {});
};
// 处理分页变化
const handlePaginationChange = (page: number, pageSize: number) => {
fetchApiProducts(page, pageSize, filters); // 传递当前filters
};
// 直接使用服务端返回的列表
// 优化的导航处理函数
const handleNavigateToProduct = useCallback((productId: string) => {
navigate(`/api-products/detail?productId=${productId}`);
}, [navigate]);
// 处理创建
const handleCreate = () => {
setEditingProduct(null);
setModalVisible(true);
};
// 处理编辑
const handleEdit = (product: ApiProduct) => {
setEditingProduct(product);
setModalVisible(true);
};
// 处理模态框成功
const handleModalSuccess = () => {
setModalVisible(false);
setEditingProduct(null);
fetchApiProducts(pagination.current, pagination.pageSize, filters);
};
// 处理模态框取消
const handleModalCancel = () => {
setModalVisible(false);
setEditingProduct(null);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">API Products</h1>
<p className="text-gray-500 mt-2">
管理和配置您的API产品
</p>
</div>
<Button onClick={handleCreate} type="primary" icon={<PlusOutlined/>}>
创建 API Product
</Button>
</div>
{/* 搜索和筛选 */}
<div className="space-y-4">
{/* 搜索框 */}
<div className="flex items-center max-w-xl">
{/* 左侧:搜索类型选择器 */}
<Select
value={searchType}
onChange={setSearchType}
style={{
width: 120,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
backgroundColor: '#f5f5f5',
}}
className="h-10"
size="large"
>
{searchTypeOptions.map(option => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
{/* 中间:搜索值输入框或选择框 */}
{searchType === 'type' ? (
<Select
placeholder="请选择产品类型"
value={searchValue}
onChange={(value) => {
setSearchValue(value);
// 对于类型选择,立即执行搜索
if (value) {
const typeLabel = typeOptions.find(opt => opt.value === value)?.label || value;
const labelText = `产品类型:${typeLabel}`;
const newFilter = { type: 'type', value, label: labelText };
const updatedFilters = activeFilters.filter(f => f.type !== 'type');
updatedFilters.push(newFilter);
setActiveFilters(updatedFilters);
const filters: { type?: string, name?: string } = {};
updatedFilters.forEach(filter => {
if (filter.type === 'type' || filter.type === 'name') {
filters[filter.type] = filter.value;
}
});
setFilters(filters);
fetchApiProducts(1, pagination.pageSize, filters);
setSearchValue('');
}
}}
style={{
flex: 1,
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
}}
allowClear
onClear={clearAllFilters}
className="h-10"
size="large"
>
{typeOptions.map(option => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
) : (
<Input
placeholder="请输入要检索的产品名称"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
style={{
flex: 1,
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
}}
onPressEnter={handleSearch}
allowClear
onClear={() => setSearchValue('')}
size="large"
className="h-10"
/>
)}
{/* 右侧:搜索按钮 */}
<Button
icon={<SearchOutlined />}
onClick={handleSearch}
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
width: 48,
}}
className="h-10"
size="large"
/>
</div>
{/* 筛选条件标签 */}
{activeFilters.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">筛选条件:</span>
<Space wrap>
{activeFilters.map(filter => (
<Tag
key={filter.type}
closable
onClose={() => removeFilter(filter.type)}
style={{
backgroundColor: '#f5f5f5',
border: '1px solid #d9d9d9',
borderRadius: '16px',
color: '#666',
fontSize: '12px',
padding: '4px 12px',
}}
>
{filter.label}
</Tag>
))}
</Space>
<Button
type="link"
size="small"
onClick={clearAllFilters}
className="text-blue-500 hover:text-blue-600 text-sm"
>
清除筛选条件
</Button>
</div>
)}
</div>
{loading ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: pagination.pageSize || 12 }).map((_, index) => (
<div key={index} className="h-full rounded-lg shadow-lg bg-white p-4">
<div className="flex items-start space-x-4">
<Skeleton.Avatar size={48} active />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
<Skeleton.Input active size="small" style={{ width: 120 }} />
<Skeleton.Input active size="small" style={{ width: 60 }} />
</div>
<Skeleton.Input active size="small" style={{ width: '100%', marginBottom: 12 }} />
<Skeleton.Input active size="small" style={{ width: '80%', marginBottom: 8 }} />
<div className="flex items-center justify-between">
<Skeleton.Input active size="small" style={{ width: 60 }} />
<Skeleton.Input active size="small" style={{ width: 80 }} />
</div>
</div>
</div>
</div>
))}
</div>
) : (
<>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{apiProducts.map((product) => (
<ProductCard
key={product.productId}
product={product}
onNavigate={handleNavigateToProduct}
handleRefresh={() => fetchApiProducts(pagination.current, pagination.pageSize, filters)}
onEdit={handleEdit}
/>
))}
</div>
{pagination.total > 0 && (
<div className="flex justify-center mt-6">
<Pagination
current={pagination.current}
pageSize={pagination.pageSize}
total={pagination.total}
onChange={handlePaginationChange}
showSizeChanger
showQuickJumper
showTotal={(total) => `共 ${total} 条`}
pageSizeOptions={['6', '12', '24', '48']}
/>
</div>
)}
</>
)}
<ApiProductFormModal
visible={modalVisible}
onCancel={handleModalCancel}
onSuccess={handleModalSuccess}
productId={editingProduct?.productId}
initialData={editingProduct || undefined}
/>
</div>
)
}
```