This is page 4 of 9. Use http://codebase.md/higress-group/himarket?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .cursor │ └── rules │ ├── api-style.mdc │ └── project-architecture.mdc ├── .gitignore ├── build.sh ├── deploy │ ├── docker │ │ ├── docker-compose.yml │ │ └── Docker部署说明.md │ └── helm │ ├── Chart.yaml │ ├── Helm部署说明.md │ ├── templates │ │ ├── _helpers.tpl │ │ ├── himarket-admin-cm.yaml │ │ ├── himarket-admin-deployment.yaml │ │ ├── himarket-admin-service.yaml │ │ ├── himarket-frontend-cm.yaml │ │ ├── himarket-frontend-deployment.yaml │ │ ├── himarket-frontend-service.yaml │ │ ├── himarket-server-cm.yaml │ │ ├── himarket-server-deployment.yaml │ │ ├── himarket-server-service.yaml │ │ ├── mysql.yaml │ │ └── serviceaccount.yaml │ └── values.yaml ├── LICENSE ├── NOTICE ├── pom.xml ├── portal-bootstrap │ ├── Dockerfile │ ├── pom.xml │ └── src │ ├── main │ │ ├── java │ │ │ └── com │ │ │ └── alibaba │ │ │ └── apiopenplatform │ │ │ ├── config │ │ │ │ ├── AsyncConfig.java │ │ │ │ ├── FilterConfig.java │ │ │ │ ├── PageConfig.java │ │ │ │ ├── RestTemplateConfig.java │ │ │ │ ├── SecurityConfig.java │ │ │ │ └── SwaggerConfig.java │ │ │ ├── filter │ │ │ │ └── PortalResolvingFilter.java │ │ │ └── PortalApplication.java │ │ └── resources │ │ └── application.yaml │ └── test │ └── java │ └── com │ └── alibaba │ └── apiopenplatform │ └── integration │ └── AdministratorAuthIntegrationTest.java ├── portal-dal │ ├── pom.xml │ └── src │ └── main │ └── java │ └── com │ └── alibaba │ └── apiopenplatform │ ├── converter │ │ ├── AdpAIGatewayConfigConverter.java │ │ ├── APIGConfigConverter.java │ │ ├── APIGRefConfigConverter.java │ │ ├── ApiKeyConfigConverter.java │ │ ├── ConsumerAuthConfigConverter.java │ │ ├── GatewayConfigConverter.java │ │ ├── HigressConfigConverter.java │ │ ├── HigressRefConfigConverter.java │ │ ├── HmacConfigConverter.java │ │ ├── JsonConverter.java │ │ ├── JwtConfigConverter.java │ │ ├── NacosRefConfigConverter.java │ │ ├── PortalSettingConfigConverter.java │ │ ├── PortalUiConfigConverter.java │ │ └── ProductIconConverter.java │ ├── entity │ │ ├── Administrator.java │ │ ├── BaseEntity.java │ │ ├── Consumer.java │ │ ├── ConsumerCredential.java │ │ ├── ConsumerRef.java │ │ ├── Developer.java │ │ ├── DeveloperExternalIdentity.java │ │ ├── Gateway.java │ │ ├── NacosInstance.java │ │ ├── Portal.java │ │ ├── PortalDomain.java │ │ ├── Product.java │ │ ├── ProductPublication.java │ │ ├── ProductRef.java │ │ └── ProductSubscription.java │ ├── repository │ │ ├── AdministratorRepository.java │ │ ├── BaseRepository.java │ │ ├── ConsumerCredentialRepository.java │ │ ├── ConsumerRefRepository.java │ │ ├── ConsumerRepository.java │ │ ├── DeveloperExternalIdentityRepository.java │ │ ├── DeveloperRepository.java │ │ ├── GatewayRepository.java │ │ ├── NacosInstanceRepository.java │ │ ├── PortalDomainRepository.java │ │ ├── PortalRepository.java │ │ ├── ProductPublicationRepository.java │ │ ├── ProductRefRepository.java │ │ ├── ProductRepository.java │ │ └── SubscriptionRepository.java │ └── support │ ├── common │ │ ├── Encrypted.java │ │ ├── Encryptor.java │ │ └── User.java │ ├── consumer │ │ ├── AdpAIAuthConfig.java │ │ ├── APIGAuthConfig.java │ │ ├── ApiKeyConfig.java │ │ ├── ConsumerAuthConfig.java │ │ ├── HigressAuthConfig.java │ │ ├── HmacConfig.java │ │ └── JwtConfig.java │ ├── enums │ │ ├── APIGAPIType.java │ │ ├── ConsumerAuthType.java │ │ ├── ConsumerStatus.java │ │ ├── CredentialMode.java │ │ ├── DeveloperAuthType.java │ │ ├── DeveloperStatus.java │ │ ├── DomainType.java │ │ ├── GatewayType.java │ │ ├── GrantType.java │ │ ├── HigressAPIType.java │ │ ├── JwtAlgorithm.java │ │ ├── ProductIconType.java │ │ ├── ProductStatus.java │ │ ├── ProductType.java │ │ ├── ProtocolType.java │ │ ├── PublicKeyFormat.java │ │ ├── SourceType.java │ │ ├── SubscriptionStatus.java │ │ └── UserType.java │ ├── gateway │ │ ├── AdpAIGatewayConfig.java │ │ ├── APIGConfig.java │ │ ├── GatewayConfig.java │ │ └── HigressConfig.java │ ├── portal │ │ ├── AuthCodeConfig.java │ │ ├── IdentityMapping.java │ │ ├── JwtBearerConfig.java │ │ ├── OAuth2Config.java │ │ ├── OidcConfig.java │ │ ├── PortalSettingConfig.java │ │ ├── PortalUiConfig.java │ │ └── PublicKeyConfig.java │ └── product │ ├── APIGRefConfig.java │ ├── HigressRefConfig.java │ ├── NacosRefConfig.java │ └── ProductIcon.java ├── portal-server │ ├── pom.xml │ └── src │ └── main │ └── java │ └── com │ └── alibaba │ └── apiopenplatform │ ├── controller │ │ ├── AdministratorController.java │ │ ├── ConsumerController.java │ │ ├── DeveloperController.java │ │ ├── GatewayController.java │ │ ├── NacosController.java │ │ ├── OAuth2Controller.java │ │ ├── OidcController.java │ │ ├── PortalController.java │ │ └── ProductController.java │ ├── core │ │ ├── advice │ │ │ ├── ExceptionAdvice.java │ │ │ └── ResponseAdvice.java │ │ ├── annotation │ │ │ ├── AdminAuth.java │ │ │ ├── AdminOrDeveloperAuth.java │ │ │ └── DeveloperAuth.java │ │ ├── constant │ │ │ ├── CommonConstants.java │ │ │ ├── IdpConstants.java │ │ │ ├── JwtConstants.java │ │ │ └── Resources.java │ │ ├── event │ │ │ ├── DeveloperDeletingEvent.java │ │ │ ├── PortalDeletingEvent.java │ │ │ └── ProductDeletingEvent.java │ │ ├── exception │ │ │ ├── BusinessException.java │ │ │ └── ErrorCode.java │ │ ├── response │ │ │ └── Response.java │ │ ├── security │ │ │ ├── ContextHolder.java │ │ │ ├── DeveloperAuthenticationProvider.java │ │ │ └── JwtAuthenticationFilter.java │ │ └── utils │ │ ├── IdGenerator.java │ │ ├── PasswordHasher.java │ │ └── TokenUtil.java │ ├── dto │ │ ├── converter │ │ │ ├── InputConverter.java │ │ │ ├── NacosToGatewayToolsConverter.java │ │ │ └── OutputConverter.java │ │ ├── params │ │ │ ├── admin │ │ │ │ ├── AdminCreateParam.java │ │ │ │ ├── AdminLoginParam.java │ │ │ │ └── ResetPasswordParam.java │ │ │ ├── consumer │ │ │ │ ├── CreateConsumerParam.java │ │ │ │ ├── CreateCredentialParam.java │ │ │ │ ├── CreateSubscriptionParam.java │ │ │ │ ├── QueryConsumerParam.java │ │ │ │ ├── QuerySubscriptionParam.java │ │ │ │ └── UpdateCredentialParam.java │ │ │ ├── developer │ │ │ │ ├── CreateDeveloperParam.java │ │ │ │ ├── CreateExternalDeveloperParam.java │ │ │ │ ├── DeveloperLoginParam.java │ │ │ │ ├── QueryDeveloperParam.java │ │ │ │ ├── UnbindExternalIdentityParam.java │ │ │ │ ├── UpdateDeveloperParam.java │ │ │ │ └── UpdateDeveloperStatusParam.java │ │ │ ├── gateway │ │ │ │ ├── ImportGatewayParam.java │ │ │ │ ├── QueryAdpAIGatewayParam.java │ │ │ │ ├── QueryAPIGParam.java │ │ │ │ └── QueryGatewayParam.java │ │ │ ├── nacos │ │ │ │ ├── CreateNacosParam.java │ │ │ │ ├── QueryNacosNamespaceParam.java │ │ │ │ ├── QueryNacosParam.java │ │ │ │ └── UpdateNacosParam.java │ │ │ ├── portal │ │ │ │ ├── BindDomainParam.java │ │ │ │ ├── CreatePortalParam.java │ │ │ │ └── UpdatePortalParam.java │ │ │ └── product │ │ │ ├── CreateProductParam.java │ │ │ ├── CreateProductRefParam.java │ │ │ ├── PublishProductParam.java │ │ │ ├── QueryProductParam.java │ │ │ ├── QueryProductSubscriptionParam.java │ │ │ ├── UnPublishProductParam.java │ │ │ └── UpdateProductParam.java │ │ └── result │ │ ├── AdminResult.java │ │ ├── AdpGatewayInstanceResult.java │ │ ├── AdpMcpServerListResult.java │ │ ├── AdpMCPServerResult.java │ │ ├── APIConfigResult.java │ │ ├── APIGMCPServerResult.java │ │ ├── APIResult.java │ │ ├── AuthResult.java │ │ ├── ConsumerCredentialResult.java │ │ ├── ConsumerResult.java │ │ ├── DeveloperResult.java │ │ ├── GatewayMCPServerResult.java │ │ ├── GatewayResult.java │ │ ├── HigressMCPServerResult.java │ │ ├── IdpResult.java │ │ ├── IdpState.java │ │ ├── IdpTokenResult.java │ │ ├── MCPConfigResult.java │ │ ├── MCPServerResult.java │ │ ├── MseNacosResult.java │ │ ├── NacosMCPServerResult.java │ │ ├── NacosNamespaceResult.java │ │ ├── NacosResult.java │ │ ├── PageResult.java │ │ ├── PortalResult.java │ │ ├── ProductPublicationResult.java │ │ ├── ProductRefResult.java │ │ ├── ProductResult.java │ │ └── SubscriptionResult.java │ └── service │ ├── AdministratorService.java │ ├── AdpAIGatewayService.java │ ├── ConsumerService.java │ ├── DeveloperService.java │ ├── gateway │ │ ├── AdpAIGatewayOperator.java │ │ ├── AIGatewayOperator.java │ │ ├── APIGOperator.java │ │ ├── client │ │ │ ├── AdpAIGatewayClient.java │ │ │ ├── APIGClient.java │ │ │ ├── GatewayClient.java │ │ │ ├── HigressClient.java │ │ │ ├── PopGatewayClient.java │ │ │ └── SLSClient.java │ │ ├── factory │ │ │ └── HTTPClientFactory.java │ │ ├── GatewayOperator.java │ │ └── HigressOperator.java │ ├── GatewayService.java │ ├── IdpService.java │ ├── impl │ │ ├── AdministratorServiceImpl.java │ │ ├── ConsumerServiceImpl.java │ │ ├── DeveloperServiceImpl.java │ │ ├── GatewayServiceImpl.java │ │ ├── IdpServiceImpl.java │ │ ├── NacosServiceImpl.java │ │ ├── OAuth2ServiceImpl.java │ │ ├── OidcServiceImpl.java │ │ ├── PortalServiceImpl.java │ │ └── ProductServiceImpl.java │ ├── NacosService.java │ ├── OAuth2Service.java │ ├── OidcService.java │ ├── PortalService.java │ └── ProductService.java ├── portal-web │ ├── api-portal-admin │ │ ├── .env │ │ ├── .gitignore │ │ ├── bin │ │ │ ├── replace_var.py │ │ │ └── start.sh │ │ ├── Dockerfile │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── nginx.conf │ │ ├── package.json │ │ ├── postcss.config.js │ │ ├── proxy.conf │ │ ├── public │ │ │ ├── logo.png │ │ │ └── vite.svg │ │ ├── README.md │ │ ├── src │ │ │ ├── aliyunThemeToken.ts │ │ │ ├── App.css │ │ │ ├── App.tsx │ │ │ ├── assets │ │ │ │ └── react.svg │ │ │ ├── components │ │ │ │ ├── api-product │ │ │ │ │ ├── ApiProductApiDocs.tsx │ │ │ │ │ ├── ApiProductDashboard.tsx │ │ │ │ │ ├── ApiProductFormModal.tsx │ │ │ │ │ ├── ApiProductLinkApi.tsx │ │ │ │ │ ├── ApiProductOverview.tsx │ │ │ │ │ ├── ApiProductPolicy.tsx │ │ │ │ │ ├── ApiProductPortal.tsx │ │ │ │ │ ├── ApiProductUsageGuide.tsx │ │ │ │ │ ├── SwaggerUIWrapper.css │ │ │ │ │ └── SwaggerUIWrapper.tsx │ │ │ │ ├── common │ │ │ │ │ ├── AdvancedSearch.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── console │ │ │ │ │ ├── GatewayTypeSelector.tsx │ │ │ │ │ ├── ImportGatewayModal.tsx │ │ │ │ │ ├── ImportHigressModal.tsx │ │ │ │ │ ├── ImportMseNacosModal.tsx │ │ │ │ │ └── NacosTypeSelector.tsx │ │ │ │ ├── icons │ │ │ │ │ └── McpServerIcon.tsx │ │ │ │ ├── Layout.tsx │ │ │ │ ├── LayoutWrapper.tsx │ │ │ │ ├── portal │ │ │ │ │ ├── PortalConsumers.tsx │ │ │ │ │ ├── PortalDashboard.tsx │ │ │ │ │ ├── PortalDevelopers.tsx │ │ │ │ │ ├── PortalDomain.tsx │ │ │ │ │ ├── PortalFormModal.tsx │ │ │ │ │ ├── PortalOverview.tsx │ │ │ │ │ ├── PortalPublishedApis.tsx │ │ │ │ │ ├── PortalSecurity.tsx │ │ │ │ │ ├── PortalSettings.tsx │ │ │ │ │ ├── PublicKeyManager.tsx │ │ │ │ │ └── ThirdPartyAuthManager.tsx │ │ │ │ └── subscription │ │ │ │ └── SubscriptionListModal.tsx │ │ │ ├── contexts │ │ │ │ └── LoadingContext.tsx │ │ │ ├── index.css │ │ │ ├── lib │ │ │ │ ├── api.ts │ │ │ │ ├── constant.ts │ │ │ │ └── utils.ts │ │ │ ├── main.tsx │ │ │ ├── pages │ │ │ │ ├── ApiProductDetail.tsx │ │ │ │ ├── ApiProducts.tsx │ │ │ │ ├── Dashboard.tsx │ │ │ │ ├── GatewayConsoles.tsx │ │ │ │ ├── Login.tsx │ │ │ │ ├── NacosConsoles.tsx │ │ │ │ ├── PortalDetail.tsx │ │ │ │ ├── Portals.tsx │ │ │ │ └── Register.tsx │ │ │ ├── routes │ │ │ │ └── index.tsx │ │ │ ├── types │ │ │ │ ├── api-product.ts │ │ │ │ ├── consumer.ts │ │ │ │ ├── gateway.ts │ │ │ │ ├── index.ts │ │ │ │ ├── portal.ts │ │ │ │ ├── shims-js-yaml.d.ts │ │ │ │ └── subscription.ts │ │ │ └── vite-env.d.ts │ │ ├── tailwind.config.js │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ └── api-portal-frontend │ ├── .env │ ├── .gitignore │ ├── .husky │ │ └── pre-commit │ ├── bin │ │ ├── replace_var.py │ │ └── start.sh │ ├── Dockerfile │ ├── eslint.config.js │ ├── index.html │ ├── nginx.conf │ ├── package.json │ ├── postcss.config.js │ ├── proxy.conf │ ├── public │ │ ├── favicon.ico │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── MCP.png │ │ ├── MCP.svg │ │ └── vite.svg │ ├── README.md │ ├── src │ │ ├── aliyunThemeToken.ts │ │ ├── App.css │ │ ├── App.tsx │ │ ├── assets │ │ │ ├── aliyun.png │ │ │ ├── github.png │ │ │ ├── google.png │ │ │ └── react.svg │ │ ├── components │ │ │ ├── consumer │ │ │ │ ├── ConsumerBasicInfo.tsx │ │ │ │ ├── CredentialManager.tsx │ │ │ │ ├── index.ts │ │ │ │ └── SubscriptionManager.tsx │ │ │ ├── Layout.tsx │ │ │ ├── Navigation.tsx │ │ │ ├── ProductHeader.tsx │ │ │ ├── SwaggerUIWrapper.css │ │ │ ├── SwaggerUIWrapper.tsx │ │ │ └── UserInfo.tsx │ │ ├── index.css │ │ ├── lib │ │ │ ├── api.ts │ │ │ ├── statusUtils.ts │ │ │ └── utils.ts │ │ ├── main.tsx │ │ ├── pages │ │ │ ├── ApiDetail.tsx │ │ │ ├── Apis.tsx │ │ │ ├── Callback.tsx │ │ │ ├── ConsumerDetail.tsx │ │ │ ├── Consumers.tsx │ │ │ ├── GettingStarted.tsx │ │ │ ├── Home.tsx │ │ │ ├── Login.tsx │ │ │ ├── Mcp.tsx │ │ │ ├── McpDetail.tsx │ │ │ ├── OidcCallback.tsx │ │ │ ├── Profile.tsx │ │ │ ├── Register.tsx │ │ │ └── Test.css │ │ ├── router.tsx │ │ ├── types │ │ │ ├── consumer.ts │ │ │ └── index.ts │ │ └── vite-env.d.ts │ ├── tailwind.config.js │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts └── README.md ``` # Files -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/assets/react.svg: -------------------------------------------------------------------------------- ``` 1 | <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg> ``` -------------------------------------------------------------------------------- /portal-web/api-portal-frontend/src/assets/react.svg: -------------------------------------------------------------------------------- ``` 1 | <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg> ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/service/impl/AdministratorServiceImpl.java: -------------------------------------------------------------------------------- ```java 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one 3 | * or more contributor license agreements. See the NOTICE file 4 | * distributed with this work for additional information 5 | * regarding copyright ownership. The ASF licenses this file 6 | * to you under the Apache License, Version 2.0 (the 7 | * "License"); you may not use this file except in compliance 8 | * with the License. You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | package com.alibaba.apiopenplatform.service.impl; 21 | 22 | import cn.hutool.core.util.StrUtil; 23 | import com.alibaba.apiopenplatform.core.constant.Resources; 24 | import com.alibaba.apiopenplatform.core.security.ContextHolder; 25 | import com.alibaba.apiopenplatform.core.utils.TokenUtil; 26 | import com.alibaba.apiopenplatform.dto.result.AdminResult; 27 | import com.alibaba.apiopenplatform.dto.result.AuthResult; 28 | import com.alibaba.apiopenplatform.entity.Administrator; 29 | import com.alibaba.apiopenplatform.repository.AdministratorRepository; 30 | import com.alibaba.apiopenplatform.service.AdministratorService; 31 | import com.alibaba.apiopenplatform.core.utils.PasswordHasher; 32 | import com.alibaba.apiopenplatform.core.utils.IdGenerator; 33 | import lombok.RequiredArgsConstructor; 34 | import org.springframework.stereotype.Service; 35 | import org.springframework.transaction.annotation.Transactional; 36 | import com.alibaba.apiopenplatform.core.exception.BusinessException; 37 | import com.alibaba.apiopenplatform.core.exception.ErrorCode; 38 | 39 | @Service 40 | @RequiredArgsConstructor 41 | @Transactional 42 | public class AdministratorServiceImpl implements AdministratorService { 43 | 44 | private final AdministratorRepository administratorRepository; 45 | 46 | private final ContextHolder contextHolder; 47 | 48 | @Override 49 | public AuthResult login(String username, String password) { 50 | Administrator admin = administratorRepository.findByUsername(username) 51 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.ADMINISTRATOR, username)); 52 | 53 | if (!PasswordHasher.verify(password, admin.getPasswordHash())) { 54 | throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户名或密码错误"); 55 | } 56 | 57 | String token = TokenUtil.generateAdminToken(admin.getAdminId()); 58 | return AuthResult.of(token, TokenUtil.getTokenExpiresIn()); 59 | } 60 | 61 | @Override 62 | public boolean needInit() { 63 | return administratorRepository.count() == 0; 64 | } 65 | 66 | @Override 67 | public AdminResult initAdmin(String username, String password) { 68 | Administrator admin = Administrator.builder() 69 | .adminId(generateAdminId()) 70 | .username(username) 71 | .passwordHash(PasswordHasher.hash(password)) 72 | .build(); 73 | administratorRepository.save(admin); 74 | return new AdminResult().convertFrom(admin); 75 | } 76 | 77 | @Override 78 | public AdminResult getAdministrator() { 79 | Administrator administrator = findAdministrator(contextHolder.getUser()); 80 | return new AdminResult().convertFrom(administrator); 81 | } 82 | 83 | @Override 84 | @Transactional 85 | public void resetPassword(String oldPassword, String newPassword) { 86 | Administrator admin = findAdministrator(contextHolder.getUser()); 87 | 88 | if (!PasswordHasher.verify(oldPassword, admin.getPasswordHash())) { 89 | throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户名或密码错误"); 90 | } 91 | 92 | admin.setPasswordHash(PasswordHasher.hash(newPassword)); 93 | administratorRepository.save(admin); 94 | } 95 | 96 | private String generateAdminId() { 97 | return IdGenerator.genAdministratorId(); 98 | } 99 | 100 | private Administrator findAdministrator(String adminId) { 101 | return administratorRepository.findByAdminId(adminId) 102 | .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, Resources.ADMINISTRATOR, adminId)); 103 | } 104 | } ``` -------------------------------------------------------------------------------- /deploy/helm/templates/mysql.yaml: -------------------------------------------------------------------------------- ```yaml 1 | {{- if .Values.mysql.enabled }} 2 | {{- $existingSecret := (lookup "v1" "Secret" .Release.Namespace "mysql-secret") }} 3 | {{- $rootPassword := "" }} 4 | {{- $userPassword := "" }} 5 | {{- if $existingSecret }} 6 | {{- $rootPassword = (index $existingSecret.data "MYSQL_ROOT_PASSWORD" | b64dec) }} 7 | {{- $userPassword = (index $existingSecret.data "MYSQL_PASSWORD" | b64dec) }} 8 | {{- else }} 9 | {{- if .Values.mysql.auth.rootPassword }} 10 | {{- $rootPassword = .Values.mysql.auth.rootPassword }} 11 | {{- else }} 12 | {{- $rootPassword = randAlphaNum 16 }} 13 | {{- end }} 14 | {{- if .Values.mysql.auth.password }} 15 | {{- $userPassword = .Values.mysql.auth.password }} 16 | {{- else }} 17 | {{- $userPassword = randAlphaNum 16 }} 18 | {{- end }} 19 | {{- end }} 20 | --- 21 | # MySQL Secret: 存储敏感的数据库凭据(自动生成随机密码) 22 | apiVersion: v1 23 | kind: Secret 24 | metadata: 25 | name: mysql-secret 26 | type: Opaque 27 | stringData: 28 | MYSQL_ROOT_PASSWORD: {{ $rootPassword | quote }} 29 | MYSQL_DATABASE: {{ .Values.mysql.auth.database | quote }} 30 | MYSQL_USER: {{ .Values.mysql.auth.username | quote }} 31 | MYSQL_PASSWORD: {{ $userPassword | quote }} 32 | 33 | --- 34 | # HiMarket Server Secret: 应用专用敏感配置(使用相同的密码) 35 | apiVersion: v1 36 | kind: Secret 37 | metadata: 38 | name: himarket-server-secret 39 | labels: 40 | app: himarket-server 41 | type: Opaque 42 | stringData: 43 | # 使用相同的 MySQL 密码变量,确保一致性 44 | DB_HOST: "mysql-headless-svc" 45 | DB_PORT: "3306" 46 | DB_NAME: {{ .Values.mysql.auth.database | quote }} 47 | DB_USERNAME: {{ .Values.mysql.auth.username | quote }} 48 | DB_PASSWORD: {{ $userPassword | quote }} 49 | 50 | --- 51 | # MySQL Headless Service: 为 StatefulSet 提供稳定的网络域 52 | apiVersion: v1 53 | kind: Service 54 | metadata: 55 | name: mysql-headless-svc 56 | spec: 57 | ports: 58 | - port: 3306 59 | name: mysql 60 | clusterIP: None 61 | selector: 62 | app: mysql 63 | 64 | --- 65 | # MySQL External Service: 暴露数据库给外部访问(可选) 66 | {{- if .Values.mysql.service.external.enabled }} 67 | apiVersion: v1 68 | kind: Service 69 | metadata: 70 | name: mysql-external-svc 71 | spec: 72 | type: {{ .Values.mysql.service.external.type }} 73 | ports: 74 | - port: 3306 75 | targetPort: 3306 76 | protocol: TCP 77 | selector: 78 | app: mysql 79 | {{- end }} 80 | 81 | --- 82 | # MySQL StatefulSet: 部署 MySQL 应用 83 | apiVersion: apps/v1 84 | kind: StatefulSet 85 | metadata: 86 | name: mysql 87 | labels: 88 | app: mysql 89 | spec: 90 | replicas: {{ .Values.mysql.replicaCount }} 91 | selector: 92 | matchLabels: 93 | app: mysql 94 | serviceName: "mysql-headless-svc" 95 | template: 96 | metadata: 97 | labels: 98 | app: mysql 99 | spec: 100 | serviceAccountName: {{ include "himarket.serviceAccountName" . }} 101 | containers: 102 | - name: mysql 103 | image: "{{ .Values.hub }}/{{ .Values.mysql.image.repository }}:{{ .Values.mysql.image.tag }}" 104 | imagePullPolicy: {{ .Values.mysql.image.pullPolicy }} 105 | ports: 106 | - containerPort: 3306 107 | name: mysql 108 | envFrom: 109 | - secretRef: 110 | name: mysql-secret 111 | volumeMounts: 112 | - name: mysql-data 113 | mountPath: /var/lib/mysql 114 | {{- with .Values.mysql.resources }} 115 | resources: 116 | {{- toYaml . | nindent 12 }} 117 | {{- end }} 118 | # 健康检查 119 | livenessProbe: 120 | exec: 121 | command: 122 | - mysqladmin 123 | - ping 124 | - -h 125 | - localhost 126 | - -u{{ .Values.mysql.auth.username }} 127 | - -p{{ $userPassword }} 128 | initialDelaySeconds: 60 129 | periodSeconds: 10 130 | timeoutSeconds: 5 131 | readinessProbe: 132 | exec: 133 | command: 134 | - mysql 135 | - -h 136 | - localhost 137 | - -u{{ .Values.mysql.auth.username }} 138 | - -p{{ $userPassword }} 139 | - -e 140 | - "SELECT 1" 141 | initialDelaySeconds: 1 142 | periodSeconds: 1 143 | timeoutSeconds: 5 144 | 145 | volumeClaimTemplates: 146 | - metadata: 147 | name: mysql-data 148 | spec: 149 | accessModes: 150 | - {{ .Values.mysql.persistence.accessMode }} 151 | resources: 152 | requests: 153 | storage: {{ .Values.mysql.persistence.size }} 154 | {{- if .Values.mysql.persistence.storageClass }} 155 | {{- if (eq "-" .Values.mysql.persistence.storageClass) }} 156 | storageClassName: "" 157 | {{- else }} 158 | storageClassName: {{ .Values.mysql.persistence.storageClass | quote }} 159 | {{- end }} 160 | {{- end }} 161 | {{- end }} 162 | ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/controller/NacosController.java: -------------------------------------------------------------------------------- ```java 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one 3 | * or more contributor license agreements. See the NOTICE file 4 | * distributed with this work for additional information 5 | * regarding copyright ownership. The ASF licenses this file 6 | * to you under the Apache License, Version 2.0 (the 7 | * "License"); you may not use this file except in compliance 8 | * with the License. You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | package com.alibaba.apiopenplatform.controller; 21 | 22 | import com.alibaba.apiopenplatform.core.annotation.AdminAuth; 23 | import com.alibaba.apiopenplatform.dto.params.nacos.CreateNacosParam; 24 | import com.alibaba.apiopenplatform.dto.params.nacos.QueryNacosParam; 25 | import com.alibaba.apiopenplatform.dto.params.nacos.UpdateNacosParam; 26 | import com.alibaba.apiopenplatform.dto.result.MseNacosResult; 27 | import com.alibaba.apiopenplatform.dto.result.NacosMCPServerResult; 28 | import com.alibaba.apiopenplatform.dto.result.NacosNamespaceResult; 29 | import com.alibaba.apiopenplatform.dto.result.NacosResult; 30 | import com.alibaba.apiopenplatform.dto.result.PageResult; 31 | import com.alibaba.apiopenplatform.service.NacosService; 32 | import io.swagger.v3.oas.annotations.Operation; 33 | import io.swagger.v3.oas.annotations.tags.Tag; 34 | import lombok.RequiredArgsConstructor; 35 | import org.springframework.data.domain.Pageable; 36 | import org.springframework.web.bind.annotation.*; 37 | 38 | import javax.validation.Valid; 39 | 40 | @Tag(name = "Nacos资源管理", description = "Nacos实例管理与能力市场统一控制器") 41 | @RestController 42 | @RequestMapping("/nacos") 43 | @RequiredArgsConstructor 44 | @AdminAuth 45 | public class NacosController { 46 | 47 | private final NacosService nacosService; 48 | 49 | @Operation(summary = "获取Nacos实例列表", description = "分页获取Nacos实例列表") 50 | @GetMapping 51 | public PageResult<NacosResult> listNacosInstances(Pageable pageable) { 52 | return nacosService.listNacosInstances(pageable); 53 | } 54 | 55 | @Operation(summary = "从阿里云MSE获取Nacos集群列表") 56 | @GetMapping("/mse") 57 | public PageResult<MseNacosResult> fetchNacos(@Valid QueryNacosParam param, 58 | Pageable pageable) { 59 | return nacosService.fetchNacos(param, pageable); 60 | } 61 | 62 | @Operation(summary = "获取Nacos实例详情", description = "根据ID获取Nacos实例详细信息") 63 | @GetMapping("/{nacosId}") 64 | public NacosResult getNacosInstance(@PathVariable String nacosId) { 65 | return nacosService.getNacosInstance(nacosId); 66 | } 67 | 68 | @Operation(summary = "创建Nacos实例", description = "创建新的Nacos实例") 69 | @PostMapping 70 | public void createNacosInstance(@RequestBody @Valid CreateNacosParam param) { 71 | nacosService.createNacosInstance(param); 72 | } 73 | 74 | @Operation(summary = "更新Nacos实例", description = "更新指定Nacos实例信息") 75 | @PutMapping("/{nacosId}") 76 | public void updateNacosInstance(@PathVariable String nacosId, @RequestBody @Valid UpdateNacosParam param) { 77 | nacosService.updateNacosInstance(nacosId, param); 78 | } 79 | 80 | @Operation(summary = "删除Nacos实例", description = "删除指定的Nacos实例") 81 | @DeleteMapping("/{nacosId}") 82 | public void deleteNacosInstance(@PathVariable String nacosId) { 83 | nacosService.deleteNacosInstance(nacosId); 84 | } 85 | 86 | @Operation(summary = "获取Nacos中的MCP Server列表", description = "获取指定Nacos实例中的MCP Server列表,可按命名空间过滤") 87 | @GetMapping("/{nacosId}/mcp-servers") 88 | public PageResult<NacosMCPServerResult> fetchMcpServers(@PathVariable String nacosId, 89 | @RequestParam(value = "namespaceId", required = false) String namespaceId, 90 | Pageable pageable) throws Exception { 91 | return nacosService.fetchMcpServers(nacosId, namespaceId, pageable); 92 | } 93 | 94 | @Operation(summary = "获取指定Nacos实例的命名空间列表") 95 | @GetMapping("/{nacosId}/namespaces") 96 | public PageResult<NacosNamespaceResult> fetchNamespaces(@PathVariable String nacosId, 97 | Pageable pageable) throws Exception { 98 | return nacosService.fetchNamespaces(nacosId, pageable); 99 | } 100 | 101 | } ``` -------------------------------------------------------------------------------- /portal-web/api-portal-frontend/src/pages/ConsumerDetail.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { useEffect, useState } from "react"; 2 | import { useParams, useNavigate } from "react-router-dom"; 3 | import { Layout } from "../components/Layout"; 4 | import { Alert, Tabs } from "antd"; 5 | import { ArrowLeftOutlined } from "@ant-design/icons"; 6 | import api from "../lib/api"; 7 | import { ConsumerBasicInfo, CredentialManager, SubscriptionManager } from "../components/consumer"; 8 | import type { Consumer, Subscription } from "../types/consumer"; 9 | import type { ApiResponse } from "../types"; 10 | 11 | function ConsumerDetailPage() { 12 | const { consumerId } = useParams(); 13 | const navigate = useNavigate(); 14 | const [subscriptionsLoading, setSubscriptionsLoading] = useState(false); 15 | const [error, setError] = useState(''); 16 | const [consumer, setConsumer] = useState<Consumer>(); 17 | const [subscriptions, setSubscriptions] = useState<Subscription[]>([]); 18 | const [activeTab, setActiveTab] = useState('basic'); 19 | 20 | useEffect(() => { 21 | if (!consumerId) return; 22 | 23 | const fetchConsumerDetail = async () => { 24 | try { 25 | const response: ApiResponse<Consumer> = await api.get(`/consumers/${consumerId}`); 26 | if (response?.code === "SUCCESS" && response?.data) { 27 | setConsumer(response.data); 28 | } 29 | } catch (error) { 30 | console.error('获取消费者详情失败:', error); 31 | setError('加载失败,请稍后重试'); 32 | } 33 | }; 34 | 35 | const fetchSubscriptions = async () => { 36 | setSubscriptionsLoading(true); 37 | try { 38 | const response: ApiResponse<{content: Subscription[], totalElements: number}> = await api.get(`/consumers/${consumerId}/subscriptions`); 39 | if (response?.code === "SUCCESS" && response?.data) { 40 | // 从分页数据中提取实际的订阅数组 41 | const subscriptionsData = response.data.content || []; 42 | setSubscriptions(subscriptionsData); 43 | } 44 | } catch (error) { 45 | console.error('获取订阅列表失败:', error); 46 | } finally { 47 | setSubscriptionsLoading(false); 48 | } 49 | }; 50 | 51 | const loadData = async () => { 52 | try { 53 | await Promise.all([ 54 | fetchConsumerDetail(), 55 | fetchSubscriptions() 56 | ]); 57 | } finally { 58 | // 不设置loading状态,避免闪烁 59 | } 60 | }; 61 | 62 | loadData(); 63 | }, [consumerId]); 64 | 65 | if (error) { 66 | return ( 67 | <Layout> 68 | <Alert 69 | message="加载失败" 70 | description={error} 71 | type="error" 72 | showIcon 73 | className="my-8" /> 74 | </Layout> 75 | ); 76 | } 77 | 78 | return ( 79 | <Layout> 80 | {consumer ? ( 81 | <> 82 | {/* 消费者头部 - 返回按钮 + 消费者名称 */} 83 | <div className="mb-2"> 84 | <div className="flex items-center gap-2"> 85 | <ArrowLeftOutlined 86 | className="text-gray-500 hover:text-gray-700 cursor-pointer" 87 | style={{ fontSize: '20px', fontWeight: 'normal' }} 88 | onClick={() => navigate('/consumers')} 89 | /> 90 | <span className="text-2xl font-normal text-gray-500"> 91 | {consumer.name} 92 | </span> 93 | </div> 94 | </div> 95 | 96 | <Tabs activeKey={activeTab} onChange={setActiveTab}> 97 | <Tabs.TabPane tab="基本信息" key="basic"> 98 | <ConsumerBasicInfo consumer={consumer} /> 99 | <div className="mt-6"> 100 | <CredentialManager 101 | consumerId={consumerId!} 102 | /> 103 | </div> 104 | </Tabs.TabPane> 105 | 106 | <Tabs.TabPane tab="订阅列表" key="authorization"> 107 | <SubscriptionManager 108 | consumerId={consumerId!} 109 | subscriptions={subscriptions} 110 | onSubscriptionsChange={async () => { 111 | // 重新获取订阅列表 112 | if (consumerId) { 113 | setSubscriptionsLoading(true); 114 | try { 115 | const response: ApiResponse<{content: Subscription[], totalElements: number}> = await api.get(`/consumers/${consumerId}/subscriptions`); 116 | if (response?.code === "SUCCESS" && response?.data) { 117 | // 从分页数据中提取实际的订阅数组 118 | const subscriptionsData = response.data.content || []; 119 | setSubscriptions(subscriptionsData); 120 | } 121 | } catch (error) { 122 | console.error('获取订阅列表失败:', error); 123 | } finally { 124 | setSubscriptionsLoading(false); 125 | } 126 | } 127 | }} 128 | loading={subscriptionsLoading} 129 | /> 130 | </Tabs.TabPane> 131 | </Tabs> 132 | </> 133 | ) : ( 134 | <div className="flex items-center justify-center h-64"> 135 | <div className="text-gray-500">加载中...</div> 136 | </div> 137 | )} 138 | </Layout> 139 | ); 140 | } 141 | 142 | export default ConsumerDetailPage; 143 | ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/core/security/JwtAuthenticationFilter.java: -------------------------------------------------------------------------------- ```java 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one 3 | * or more contributor license agreements. See the NOTICE file 4 | * distributed with this work for additional information 5 | * regarding copyright ownership. The ASF licenses this file 6 | * to you under the Apache License, Version 2.0 (the 7 | * "License"); you may not use this file except in compliance 8 | * with the License. You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | package com.alibaba.apiopenplatform.core.security; 21 | 22 | import com.alibaba.apiopenplatform.core.constant.CommonConstants; 23 | import com.alibaba.apiopenplatform.core.utils.TokenUtil; 24 | import com.alibaba.apiopenplatform.support.common.User; 25 | import lombok.extern.slf4j.Slf4j; 26 | import org.jetbrains.annotations.NotNull; 27 | import org.springframework.security.core.Authentication; 28 | import org.springframework.security.core.authority.SimpleGrantedAuthority; 29 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 30 | import org.springframework.security.core.context.SecurityContextHolder; 31 | import org.springframework.web.filter.OncePerRequestFilter; 32 | 33 | import javax.servlet.FilterChain; 34 | import javax.servlet.ServletException; 35 | import javax.servlet.http.HttpServletRequest; 36 | import javax.servlet.http.HttpServletResponse; 37 | import java.io.IOException; 38 | import java.util.Collections; 39 | 40 | @Slf4j 41 | public class JwtAuthenticationFilter extends OncePerRequestFilter { 42 | 43 | // 白名单路径 44 | private static final String[] WHITELIST_PATHS = { 45 | "/admins/init", 46 | "/admins/need-init", 47 | "/admins/login", 48 | "/developers", 49 | "/developers/login", 50 | "/developers/authorize", 51 | "/developers/callback", 52 | "/developers/providers", 53 | "/developers/oidc/authorize", 54 | "/developers/oidc/callback", 55 | "/developers/oidc/providers", 56 | "/developers/oauth2/token", 57 | "/portal/swagger-ui.html", 58 | "/portal/swagger-ui/**", 59 | "/portal/v3/api-docs/**", 60 | "/favicon.ico", 61 | "/error" 62 | }; 63 | 64 | @Override 65 | protected void doFilterInternal(@NotNull HttpServletRequest request, 66 | @NotNull HttpServletResponse response, 67 | @NotNull FilterChain chain) 68 | throws IOException, ServletException { 69 | 70 | // 检查是否是白名单路径 71 | if (isWhitelistPath(request.getRequestURI())) { 72 | chain.doFilter(request, response); 73 | return; 74 | } 75 | 76 | try { 77 | String token = TokenUtil.getTokenFromRequest(request); 78 | if (token != null) { 79 | // 检查token是否被撤销 80 | if (TokenUtil.isTokenRevoked(token)) { 81 | log.debug("Token已被撤销: {}", token); 82 | SecurityContextHolder.clearContext(); 83 | } else { 84 | try { 85 | authenticateRequest(token); 86 | } catch (Exception e) { 87 | log.debug("Token认证失败: {}", e.getMessage()); 88 | SecurityContextHolder.clearContext(); 89 | } 90 | } 91 | } 92 | } catch (Exception e) { 93 | log.debug("Token处理异常: {}", e.getMessage()); 94 | SecurityContextHolder.clearContext(); 95 | } 96 | chain.doFilter(request, response); 97 | } 98 | 99 | private boolean isWhitelistPath(String requestURI) { 100 | for (String whitelistPath : WHITELIST_PATHS) { 101 | if (whitelistPath.endsWith("/**")) { 102 | // 处理通配符路径 103 | String basePath = whitelistPath.substring(0, whitelistPath.length() - 2); 104 | if (requestURI.startsWith(basePath)) { 105 | return true; 106 | } 107 | } else if (requestURI.equals(whitelistPath)) { 108 | return true; 109 | } 110 | } 111 | return false; 112 | } 113 | 114 | private void authenticateRequest(String token) { 115 | User user = TokenUtil.parseUser(token); 116 | // 设置认证信息 117 | String role = CommonConstants.ROLE_PREFIX + user.getUserType().name(); 118 | Authentication authentication = new UsernamePasswordAuthenticationToken( 119 | user.getUserId(), 120 | null, 121 | Collections.singletonList(new SimpleGrantedAuthority(role)) 122 | ); 123 | SecurityContextHolder.getContext().setAuthentication(authentication); 124 | } 125 | } ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/pages/Login.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import React, { useEffect, useState } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | import api from "../lib/api"; 4 | import { authApi } from '@/lib/api' 5 | import { Form, Input, Button, Alert } from "antd"; 6 | 7 | const Login: React.FC = () => { 8 | const [loading, setLoading] = useState(false); 9 | const [error, setError] = useState(""); 10 | const [isRegister, setIsRegister] = useState<boolean | null>(null); // null 表示正在加载 11 | const navigate = useNavigate(); 12 | 13 | // 页面加载时检查权限 14 | useEffect(() => { 15 | const checkAuth = async () => { 16 | try { 17 | const response = await authApi.getNeedInit(); // 替换为你的权限接口 18 | setIsRegister(response.data === true); // 根据接口返回值决定是否显示注册表单 19 | } catch (err) { 20 | setIsRegister(false); // 默认显示登录表单 21 | } 22 | }; 23 | 24 | checkAuth(); 25 | }, []); 26 | 27 | // 登录表单提交 28 | const handleLogin = async (values: { username: string; password: string }) => { 29 | setLoading(true); 30 | setError(""); 31 | try { 32 | const response = await api.post("/admins/login", { 33 | username: values.username, 34 | password: values.password, 35 | }); 36 | const accessToken = response.data.access_token; 37 | localStorage.setItem('access_token', accessToken); 38 | localStorage.setItem('userInfo', JSON.stringify(response.data)); 39 | navigate('/portals'); 40 | } catch { 41 | setError("账号或密码错误"); 42 | } finally { 43 | setLoading(false); 44 | } 45 | }; 46 | 47 | // 注册表单提交 48 | const handleRegister = async (values: { username: string; password: string; confirmPassword: string }) => { 49 | setLoading(true); 50 | setError(""); 51 | if (values.password !== values.confirmPassword) { 52 | setError("两次输入的密码不一致"); 53 | setLoading(false); 54 | return; 55 | } 56 | try { 57 | const response = await api.post("/admins/init", { 58 | username: values.username, 59 | password: values.password, 60 | }); 61 | if (response.data.adminId) { 62 | setIsRegister(false); // 初始化成功后切换到登录状态 63 | } 64 | } catch { 65 | setError("初始化失败,请重试"); 66 | } finally { 67 | setLoading(false); 68 | } 69 | }; 70 | 71 | return ( 72 | <div className="flex items-center justify-center min-h-screen bg-white"> 73 | <div className="bg-white p-8 rounded-xl shadow-2xl w-full max-w-md flex flex-col items-center border border-gray-100"> 74 | {/* Logo */} 75 | <div className="mb-4"> 76 | <img src="/logo.png" alt="Logo" className="w-16 h-16 mx-auto mb-4" /> 77 | </div> 78 | <h2 className="text-2xl font-bold mb-6 text-gray-900 text-center"> 79 | {isRegister ? "注册Admin账号" : "登录HiMarket-后台"} 80 | </h2> 81 | 82 | {/* 登录表单 */} 83 | {!isRegister && ( 84 | <Form 85 | className="w-full flex flex-col gap-4" 86 | layout="vertical" 87 | onFinish={handleLogin} 88 | > 89 | <Form.Item 90 | name="username" 91 | rules={[{ required: true, message: "请输入账号" }]} 92 | > 93 | <Input placeholder="账号" size="large" /> 94 | </Form.Item> 95 | <Form.Item 96 | name="password" 97 | rules={[{ required: true, message: "请输入密码" }]} 98 | > 99 | <Input.Password placeholder="密码" size="large" /> 100 | </Form.Item> 101 | {error && <Alert message={error} type="error" showIcon className="mb-2" />} 102 | <Form.Item> 103 | <Button 104 | type="primary" 105 | htmlType="submit" 106 | className="w-full" 107 | loading={loading} 108 | size="large" 109 | > 110 | 登录 111 | </Button> 112 | </Form.Item> 113 | </Form> 114 | )} 115 | 116 | {/* 注册表单 */} 117 | {isRegister && ( 118 | <Form 119 | className="w-full flex flex-col gap-4" 120 | layout="vertical" 121 | onFinish={handleRegister} 122 | > 123 | <Form.Item 124 | name="username" 125 | rules={[{ required: true, message: "请输入账号" }]} 126 | > 127 | <Input placeholder="账号" size="large" /> 128 | </Form.Item> 129 | <Form.Item 130 | name="password" 131 | rules={[{ required: true, message: "请输入密码" }]} 132 | > 133 | <Input.Password placeholder="密码" size="large" /> 134 | </Form.Item> 135 | <Form.Item 136 | name="confirmPassword" 137 | rules={[{ required: true, message: "请确认密码" }]} 138 | > 139 | <Input.Password placeholder="确认密码" size="large" /> 140 | </Form.Item> 141 | {error && <Alert message={error} type="error" showIcon className="mb-2" />} 142 | <Form.Item> 143 | <Button 144 | type="primary" 145 | htmlType="submit" 146 | className="w-full" 147 | loading={loading} 148 | size="large" 149 | > 150 | 初始化 151 | </Button> 152 | </Form.Item> 153 | </Form> 154 | )} 155 | </div> 156 | </div> 157 | ); 158 | }; 159 | 160 | export default Login; 161 | ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/console/ImportMseNacosModal.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { useState } from 'react' 2 | import { Button, Table, Modal, Form, Input, message } from 'antd' 3 | import { nacosApi } from '@/lib/api' 4 | 5 | interface MseNacosItem { 6 | instanceId: string 7 | name: string 8 | serverIntranetEndpoint?: string 9 | serverInternetEndpoint?: string 10 | version?: string 11 | } 12 | 13 | interface ImportMseNacosModalProps { 14 | visible: boolean 15 | onCancel: () => void 16 | // 将选中的 MSE Nacos 信息带入创建表单 17 | onPrefill: (values: { 18 | nacosName?: string 19 | serverUrl?: string 20 | serverInternetEndpoint?: string 21 | serverIntranetEndpoint?: string 22 | username?: string 23 | password?: string 24 | accessKey?: string 25 | secretKey?: string 26 | description?: string 27 | nacosId?: string 28 | }) => void 29 | } 30 | 31 | export default function ImportMseNacosModal({ visible, onCancel, onPrefill }: ImportMseNacosModalProps) { 32 | const [importForm] = Form.useForm() 33 | 34 | const [loading, setLoading] = useState(false) 35 | const [list, setList] = useState<MseNacosItem[]>([]) 36 | const [selected, setSelected] = useState<MseNacosItem | null>(null) 37 | const [auth, setAuth] = useState({ 38 | regionId: '', 39 | accessKey: '', 40 | secretKey: '' 41 | }) 42 | const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 }) 43 | 44 | const fetchMseNacos = async (values: any, page = 0, size = 10) => { 45 | setLoading(true) 46 | try { 47 | const res = await nacosApi.getMseNacos({ ...values, page, size }) 48 | setList(res.data?.content || []) 49 | setPagination({ current: page + 1, pageSize: size, total: res.data?.totalElements || 0 }) 50 | } catch (e: any) { 51 | // message.error(e?.response?.data?.message || '获取 MSE Nacos 列表失败') 52 | } finally { 53 | setLoading(false) 54 | } 55 | } 56 | 57 | const handleImport = async () => { 58 | if (!selected) { 59 | message.warning('请选择一个 Nacos 实例') 60 | return 61 | } 62 | // 将关键信息带出到创建表单,供用户补充 63 | onPrefill({ 64 | nacosName: selected.name, 65 | serverUrl: selected.serverInternetEndpoint || selected.serverIntranetEndpoint, 66 | serverInternetEndpoint: selected.serverInternetEndpoint, 67 | serverIntranetEndpoint: selected.serverIntranetEndpoint, 68 | accessKey: auth.accessKey, 69 | secretKey: auth.secretKey, 70 | nacosId: selected.instanceId, 71 | }) 72 | handleCancel() 73 | } 74 | 75 | const handleCancel = () => { 76 | setSelected(null) 77 | setList([]) 78 | setPagination({ current: 1, pageSize: 10, total: 0 }) 79 | importForm.resetFields() 80 | onCancel() 81 | } 82 | 83 | return ( 84 | <Modal title="导入 MSE Nacos 实例" open={visible} onCancel={handleCancel} footer={null} width={800}> 85 | <Form form={importForm} layout="vertical" preserve={false}> 86 | {list.length === 0 && ( 87 | <div className="mb-4"> 88 | <h3 className="text-lg font-medium mb-3">认证信息</h3> 89 | <Form.Item label="Region" name="regionId" rules={[{ required: true, message: '请输入region' }]}> 90 | <Input /> 91 | </Form.Item> 92 | <Form.Item label="Access Key" name="accessKey" rules={[{ required: true, message: '请输入accessKey' }]}> 93 | <Input /> 94 | </Form.Item> 95 | <Form.Item label="Secret Key" name="secretKey" rules={[{ required: true, message: '请输入secretKey' }]}> 96 | <Input.Password /> 97 | </Form.Item> 98 | <Button 99 | type="primary" 100 | onClick={() => { 101 | importForm.validateFields().then((values) => { 102 | setAuth(values) 103 | fetchMseNacos(values) 104 | }) 105 | }} 106 | loading={loading} 107 | > 108 | 获取实例列表 109 | </Button> 110 | </div> 111 | )} 112 | 113 | {list.length > 0 && ( 114 | <div className="mb-4"> 115 | <h3 className="text-lg font-medium mb-3">选择 Nacos 实例</h3> 116 | <Table 117 | rowKey="instanceId" 118 | columns={[ 119 | { title: '实例ID', dataIndex: 'instanceId' }, 120 | { title: '名称', dataIndex: 'name' }, 121 | { title: '版本', dataIndex: 'version' }, 122 | ]} 123 | dataSource={list} 124 | rowSelection={{ 125 | type: 'radio', 126 | selectedRowKeys: selected ? [selected.instanceId] : [], 127 | onChange: (_selectedRowKeys, selectedRows) => setSelected(selectedRows[0]), 128 | }} 129 | pagination={{ 130 | current: pagination.current, 131 | pageSize: pagination.pageSize, 132 | total: pagination.total, 133 | onChange: (page, pageSize) => fetchMseNacos(auth, page - 1, pageSize), 134 | showSizeChanger: true, 135 | showQuickJumper: true, 136 | showTotal: (total) => `共 ${total} 条`, 137 | }} 138 | size="small" 139 | /> 140 | </div> 141 | )} 142 | 143 | {selected && ( 144 | <div className="text-right"> 145 | <Button type="primary" onClick={handleImport}> 146 | 导入 147 | </Button> 148 | </div> 149 | )} 150 | </Form> 151 | </Modal> 152 | ) 153 | } 154 | ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/controller/GatewayController.java: -------------------------------------------------------------------------------- ```java 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one 3 | * or more contributor license agreements. See the NOTICE file 4 | * distributed with this work for additional information 5 | * regarding copyright ownership. The ASF licenses this file 6 | * to you under the Apache License, Version 2.0 (the 7 | * "License"); you may not use this file except in compliance 8 | * with the License. You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | package com.alibaba.apiopenplatform.controller; 21 | 22 | import com.alibaba.apiopenplatform.core.annotation.AdminAuth; 23 | import com.alibaba.apiopenplatform.dto.params.gateway.ImportGatewayParam; 24 | import com.alibaba.apiopenplatform.dto.params.gateway.QueryAPIGParam; 25 | import com.alibaba.apiopenplatform.dto.params.gateway.QueryAdpAIGatewayParam; 26 | import com.alibaba.apiopenplatform.dto.params.gateway.QueryGatewayParam; 27 | import com.alibaba.apiopenplatform.dto.result.GatewayMCPServerResult; 28 | import com.alibaba.apiopenplatform.dto.result.*; 29 | import com.alibaba.apiopenplatform.service.GatewayService; 30 | import com.alibaba.apiopenplatform.service.AdpAIGatewayService; 31 | import io.swagger.v3.oas.annotations.Operation; 32 | import io.swagger.v3.oas.annotations.tags.Tag; 33 | import lombok.RequiredArgsConstructor; 34 | import org.springframework.data.domain.Pageable; 35 | import org.springframework.web.bind.annotation.*; 36 | 37 | import javax.validation.Valid; 38 | 39 | @Tag(name = "网关资源管理") 40 | @RestController 41 | @RequestMapping("/gateways") 42 | @RequiredArgsConstructor 43 | @AdminAuth 44 | public class GatewayController { 45 | 46 | private final GatewayService gatewayService; 47 | private final AdpAIGatewayService adpAIGatewayService; 48 | 49 | @Operation(summary = "获取APIG Gateway列表") 50 | @GetMapping("/apig") 51 | public PageResult<GatewayResult> fetchGateways(@Valid QueryAPIGParam param, 52 | @RequestParam(defaultValue = "1") int page, 53 | @RequestParam(defaultValue = "500") int size) { 54 | return gatewayService.fetchGateways(param, page, size); 55 | } 56 | 57 | @Operation(summary = "获取ADP AI Gateway列表") 58 | @PostMapping("/adp") 59 | public PageResult<GatewayResult> fetchAdpGateways(@RequestBody @Valid QueryAdpAIGatewayParam param, 60 | @RequestParam(defaultValue = "1") int page, 61 | @RequestParam(defaultValue = "500") int size) { 62 | return adpAIGatewayService.fetchGateways(param, page, size); 63 | } 64 | 65 | @Operation(summary = "获取导入的Gateway列表") 66 | @GetMapping 67 | public PageResult<GatewayResult> listGateways(QueryGatewayParam param, Pageable pageable) { 68 | return gatewayService.listGateways(param, pageable); 69 | } 70 | 71 | @Operation(summary = "导入Gateway") 72 | @PostMapping 73 | public void importGateway(@RequestBody @Valid ImportGatewayParam param) { 74 | gatewayService.importGateway(param); 75 | } 76 | 77 | @Operation(summary = "删除Gateway") 78 | @DeleteMapping("/{gatewayId}") 79 | public void deleteGateway(@PathVariable String gatewayId) { 80 | gatewayService.deleteGateway(gatewayId); 81 | } 82 | 83 | @Operation(summary = "获取REST API列表") 84 | @GetMapping("/{gatewayId}/rest-apis") 85 | public PageResult<APIResult> fetchRESTAPIs(@PathVariable String gatewayId, 86 | @RequestParam(defaultValue = "1") int page, 87 | @RequestParam(defaultValue = "500") int size) { 88 | return gatewayService.fetchRESTAPIs(gatewayId, page, size); 89 | } 90 | 91 | // @Operation(summary = "获取API列表") 92 | // @GetMapping("/{gatewayId}/apis") 93 | // public PageResult<APIResult> fetchAPIs(@PathVariable String gatewayId, 94 | // @RequestParam String apiType, 95 | // Pageable pageable) { 96 | // return gatewayService.fetchAPIs(gatewayId, apiType, pageable); 97 | // } 98 | 99 | @Operation(summary = "获取MCP Server列表") 100 | @GetMapping("/{gatewayId}/mcp-servers") 101 | public PageResult<GatewayMCPServerResult> fetchMcpServers(@PathVariable String gatewayId, 102 | @RequestParam(defaultValue = "1") int page, 103 | @RequestParam(defaultValue = "500") int size) { 104 | return gatewayService.fetchMcpServers(gatewayId, page, size); 105 | } 106 | 107 | @Operation(summary = "获取仪表板URL") 108 | @GetMapping("/{gatewayId}/dashboard") 109 | public String getDashboard(@PathVariable String gatewayId, 110 | @RequestParam(required = false, defaultValue = "API") String type) { 111 | return gatewayService.getDashboard(gatewayId, type); 112 | } 113 | } 114 | ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/service/gateway/GatewayOperator.java: -------------------------------------------------------------------------------- ```java 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one 3 | * or more contributor license agreements. See the NOTICE file 4 | * distributed with this work for additional information 5 | * regarding copyright ownership. The ASF licenses this file 6 | * to you under the Apache License, Version 2.0 (the 7 | * "License"); you may not use this file except in compliance 8 | * with the License. You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | package com.alibaba.apiopenplatform.service.gateway; 21 | 22 | import com.alibaba.apiopenplatform.core.exception.BusinessException; 23 | import com.alibaba.apiopenplatform.core.exception.ErrorCode; 24 | import com.alibaba.apiopenplatform.dto.result.GatewayMCPServerResult; 25 | import com.alibaba.apiopenplatform.dto.result.*; 26 | import com.alibaba.apiopenplatform.entity.*; 27 | import com.alibaba.apiopenplatform.service.gateway.client.APIGClient; 28 | import com.alibaba.apiopenplatform.service.gateway.client.GatewayClient; 29 | import com.alibaba.apiopenplatform.service.gateway.client.HigressClient; 30 | import com.alibaba.apiopenplatform.support.consumer.ConsumerAuthConfig; 31 | import com.alibaba.apiopenplatform.support.enums.GatewayType; 32 | import com.alibaba.apiopenplatform.support.gateway.GatewayConfig; 33 | import lombok.extern.slf4j.Slf4j; 34 | 35 | import java.util.Map; 36 | import java.util.concurrent.ConcurrentHashMap; 37 | 38 | @Slf4j 39 | public abstract class GatewayOperator<T> { 40 | 41 | private final Map<String, GatewayClient> clientCache = new ConcurrentHashMap<>(); 42 | 43 | abstract public PageResult<APIResult> fetchHTTPAPIs(Gateway gateway, int page, int size); 44 | 45 | abstract public PageResult<APIResult> fetchRESTAPIs(Gateway gateway, int page, int size); 46 | 47 | abstract public PageResult<? extends GatewayMCPServerResult> fetchMcpServers(Gateway gateway, int page, int size); 48 | 49 | abstract public String fetchAPIConfig(Gateway gateway, Object config); 50 | 51 | abstract public String fetchMcpConfig(Gateway gateway, Object conf); 52 | 53 | abstract public PageResult<GatewayResult> fetchGateways(Object param, int page, int size); 54 | 55 | abstract public String createConsumer(Consumer consumer, ConsumerCredential credential, GatewayConfig config); 56 | 57 | abstract public void updateConsumer(String consumerId, ConsumerCredential credential, GatewayConfig config); 58 | 59 | abstract public void deleteConsumer(String consumerId, GatewayConfig config); 60 | 61 | /** 62 | * 检查消费者是否存在于网关中 63 | * @param consumerId 消费者ID 64 | * @param config 网关配置 65 | * @return 是否存在 66 | */ 67 | abstract public boolean isConsumerExists(String consumerId, GatewayConfig config); 68 | 69 | abstract public ConsumerAuthConfig authorizeConsumer(Gateway gateway, String consumerId, Object refConfig); 70 | 71 | abstract public void revokeConsumerAuthorization(Gateway gateway, String consumerId, ConsumerAuthConfig authConfig); 72 | 73 | abstract public APIResult fetchAPI(Gateway gateway, String apiId); 74 | 75 | abstract public GatewayType getGatewayType(); 76 | 77 | /** 78 | * 获取网关控制台仪表盘链接 79 | * @param gateway 网关实体 80 | * @return 仪表盘访问链接 81 | */ 82 | abstract public String getDashboard(Gateway gateway,String type); 83 | 84 | @SuppressWarnings("unchecked") 85 | protected T getClient(Gateway gateway) { 86 | String clientKey = gateway.getGatewayType().isAPIG() ? 87 | gateway.getApigConfig().buildUniqueKey() : gateway.getHigressConfig().buildUniqueKey(); 88 | return (T) clientCache.computeIfAbsent( 89 | clientKey, 90 | key -> createClient(gateway) 91 | ); 92 | } 93 | 94 | // @SuppressWarnings("unchecked") 95 | // protected T getClient(Gateway gateway) { 96 | // String clientKey = gateway.getGatewayType().isAPIG() ? 97 | // gateway.getApigConfig().buildUniqueKey() : gateway.getHigressConfig().buildUniqueKey(); 98 | // return (T) clientCache.computeIfAbsent( 99 | // clientKey, 100 | // key -> createClient(gateway) 101 | // ); 102 | // } 103 | 104 | /** 105 | * 创建网关客户端 106 | */ 107 | private GatewayClient createClient(Gateway gateway) { 108 | switch (gateway.getGatewayType()) { 109 | case APIG_API: 110 | case APIG_AI: 111 | return new APIGClient(gateway.getApigConfig()); 112 | case HIGRESS: 113 | return new HigressClient(gateway.getHigressConfig()); 114 | default: 115 | throw new BusinessException(ErrorCode.INTERNAL_ERROR, 116 | "No factory found for gateway type: " + gateway.getGatewayType()); 117 | } 118 | } 119 | 120 | /** 121 | * 移除网关客户端 122 | */ 123 | public void removeClient(String instanceId) { 124 | GatewayClient client = clientCache.remove(instanceId); 125 | try { 126 | client.close(); 127 | } catch (Exception e) { 128 | log.error("Error closing client for instance: {}", instanceId, e); 129 | } 130 | } 131 | } 132 | ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/controller/ProductController.java: -------------------------------------------------------------------------------- ```java 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one 3 | * or more contributor license agreements. See the NOTICE file 4 | * distributed with this work for additional information 5 | * regarding copyright ownership. The ASF licenses this file 6 | * to you under the Apache License, Version 2.0 (the 7 | * "License"); you may not use this file except in compliance 8 | * with the License. You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | package com.alibaba.apiopenplatform.controller; 21 | 22 | import com.alibaba.apiopenplatform.core.annotation.AdminAuth; 23 | import com.alibaba.apiopenplatform.core.annotation.AdminOrDeveloperAuth; 24 | import com.alibaba.apiopenplatform.dto.params.product.*; 25 | import com.alibaba.apiopenplatform.dto.params.product.CreateProductRefParam; 26 | import com.alibaba.apiopenplatform.dto.result.*; 27 | import com.alibaba.apiopenplatform.service.ProductService; 28 | import io.swagger.v3.oas.annotations.Operation; 29 | import io.swagger.v3.oas.annotations.tags.Tag; 30 | import lombok.RequiredArgsConstructor; 31 | import lombok.extern.slf4j.Slf4j; 32 | import org.springframework.data.domain.Pageable; 33 | import org.springframework.web.bind.annotation.*; 34 | 35 | import javax.validation.Valid; 36 | 37 | @Tag(name = "API产品管理", description = "提供API产品的创建、更新、删除、查询、订阅等管理功能") 38 | @RestController 39 | @RequestMapping("/products") 40 | @Slf4j 41 | @RequiredArgsConstructor 42 | public class ProductController { 43 | 44 | private final ProductService productService; 45 | 46 | @Operation(summary = "创建API产品") 47 | @PostMapping 48 | @AdminAuth 49 | public ProductResult createProduct(@RequestBody @Valid CreateProductParam param) { 50 | return productService.createProduct(param); 51 | } 52 | 53 | @Operation(summary = "获取API产品列表") 54 | @GetMapping 55 | public PageResult<ProductResult> listProducts(QueryProductParam param, 56 | Pageable pageable) { 57 | return productService.listProducts(param, pageable); 58 | } 59 | 60 | @Operation(summary = "获取API产品详情") 61 | @GetMapping("/{productId}") 62 | public ProductResult getProduct(@PathVariable String productId) { 63 | return productService.getProduct(productId); 64 | } 65 | 66 | @Operation(summary = "更新API产品") 67 | @PutMapping("/{productId}") 68 | @AdminAuth 69 | public ProductResult updateProduct(@PathVariable String productId, @RequestBody @Valid UpdateProductParam param) { 70 | return productService.updateProduct(productId, param); 71 | } 72 | 73 | @Operation(summary = "发布API产品") 74 | @PostMapping("/{productId}/publications/{portalId}") 75 | @AdminAuth 76 | public void publishProduct(@PathVariable String productId, @PathVariable String portalId) { 77 | productService.publishProduct(productId, portalId); 78 | } 79 | 80 | @Operation(summary = "获取API产品的发布信息") 81 | @GetMapping("/{productId}/publications") 82 | @AdminAuth 83 | public PageResult<ProductPublicationResult> getPublications(@PathVariable String productId, Pageable pageable) { 84 | return productService.getPublications(productId, pageable); 85 | } 86 | 87 | @Operation(summary = "下线API产品") 88 | @DeleteMapping("/{productId}/publications/{portalId}") 89 | @AdminAuth 90 | public void unpublishProduct(@PathVariable String productId, @PathVariable String portalId) { 91 | productService.unpublishProduct(productId, portalId); 92 | } 93 | 94 | @Operation(summary = "删除API产品") 95 | @DeleteMapping("/{productId}") 96 | @AdminAuth 97 | public void deleteProduct(@PathVariable String productId) { 98 | productService.deleteProduct(productId); 99 | } 100 | 101 | @Operation(summary = "API产品关联API或MCP Server") 102 | @PostMapping("/{productId}/ref") 103 | @AdminAuth 104 | public void addProductRef(@PathVariable String productId, @RequestBody @Valid CreateProductRefParam param) throws Exception { 105 | productService.addProductRef(productId, param); 106 | } 107 | 108 | @Operation(summary = "获取API产品关联的API或MCP Server") 109 | @GetMapping("/{productId}/ref") 110 | public ProductRefResult getProductRef(@PathVariable String productId) { 111 | return productService.getProductRef(productId); 112 | } 113 | 114 | @Operation(summary = "删除API产品关联的API或MCP Server") 115 | @DeleteMapping("/{productId}/ref") 116 | @AdminAuth 117 | public void deleteProductRef(@PathVariable String productId) { 118 | productService.deleteProductRef(productId); 119 | } 120 | 121 | @Operation(summary = "获取API产品的Dashboard监控面板URL") 122 | @GetMapping("/{productId}/dashboard") 123 | public String getProductDashboard(@PathVariable String productId) { 124 | return productService.getProductDashboard(productId); 125 | } 126 | 127 | @Operation(summary = "获取产品的订阅列表") 128 | @GetMapping("/{productId}/subscriptions") 129 | @AdminOrDeveloperAuth 130 | public PageResult<SubscriptionResult> listProductSubscriptions( 131 | @PathVariable String productId, 132 | QueryProductSubscriptionParam param, 133 | Pageable pageable) { 134 | return productService.listProductSubscriptions(productId, param, pageable); 135 | } 136 | } 137 | ``` -------------------------------------------------------------------------------- /portal-bootstrap/src/main/java/com/alibaba/apiopenplatform/config/SecurityConfig.java: -------------------------------------------------------------------------------- ```java 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one 3 | * or more contributor license agreements. See the NOTICE file 4 | * distributed with this work for additional information 5 | * regarding copyright ownership. The ASF licenses this file 6 | * to you under the Apache License, Version 2.0 (the 7 | * "License"); you may not use this file except in compliance 8 | * with the License. You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | package com.alibaba.apiopenplatform.config; 21 | 22 | import com.alibaba.apiopenplatform.core.security.JwtAuthenticationFilter; 23 | import com.alibaba.apiopenplatform.core.utils.TokenUtil; 24 | import lombok.RequiredArgsConstructor; 25 | import lombok.extern.slf4j.Slf4j; 26 | import org.springframework.context.annotation.Bean; 27 | import org.springframework.context.annotation.Configuration; 28 | import org.springframework.security.authentication.AuthenticationManager; 29 | import org.springframework.security.config.Customizer; 30 | import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; 31 | import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; 32 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 33 | import org.springframework.security.config.http.SessionCreationPolicy; 34 | import org.springframework.security.web.SecurityFilterChain; 35 | import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; 36 | 37 | import org.springframework.web.cors.CorsConfiguration; 38 | import org.springframework.web.cors.CorsConfigurationSource; 39 | import org.springframework.web.cors.UrlBasedCorsConfigurationSource; 40 | 41 | import java.util.*; 42 | 43 | import com.alibaba.apiopenplatform.core.security.DeveloperAuthenticationProvider; 44 | import org.springframework.http.HttpMethod; 45 | 46 | /** 47 | * Spring Security安全配置,集成JWT认证与接口权限控制,支持管理员和开发者多用户体系 48 | * 49 | */ 50 | @Configuration 51 | @RequiredArgsConstructor 52 | @Slf4j 53 | @EnableGlobalMethodSecurity(prePostEnabled = true) 54 | public class SecurityConfig { 55 | 56 | private final DeveloperAuthenticationProvider developerAuthenticationProvider; 57 | 58 | // Auth相关 59 | private static final String[] AUTH_WHITELIST = { 60 | "/admins/init", 61 | "/admins/need-init", 62 | "/admins/login", 63 | "/developers", 64 | "/developers/login", 65 | "/developers/authorize", 66 | "/developers/callback", 67 | "/developers/providers", 68 | "/developers/oidc/authorize", 69 | "/developers/oidc/callback", 70 | "/developers/oidc/providers", 71 | "/developers/oauth2/token" 72 | }; 73 | 74 | // Swagger API文档相关 75 | private static final String[] SWAGGER_WHITELIST = { 76 | "/portal/swagger-ui.html", 77 | "/portal/swagger-ui/**", 78 | "/portal/v3/api-docs/**" 79 | }; 80 | 81 | // 系统路径白名单 82 | private static final String[] SYSTEM_WHITELIST = { 83 | "/favicon.ico", 84 | "/error" 85 | }; 86 | 87 | @Bean 88 | public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { 89 | http 90 | .cors(Customizer.withDefaults()) 91 | .csrf().disable() 92 | .sessionManagement() 93 | .sessionCreationPolicy(SessionCreationPolicy.STATELESS) 94 | // .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) 95 | .and() 96 | .authorizeRequests() 97 | // OPTIONS请求放行 98 | .antMatchers(HttpMethod.OPTIONS, "/**").permitAll() 99 | // 认证相关接口放行 100 | .antMatchers(AUTH_WHITELIST).permitAll() 101 | // Swagger相关接口放行 102 | .antMatchers(SWAGGER_WHITELIST).permitAll() 103 | // 系统路径放行 104 | .antMatchers(SYSTEM_WHITELIST).permitAll() 105 | .anyRequest().authenticated() 106 | .and() 107 | .addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) 108 | .authenticationProvider(developerAuthenticationProvider); 109 | return http.build(); 110 | } 111 | 112 | @Bean 113 | public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { 114 | return authenticationConfiguration.getAuthenticationManager(); 115 | } 116 | 117 | @Bean 118 | public CorsConfigurationSource corsConfigurationSource() { 119 | CorsConfiguration corsConfig = new CorsConfiguration(); 120 | corsConfig.setAllowedOriginPatterns(Collections.singletonList("*")); 121 | corsConfig.setAllowedMethods(Collections.singletonList("*")); 122 | corsConfig.setAllowedHeaders(Collections.singletonList("*")); 123 | corsConfig.setAllowCredentials(true); 124 | corsConfig.setMaxAge(3600L); 125 | 126 | UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); 127 | source.registerCorsConfiguration("/**", corsConfig); 128 | return source; 129 | } 130 | } ``` -------------------------------------------------------------------------------- /portal-server/pom.xml: -------------------------------------------------------------------------------- ``` 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <project xmlns="http://maven.apache.org/POM/4.0.0" 3 | xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 4 | xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 5 | <modelVersion>4.0.0</modelVersion> 6 | <parent> 7 | <groupId>com.alibaba.himarket</groupId> 8 | <artifactId>himarket</artifactId> 9 | <version>1.0-SNAPSHOT</version> 10 | </parent> 11 | 12 | <artifactId>portal-server</artifactId> 13 | 14 | <properties> 15 | <maven.compiler.source>8</maven.compiler.source> 16 | <maven.compiler.target>8</maven.compiler.target> 17 | <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> 18 | </properties> 19 | 20 | <dependencies> 21 | <dependency> 22 | <groupId>com.alibaba.himarket</groupId> 23 | <artifactId>portal-dal</artifactId> 24 | <version>1.0-SNAPSHOT</version> 25 | <exclusions> 26 | <exclusion> 27 | <artifactId>checker-qual</artifactId> 28 | <groupId>org.checkerframework</groupId> 29 | </exclusion> 30 | </exclusions> 31 | </dependency> 32 | 33 | <dependency> 34 | <groupId>org.springframework.boot</groupId> 35 | <artifactId>spring-boot-starter-web</artifactId> 36 | </dependency> 37 | 38 | <dependency> 39 | <groupId>org.springframework.boot</groupId> 40 | <artifactId>spring-boot-starter-validation</artifactId> 41 | </dependency> 42 | 43 | <dependency> 44 | <groupId>org.springframework.boot</groupId> 45 | <artifactId>spring-boot-starter-oauth2-client</artifactId> 46 | <version>${spring-boot.version}</version> 47 | </dependency> 48 | 49 | <dependency> 50 | <groupId>org.springdoc</groupId> 51 | <artifactId>springdoc-openapi-ui</artifactId> 52 | </dependency> 53 | 54 | <dependency> 55 | <groupId>org.springframework.boot</groupId> 56 | <artifactId>spring-boot-starter-test</artifactId> 57 | </dependency> 58 | 59 | <dependency> 60 | <groupId>org.springframework.boot</groupId> 61 | <artifactId>spring-boot-starter-security</artifactId> 62 | </dependency> 63 | 64 | <!-- <dependency>--> 65 | <!-- <groupId>org.springframework.boot</groupId>--> 66 | <!-- <artifactId>spring-boot-starter-mail</artifactId>--> 67 | <!-- </dependency>--> 68 | 69 | <dependency> 70 | <groupId>org.springframework.boot</groupId> 71 | <artifactId>spring-boot-starter-thymeleaf</artifactId> 72 | </dependency> 73 | 74 | <dependency> 75 | <groupId>com.fasterxml.jackson.datatype</groupId> 76 | <artifactId>jackson-datatype-jsr310</artifactId> 77 | </dependency> 78 | 79 | <dependency> 80 | <groupId>com.aliyun</groupId> 81 | <artifactId>aliyun-java-sdk-core</artifactId> 82 | <exclusions> 83 | <exclusion> 84 | <artifactId>org.jacoco.agent</artifactId> 85 | <groupId>org.jacoco</groupId> 86 | </exclusion> 87 | </exclusions> 88 | </dependency> 89 | 90 | <dependency> 91 | <groupId>com.aliyun</groupId> 92 | <artifactId>alibabacloud-apig20240327</artifactId> 93 | <exclusions> 94 | <exclusion> 95 | <artifactId>annotations</artifactId> 96 | <groupId>org.jetbrains</groupId> 97 | </exclusion> 98 | </exclusions> 99 | </dependency> 100 | 101 | <dependency> 102 | <groupId>com.squareup.okhttp3</groupId> 103 | <artifactId>okhttp</artifactId> 104 | </dependency> 105 | 106 | <dependency> 107 | <groupId>com.alibaba.nacos</groupId> 108 | <artifactId>nacos-maintainer-client</artifactId> 109 | <version>3.0.2</version> 110 | </dependency> 111 | 112 | <dependency> 113 | <groupId>com.aliyun</groupId> 114 | <artifactId>mse20190531</artifactId> 115 | <exclusions> 116 | <exclusion> 117 | <artifactId>dom4j</artifactId> 118 | <groupId>org.dom4j</groupId> 119 | </exclusion> 120 | <exclusion> 121 | <artifactId>credentials-java</artifactId> 122 | <groupId>com.aliyun</groupId> 123 | </exclusion> 124 | </exclusions> 125 | </dependency> 126 | 127 | <dependency> 128 | <groupId>com.aliyun</groupId> 129 | <artifactId>alibabacloud-sls20201230</artifactId> 130 | <version>4.0.11</version> 131 | </dependency> 132 | 133 | <dependency> 134 | <groupId>org.bouncycastle</groupId> 135 | <artifactId>bcprov-jdk15to18</artifactId> 136 | </dependency> 137 | <dependency> 138 | <groupId>cn.hutool</groupId> 139 | <artifactId>hutool-all</artifactId> 140 | <exclusions> 141 | <exclusion> 142 | <groupId>org.bouncycastle</groupId> 143 | <artifactId>bcprov-jdk16</artifactId> 144 | </exclusion> 145 | </exclusions> 146 | </dependency> 147 | 148 | <!-- SnakeYAML for JSON to YAML conversion --> 149 | <dependency> 150 | <groupId>org.yaml</groupId> 151 | <artifactId>snakeyaml</artifactId> 152 | <version>2.0</version> 153 | </dependency> 154 | </dependencies> 155 | 156 | </project> ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/Layout.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import React, { useState, useEffect } from 'react' 2 | import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom' 3 | import { GlobalOutlined, AppstoreOutlined, DesktopOutlined, UserOutlined, MenuOutlined, SettingOutlined } from '@ant-design/icons' 4 | import { Button } from 'antd' 5 | import { isAuthenticated, removeToken } from '../lib/utils' 6 | 7 | interface NavigationItem { 8 | name: string 9 | cn: string 10 | href: string 11 | icon: React.ComponentType<any> 12 | children?: NavigationItem[] 13 | } 14 | 15 | const Layout: React.FC = () => { 16 | const location = useLocation() 17 | const navigate = useNavigate() 18 | const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(false) 19 | const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false) 20 | 21 | useEffect(() => { 22 | // 检查 cookie 中的 token 来判断登录状态 23 | const checkAuthStatus = () => { 24 | const hasToken = isAuthenticated() 25 | setIsLoggedIn(hasToken) 26 | } 27 | 28 | checkAuthStatus() 29 | // 监听 storage 变化(当其他标签页登录/登出时) 30 | window.addEventListener('storage', checkAuthStatus) 31 | 32 | return () => { 33 | window.removeEventListener('storage', checkAuthStatus) 34 | } 35 | }, []) 36 | 37 | useEffect(() => { 38 | // 进入详情页自动折叠侧边栏 39 | if (location.pathname.startsWith('/portals/detail') || location.pathname.startsWith('/api-products/detail')) { 40 | setSidebarCollapsed(true) 41 | } else { 42 | setSidebarCollapsed(false) 43 | } 44 | }, [location.pathname]) 45 | 46 | const navigation: NavigationItem[] = [ 47 | { name: 'Portal', cn: '门户', href: '/portals', icon: GlobalOutlined }, 48 | { name: 'API Products', cn: 'API产品', href: '/api-products', icon: AppstoreOutlined }, 49 | { 50 | name: '实例管理', 51 | cn: '实例管理', 52 | href: '/consoles', 53 | icon: SettingOutlined, 54 | children: [ 55 | { name: 'Nacos实例', cn: 'Nacos实例', href: '/consoles/nacos', icon: DesktopOutlined }, 56 | { name: '网关实例', cn: '网关实例', href: '/consoles/gateway', icon: DesktopOutlined }, 57 | ] 58 | }, 59 | ] 60 | 61 | const toggleSidebar = () => { 62 | setSidebarCollapsed(!sidebarCollapsed) 63 | } 64 | 65 | const handleLogout = () => { 66 | removeToken() 67 | setIsLoggedIn(false) 68 | navigate('/login') 69 | } 70 | 71 | const isMenuActive = (item: NavigationItem): boolean => { 72 | if (location.pathname === item.href) return true 73 | if (item.children) { 74 | return item.children.some(child => location.pathname === child.href) 75 | } 76 | return false 77 | } 78 | 79 | const renderMenuItem = (item: NavigationItem, level: number = 0) => { 80 | const Icon = item.icon 81 | const isActive = isMenuActive(item) 82 | const hasChildren = item.children && item.children.length > 0 83 | 84 | return ( 85 | <div key={item.name}> 86 | <Link 87 | to={item.href} 88 | className={`flex items-center mt-2 px-3 py-3 rounded-lg transition-colors duration-150 ${ 89 | level > 0 ? 'ml-4' : '' 90 | } ${ 91 | isActive && !hasChildren 92 | ? 'bg-gray-100 text-black font-semibold' 93 | : 'text-gray-500 hover:text-black hover:bg-gray-50' 94 | }`} 95 | title={sidebarCollapsed ? item.name : ''} 96 | > 97 | <Icon className="mr-3 h-5 w-5 flex-shrink-0" /> 98 | {!sidebarCollapsed && ( 99 | <div className="flex flex-col flex-1"> 100 | <span className="text-base leading-none">{item.name}</span> 101 | </div> 102 | )} 103 | </Link> 104 | {!sidebarCollapsed && hasChildren && ( 105 | <div className="ml-2"> 106 | {item.children!.map(child => renderMenuItem(child, level + 1))} 107 | </div> 108 | )} 109 | </div> 110 | ) 111 | } 112 | 113 | return ( 114 | <div className="min-h-screen bg-background"> 115 | {/* 顶部导航栏 */} 116 | <header className="w-full h-16 flex items-center justify-between px-8 bg-white border-b shadow-sm"> 117 | <div className="flex items-center space-x-2"> 118 | <div className="bg-white"> 119 | <Button 120 | type="text" 121 | icon={<MenuOutlined />} 122 | onClick={toggleSidebar} 123 | className="hover:bg-gray-100" 124 | /> 125 | </div> 126 | <span className="text-2xl font-bold">HiMarket</span> 127 | </div> 128 | {/* 顶部右侧用户信息或登录按钮 */} 129 | {isLoggedIn ? ( 130 | <div className="flex items-center space-x-2"> 131 | <UserOutlined className="mr-2 text-lg" /> 132 | <span>admin</span> 133 | <button 134 | onClick={handleLogout} 135 | className="ml-2 px-2 py-1 rounded bg-gray-200 hover:bg-gray-300" 136 | > 137 | 退出 138 | </button> 139 | </div> 140 | ) : ( 141 | <button onClick={() => navigate('/login')} className="flex items-center px-4 py-2 rounded bg-black text-white hover:bg-gray-800"> 142 | <UserOutlined className="mr-2" /> 登录 143 | </button> 144 | )} 145 | </header> 146 | <div className="flex"> 147 | {/* 侧边栏 */} 148 | <aside className={`bg-white border-r min-h-screen pt-8 transition-all duration-300 ${ 149 | sidebarCollapsed ? 'w-16' : 'w-64' 150 | }`}> 151 | <nav className="flex flex-col space-y-2 px-4"> 152 | {navigation.map(item => renderMenuItem(item))} 153 | </nav> 154 | </aside> 155 | 156 | {/* 主内容区域 */} 157 | <div className="flex-1 min-h-screen overflow-hidden"> 158 | <main className="p-8 w-full max-w-full overflow-x-hidden"> 159 | <Outlet /> 160 | </main> 161 | </div> 162 | </div> 163 | </div> 164 | ) 165 | } 166 | 167 | export default Layout 168 | ``` -------------------------------------------------------------------------------- /portal-web/api-portal-frontend/src/components/SwaggerUIWrapper.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import React from 'react'; 2 | import SwaggerUI from 'swagger-ui-react'; 3 | import 'swagger-ui-react/swagger-ui.css'; 4 | import './SwaggerUIWrapper.css'; 5 | import * as yaml from 'js-yaml'; 6 | import { message } from 'antd'; 7 | 8 | interface SwaggerUIWrapperProps { 9 | apiSpec: string; 10 | } 11 | 12 | export const SwaggerUIWrapper: React.FC<SwaggerUIWrapperProps> = ({ apiSpec }) => { 13 | // 直接解析原始规范,不进行重新构建 14 | let swaggerSpec: any; 15 | 16 | try { 17 | // 尝试解析YAML格式 18 | try { 19 | swaggerSpec = yaml.load(apiSpec); 20 | } catch { 21 | // 如果YAML解析失败,尝试JSON格式 22 | swaggerSpec = JSON.parse(apiSpec); 23 | } 24 | 25 | if (!swaggerSpec || !swaggerSpec.paths) { 26 | throw new Error('Invalid OpenAPI specification'); 27 | } 28 | 29 | // 为没有tags的操作添加默认标签,避免显示"default" 30 | Object.keys(swaggerSpec.paths).forEach(path => { 31 | const pathItem = swaggerSpec.paths[path]; 32 | Object.keys(pathItem).forEach(method => { 33 | const operation = pathItem[method]; 34 | if (operation && typeof operation === 'object' && !operation.tags) { 35 | operation.tags = ['接口列表']; 36 | } 37 | }); 38 | }); 39 | } catch (error) { 40 | console.error('OpenAPI规范解析失败:', error); 41 | return ( 42 | <div className="text-center text-gray-500 py-8 bg-gray-50 rounded-lg"> 43 | <p>无法解析OpenAPI规范</p> 44 | <div className="text-sm text-gray-400 mt-2"> 45 | 请检查API配置格式是否正确 46 | </div> 47 | <div className="text-xs text-gray-400 mt-1"> 48 | 错误详情: {error instanceof Error ? error.message : String(error)} 49 | </div> 50 | </div> 51 | ); 52 | } 53 | 54 | return ( 55 | <div className="swagger-ui-wrapper"> 56 | <SwaggerUI 57 | spec={swaggerSpec} 58 | docExpansion="list" 59 | displayRequestDuration={true} 60 | tryItOutEnabled={true} 61 | filter={false} 62 | defaultModelsExpandDepth={0} 63 | defaultModelExpandDepth={0} 64 | displayOperationId={true} 65 | supportedSubmitMethods={['get', 'post', 'put', 'delete', 'patch', 'head', 'options']} 66 | deepLinking={false} 67 | requestInterceptor={(request: any) => { 68 | console.log('Request:', request); 69 | return request; 70 | }} 71 | responseInterceptor={(response: any) => { 72 | console.log('Response:', response); 73 | return response; 74 | }} 75 | onComplete={() => { 76 | console.log('Swagger UI loaded'); 77 | // 添加服务器复制功能 - 使用requestAnimationFrame优化性能 78 | const addCopyButton = () => { 79 | const serversContainer = document.querySelector('.swagger-ui .servers'); 80 | if (serversContainer && !serversContainer.querySelector('.copy-server-btn')) { 81 | const copyBtn = document.createElement('button'); 82 | copyBtn.className = 'copy-server-btn'; 83 | copyBtn.innerHTML = ` 84 | <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"> 85 | <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/> 86 | </svg> 87 | `; 88 | copyBtn.title = '复制服务器地址'; 89 | copyBtn.style.cssText = ` 90 | position: absolute; 91 | right: 12px; 92 | top: 50%; 93 | transform: translateY(-50%); 94 | background: transparent; 95 | border: none; 96 | border-radius: 4px; 97 | padding: 6px 8px; 98 | cursor: pointer; 99 | color: #666; 100 | transition: all 0.2s; 101 | z-index: 10; 102 | display: flex; 103 | align-items: center; 104 | justify-content: center; 105 | `; 106 | 107 | // 添加hover效果 108 | copyBtn.addEventListener('mouseenter', () => { 109 | copyBtn.style.background = '#f5f5f5'; 110 | copyBtn.style.color = '#1890ff'; 111 | }); 112 | 113 | copyBtn.addEventListener('mouseleave', () => { 114 | copyBtn.style.background = 'transparent'; 115 | copyBtn.style.color = '#666'; 116 | }); 117 | 118 | copyBtn.addEventListener('click', () => { 119 | const serverSelect = serversContainer.querySelector('select') as HTMLSelectElement; 120 | if (serverSelect && serverSelect.value) { 121 | navigator.clipboard.writeText(serverSelect.value) 122 | .then(() => { 123 | message.success('服务器地址已复制到剪贴板', 1); 124 | }) 125 | .catch(() => { 126 | // 降级到传统复制方法 127 | const textArea = document.createElement('textarea'); 128 | textArea.value = serverSelect.value; 129 | document.body.appendChild(textArea); 130 | textArea.select(); 131 | document.execCommand('copy'); 132 | document.body.removeChild(textArea); 133 | message.success('服务器地址已复制到剪贴板', 1); 134 | }); 135 | } 136 | }); 137 | 138 | 139 | serversContainer.appendChild(copyBtn); 140 | 141 | // 调整服务器选择框的padding 142 | const serverSelect = serversContainer.querySelector('select') as HTMLSelectElement; 143 | if (serverSelect) { 144 | serverSelect.style.paddingRight = '50px'; 145 | } 146 | } 147 | }; 148 | 149 | // 立即尝试添加按钮,如果失败则在下一帧重试 150 | addCopyButton(); 151 | if (!document.querySelector('.swagger-ui .servers .copy-server-btn')) { 152 | requestAnimationFrame(addCopyButton); 153 | } 154 | }} 155 | /> 156 | </div> 157 | ); 158 | }; 159 | ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/api-product/SwaggerUIWrapper.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import React from 'react'; 2 | import SwaggerUI from 'swagger-ui-react'; 3 | import 'swagger-ui-react/swagger-ui.css'; 4 | import './SwaggerUIWrapper.css'; 5 | import * as yaml from 'js-yaml'; 6 | import { message } from 'antd'; 7 | import { copyToClipboard } from '@/lib/utils'; 8 | 9 | interface SwaggerUIWrapperProps { 10 | apiSpec: string; 11 | } 12 | 13 | export const SwaggerUIWrapper: React.FC<SwaggerUIWrapperProps> = ({ apiSpec }) => { 14 | // 直接解析原始规范,不进行重新构建 15 | let swaggerSpec: any; 16 | 17 | try { 18 | // 尝试解析YAML格式 19 | try { 20 | swaggerSpec = yaml.load(apiSpec); 21 | } catch { 22 | // 如果YAML解析失败,尝试JSON格式 23 | swaggerSpec = JSON.parse(apiSpec); 24 | } 25 | 26 | if (!swaggerSpec || !swaggerSpec.paths) { 27 | throw new Error('Invalid OpenAPI specification'); 28 | } 29 | 30 | // 为没有tags的操作添加默认标签,避免显示"default" 31 | Object.keys(swaggerSpec.paths).forEach(path => { 32 | const pathItem = swaggerSpec.paths[path]; 33 | Object.keys(pathItem).forEach(method => { 34 | const operation = pathItem[method]; 35 | if (operation && typeof operation === 'object' && !operation.tags) { 36 | operation.tags = ['接口列表']; 37 | } 38 | }); 39 | }); 40 | } catch (error) { 41 | console.error('OpenAPI规范解析失败:', error); 42 | return ( 43 | <div className="text-center text-gray-500 py-8 bg-gray-50 rounded-lg"> 44 | <p>无法解析OpenAPI规范</p> 45 | <div className="text-sm text-gray-400 mt-2"> 46 | 请检查API配置格式是否正确 47 | </div> 48 | <div className="text-xs text-gray-400 mt-1"> 49 | 错误详情: {error instanceof Error ? error.message : String(error)} 50 | </div> 51 | </div> 52 | ); 53 | } 54 | 55 | return ( 56 | <div className="swagger-ui-wrapper"> 57 | <SwaggerUI 58 | spec={swaggerSpec} 59 | docExpansion="list" 60 | displayRequestDuration={true} 61 | tryItOutEnabled={true} 62 | filter={false} 63 | showRequestHeaders={true} 64 | showCommonExtensions={true} 65 | defaultModelsExpandDepth={0} 66 | defaultModelExpandDepth={0} 67 | displayOperationId={true} 68 | enableCORS={true} 69 | supportedSubmitMethods={['get', 'post', 'put', 'delete', 'patch', 'head', 'options']} 70 | deepLinking={false} 71 | showMutatedRequest={true} 72 | requestInterceptor={(request: any) => { 73 | console.log('Request:', request); 74 | return request; 75 | }} 76 | responseInterceptor={(response: any) => { 77 | console.log('Response:', response); 78 | return response; 79 | }} 80 | onComplete={() => { 81 | console.log('Swagger UI loaded'); 82 | // 添加服务器复制功能 83 | setTimeout(() => { 84 | const serversContainer = document.querySelector('.swagger-ui .servers'); 85 | if (serversContainer && !serversContainer.querySelector('.copy-server-btn')) { 86 | const copyBtn = document.createElement('button'); 87 | copyBtn.className = 'copy-server-btn'; 88 | copyBtn.innerHTML = ` 89 | <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"> 90 | <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/> 91 | </svg> 92 | `; 93 | copyBtn.title = '复制服务器地址'; 94 | copyBtn.style.cssText = ` 95 | position: absolute; 96 | right: 12px; 97 | top: 50%; 98 | transform: translateY(-50%); 99 | background: transparent; 100 | border: none; 101 | border-radius: 4px; 102 | padding: 6px 8px; 103 | cursor: pointer; 104 | color: #666; 105 | transition: all 0.2s; 106 | z-index: 10; 107 | display: flex; 108 | align-items: center; 109 | justify-content: center; 110 | `; 111 | 112 | copyBtn.addEventListener('click', async () => { 113 | const serverSelect = serversContainer.querySelector('select') as HTMLSelectElement; 114 | if (serverSelect && serverSelect.value) { 115 | try { 116 | await copyToClipboard(serverSelect.value); 117 | message.success('服务器地址已复制到剪贴板', 1); 118 | } catch { 119 | message.error('复制失败,请手动复制'); 120 | } 121 | } 122 | }); 123 | 124 | // 添加hover效果 125 | copyBtn.addEventListener('mouseenter', () => { 126 | copyBtn.style.background = '#f5f5f5'; 127 | copyBtn.style.color = '#1890ff'; 128 | }); 129 | 130 | copyBtn.addEventListener('mouseleave', () => { 131 | copyBtn.style.background = 'transparent'; 132 | copyBtn.style.color = '#666'; 133 | }); 134 | 135 | serversContainer.appendChild(copyBtn); 136 | 137 | // 调整服务器选择框的padding 138 | const serverSelect = serversContainer.querySelector('select') as HTMLSelectElement; 139 | if (serverSelect) { 140 | serverSelect.style.paddingRight = '50px'; 141 | } 142 | } 143 | }, 1000); 144 | }} 145 | syntaxHighlight={{ 146 | activated: true, 147 | theme: 'agate' 148 | }} 149 | requestSnippetsEnabled={true} 150 | requestSnippets={{ 151 | generators: { 152 | 'curl_bash': { 153 | title: 'cURL (bash)', 154 | syntax: 'bash' 155 | }, 156 | 'curl_powershell': { 157 | title: 'cURL (PowerShell)', 158 | syntax: 'powershell' 159 | }, 160 | 'curl_cmd': { 161 | title: 'cURL (CMD)', 162 | syntax: 'bash' 163 | } 164 | } 165 | }} 166 | /> 167 | </div> 168 | ); 169 | }; 170 | ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/pages/Dashboard.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { Card, Row, Col, Statistic, Progress, Table } from 'antd' 2 | import { 3 | EyeOutlined, 4 | UserOutlined, 5 | ApiOutlined, 6 | GlobalOutlined, 7 | ArrowUpOutlined, 8 | ArrowDownOutlined 9 | } from '@ant-design/icons' 10 | 11 | const mockRecentActivity = [ 12 | { 13 | key: '1', 14 | action: 'Portal访问', 15 | description: 'Company Portal被访问了1250次', 16 | time: '2小时前', 17 | user: '[email protected]' 18 | }, 19 | { 20 | key: '2', 21 | action: 'API调用', 22 | description: 'Payment API被调用了8765次', 23 | time: '4小时前', 24 | user: '[email protected]' 25 | }, 26 | { 27 | key: '3', 28 | action: '新用户注册', 29 | description: '新开发者注册了账户', 30 | time: '6小时前', 31 | user: '[email protected]' 32 | } 33 | ] 34 | 35 | export default function Dashboard() { 36 | const activityColumns = [ 37 | { 38 | title: '操作', 39 | dataIndex: 'action', 40 | key: 'action', 41 | }, 42 | { 43 | title: '描述', 44 | dataIndex: 'description', 45 | key: 'description', 46 | }, 47 | { 48 | title: '用户', 49 | dataIndex: 'user', 50 | key: 'user', 51 | }, 52 | { 53 | title: '时间', 54 | dataIndex: 'time', 55 | key: 'time', 56 | }, 57 | ] 58 | 59 | return ( 60 | <div className="space-y-6"> 61 | <div> 62 | <h1 className="text-3xl font-bold tracking-tight">仪表板</h1> 63 | <p className="text-gray-500 mt-2"> 64 | 欢迎使用HiMarket管理系统 65 | </p> 66 | </div> 67 | 68 | {/* 统计卡片 */} 69 | <Row gutter={[16, 16]}> 70 | <Col xs={24} sm={12} lg={6}> 71 | <Card> 72 | <Statistic 73 | title="Portal访问量" 74 | value={1250} 75 | prefix={<EyeOutlined />} 76 | valueStyle={{ color: '#3f8600' }} 77 | suffix={<ArrowUpOutlined style={{ fontSize: '14px' }} />} 78 | /> 79 | </Card> 80 | </Col> 81 | <Col xs={24} sm={12} lg={6}> 82 | <Card> 83 | <Statistic 84 | title="注册用户" 85 | value={45} 86 | prefix={<UserOutlined />} 87 | valueStyle={{ color: '#1890ff' }} 88 | suffix={<ArrowUpOutlined style={{ fontSize: '14px' }} />} 89 | /> 90 | </Card> 91 | </Col> 92 | <Col xs={24} sm={12} lg={6}> 93 | <Card> 94 | <Statistic 95 | title="API调用" 96 | value={8765} 97 | prefix={<ApiOutlined />} 98 | valueStyle={{ color: '#722ed1' }} 99 | suffix={<ArrowUpOutlined style={{ fontSize: '14px' }} />} 100 | /> 101 | </Card> 102 | </Col> 103 | <Col xs={24} sm={12} lg={6}> 104 | <Card> 105 | <Statistic 106 | title="活跃Portal" 107 | value={3} 108 | prefix={<GlobalOutlined />} 109 | valueStyle={{ color: '#fa8c16' }} 110 | suffix={<ArrowDownOutlined style={{ fontSize: '14px' }} />} 111 | /> 112 | </Card> 113 | </Col> 114 | </Row> 115 | 116 | {/* 详细信息 */} 117 | <Row gutter={[16, 16]}> 118 | <Col xs={24} lg={12}> 119 | <Card title="系统状态" className="h-full"> 120 | <div className="space-y-4"> 121 | <div> 122 | <div className="flex justify-between mb-2"> 123 | <span>系统负载</span> 124 | <span className="text-blue-600">75%</span> 125 | </div> 126 | <Progress percent={75} strokeColor="#1890ff" /> 127 | </div> 128 | <div> 129 | <div className="flex justify-between mb-2"> 130 | <span>API响应时间</span> 131 | <span className="text-green-600">245ms</span> 132 | </div> 133 | <Progress percent={85} strokeColor="#52c41a" /> 134 | </div> 135 | <div> 136 | <div className="flex justify-between mb-2"> 137 | <span>错误率</span> 138 | <span className="text-red-600">0.12%</span> 139 | </div> 140 | <Progress percent={1.2} strokeColor="#ff4d4f" /> 141 | </div> 142 | </div> 143 | </Card> 144 | </Col> 145 | <Col xs={24} lg={12}> 146 | <Card title="快速操作" className="h-full"> 147 | <div className="space-y-4"> 148 | <div className="grid grid-cols-2 gap-4"> 149 | <div className="text-center p-4 border rounded-lg hover:bg-gray-50 cursor-pointer"> 150 | <GlobalOutlined className="text-2xl text-blue-500 mb-2" /> 151 | <div className="font-medium">创建Portal</div> 152 | <div className="text-sm text-gray-500">新建开发者门户</div> 153 | </div> 154 | <div className="text-center p-4 border rounded-lg hover:bg-gray-50 cursor-pointer"> 155 | <ApiOutlined className="text-2xl text-green-500 mb-2" /> 156 | <div className="font-medium">发布API</div> 157 | <div className="text-sm text-gray-500">发布新的API产品</div> 158 | </div> 159 | <div className="text-center p-4 border rounded-lg hover:bg-gray-50 cursor-pointer"> 160 | <UserOutlined className="text-2xl text-purple-500 mb-2" /> 161 | <div className="font-medium">管理用户</div> 162 | <div className="text-sm text-gray-500">管理开发者账户</div> 163 | </div> 164 | <div className="text-center p-4 border rounded-lg hover:bg-gray-50 cursor-pointer"> 165 | <EyeOutlined className="text-2xl text-orange-500 mb-2" /> 166 | <div className="font-medium">查看统计</div> 167 | <div className="text-sm text-gray-500">查看使用统计</div> 168 | </div> 169 | </div> 170 | </div> 171 | </Card> 172 | </Col> 173 | </Row> 174 | 175 | {/* 最近活动 */} 176 | <Card title="最近活动"> 177 | <Table 178 | columns={activityColumns} 179 | dataSource={mockRecentActivity} 180 | rowKey="key" 181 | pagination={false} 182 | size="small" 183 | /> 184 | </Card> 185 | </div> 186 | ) 187 | } ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/portal/PortalDomain.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import {Card, Button, Table, Modal, Form, Input, Select, message, Space} from 'antd' 2 | import {PlusOutlined, ExclamationCircleOutlined} from '@ant-design/icons' 3 | import {useState} from 'react' 4 | import {Portal} from '@/types' 5 | import {portalApi} from '@/lib/api' 6 | 7 | interface PortalDomainProps { 8 | portal: Portal 9 | onRefresh?: () => void 10 | } 11 | 12 | export function PortalDomain({portal, onRefresh}: PortalDomainProps) { 13 | const [domainModalVisible, setDomainModalVisible] = useState(false) 14 | const [domainForm] = Form.useForm() 15 | const [domainLoading, setDomainLoading] = useState(false) 16 | 17 | const handleAddDomain = () => { 18 | setDomainModalVisible(true) 19 | } 20 | 21 | const handleDomainModalOk = async () => { 22 | try { 23 | setDomainLoading(true) 24 | const values = await domainForm.validateFields() 25 | 26 | await portalApi.bindDomain(portal.portalId, { 27 | domain: values.domain, 28 | type: 'CUSTOM', 29 | protocol: values.protocol 30 | }) 31 | 32 | message.success('域名绑定成功') 33 | setDomainModalVisible(false) 34 | domainForm.resetFields() 35 | onRefresh?.() 36 | } catch (error) { 37 | message.error('绑定域名失败') 38 | } finally { 39 | setDomainLoading(false) 40 | } 41 | } 42 | 43 | const handleDomainModalCancel = () => { 44 | setDomainModalVisible(false) 45 | domainForm.resetFields() 46 | } 47 | 48 | const handleDeleteDomain = async (domain: string) => { 49 | Modal.confirm({ 50 | title: '确认解绑', 51 | icon: <ExclamationCircleOutlined/>, 52 | content: `确定要解绑域名 "${domain}" 吗?此操作不可恢复。`, 53 | okText: '确认解绑', 54 | okType: 'danger', 55 | cancelText: '取消', 56 | async onOk() { 57 | try { 58 | await portalApi.unbindDomain(portal.portalId, domain) 59 | message.success('域名解绑成功') 60 | onRefresh?.() 61 | } catch (error) { 62 | message.error('解绑域名失败') 63 | } 64 | }, 65 | }) 66 | } 67 | 68 | const domainColumns = [ 69 | { 70 | title: '域名', 71 | dataIndex: 'domain', 72 | key: 'domain', 73 | }, 74 | { 75 | title: '协议', 76 | dataIndex: 'protocol', 77 | key: 'protocol', 78 | render: (protocol: string) => protocol?.toUpperCase() || 'HTTP' 79 | }, 80 | { 81 | title: '类型', 82 | dataIndex: 'type', 83 | key: 'type', 84 | render: (type: string) => type === 'CUSTOM' ? '自定义域名' : '系统域名' 85 | }, 86 | { 87 | title: '操作', 88 | key: 'action', 89 | render: (_: any, record: any) => ( 90 | <Space> 91 | {record.type === 'CUSTOM' ? ( 92 | <Button 93 | type="link" 94 | danger 95 | size="small" 96 | onClick={() => handleDeleteDomain(record.domain)} 97 | > 98 | 解绑 99 | </Button> 100 | ) : ( 101 | <span className="text-gray-400 text-sm">-</span> 102 | )} 103 | </Space> 104 | ), 105 | }, 106 | ] 107 | 108 | return ( 109 | <div className="p-6 space-y-6"> 110 | <div className="flex justify-between items-center"> 111 | <div> 112 | <h1 className="text-2xl font-bold mb-2">域名列表</h1> 113 | <p className="text-gray-600">管理Portal的域名配置</p> 114 | </div> 115 | <Space> 116 | <Button type="primary" icon={<PlusOutlined/>} onClick={handleAddDomain}> 117 | 绑定域名 118 | </Button> 119 | </Space> 120 | </div> 121 | 122 | <Card> 123 | <div className="space-y-6"> 124 | {/* 域名列表内容 */} 125 | <div> 126 | <Table 127 | columns={domainColumns} 128 | dataSource={portal.portalDomainConfig || []} 129 | rowKey="domain" 130 | pagination={false} 131 | size="small" 132 | locale={{ 133 | emptyText: '暂无绑定域名' 134 | }} 135 | /> 136 | </div> 137 | </div> 138 | </Card> 139 | 140 | {/* 域名绑定模态框 */} 141 | <Modal 142 | title="绑定域名" 143 | open={domainModalVisible} 144 | onOk={handleDomainModalOk} 145 | onCancel={handleDomainModalCancel} 146 | confirmLoading={domainLoading} 147 | destroyOnClose 148 | > 149 | <Form form={domainForm} layout="vertical" initialValues={{ protocol: 'HTTP' }}> 150 | <Form.Item 151 | name="domain" 152 | label="域名" 153 | rules={[{ required: true, message: '请输入要绑定的域名' }]} 154 | > 155 | <Input placeholder="例如:example.com" /> 156 | </Form.Item> 157 | 158 | <Form.Item 159 | name="protocol" 160 | label="协议" 161 | rules={[{ required: true, message: '请选择协议' }]} 162 | > 163 | <Select placeholder="请选择协议"> 164 | <Select.Option value="HTTPS">HTTPS</Select.Option> 165 | <Select.Option value="HTTP">HTTP</Select.Option> 166 | </Select> 167 | </Form.Item> 168 | </Form> 169 | </Modal> 170 | </div> 171 | ) 172 | } 173 | ``` -------------------------------------------------------------------------------- /portal-web/api-portal-frontend/src/pages/Login.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import React, { useEffect, useState } from "react"; 2 | import { Link, useNavigate } from "react-router-dom"; 3 | import { Form, Input, Button, Card, Divider, message } from "antd"; 4 | import { UserOutlined, LockOutlined } from "@ant-design/icons"; 5 | import api, { getOidcProviders, type IdpResult } from "../lib/api"; 6 | import aliyunIcon from "../assets/aliyun.png"; 7 | import githubIcon from "../assets/github.png"; 8 | import googleIcon from "../assets/google.png"; 9 | import { AxiosError } from "axios"; 10 | 11 | 12 | const oidcIcons: Record<string, React.ReactNode> = { 13 | google: <img src={googleIcon} alt="Google" className="w-5 h-5 mr-2" />, 14 | github: <img src={githubIcon} alt="GitHub" className="w-6 h-6 mr-2" />, 15 | aliyun: <img src={aliyunIcon} alt="Aliyun" className="w-6 h-6 mr-2" />, 16 | }; 17 | 18 | const Login: React.FC = () => { 19 | const [providers, setProviders] = useState<IdpResult[]>([]); 20 | const [loading, setLoading] = useState(false); 21 | const navigate = useNavigate(); 22 | 23 | useEffect(() => { 24 | // 使用OidcController的接口获取OIDC提供商 25 | getOidcProviders() 26 | .then((response: any) => { 27 | console.log('OIDC providers response:', response); 28 | 29 | // 处理不同的响应格式 30 | let providersData: IdpResult[]; 31 | if (Array.isArray(response)) { 32 | providersData = response; 33 | } else if (response && Array.isArray(response.data)) { 34 | providersData = response.data; 35 | } else if (response && response.data) { 36 | console.warn('Unexpected response format:', response); 37 | providersData = []; 38 | } else { 39 | providersData = []; 40 | } 41 | 42 | console.log('Processed providers data:', providersData); 43 | setProviders(providersData); 44 | }) 45 | .catch((error) => { 46 | console.error('Failed to fetch OIDC providers:', error); 47 | setProviders([]); 48 | }); 49 | }, []); 50 | 51 | // 账号密码登录 52 | const handlePasswordLogin = async (values: { username: string; password: string }) => { 53 | setLoading(true); 54 | try { 55 | const res = await api.post("/developers/login", { 56 | username: values.username, 57 | password: values.password, 58 | }); 59 | // 登录成功后跳转到首页并携带access_token 60 | if (res && res.data && res.data.access_token) { 61 | message.success('登录成功!', 1); 62 | localStorage.setItem('access_token', res.data.access_token) 63 | navigate('/') 64 | } else { 65 | message.error("登录失败,未获取到access_token"); 66 | } 67 | } catch (error) { 68 | if (error instanceof AxiosError) { 69 | message.error(error.response?.data.message || "登录失败,请检查账号密码是否正确"); 70 | } else { 71 | message.error("登录失败"); 72 | } 73 | } finally { 74 | setLoading(false); 75 | } 76 | }; 77 | 78 | // 跳转到 OIDC 授权 - 对接OidcController 79 | const handleOidcLogin = (provider: string) => { 80 | // 获取API前缀配置 81 | const apiPrefix = api.defaults.baseURL || '/api/v1'; 82 | 83 | // 构建授权URL - 对接 /developers/oidc/authorize 84 | const authUrl = new URL(`${window.location.origin}${apiPrefix}/developers/oidc/authorize`); 85 | authUrl.searchParams.set('provider', provider); 86 | 87 | console.log('Redirecting to OIDC authorization:', authUrl.toString()); 88 | 89 | // 跳转到OIDC授权服务器 90 | window.location.href = authUrl.toString(); 91 | }; 92 | 93 | return ( 94 | <div className="flex items-center justify-center min-h-screen bg-gray-50"> 95 | <Card className="w-full max-w-md shadow-lg"> 96 | {/* Logo */} 97 | <div className="text-center mb-6"> 98 | <img src="/logo.png" alt="Logo" className="w-16 h-16 mx-auto mb-4" /> 99 | <h2 className="text-2xl font-bold text-gray-900">登录HiMarket-前台</h2> 100 | </div> 101 | 102 | {/* 账号密码登录表单 */} 103 | <Form 104 | name="login" 105 | onFinish={handlePasswordLogin} 106 | autoComplete="off" 107 | layout="vertical" 108 | size="large" 109 | > 110 | <Form.Item 111 | name="username" 112 | rules={[ 113 | { required: true, message: '请输入账号' } 114 | ]} 115 | > 116 | <Input 117 | prefix={<UserOutlined />} 118 | placeholder="账号" 119 | autoComplete="username" 120 | /> 121 | </Form.Item> 122 | 123 | <Form.Item 124 | name="password" 125 | rules={[ 126 | { required: true, message: '请输入密码' } 127 | ]} 128 | > 129 | <Input.Password 130 | prefix={<LockOutlined />} 131 | placeholder="密码" 132 | autoComplete="current-password" 133 | /> 134 | </Form.Item> 135 | 136 | <Form.Item> 137 | <Button 138 | type="primary" 139 | htmlType="submit" 140 | loading={loading} 141 | className="w-full" 142 | size="large" 143 | > 144 | {loading ? "登录中..." : "登录"} 145 | </Button> 146 | </Form.Item> 147 | </Form> 148 | 149 | {/* 分隔线 */} 150 | <Divider plain>或</Divider> 151 | 152 | {/* OIDC 登录按钮 */} 153 | <div className="flex flex-col gap-3"> 154 | {!Array.isArray(providers) || providers.length === 0 ? ( 155 | <div className="text-gray-400 text-center">暂无可用第三方登录</div> 156 | ) : ( 157 | providers.map((provider) => ( 158 | <Button 159 | key={provider.provider} 160 | onClick={() => handleOidcLogin(provider.provider)} 161 | className="w-full flex items-center justify-center" 162 | size="large" 163 | icon={oidcIcons[provider.provider.toLowerCase()] || <span>🆔</span>} 164 | > 165 | 使用{provider.name || provider.provider}登录 166 | </Button> 167 | )) 168 | )} 169 | </div> 170 | 171 | {/* 底部提示 */} 172 | <div className="mt-6 text-center text-gray-500"> 173 | 没有账号? 174 | <Link to="/register" className="text-blue-500 hover:underline ml-1"> 175 | 注册 176 | </Link> 177 | </div> 178 | </Card> 179 | </div> 180 | ); 181 | }; 182 | 183 | export default Login; 184 | ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/portal/PortalConsumers.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { Card, Table, Badge, Button, Space, Avatar, Tag, Input } from 'antd' 2 | import { SearchOutlined, UserAddOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons' 3 | import { useState } from 'react' 4 | import { Portal, DeveloperStats } from '@/types' 5 | import { formatDateTime } from '@/lib/utils' 6 | 7 | interface PortalConsumersProps { 8 | portal: Portal 9 | } 10 | 11 | const mockConsumers: DeveloperStats[] = [ 12 | { 13 | id: "1", 14 | name: "企业A", 15 | email: "[email protected]", 16 | status: "active", 17 | plan: "premium", 18 | joinedAt: "2025-01-01T10:00:00Z", 19 | lastActive: "2025-01-08T15:30:00Z", 20 | apiCalls: 15420, 21 | subscriptions: 3 22 | }, 23 | { 24 | id: "2", 25 | name: "企业B", 26 | email: "[email protected]", 27 | status: "active", 28 | plan: "standard", 29 | joinedAt: "2025-01-02T11:00:00Z", 30 | lastActive: "2025-01-08T14:20:00Z", 31 | apiCalls: 8765, 32 | subscriptions: 2 33 | }, 34 | { 35 | id: "3", 36 | name: "企业C", 37 | email: "[email protected]", 38 | status: "inactive", 39 | plan: "basic", 40 | joinedAt: "2025-01-03T12:00:00Z", 41 | lastActive: "2025-01-05T09:15:00Z", 42 | apiCalls: 1200, 43 | subscriptions: 1 44 | } 45 | ] 46 | 47 | export function PortalConsumers({ portal }: PortalConsumersProps) { 48 | const [consumers, setConsumers] = useState<DeveloperStats[]>(mockConsumers) 49 | const [searchText, setSearchText] = useState('') 50 | 51 | const filteredConsumers = consumers.filter(consumer => 52 | consumer.name.toLowerCase().includes(searchText.toLowerCase()) || 53 | consumer.email.toLowerCase().includes(searchText.toLowerCase()) 54 | ) 55 | 56 | const getPlanColor = (plan: string) => { 57 | switch (plan) { 58 | case 'premium': 59 | return 'gold' 60 | case 'standard': 61 | return 'blue' 62 | case 'basic': 63 | return 'green' 64 | default: 65 | return 'default' 66 | } 67 | } 68 | 69 | const getPlanText = (plan: string) => { 70 | switch (plan) { 71 | case 'premium': 72 | return '高级版' 73 | case 'standard': 74 | return '标准版' 75 | case 'basic': 76 | return '基础版' 77 | default: 78 | return plan 79 | } 80 | } 81 | 82 | const columns = [ 83 | { 84 | title: '消费者', 85 | dataIndex: 'name', 86 | key: 'name', 87 | render: (name: string, record: DeveloperStats) => ( 88 | <div className="flex items-center space-x-3"> 89 | <Avatar className="bg-green-500"> 90 | {name.charAt(0).toUpperCase()} 91 | </Avatar> 92 | <div> 93 | <div className="font-medium">{name}</div> 94 | <div className="text-sm text-gray-500">{record.email}</div> 95 | </div> 96 | </div> 97 | ), 98 | }, 99 | { 100 | title: '状态', 101 | dataIndex: 'status', 102 | key: 'status', 103 | render: (status: string) => ( 104 | <Badge status={status === 'active' ? 'success' : 'default'} text={status === 'active' ? '活跃' : '非活跃'} /> 105 | ) 106 | }, 107 | { 108 | title: '套餐', 109 | dataIndex: 'plan', 110 | key: 'plan', 111 | render: (plan: string) => ( 112 | <Tag color={getPlanColor(plan)}> 113 | {getPlanText(plan)} 114 | </Tag> 115 | ) 116 | }, 117 | { 118 | title: 'API调用', 119 | dataIndex: 'apiCalls', 120 | key: 'apiCalls', 121 | render: (calls: number) => calls.toLocaleString() 122 | }, 123 | { 124 | title: '订阅数', 125 | dataIndex: 'subscriptions', 126 | key: 'subscriptions', 127 | render: (subscriptions: number) => subscriptions.toLocaleString() 128 | }, 129 | { 130 | title: '加入时间', 131 | dataIndex: 'joinedAt', 132 | key: 'joinedAt', 133 | render: (date: string) => formatDateTime(date) 134 | }, 135 | { 136 | title: '最后活跃', 137 | dataIndex: 'lastActive', 138 | key: 'lastActive', 139 | render: (date: string) => formatDateTime(date) 140 | }, 141 | { 142 | title: '操作', 143 | key: 'action', 144 | render: (_: any, record: DeveloperStats) => ( 145 | <Space size="middle"> 146 | <Button type="link" icon={<EditOutlined />}> 147 | 编辑 148 | </Button> 149 | <Button type="link" danger icon={<DeleteOutlined />}> 150 | 删除 151 | </Button> 152 | </Space> 153 | ), 154 | }, 155 | ] 156 | 157 | return ( 158 | <div className="p-6 space-y-6"> 159 | <div className="flex justify-between items-center"> 160 | <div> 161 | <h1 className="text-2xl font-bold mb-2">消费者</h1> 162 | <p className="text-gray-600">管理Portal的消费者用户</p> 163 | </div> 164 | <Button type="primary" icon={<UserAddOutlined />}> 165 | 添加消费者 166 | </Button> 167 | </div> 168 | 169 | <Card> 170 | <div className="mb-4"> 171 | <Input 172 | placeholder="搜索消费者..." 173 | prefix={<SearchOutlined />} 174 | value={searchText} 175 | onChange={(e) => setSearchText(e.target.value)} 176 | style={{ width: 300 }} 177 | /> 178 | </div> 179 | <Table 180 | columns={columns} 181 | dataSource={filteredConsumers} 182 | rowKey="id" 183 | pagination={false} 184 | /> 185 | </Card> 186 | 187 | {/* <Card title="消费者统计"> 188 | <div className="grid grid-cols-4 gap-4"> 189 | <div className="text-center"> 190 | <div className="text-2xl font-bold text-blue-600">{consumers.length}</div> 191 | <div className="text-sm text-gray-500">总消费者</div> 192 | </div> 193 | <div className="text-center"> 194 | <div className="text-2xl font-bold text-green-600"> 195 | {consumers.filter(c => c.status === 'active').length} 196 | </div> 197 | <div className="text-sm text-gray-500">活跃消费者</div> 198 | </div> 199 | <div className="text-center"> 200 | <div className="text-2xl font-bold text-purple-600"> 201 | {consumers.reduce((sum, c) => sum + c.apiCalls, 0).toLocaleString()} 202 | </div> 203 | <div className="text-sm text-gray-500">总API调用</div> 204 | </div> 205 | <div className="text-center"> 206 | <div className="text-2xl font-bold text-orange-600"> 207 | {consumers.reduce((sum, c) => sum + c.subscriptions, 0)} 208 | </div> 209 | <div className="text-sm text-gray-500">总订阅数</div> 210 | </div> 211 | </div> 212 | </Card> */} 213 | </div> 214 | ) 215 | } ``` -------------------------------------------------------------------------------- /portal-server/src/main/java/com/alibaba/apiopenplatform/controller/ConsumerController.java: -------------------------------------------------------------------------------- ```java 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one 3 | * or more contributor license agreements. See the NOTICE file 4 | * distributed with this work for additional information 5 | * regarding copyright ownership. The ASF licenses this file 6 | * to you under the Apache License, Version 2.0 (the 7 | * "License"); you may not use this file except in compliance 8 | * with the License. You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | package com.alibaba.apiopenplatform.controller; 21 | 22 | import com.alibaba.apiopenplatform.core.annotation.AdminAuth; 23 | import com.alibaba.apiopenplatform.core.annotation.DeveloperAuth; 24 | import com.alibaba.apiopenplatform.core.annotation.AdminOrDeveloperAuth; 25 | import com.alibaba.apiopenplatform.dto.params.consumer.CreateCredentialParam; 26 | import com.alibaba.apiopenplatform.dto.params.consumer.QueryConsumerParam; 27 | import com.alibaba.apiopenplatform.dto.params.consumer.CreateConsumerParam; 28 | import com.alibaba.apiopenplatform.dto.params.consumer.UpdateCredentialParam; 29 | import com.alibaba.apiopenplatform.dto.params.consumer.CreateSubscriptionParam; 30 | import com.alibaba.apiopenplatform.dto.params.consumer.QuerySubscriptionParam; 31 | import com.alibaba.apiopenplatform.dto.result.ConsumerCredentialResult; 32 | import com.alibaba.apiopenplatform.dto.result.ConsumerResult; 33 | import com.alibaba.apiopenplatform.dto.result.PageResult; 34 | import com.alibaba.apiopenplatform.dto.result.SubscriptionResult; 35 | import com.alibaba.apiopenplatform.service.ConsumerService; 36 | import io.swagger.v3.oas.annotations.Operation; 37 | import io.swagger.v3.oas.annotations.tags.Tag; 38 | import lombok.RequiredArgsConstructor; 39 | import org.springframework.data.domain.Pageable; 40 | import org.springframework.validation.annotation.Validated; 41 | import org.springframework.web.bind.annotation.*; 42 | 43 | import javax.validation.Valid; 44 | 45 | @Tag(name = "Consumer管理", description = "提供Consumer注册、审批、产品订阅等管理功能") 46 | @RestController 47 | @RequestMapping("/consumers") 48 | @RequiredArgsConstructor 49 | @Validated 50 | public class ConsumerController { 51 | 52 | private final ConsumerService consumerService; 53 | 54 | @Operation(summary = "获取Consumer列表") 55 | @GetMapping 56 | public PageResult<ConsumerResult> listConsumers(QueryConsumerParam param, 57 | Pageable pageable) { 58 | return consumerService.listConsumers(param, pageable); 59 | } 60 | 61 | @Operation(summary = "获取Consumer") 62 | @GetMapping("/{consumerId}") 63 | public ConsumerResult getConsumer(@PathVariable String consumerId) { 64 | return consumerService.getConsumer(consumerId); 65 | } 66 | 67 | @Operation(summary = "注册Consumer") 68 | @PostMapping 69 | @DeveloperAuth 70 | public ConsumerResult createConsumer(@RequestBody @Valid CreateConsumerParam param) { 71 | return consumerService.createConsumer(param); 72 | } 73 | 74 | @Operation(summary = "删除Consumer") 75 | @DeleteMapping("/{consumerId}") 76 | public void deleteDevConsumer(@PathVariable String consumerId) { 77 | consumerService.deleteConsumer(consumerId); 78 | } 79 | 80 | @Operation(summary = "生成Consumer凭证") 81 | @PostMapping("/{consumerId}/credentials") 82 | @DeveloperAuth 83 | public void createCredential(@PathVariable String consumerId, 84 | @RequestBody @Valid CreateCredentialParam param) { 85 | consumerService.createCredential(consumerId, param); 86 | } 87 | 88 | @Operation(summary = "获取Consumer凭证信息") 89 | @GetMapping("/{consumerId}/credentials") 90 | @DeveloperAuth 91 | public ConsumerCredentialResult getCredential(@PathVariable String consumerId) { 92 | return consumerService.getCredential(consumerId); 93 | } 94 | 95 | @Operation(summary = "更新Consumer凭证") 96 | @PutMapping("/{consumerId}/credentials") 97 | @DeveloperAuth 98 | public void updateCredential(@PathVariable String consumerId, 99 | @RequestBody @Valid UpdateCredentialParam param) { 100 | consumerService.updateCredential(consumerId, param); 101 | } 102 | 103 | @Operation(summary = "删除Consumer凭证") 104 | @DeleteMapping("/{consumerId}/credentials") 105 | @DeveloperAuth 106 | public void deleteCredential(@PathVariable String consumerId) { 107 | consumerService.deleteCredential(consumerId); 108 | } 109 | 110 | @Operation(summary = "订阅API产品") 111 | @PostMapping("/{consumerId}/subscriptions") 112 | @DeveloperAuth 113 | public SubscriptionResult subscribeProduct(@PathVariable String consumerId, 114 | @RequestBody @Valid CreateSubscriptionParam param) { 115 | return consumerService.subscribeProduct(consumerId, param); 116 | } 117 | 118 | @Operation(summary = "获取Consumer的订阅列表") 119 | @GetMapping("/{consumerId}/subscriptions") 120 | @AdminOrDeveloperAuth 121 | public PageResult<SubscriptionResult> listSubscriptions(@PathVariable String consumerId, 122 | QuerySubscriptionParam param, 123 | Pageable pageable) { 124 | return consumerService.listSubscriptions(consumerId, param, pageable); 125 | } 126 | 127 | @Operation(summary = "取消订阅") 128 | @DeleteMapping("/{consumerId}/subscriptions/{productId}") 129 | public void deleteSubscription(@PathVariable String consumerId, @PathVariable String productId) { 130 | consumerService.unsubscribeProduct(consumerId, productId); 131 | } 132 | 133 | @Operation(summary = "审批订阅申请") 134 | @PatchMapping("/{consumerId}/subscriptions/{productId}") 135 | @AdminAuth 136 | public SubscriptionResult approveSubscription(@PathVariable String consumerId, @PathVariable String productId) { 137 | return consumerService.approveSubscription(consumerId, productId); 138 | } 139 | } 140 | ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/common/AdvancedSearch.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import React, { useState, useEffect } from 'react'; 2 | import { Select, Input, Button, Tag, Space } from 'antd'; 3 | import { SearchOutlined, CloseOutlined } from '@ant-design/icons'; 4 | // import './AdvancedSearch.css'; 5 | 6 | const { Option } = Select; 7 | 8 | export interface SearchParam { 9 | label: string; 10 | name: string; 11 | placeholder: string; 12 | type?: 'input' | 'select'; 13 | optionList?: Array<{ label: string; value: string }>; 14 | } 15 | 16 | interface AdvancedSearchProps { 17 | searchParamsList: SearchParam[]; 18 | onSearch: (searchName: string, searchValue: string) => void; 19 | onClear?: () => void; 20 | className?: string; 21 | } 22 | 23 | export const AdvancedSearch: React.FC<AdvancedSearchProps> = ({ 24 | searchParamsList, 25 | onSearch, 26 | onClear, 27 | className = '' 28 | }) => { 29 | const [activeSearchName, setActiveSearchName] = useState<string>(''); 30 | const [activeSearchValue, setActiveSearchValue] = useState<string>(''); 31 | const [tagList, setTagList] = useState<Array<SearchParam & { value: string }>>([]); 32 | const [isInitialized, setIsInitialized] = useState<boolean>(false); 33 | 34 | useEffect(() => { 35 | // 防止初始化时自动触发搜索 36 | if (isInitialized && activeSearchName) { 37 | setActiveSearchValue(''); // 清空输入框 38 | setTagList([]); // 清空关联标签 39 | onSearch(activeSearchName, ''); 40 | } 41 | }, [activeSearchName, isInitialized]); // 移除 onSearch 避免无限循环 42 | 43 | useEffect(() => { 44 | if (searchParamsList.length > 0) { 45 | setActiveSearchName(searchParamsList[0].name); 46 | setIsInitialized(true); // 标记为已初始化 47 | } 48 | }, [searchParamsList]); 49 | 50 | const handleSearch = () => { 51 | if (activeSearchValue.trim()) { 52 | // 添加到标签列表 53 | const currentParam = searchParamsList.find(item => item.name === activeSearchName); 54 | if (currentParam) { 55 | const newTag = { 56 | ...currentParam, 57 | value: activeSearchValue 58 | }; 59 | setTagList(prev => { 60 | const filtered = prev.filter(tag => tag.name !== activeSearchName); 61 | return [...filtered, newTag]; 62 | }); 63 | } 64 | 65 | onSearch(activeSearchName, activeSearchValue); 66 | setActiveSearchValue(''); 67 | } 68 | }; 69 | 70 | const handleClearOne = (tagName: string) => { 71 | setTagList(prev => prev.filter(tag => tag.name !== tagName)); 72 | onSearch(tagName, ''); 73 | }; 74 | 75 | const handleClearAll = () => { 76 | setTagList([]); 77 | if (onClear) { 78 | onClear(); 79 | } 80 | }; 81 | 82 | const handleSelectOne = (tagName: string) => { 83 | const tag = tagList.find(t => t.name === tagName); 84 | if (tag) { 85 | setActiveSearchName(tagName); 86 | setActiveSearchValue(tag.value); 87 | } 88 | }; 89 | 90 | const getCurrentParam = () => { 91 | return searchParamsList.find(item => item.name === activeSearchName); 92 | }; 93 | 94 | const currentParam = getCurrentParam(); 95 | 96 | return ( 97 | <div className={`flex flex-col gap-4 ${className}`}> 98 | {/* 搜索控件 */} 99 | <div className="flex items-center"> 100 | {/* 左侧:搜索字段选择器 */} 101 | <Select 102 | value={activeSearchName} 103 | onChange={setActiveSearchName} 104 | style={{ 105 | width: 120, 106 | borderTopRightRadius: 0, 107 | borderBottomRightRadius: 0, 108 | borderRight: 'none' 109 | }} 110 | className="h-10" 111 | size="large" 112 | > 113 | {searchParamsList.map(item => ( 114 | <Option key={item.name} value={item.name}> 115 | {item.label} 116 | </Option> 117 | ))} 118 | </Select> 119 | 120 | {/* 中间:搜索值输入框 */} 121 | {currentParam?.type === 'select' ? ( 122 | <Select 123 | placeholder={currentParam.placeholder} 124 | value={activeSearchValue} 125 | onChange={(value) => { 126 | setActiveSearchValue(value); 127 | // 自动触发搜索 128 | if (value) { 129 | onSearch(activeSearchName, value); 130 | } 131 | }} 132 | style={{ 133 | width: 400, 134 | borderTopLeftRadius: 0, 135 | borderBottomLeftRadius: 0 136 | }} 137 | allowClear 138 | onClear={() => { 139 | setActiveSearchValue(''); 140 | onClear?.(); 141 | }} 142 | className="h-10" 143 | size="large" 144 | > 145 | {currentParam.optionList?.map(item => ( 146 | <Option key={item.value} value={item.value}> 147 | {item.label} 148 | </Option> 149 | ))} 150 | </Select> 151 | ) : ( 152 | <Input 153 | placeholder={currentParam?.placeholder} 154 | value={activeSearchValue} 155 | onChange={(e) => setActiveSearchValue(e.target.value)} 156 | style={{ 157 | width: 400, 158 | borderTopLeftRadius: 0, 159 | borderBottomLeftRadius: 0 160 | }} 161 | onPressEnter={handleSearch} 162 | allowClear 163 | onClear={() => setActiveSearchValue('')} 164 | size="large" 165 | className="h-10" 166 | suffix={ 167 | <Button 168 | type="text" 169 | icon={<SearchOutlined />} 170 | onClick={handleSearch} 171 | size="small" 172 | className="h-8 w-8 flex items-center justify-center" 173 | /> 174 | } 175 | /> 176 | )} 177 | </div> 178 | 179 | {/* 搜索标签 */} 180 | {tagList.length > 0 && ( 181 | <div className="mt-4"> 182 | <div className="flex items-center gap-2 mb-2"> 183 | <span className="text-sm text-gray-500">已选择的筛选条件:</span> 184 | <Button 185 | type="link" 186 | size="small" 187 | onClick={handleClearAll} 188 | className="text-gray-400 hover:text-gray-600" 189 | > 190 | 清除全部 191 | </Button> 192 | </div> 193 | <Space wrap> 194 | {tagList.map(tag => ( 195 | <Tag 196 | key={tag.name} 197 | closable 198 | onClose={() => handleClearOne(tag.name)} 199 | onClick={() => handleSelectOne(tag.name)} 200 | className="cursor-pointer" 201 | color={tag.name === activeSearchName ? 'blue' : 'default'} 202 | > 203 | {tag.label}:{tag.value} 204 | </Tag> 205 | ))} 206 | </Space> 207 | </div> 208 | )} 209 | </div> 210 | ); 211 | }; 212 | ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/subscription/SubscriptionListModal.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { Modal, Table, Badge, message, Button, Popconfirm } from 'antd'; 2 | import { useEffect, useState } from 'react'; 3 | import { Subscription } from '@/types/subscription'; 4 | import { portalApi } from '@/lib/api'; 5 | import { formatDateTime } from '@/lib/utils'; 6 | 7 | interface SubscriptionListModalProps { 8 | visible: boolean; 9 | consumerId: string; 10 | consumerName: string; 11 | onCancel: () => void; 12 | } 13 | 14 | export function SubscriptionListModal({ 15 | visible, 16 | consumerId, 17 | consumerName, 18 | onCancel 19 | }: SubscriptionListModalProps) { 20 | const [subscriptions, setSubscriptions] = useState<Subscription[]>([]); 21 | const [loading, setLoading] = useState(false); 22 | const [actionLoading, setActionLoading] = useState<string | null>(null); 23 | const [pagination, setPagination] = useState({ 24 | current: 1, 25 | pageSize: 10, 26 | total: 0, 27 | showSizeChanger: true, 28 | showQuickJumper: true, 29 | showTotal: (total: number) => `共 ${total} 条` 30 | }); 31 | 32 | useEffect(() => { 33 | if (visible && consumerId) { 34 | fetchSubscriptions(); 35 | } 36 | }, [visible, consumerId, pagination.current, pagination.pageSize]); 37 | 38 | const fetchSubscriptions = () => { 39 | setLoading(true); 40 | portalApi.getConsumerSubscriptions(consumerId, { 41 | page: pagination.current - 1, // 后端从0开始 42 | size: pagination.pageSize 43 | }).then((res) => { 44 | setSubscriptions(res.data.content || []); 45 | setPagination(prev => ({ 46 | ...prev, 47 | total: res.data.totalElements || 0 48 | })); 49 | }).catch((err) => { 50 | message.error('获取订阅列表失败'); 51 | }).finally(() => { 52 | setLoading(false); 53 | }); 54 | }; 55 | 56 | const handleTableChange = (paginationInfo: any) => { 57 | setPagination(prev => ({ 58 | ...prev, 59 | current: paginationInfo.current, 60 | pageSize: paginationInfo.pageSize 61 | })); 62 | }; 63 | 64 | const handleApproveSubscription = async (subscription: Subscription) => { 65 | setActionLoading(`${subscription.consumerId}-${subscription.productId}-approve`); 66 | try { 67 | await portalApi.approveSubscription(subscription.consumerId, subscription.productId); 68 | message.success('审批通过成功'); 69 | fetchSubscriptions(); // 重新获取数据 70 | } catch (error: any) { 71 | const errorMessage = error.response?.data?.message || error.message || '审批失败'; 72 | message.error(`审批失败: ${errorMessage}`); 73 | } finally { 74 | setActionLoading(null); 75 | } 76 | }; 77 | 78 | const handleDeleteSubscription = async (subscription: Subscription) => { 79 | setActionLoading(`${subscription.consumerId}-${subscription.productId}-delete`); 80 | try { 81 | await portalApi.deleteSubscription(subscription.consumerId, subscription.productId); 82 | message.success('删除订阅成功'); 83 | fetchSubscriptions(); // 重新获取数据 84 | } catch (error: any) { 85 | const errorMessage = error.response?.data?.message || error.message || '删除订阅失败'; 86 | message.error(`删除订阅失败: ${errorMessage}`); 87 | } finally { 88 | setActionLoading(null); 89 | } 90 | }; 91 | 92 | const columns = [ 93 | { 94 | title: '产品名称', 95 | dataIndex: 'productName', 96 | key: 'productName', 97 | render: (productName: string) => ( 98 | <div> 99 | <div className="font-medium">{productName || '未知产品'}</div> 100 | </div> 101 | ) 102 | }, 103 | { 104 | title: '产品类型', 105 | dataIndex: 'productType', 106 | key: 'productType', 107 | render: (productType: string) => ( 108 | <Badge 109 | color={productType === 'REST_API' ? 'blue' : 'purple'} 110 | text={productType === 'REST_API' ? 'REST API' : 'MCP Server'} 111 | /> 112 | ) 113 | }, 114 | { 115 | title: '订阅状态', 116 | dataIndex: 'status', 117 | key: 'status', 118 | render: (status: string) => ( 119 | <Badge 120 | status={status === 'APPROVED' ? 'success' : 'processing'} 121 | text={status === 'APPROVED' ? '已通过' : '待审批'} 122 | /> 123 | ) 124 | }, 125 | { 126 | title: '订阅时间', 127 | dataIndex: 'createAt', 128 | key: 'createAt', 129 | render: (date: string) => formatDateTime(date) 130 | }, 131 | { 132 | title: '更新时间', 133 | dataIndex: 'updatedAt', 134 | key: 'updatedAt', 135 | render: (date: string) => formatDateTime(date) 136 | }, 137 | { 138 | title: '操作', 139 | key: 'action', 140 | width: 120, 141 | render: (_: any, record: Subscription) => { 142 | const loadingKey = `${record.consumerId}-${record.productId}`; 143 | const isApproving = actionLoading === `${loadingKey}-approve`; 144 | const isDeleting = actionLoading === `${loadingKey}-delete`; 145 | 146 | if (record.status === 'PENDING') { 147 | return ( 148 | <Button 149 | type="primary" 150 | size="small" 151 | loading={isApproving} 152 | onClick={() => handleApproveSubscription(record)} 153 | > 154 | 审批通过 155 | </Button> 156 | ); 157 | } else if (record.status === 'APPROVED') { 158 | return ( 159 | <Popconfirm 160 | title="确定要删除这个订阅吗?" 161 | description="删除后将无法恢复" 162 | onConfirm={() => handleDeleteSubscription(record)} 163 | okText="确定" 164 | cancelText="取消" 165 | > 166 | <Button 167 | type="default" 168 | size="small" 169 | danger 170 | loading={isDeleting} 171 | > 172 | 删除订阅 173 | </Button> 174 | </Popconfirm> 175 | ); 176 | } 177 | return null; 178 | } 179 | } 180 | ]; 181 | 182 | const pendingCount = subscriptions.filter(s => s.status === 'PENDING').length; 183 | const approvedCount = subscriptions.filter(s => s.status === 'APPROVED').length; 184 | 185 | return ( 186 | <Modal 187 | title={ 188 | <div> 189 | <div className="text-lg font-semibold">订阅列表 - {consumerName}</div> 190 | <div className="text-sm text-gray-500 mt-1"> 191 | 待审批: <Badge count={pendingCount} style={{ backgroundColor: '#faad14' }} /> | 192 | 已通过: <Badge count={approvedCount} style={{ backgroundColor: '#52c41a' }} /> 193 | </div> 194 | </div> 195 | } 196 | open={visible} 197 | onCancel={onCancel} 198 | footer={null} 199 | width={1000} 200 | destroyOnClose 201 | > 202 | <Table 203 | columns={columns} 204 | dataSource={subscriptions} 205 | rowKey="subscriptionId" 206 | loading={loading} 207 | pagination={pagination} 208 | onChange={handleTableChange} 209 | locale={{ 210 | emptyText: '暂无订阅记录' 211 | }} 212 | /> 213 | </Modal> 214 | ); 215 | } 216 | 217 | 218 | 219 | ``` -------------------------------------------------------------------------------- /portal-web/api-portal-admin/src/components/api-product/ApiProductOverview.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { useState, useEffect } from 'react' 2 | import { useNavigate } from 'react-router-dom' 3 | import { Card, Row, Col, Statistic, Button, message } from 'antd' 4 | import { 5 | ApiOutlined, 6 | GlobalOutlined, 7 | TeamOutlined, 8 | EditOutlined, 9 | CheckCircleFilled, 10 | MinusCircleFilled, 11 | CopyOutlined, 12 | ExclamationCircleFilled, 13 | ClockCircleFilled 14 | } from '@ant-design/icons' 15 | import type { ApiProduct } from '@/types/api-product' 16 | import { getServiceName, formatDateTime, copyToClipboard } from '@/lib/utils' 17 | import { apiProductApi } from '@/lib/api' 18 | 19 | 20 | interface ApiProductOverviewProps { 21 | apiProduct: ApiProduct 22 | linkedService: any | null 23 | onEdit: () => void 24 | } 25 | 26 | export function ApiProductOverview({ apiProduct, linkedService, onEdit }: ApiProductOverviewProps) { 27 | 28 | const [portalCount, setPortalCount] = useState(0) 29 | const [subscriberCount] = useState(0) 30 | 31 | const navigate = useNavigate() 32 | 33 | useEffect(() => { 34 | if (apiProduct.productId) { 35 | fetchPublishedPortals() 36 | } 37 | }, [apiProduct.productId]) 38 | 39 | const fetchPublishedPortals = async () => { 40 | try { 41 | const res = await apiProductApi.getApiProductPublications(apiProduct.productId) 42 | setPortalCount(res.data.content?.length || 0) 43 | } catch (error) { 44 | } finally { 45 | } 46 | } 47 | 48 | 49 | return ( 50 | <div className="p-6 space-y-6"> 51 | <div> 52 | <h1 className="text-2xl font-bold mb-2">概览</h1> 53 | <p className="text-gray-600">API产品概览</p> 54 | </div> 55 | 56 | {/* 基本信息 */} 57 | <Card 58 | title="基本信息" 59 | extra={ 60 | <Button 61 | type="primary" 62 | icon={<EditOutlined />} 63 | onClick={onEdit} 64 | > 65 | 编辑 66 | </Button> 67 | } 68 | > 69 | <div> 70 | <div className="grid grid-cols-6 gap-8 items-center pt-0 pb-2"> 71 | <span className="text-xs text-gray-600">产品名称:</span> 72 | <span className="col-span-2 text-xs text-gray-900">{apiProduct.name}</span> 73 | <span className="text-xs text-gray-600">产品ID:</span> 74 | <div className="col-span-2 flex items-center gap-2"> 75 | <span className="text-xs text-gray-700">{apiProduct.productId}</span> 76 | <Button 77 | type="text" 78 | size="small" 79 | icon={<CopyOutlined />} 80 | onClick={async () => { 81 | try { 82 | await copyToClipboard(apiProduct.productId); 83 | message.success('产品ID已复制'); 84 | } catch { 85 | message.error('复制失败,请手动复制'); 86 | } 87 | }} 88 | className="h-auto p-1 min-w-0" 89 | /> 90 | </div> 91 | </div> 92 | 93 | <div className="grid grid-cols-6 gap-8 items-center pt-2 pb-2"> 94 | <span className="text-xs text-gray-600">类型:</span> 95 | <span className="col-span-2 text-xs text-gray-900"> 96 | {apiProduct.type === 'REST_API' ? 'REST API' : 'MCP Server'} 97 | </span> 98 | <span className="text-xs text-gray-600">状态:</span> 99 | <div className="col-span-2 flex items-center"> 100 | {apiProduct.status === "PENDING" ? ( 101 | <ExclamationCircleFilled className="text-yellow-500 mr-2" style={{fontSize: '10px'}} /> 102 | ) : apiProduct.status === "READY" ? ( 103 | <ClockCircleFilled className="text-blue-500 mr-2" style={{fontSize: '10px'}} /> 104 | ) : ( 105 | <CheckCircleFilled className="text-green-500 mr-2" style={{fontSize: '10px'}} /> 106 | )} 107 | <span className="text-xs text-gray-900"> 108 | {apiProduct.status === "PENDING" ? "待配置" : apiProduct.status === "READY" ? "待发布" : "已发布"} 109 | </span> 110 | </div> 111 | </div> 112 | 113 | <div className="grid grid-cols-6 gap-8 items-center pt-2 pb-2"> 114 | <span className="text-xs text-gray-600">自动审批订阅:</span> 115 | <div className="col-span-2 flex items-center"> 116 | {apiProduct.autoApprove === true ? ( 117 | <CheckCircleFilled className="text-green-500 mr-2" style={{fontSize: '10px'}} /> 118 | ) : ( 119 | <MinusCircleFilled className="text-gray-400 mr-2" style={{fontSize: '10px'}} /> 120 | )} 121 | <span className="text-xs text-gray-900"> 122 | {apiProduct.autoApprove === true ? '已开启' : '已关闭'} 123 | </span> 124 | </div> 125 | <span className="text-xs text-gray-600">创建时间:</span> 126 | <span className="col-span-2 text-xs text-gray-700">{formatDateTime(apiProduct.createAt)}</span> 127 | </div> 128 | 129 | {apiProduct.description && ( 130 | <div className="grid grid-cols-6 gap-8 pt-2 pb-2"> 131 | <span className="text-xs text-gray-600">描述:</span> 132 | <span className="col-span-5 text-xs text-gray-700 leading-relaxed"> 133 | {apiProduct.description} 134 | </span> 135 | </div> 136 | )} 137 | 138 | </div> 139 | </Card> 140 | 141 | {/* 统计数据 */} 142 | <Row gutter={[16, 16]}> 143 | <Col xs={24} sm={12} lg={8}> 144 | <Card 145 | className="cursor-pointer hover:shadow-md transition-shadow" 146 | onClick={() => { 147 | navigate(`/api-products/detail?productId=${apiProduct.productId}&tab=portal`) 148 | }} 149 | > 150 | <Statistic 151 | title="发布的门户" 152 | value={portalCount} 153 | prefix={<GlobalOutlined className="text-blue-500" />} 154 | valueStyle={{ color: '#1677ff', fontSize: '24px' }} 155 | /> 156 | </Card> 157 | </Col> 158 | <Col xs={24} sm={12} lg={8}> 159 | <Card 160 | className="cursor-pointer hover:shadow-md transition-shadow" 161 | onClick={() => { 162 | navigate(`/api-products/detail?productId=${apiProduct.productId}&tab=link-api`) 163 | }} 164 | > 165 | <Statistic 166 | title="关联API" 167 | value={getServiceName(linkedService) || '未关联'} 168 | prefix={<ApiOutlined className="text-blue-500" />} 169 | valueStyle={{ color: '#1677ff', fontSize: '24px' }} 170 | /> 171 | </Card> 172 | </Col> 173 | <Col xs={24} sm={12} lg={8}> 174 | <Card className="hover:shadow-md transition-shadow"> 175 | <Statistic 176 | title="订阅用户" 177 | value={subscriberCount} 178 | prefix={<TeamOutlined className="text-blue-500" />} 179 | valueStyle={{ color: '#1677ff', fontSize: '24px' }} 180 | /> 181 | </Card> 182 | </Col> 183 | </Row> 184 | 185 | </div> 186 | ) 187 | } ```