From 8627f7b68fd6756f85fdc0f90bcb74300db8d96c Mon Sep 17 00:00:00 2001 From: jianxing Date: Thu, 10 Aug 2023 18:31:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=B3=BB=E7=BB=9F=E8=AE=BE=E7=BD=AE):=20?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E9=9B=86=E6=88=90=E6=8E=A5=E5=8F=A3=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --task=1012656 --user=陈建星 系统设置-组织-服务集成-后台 https://www.tapd.cn/55049933/s/1401952 --- .../metersphere-platform-plugin-sdk/pom.xml | 14 + .../plugin/platform/api/AbstractPlatform.java | 25 ++ .../platform/api/AbstractPlatformPlugin.java | 43 ++- .../plugin/platform/api/BaseClient.java | 108 ++++++++ .../plugin/platform/api/Platform.java | 135 +--------- .../plugin/platform/dto/PlatformRequest.java | 3 +- .../platform/utils/EnvProxySelector.java | 45 ++++ .../platform/utils/PluginBeanUtils.java | 69 +++++ .../platform/utils/PluginCodingUtils.java | 168 ++++++++++++ .../plugin/sdk/util/MSPluginException.java | 23 ++ backend/framework/plugin/pom.xml | 26 -- .../RestControllerExceptionHandler.java | 5 +- .../handler/result/CommonResultCode.java | 5 +- .../sdk/plugin/loader/PluginManager.java | 56 ++-- .../sdk/service/PlatformPluginService.java | 108 ++++++++ .../sdk/service/PluginLoadService.java | 43 +++ .../sdk/util/FilterChainUtils.java | 2 +- .../resources/i18n/commons_en_US.properties | 6 +- .../resources/i18n/commons_zh_CN.properties | 6 +- .../resources/i18n/commons_zh_TW.properties | 6 +- .../resources/i18n/system_en_US.properties | 1 + .../resources/i18n/system_zh_CN.properties | 17 +- .../resources/i18n/system_zh_TW.properties | 1 + .../io/metersphere/sdk/base/BaseTest.java | 14 +- .../base/param/NotEmptyParamGenerator.java | 13 +- .../system/controller/PluginController.java | 5 +- .../ServiceIntegrationController.java | 25 +- .../system/dto/ServiceIntegrationDTO.java | 2 +- .../system/request/PluginUpdateRequest.java | 2 +- .../ServiceIntegrationUpdateRequest.java | 10 +- .../system/service/PluginService.java | 48 +++- .../service/ServiceIntegrationService.java | 66 ++++- .../controller/PluginControllerTests.java | 6 +- .../ServiceIntegrationControllerTests.java | 252 ++++++++++++++---- .../param/PluginUpdateRequestDefinition.java | 2 +- ...iceIntegrationUpdateRequestDefinition.java | 2 +- .../file/metersphere-jira-plugin-3.x.jar | Bin 4035 -> 41971 bytes 37 files changed, 1071 insertions(+), 291 deletions(-) create mode 100644 backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/api/AbstractPlatform.java create mode 100644 backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/api/BaseClient.java create mode 100644 backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/utils/EnvProxySelector.java create mode 100644 backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/utils/PluginBeanUtils.java create mode 100644 backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/utils/PluginCodingUtils.java create mode 100644 backend/framework/plugin/metersphere-plugin-sdk/src/main/java/io/metersphere/plugin/sdk/util/MSPluginException.java create mode 100644 backend/framework/sdk/src/main/java/io/metersphere/sdk/service/PlatformPluginService.java diff --git a/backend/framework/plugin/metersphere-platform-plugin-sdk/pom.xml b/backend/framework/plugin/metersphere-platform-plugin-sdk/pom.xml index 1ac4dd98f4..c178185fff 100644 --- a/backend/framework/plugin/metersphere-platform-plugin-sdk/pom.xml +++ b/backend/framework/plugin/metersphere-platform-plugin-sdk/pom.xml @@ -18,6 +18,20 @@ metersphere-plugin-sdk ${revision} + + + + org.springframework + spring-web + + + org.apache.httpcomponents.client5 + httpclient5 + + + commons-codec + commons-codec + diff --git a/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/api/AbstractPlatform.java b/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/api/AbstractPlatform.java new file mode 100644 index 0000000000..d62cef52a0 --- /dev/null +++ b/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/api/AbstractPlatform.java @@ -0,0 +1,25 @@ +package io.metersphere.plugin.platform.api; + +import io.metersphere.plugin.platform.dto.PlatformRequest; +import io.metersphere.plugin.sdk.util.JSON; +import io.metersphere.plugin.sdk.util.MSPluginException; +import org.apache.commons.lang3.StringUtils; + +public abstract class AbstractPlatform implements Platform { + protected PlatformRequest request; + + public AbstractPlatform(PlatformRequest request) { + this.request = request; + } + + public T getIntegrationConfig(String integrationConfig, Class clazz) { + if (StringUtils.isBlank(integrationConfig)) { + throw new MSPluginException("服务集成配置为空"); + } + return JSON.parseObject(integrationConfig, clazz); + } + + public String getPluginId() { + return request.getPluginId(); + } +} diff --git a/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/api/AbstractPlatformPlugin.java b/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/api/AbstractPlatformPlugin.java index dcead1c8d8..9dfa0c244b 100644 --- a/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/api/AbstractPlatformPlugin.java +++ b/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/api/AbstractPlatformPlugin.java @@ -3,9 +3,48 @@ package io.metersphere.plugin.platform.api; import io.metersphere.plugin.sdk.api.AbstractMsPlugin; public abstract class AbstractPlatformPlugin extends AbstractMsPlugin { - private static final String PLATFORM_PLUGIN_TYPE = "PLATFORM"; + private static final String DEFAULT_PLATFORM_PLUGIN_TYPE = "PLATFORM"; + private static final String DEFAULT_INTEGRATION_SCRIPT_ID = "integration"; + private static final String DEFAULT_PROJECT_SCRIPT_ID = "project"; + private static final String DEFAULT_ACCOUNT_SCRIPT_ID = "account"; @Override public String getType() { - return PLATFORM_PLUGIN_TYPE; + return DEFAULT_PLATFORM_PLUGIN_TYPE; + } + + /** + * 返回插件的描述信息 + * @return + */ + public abstract String getDescription(); + + /** + * 返回插件的logo路径,比如:/static/jira.png + * @return + */ + public abstract String getLogo(); + + /** + * 返回服务集成脚本的ID,默认为 integration + * @return + */ + public String getIntegrationScriptId() { + return DEFAULT_INTEGRATION_SCRIPT_ID; + } + + /** + * 返回项目配置脚本的ID,默认为 project + * @return + */ + public String getProjectScriptId() { + return DEFAULT_PROJECT_SCRIPT_ID; + } + + /** + * 返回个人账号脚本的ID,默认为 account + * @return + */ + public String getAccountScriptId() { + return DEFAULT_ACCOUNT_SCRIPT_ID; } } diff --git a/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/api/BaseClient.java b/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/api/BaseClient.java new file mode 100644 index 0000000000..a1d652bddd --- /dev/null +++ b/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/api/BaseClient.java @@ -0,0 +1,108 @@ +package io.metersphere.plugin.platform.api; + +import io.metersphere.plugin.platform.utils.EnvProxySelector; +import io.metersphere.plugin.platform.utils.PluginCodingUtils; +import io.metersphere.plugin.sdk.util.JSON; +import io.metersphere.plugin.sdk.util.MSPluginException; +import io.metersphere.plugin.sdk.util.PluginLogUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.impl.routing.SystemDefaultRoutePlanner; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder; +import org.apache.hc.core5.ssl.SSLContexts; +import org.apache.hc.core5.ssl.TrustStrategy; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +import javax.net.ssl.SSLContext; +import java.net.URI; +import java.net.URISyntaxException; +import java.security.cert.X509Certificate; +import java.util.Arrays; + +public abstract class BaseClient { + + protected RestTemplate restTemplate; + + public BaseClient() { + try { + TrustStrategy acceptingTrustStrategy = (X509Certificate[] chain, String authType) -> true; + + SSLContext sslContext = SSLContexts.custom() + .loadTrustMaterial(null, acceptingTrustStrategy) + .build(); + + SSLConnectionSocketFactory csf = SSLConnectionSocketFactoryBuilder + .create() + .setSslContext(sslContext) + .build(); + + CloseableHttpClient httpClient = HttpClients.custom() + // 可以支持设置系统代理 + .setRoutePlanner(new SystemDefaultRoutePlanner(new EnvProxySelector())) + // 忽略 https + .setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create() + .setSSLSocketFactory(csf) + .build()) + .build(); + + HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(); + requestFactory.setHttpClient(httpClient); + + restTemplate = new RestTemplate(requestFactory); + } catch (Exception e) { + PluginLogUtils.error(e); + } + } + + protected HttpHeaders getBasicHttpHeaders(String userName, String passWd) { + String authKey = PluginCodingUtils.base64Encoding(userName + ":" + passWd); + HttpHeaders headers = new HttpHeaders(); + headers.setBasicAuth(authKey); + headers.add(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE); + return headers; + } + + protected HttpHeaders getBearHttpHeaders(String token) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(token); + headers.add(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE); + return headers; + } + + protected String getResult(ResponseEntity response) { + int statusCodeValue = response.getStatusCodeValue(); + PluginLogUtils.info("responseCode: " + statusCodeValue); + if (statusCodeValue >= 400) { + throw new MSPluginException(response.getBody()); + } + PluginLogUtils.info("result: " + response.getBody()); + return response.getBody(); + } + + protected Object getResultForList(Class clazz, ResponseEntity response) { + return Arrays.asList(JSON.parseArray(getResult(response), clazz).toArray()); + } + + protected Object getResultForObject(Class clazz, ResponseEntity response) { + return JSON.parseObject(getResult(response), clazz); + } + + public void validateProxyUrl(String url, String... path) { + try { + if (!StringUtils.containsAny(new URI(url).getPath(), path)) { + // 只允许访问图片 + throw new MSPluginException("illegal path"); + } + } catch (URISyntaxException e) { + PluginLogUtils.error(e); + throw new MSPluginException("illegal path"); + } + } +} diff --git a/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/api/Platform.java b/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/api/Platform.java index c6dde0a4e5..c570791df1 100644 --- a/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/api/Platform.java +++ b/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/api/Platform.java @@ -1,147 +1,14 @@ package io.metersphere.plugin.platform.api; -import io.metersphere.plugin.platform.dto.*; - -import java.util.List; - /** - * 平台对接相关业务 + * 平台对接相关业务接口 * @author jianxing.chen */ public interface Platform { - /** - * 获取平台相关需求 - * 功能用例关联需求时调用 - * @param projectConfig 项目设置表单值 - * @return 需求列表 - */ - List getDemands(String projectConfig); - - /** - * 创建缺陷并封装 MS 返回 - * 创建缺陷时调用 - * @param bugsRequest bugRequest - * @return MS 缺陷 - */ - MsBugDTO addBug(PlatformBugUpdateRequest bugsRequest); - - /** - * 项目设置和缺陷表单中,调用接口获取下拉框选项 - * 配置文件的表单中选项值配置了 optionMethod ,则调用获取表单的选项值 - * @return 返回下拉列表 - */ - List getFormOptions(GetOptionRequest request); - - /** - * 更新缺陷 - * 编辑缺陷时调用 - * @param request - * @return MS 缺陷 - */ - MsBugDTO updateBug(PlatformBugUpdateRequest request); - - /** - * 删除缺陷平台缺陷 - * 删除缺陷时调用 - * @param id 平台的缺陷 ID - */ - void deleteBug(String id); - /** * 校验服务集成配置 * 服务集成点击校验时调用 */ void validateIntegrationConfig(); - - /** - * 校验项目配置 - * 项目设置成点击校验项目 key 时调用 - */ - void validateProjectConfig(String projectConfig); - - /** - * 校验用户配置配置 - * 用户信息,校验第三方信息时调用 - */ - void validateUserConfig(String userConfig); - - /** - * 支持附件上传 - * 编辑缺陷上传附件是会调用判断是否支持附件上传 - * 如果支持会调用 syncBugsAttachment 上传缺陷到第三方平台 - */ - boolean isAttachmentUploadSupport(); - - /** - * 同步缺陷最新变更 - * 开源用户点击同步缺陷时调用 - */ - SyncBugResult syncBugs(SyncBugRequest request); - - /** - * 同步项目下所有的缺陷 - * 企业版用户会调用同步缺陷 - */ - void syncAllBugs(SyncAllBugRequest request); - - /** - * 获取附件内容 - * 同步缺陷中,同步附件时会调用 - * @param fileKey 文件关键字 - */ - byte[] getAttachmentContent(String fileKey); - - /** - * 获取第三方平台缺陷的自定义字段 - * 需要 PluginMetaInfo 的 isThirdPartTemplateSupport 返回 true - * @return - */ - List getThirdPartCustomField(String projectConfig); - - /** - * Get请求的代理 - * 目前使用场景:富文本框中如果有图片是存储在第三方平台,MS 通过 url 访问 - * 这时如果第三方平台需要登入才能访问到静态资源,可以通过将富文本框图片内容构造如下格式访问 - * ![name](/resource/md/get/path?platform=Jira?project_id=&organization_id=&path=) - * @param path - * @return - */ - Object proxyForGet(String path, Class responseEntityClazz); - - /** - * 同步 MS 缺陷附件到第三方平台 - * isAttachmentUploadSupport 返回为 true 时,同步和创建缺陷时会调用 - */ - void syncBugsAttachment(SyncBugAttachmentRequest request); - - /** - * 获取第三方平台的状态列表 - * 缺陷列表和编辑缺陷时会调用 - * @return - */ - List getStatusList(String projectConfig); - - /** - * 获取第三方平台的状态转移列表 - * 即编辑缺陷时的可选状态 - * 默认会调用 getStatusList,可重写覆盖 - * @param bugId - * @return - */ - List getTransitions(String projectConfig, String bugId); - - /** - * 用例关联需求时调用 - * 可在第三方平台添加用例和需求的关联关系 - * @param request - */ - void handleDemandUpdate(DemandUpdateRequest request); - - /** - * 用例批量关联需求时调用 - * 可在第三方平台添加用例和需求的关联关系 - * @param request - */ - void handleDemandUpdateBatch(DemandUpdateRequest request); } diff --git a/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/dto/PlatformRequest.java b/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/dto/PlatformRequest.java index bebd25c9be..1ec77aaddf 100644 --- a/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/dto/PlatformRequest.java +++ b/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/dto/PlatformRequest.java @@ -8,5 +8,6 @@ import lombok.Setter; public class PlatformRequest { private String integrationConfig; private String organizationId; - private String userPlatformInfo; + private String userAccountConfig; + private String pluginId; } diff --git a/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/utils/EnvProxySelector.java b/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/utils/EnvProxySelector.java new file mode 100644 index 0000000000..8d3c75b132 --- /dev/null +++ b/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/utils/EnvProxySelector.java @@ -0,0 +1,45 @@ +package io.metersphere.plugin.platform.utils; + +import io.metersphere.plugin.sdk.util.PluginLogUtils; +import org.apache.commons.lang3.StringUtils; + +import java.io.IOException; +import java.net.*; +import java.util.Arrays; +import java.util.List; + +public class EnvProxySelector extends ProxySelector { + ProxySelector defaultProxySelector = ProxySelector.getDefault(); + + @Override + public void connectFailed(URI uri, SocketAddress sa, IOException ioe) { + PluginLogUtils.error("connectFailed : " + uri); + } + + /** + * 获取环境变量http代理配置, + * + * @param uri + * @return + */ + @Override + public List select(URI uri) { + String httpProxy = System.getenv("http_proxy"); + String httpsProxy = System.getenv("https_proxy"); + URI proxy = null; + try { + if (StringUtils.equalsIgnoreCase(uri.getScheme(), "https") + && StringUtils.isNotBlank(httpsProxy)) { + proxy = new URI(httpsProxy); + } else if (StringUtils.isNotBlank(httpProxy)) { + proxy = new URI(httpProxy); + } + if (proxy != null) { + return Arrays.asList(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxy.getHost(), proxy.getPort()))); + } + } catch (URISyntaxException e) { + PluginLogUtils.error(e); + } + return defaultProxySelector.select(uri); + } +} diff --git a/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/utils/PluginBeanUtils.java b/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/utils/PluginBeanUtils.java new file mode 100644 index 0000000000..8d65a4710f --- /dev/null +++ b/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/utils/PluginBeanUtils.java @@ -0,0 +1,69 @@ +package io.metersphere.plugin.platform.utils; + +import io.metersphere.plugin.sdk.util.PluginLogUtils; +import org.apache.commons.lang3.StringUtils; + +import java.lang.reflect.Method; + +public class PluginBeanUtils { + + public static T copyBean(T target, Object source) { + try { + org.springframework.beans.BeanUtils.copyProperties(source, target); + return target; + } catch (Exception e) { + throw new RuntimeException("Failed to copy object: ", e); + } + } + + public static T copyBean(T target, Object source, String... ignoreProperties) { + try { + org.springframework.beans.BeanUtils.copyProperties(source, target, ignoreProperties); + return target; + } catch (Exception e) { + throw new RuntimeException("Failed to copy object: ", e); + } + } + + public static Object getFieldValueByName(String fieldName, Object bean) { + try { + if (StringUtils.isBlank(fieldName)) { + return null; + } + String firstLetter = fieldName.substring(0, 1).toUpperCase(); + String getter = "get" + firstLetter + fieldName.substring(1); + Method method = bean.getClass().getMethod(getter); + return method.invoke(bean); + } catch (Exception e) { + PluginLogUtils.error("failed to getFieldValueByName. ", e); + return null; + } + } + + public static void setFieldValueByName(Object bean, String fieldName, Object value, Class type) { + try { + if (StringUtils.isBlank(fieldName)) { + return; + } + String firstLetter = fieldName.substring(0, 1).toUpperCase(); + String setter = "set" + firstLetter + fieldName.substring(1); + Method method = bean.getClass().getMethod(setter, type); + method.invoke(bean, value); + } catch (Exception e) { + PluginLogUtils.error("failed to setFieldValueByName. ", e); + } + } + + public static Method getMethod(Object bean, String fieldName, Class type) { + try { + if (StringUtils.isBlank(fieldName)) { + return null; + } + String firstLetter = fieldName.substring(0, 1).toUpperCase(); + String setter = "set" + firstLetter + fieldName.substring(1); + return bean.getClass().getMethod(setter, type); + } catch (Exception e) { + return null; + } + } +} diff --git a/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/utils/PluginCodingUtils.java b/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/utils/PluginCodingUtils.java new file mode 100644 index 0000000000..1a1758a47d --- /dev/null +++ b/backend/framework/plugin/metersphere-platform-plugin-sdk/src/main/java/io/metersphere/plugin/platform/utils/PluginCodingUtils.java @@ -0,0 +1,168 @@ +package io.metersphere.plugin.platform.utils; + +import org.apache.commons.codec.binary.Base64; +import org.apache.commons.lang3.StringUtils; + +import javax.crypto.*; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** + * 加密解密工具 + * + * @author kun.mo + */ +public class PluginCodingUtils { + + private static final String UTF_8 = StandardCharsets.UTF_8.name(); + + private static final char[] HEX_DIGITS = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'}; + + /** + * MD5加密 + * + * @param src 要加密的串 + * @return 加密后的字符串 + */ + public static String md5(String src) { + return md5(src, UTF_8); + } + + /** + * MD5加密 + * + * @param src 要加密的串 + * @param charset 加密字符集 + * @return 加密后的字符串 + */ + public static String md5(String src, String charset) { + try { + byte[] strTemp = StringUtils.isEmpty(charset) ? src.getBytes() : src.getBytes(charset); + MessageDigest mdTemp = MessageDigest.getInstance("MD5"); + mdTemp.update(strTemp); + + byte[] md = mdTemp.digest(); + int j = md.length; + char[] str = new char[j * 2]; + int k = 0; + + for (byte byte0 : md) { + str[k++] = HEX_DIGITS[byte0 >>> 4 & 0xf]; + str[k++] = HEX_DIGITS[byte0 & 0xf]; + } + + return new String(str); + } catch (Exception e) { + throw new RuntimeException("MD5 encrypt error:", e); + } + } + + /** + * BASE64解密 + * + * @param src 待解密的字符串 + * @return 解密后的字符串 + */ + public static String base64Decoding(String src) { + byte[] b; + String result = null; + if (src != null) { + try { + b = Base64.decodeBase64(src); + result = new String(b, UTF_8); + } catch (Exception e) { + throw new RuntimeException("BASE64 decoding error:", e); + } + } + return result; + } + + /** + * BASE64加密 + * + * @param src 待加密的字符串 + * @return 加密后的字符串 + */ + public static String base64Encoding(String src) { + String result = null; + if (src != null) { + try { + result = Base64.encodeBase64String(src.getBytes(UTF_8)); + } catch (Exception e) { + throw new RuntimeException("BASE64 encoding error:", e); + } + } + return result; + } + + /** + * AES加密 + * + * @param src 待加密字符串 + * @param secretKey 密钥 + * @param iv 向量 + * @return 加密后字符串 + */ + public static String aesEncrypt(String src, String secretKey, String iv) { + if (StringUtils.isBlank(secretKey)) { + throw new RuntimeException("secretKey is empty"); + } + + try { + byte[] raw = secretKey.getBytes(UTF_8); + SecretKeySpec secretKeySpec = new SecretKeySpec(raw, "AES"); + // "算法/模式/补码方式" ECB + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + IvParameterSpec iv1 = new IvParameterSpec(iv.getBytes()); + cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, iv1); + byte[] encrypted = cipher.doFinal(src.getBytes(UTF_8)); + return Base64.encodeBase64String(encrypted); + } catch (Exception e) { + throw new RuntimeException("AES encrypt error:", e); + } + + } + + /** + * AES 解密 + * + * @param src 待解密字符串 + * @param secretKey 密钥 + * @param iv 向量 + * @return 解密后字符串 + */ + public static String aesDecrypt(String src, String secretKey, String iv) { + if (StringUtils.isBlank(secretKey)) { + throw new RuntimeException("secretKey is empty"); + } + try { + byte[] raw = secretKey.getBytes(UTF_8); + SecretKeySpec secretKeySpec = new SecretKeySpec(raw, "AES"); + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + IvParameterSpec iv1 = new IvParameterSpec(iv.getBytes()); + cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, iv1); + byte[] encrypted1 = Base64.decodeBase64(src); + byte[] original = cipher.doFinal(encrypted1); + return new String(original, UTF_8); + } catch (BadPaddingException | IllegalBlockSizeException e) { + // 解密的原字符串为非加密字符串,则直接返回原字符串 + return src; + } catch (Exception e) { + throw new RuntimeException("decrypt error,please check parameters", e); + } + } + + public static String secretKey() { + try { + KeyGenerator keyGen = KeyGenerator.getInstance("AES"); + keyGen.init(128); + SecretKey secretKey = keyGen.generateKey(); + return Base64.encodeBase64String(secretKey.getEncoded()); + } catch (Exception e) { + throw new RuntimeException("generate secretKey error", e); + } + + } +} diff --git a/backend/framework/plugin/metersphere-plugin-sdk/src/main/java/io/metersphere/plugin/sdk/util/MSPluginException.java b/backend/framework/plugin/metersphere-plugin-sdk/src/main/java/io/metersphere/plugin/sdk/util/MSPluginException.java new file mode 100644 index 0000000000..746979737c --- /dev/null +++ b/backend/framework/plugin/metersphere-plugin-sdk/src/main/java/io/metersphere/plugin/sdk/util/MSPluginException.java @@ -0,0 +1,23 @@ +package io.metersphere.plugin.sdk.util; + +/** + * 插件异常类 + * @author jianxing + */ +public class MSPluginException extends RuntimeException { + public MSPluginException() { + super(); + } + + public MSPluginException(String message) { + super(message); + } + + public MSPluginException(String message, Throwable cause) { + super(message, cause); + } + + public MSPluginException(Throwable cause) { + super(cause); + } +} diff --git a/backend/framework/plugin/pom.xml b/backend/framework/plugin/pom.xml index dd574d75a5..7d910b64d5 100644 --- a/backend/framework/plugin/pom.xml +++ b/backend/framework/plugin/pom.xml @@ -8,7 +8,6 @@ io.metersphere framework ${revision} - io.metersphere @@ -25,29 +24,4 @@ metersphere-api-plugin-sdk metersphere-platform-plugin-sdk - - - - org.apache.jmeter - ApacheJMeter_core - ${jmeter.version} - - - org.apache.jmeter - jorphan - ${jmeter.version} - - - org.projectlombok - lombok - - - org.apache.commons - commons-lang3 - - - com.fasterxml.jackson.core - jackson-annotations - - \ No newline at end of file diff --git a/backend/framework/sdk/src/main/java/io/metersphere/sdk/controller/handler/RestControllerExceptionHandler.java b/backend/framework/sdk/src/main/java/io/metersphere/sdk/controller/handler/RestControllerExceptionHandler.java index 4f57231fdb..883079f6ce 100644 --- a/backend/framework/sdk/src/main/java/io/metersphere/sdk/controller/handler/RestControllerExceptionHandler.java +++ b/backend/framework/sdk/src/main/java/io/metersphere/sdk/controller/handler/RestControllerExceptionHandler.java @@ -3,6 +3,7 @@ package io.metersphere.sdk.controller.handler; import io.metersphere.sdk.controller.handler.result.IResultCode; import io.metersphere.sdk.controller.handler.result.MsHttpResultCode; import io.metersphere.sdk.exception.MSException; +import io.metersphere.sdk.util.Translator; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.apache.shiro.ShiroException; @@ -76,11 +77,11 @@ public class RestControllerExceptionHandler { if (errorCode instanceof MsHttpResultCode) { // 如果是 MsHttpResultCode,则设置响应的状态码,取状态码的后三位 return ResponseEntity.status(errorCode.getCode() % 1000) - .body(ResultHolder.error(errorCode.getCode(), errorCode.getMessage())); + .body(ResultHolder.error(errorCode.getCode(), Translator.get(errorCode.getMessage(), errorCode.getMessage()))); } else { // 响应码返回 500,设置业务状态码 return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ResultHolder.error(errorCode.getCode(), errorCode.getMessage(), e.getMessage())); + .body(ResultHolder.error(errorCode.getCode(), Translator.get(errorCode.getMessage(), errorCode.getMessage()), e.getMessage())); } } diff --git a/backend/framework/sdk/src/main/java/io/metersphere/sdk/controller/handler/result/CommonResultCode.java b/backend/framework/sdk/src/main/java/io/metersphere/sdk/controller/handler/result/CommonResultCode.java index 94c4f99968..1cfcbbfea7 100644 --- a/backend/framework/sdk/src/main/java/io/metersphere/sdk/controller/handler/result/CommonResultCode.java +++ b/backend/framework/sdk/src/main/java/io/metersphere/sdk/controller/handler/result/CommonResultCode.java @@ -13,7 +13,10 @@ public enum CommonResultCode implements IResultCode { * 调用获取全局用户组接口,如果操作的是内置的用户组,会返回该响应码 */ INTERNAL_USER_ROLE_PERMISSION(101003, "internal_user_role_permission_error"), - USER_ROLE_RELATION_REMOVE_ADMIN_USER_PERMISSION(100004, "user_role_relation_remove_admin_user_permission_error"); + USER_ROLE_RELATION_REMOVE_ADMIN_USER_PERMISSION(100004, "user_role_relation_remove_admin_user_permission_error"), + FILE_NAME_ILLEGAL(100005, "file_name_illegal_error"), + PLUGIN_ENABLE(100006, "plugin_enable_error"), + PLUGIN_PERMISSION(100007, "plugin_permission_error"); private int code; private String message; diff --git a/backend/framework/sdk/src/main/java/io/metersphere/sdk/plugin/loader/PluginManager.java b/backend/framework/sdk/src/main/java/io/metersphere/sdk/plugin/loader/PluginManager.java index 7bd388c59b..5a8d2cc454 100644 --- a/backend/framework/sdk/src/main/java/io/metersphere/sdk/plugin/loader/PluginManager.java +++ b/backend/framework/sdk/src/main/java/io/metersphere/sdk/plugin/loader/PluginManager.java @@ -9,10 +9,7 @@ import java.io.IOException; import java.io.InputStream; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Type; -import java.util.HashMap; -import java.util.LinkedHashSet; -import java.util.Map; -import java.util.Set; +import java.util.*; /** * @author jianxing.chen @@ -24,6 +21,14 @@ public class PluginManager { */ protected Map classLoaderMap = new HashMap<>(); + /** + * 缓存查找过的类 + * 内层 map + * key 未接口的类 + * value 为实现类 + */ + protected Map> implClassCache = new HashMap<>(); + public PluginClassLoader getClassLoader(String pluginId) { return classLoaderMap.get(pluginId); } @@ -48,6 +53,7 @@ public class PluginManager { public void deletePlugin(String id) { classLoaderMap.remove(id); + implClassCache.remove(id); } /** @@ -70,40 +76,52 @@ public class PluginManager { * 获取接口的单一实现类 */ public Class getImplClass(String pluginId, Class superClazz) { - PluginClassLoader classLoader = classLoaderMap.get(pluginId); + PluginClassLoader classLoader = getPluginClassLoader(pluginId); + Map classes = implClassCache.get(pluginId); + if (classes == null) { + classes = new HashMap<>(); + implClassCache.put(pluginId, classes); + } + if (classes.get(superClazz) != null) { + return classes.get(superClazz); + } LinkedHashSet> result = new LinkedHashSet<>(); Set clazzSet = classLoader.getClazzSet(); for (Class item : clazzSet) { if (isImplClazz(superClazz, item) && !result.contains(item)) { + classes.put(superClazz, item); return item; } } return null; } + private PluginClassLoader getPluginClassLoader(String pluginId) { + PluginClassLoader classLoader = classLoaderMap.get(pluginId); + if (classLoader == null) { + throw new MSException("插件未加载"); + } + return classLoader; + } + /** * 获取指定接口最后一次加载的实现类实例 */ public T getImplInstance(String pluginId, Class superClazz) { - try { - Class clazz = getImplClass(pluginId, superClazz); - if (clazz == null) { - throw new MSException("未找到插件实现类"); - } - return clazz.getConstructor().newInstance(); - } catch (InvocationTargetException e) { - LogUtils.error(e); - throw new MSException(CommonResultCode.PLUGIN_GET_INSTANCE, e.getTargetException().getMessage()); - } catch (Exception e) { - LogUtils.error(e); - throw new MSException(CommonResultCode.PLUGIN_GET_INSTANCE, e.getMessage()); - } + return this.getImplInstance(pluginId, superClazz, null); } public T getImplInstance(String pluginId, Class superClazz, Object param) { try { Class clazz = getImplClass(pluginId, superClazz); - return clazz.getConstructor(param.getClass()).newInstance(param); + if (clazz == null) { + throw new MSException(CommonResultCode.PLUGIN_GET_INSTANCE); + } + if (param == null) { + return clazz.getConstructor().newInstance(); + } else { + return clazz.getConstructor(param.getClass()).newInstance(param); + } } catch (InvocationTargetException e) { LogUtils.error(e.getTargetException()); throw new MSException(CommonResultCode.PLUGIN_GET_INSTANCE, e.getTargetException().getMessage()); diff --git a/backend/framework/sdk/src/main/java/io/metersphere/sdk/service/PlatformPluginService.java b/backend/framework/sdk/src/main/java/io/metersphere/sdk/service/PlatformPluginService.java new file mode 100644 index 0000000000..dfcfcd4834 --- /dev/null +++ b/backend/framework/sdk/src/main/java/io/metersphere/sdk/service/PlatformPluginService.java @@ -0,0 +1,108 @@ +package io.metersphere.sdk.service; + +import io.metersphere.plugin.platform.api.Platform; +import io.metersphere.plugin.platform.dto.PlatformRequest; +import io.metersphere.sdk.constants.PluginScenarioType; +import io.metersphere.sdk.exception.MSException; +import io.metersphere.system.domain.*; +import io.metersphere.system.mapper.PluginMapper; +import io.metersphere.system.mapper.PluginOrganizationMapper; +import io.metersphere.system.mapper.ServiceIntegrationMapper; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static io.metersphere.sdk.controller.handler.result.CommonResultCode.PLUGIN_ENABLE; +import static io.metersphere.sdk.controller.handler.result.CommonResultCode.PLUGIN_PERMISSION; + +@Service +@Transactional(rollbackFor = Exception.class) +public class PlatformPluginService { + + @Resource + private PluginLoadService pluginLoadService; + @Resource + private ServiceIntegrationMapper serviceIntegrationMapper; + @Resource + private PluginMapper pluginMapper; + @Resource + private PluginOrganizationMapper pluginOrganizationMapper; + + /** + * 获取平台实例 + * + * @param pluginId + * @param orgId + * @param integrationConfig + * @return + */ + public Platform getPlatform(String pluginId, String orgId, String integrationConfig) { + if (StringUtils.isNotBlank(orgId)) { + // 服务集成的测试链接,不需要校验插件开启和状态 + Plugin plugin = pluginMapper.selectByPrimaryKey(pluginId); + checkPluginEnable(plugin); + checkPluginPermission(pluginId, orgId, plugin); + } + PlatformRequest pluginRequest = new PlatformRequest(); + pluginRequest.setIntegrationConfig(integrationConfig); + pluginRequest.setOrganizationId(orgId); + return pluginLoadService.getImplInstance(pluginId, Platform.class, pluginRequest); + } + + public Platform getPlatform(String pluginId, String orgId) { + // 这里会校验插件是否存在 + pluginLoadService.getMsPluginInstance(pluginId); + ServiceIntegration serviceIntegration = getServiceIntegrationByPluginId(pluginId); + return getPlatform(pluginId, orgId, new String(serviceIntegration.getConfiguration())); + } + + /** + * 校验该组织是否能访问插件 + * @param pluginId + * @param orgId + * @param plugin + */ + private void checkPluginPermission(String pluginId, String orgId, Plugin plugin) { + if (plugin.getGlobal()) { + return; + } + PluginOrganizationExample example = new PluginOrganizationExample(); + example.createCriteria() + .andOrganizationIdEqualTo(orgId) + .andPluginIdEqualTo(pluginId); + List pluginOrganizations = pluginOrganizationMapper.selectByExample(example); + for (PluginOrganization pluginOrganization : pluginOrganizations) { + if (StringUtils.equals(pluginOrganization.getOrganizationId(), orgId)) { + return; + } + } + throw new MSException(PLUGIN_PERMISSION); + } + + /** + * 校验插件是否启用 + * @param plugin + */ + private static void checkPluginEnable(Plugin plugin) { + if (BooleanUtils.isFalse(plugin.getEnable())) { + throw new MSException(PLUGIN_ENABLE); + } + } + + private ServiceIntegration getServiceIntegrationByPluginId(String pluginId) { + ServiceIntegrationExample example = new ServiceIntegrationExample(); + example.createCriteria().andPluginIdEqualTo(pluginId); + return serviceIntegrationMapper.selectByExampleWithBLOBs(example).get(0); + } + + public List getPlatformPlugins() { + PluginExample example = new PluginExample(); + example.createCriteria() + .andScenarioEqualTo(PluginScenarioType.PLATFORM.name()); + return pluginMapper.selectByExample(example); + } +} diff --git a/backend/framework/sdk/src/main/java/io/metersphere/sdk/service/PluginLoadService.java b/backend/framework/sdk/src/main/java/io/metersphere/sdk/service/PluginLoadService.java index 4ddd2cb06c..3fed1d8694 100644 --- a/backend/framework/sdk/src/main/java/io/metersphere/sdk/service/PluginLoadService.java +++ b/backend/framework/sdk/src/main/java/io/metersphere/sdk/service/PluginLoadService.java @@ -1,15 +1,19 @@ package io.metersphere.sdk.service; +import io.metersphere.plugin.platform.api.AbstractPlatformPlugin; import io.metersphere.plugin.sdk.api.MsPlugin; import io.metersphere.sdk.exception.MSException; import io.metersphere.sdk.plugin.loader.PluginClassLoader; import io.metersphere.sdk.plugin.loader.PluginManager; import io.metersphere.sdk.plugin.storage.MsStorageStrategy; import io.metersphere.sdk.plugin.storage.StorageStrategy; +import io.metersphere.sdk.util.JSON; import io.metersphere.sdk.util.LogUtils; import io.metersphere.system.domain.Plugin; import io.metersphere.system.domain.PluginExample; +import io.metersphere.system.domain.PluginScript; import io.metersphere.system.mapper.PluginMapper; +import io.metersphere.system.mapper.PluginScriptMapper; import jakarta.annotation.Resource; import org.codehaus.plexus.util.IOUtil; import org.codehaus.plexus.util.StringUtils; @@ -21,6 +25,8 @@ import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; /** * @author jianxing @@ -33,6 +39,8 @@ public class PluginLoadService { @Resource private PluginMapper pluginMapper; + @Resource + private PluginScriptMapper pluginScriptMapper; /** * 上传插件到 minio @@ -178,6 +186,28 @@ public class PluginLoadService { return pluginManager.getImplInstance(id, MsPlugin.class); } + public T getImplInstance(String pluginId, Class superClazz, Object param) { + return pluginManager.getImplInstance(pluginId, superClazz, param); + } + + public T getImplInstance(String pluginId, Class superClazz) { + return pluginManager.getImplInstance(pluginId, superClazz); + } + + public List getPlatformPluginInstanceList() { + return getImplInstanceList(AbstractPlatformPlugin.class); + } + + public AbstractPlatformPlugin getPlatformPluginInstance(String pluginId) { + return getImplInstance(pluginId, AbstractPlatformPlugin.class); + } + + public List getImplInstanceList(Class clazz, Object... initArgs) { + return pluginManager.getClassLoaderMap().keySet().stream() + .map(pluginId -> pluginManager.getImplInstance(pluginId, clazz, initArgs) + ).collect(Collectors.toList()); + } + public boolean hasPluginKey(String currentPluginId, String pluginKey) { for (String pluginId : pluginManager.getClassLoaderMap().keySet()) { MsPlugin msPlugin = getMsPluginInstance(pluginId); @@ -187,4 +217,17 @@ public class PluginLoadService { } return false; } + + public InputStream getResourceAsStream(String pluginId, String name) { + return pluginManager.getClassLoaderMap().get(pluginId).getResourceAsStream(name); + } + + public Map getPluginScriptConfig(String pluginId, String scriptId) { + PluginScript pluginScript = pluginScriptMapper.selectByPrimaryKey(pluginId, scriptId); + return JSON.parseMap(new String(pluginScript.getScript())); + } + + public Object getPluginScriptContent(String pluginId, String scriptId) { + return getPluginScriptConfig(pluginId, scriptId).get("script"); + } } diff --git a/backend/framework/sdk/src/main/java/io/metersphere/sdk/util/FilterChainUtils.java b/backend/framework/sdk/src/main/java/io/metersphere/sdk/util/FilterChainUtils.java index a5108bb443..5848c232cc 100644 --- a/backend/framework/sdk/src/main/java/io/metersphere/sdk/util/FilterChainUtils.java +++ b/backend/framework/sdk/src/main/java/io/metersphere/sdk/util/FilterChainUtils.java @@ -55,7 +55,7 @@ public class FilterChainUtils { filterChainDefinitionMap.put("/websocket/**", "csrf"); // 获取插件中的图片 - filterChainDefinitionMap.put("/platform/plugin/image/**", "anon"); + filterChainDefinitionMap.put("/plugin/image/**", "anon"); return filterChainDefinitionMap; } diff --git a/backend/framework/sdk/src/main/resources/i18n/commons_en_US.properties b/backend/framework/sdk/src/main/resources/i18n/commons_en_US.properties index 12fa5b393d..42d84c033b 100644 --- a/backend/framework/sdk/src/main/resources/i18n/commons_en_US.properties +++ b/backend/framework/sdk/src/main/resources/i18n/commons_en_US.properties @@ -427,4 +427,8 @@ permission.add=Create permission.edit=Update permission.delete=Delete permission.import=Import -permission.recover=Recover \ No newline at end of file +permission.recover=Recover + +file_name_illegal_error=File name is invalid +plugin_enable_error=Plugin is not enabled +plugin_permission_error=No access to this plugin \ No newline at end of file diff --git a/backend/framework/sdk/src/main/resources/i18n/commons_zh_CN.properties b/backend/framework/sdk/src/main/resources/i18n/commons_zh_CN.properties index 52cde17e47..b5920f3158 100644 --- a/backend/framework/sdk/src/main/resources/i18n/commons_zh_CN.properties +++ b/backend/framework/sdk/src/main/resources/i18n/commons_zh_CN.properties @@ -426,4 +426,8 @@ permission.add=创建 permission.edit=修改 permission.delete=删除 permission.import=导入 -permission.recover=恢复 \ No newline at end of file +permission.recover=恢复 + +file_name_illegal_error=文件名不合法 +plugin_enable_error=插件未启用 +plugin_permission_error=没有该插件的访问权限 \ No newline at end of file diff --git a/backend/framework/sdk/src/main/resources/i18n/commons_zh_TW.properties b/backend/framework/sdk/src/main/resources/i18n/commons_zh_TW.properties index ddf132b0dd..1f71ba53a1 100644 --- a/backend/framework/sdk/src/main/resources/i18n/commons_zh_TW.properties +++ b/backend/framework/sdk/src/main/resources/i18n/commons_zh_TW.properties @@ -426,4 +426,8 @@ permission.add=創建 permission.edit=修改 permission.delete=刪除 permission.import=導入 -permission.recover=恢復 \ No newline at end of file +permission.recover=恢復 + +file_name_illegal_error=文件名不合法 +plugin_enable_error=插件未啟用 +plugin_permission_error=沒有該插件的訪問權限 \ No newline at end of file diff --git a/backend/framework/sdk/src/main/resources/i18n/system_en_US.properties b/backend/framework/sdk/src/main/resources/i18n/system_en_US.properties index 94e6ac80ff..119978b66f 100644 --- a/backend/framework/sdk/src/main/resources/i18n/system_en_US.properties +++ b/backend/framework/sdk/src/main/resources/i18n/system_en_US.properties @@ -175,6 +175,7 @@ service_integration.plugin_id.length_range=pluginId length must be between {min} service_integration.organization_id.not_blank=organizationId cannot be empty service_integration.organization_id.length_range=organizationId length must be between {min} and {max} service_integration_exist_error=Service integration configuration already exists +service_integration.configuration.not_blank=Service integration configuration cannot be empty # permission permission.system_plugin.name=Plugin permission.system_organization_project.name=Organization Project diff --git a/backend/framework/sdk/src/main/resources/i18n/system_zh_CN.properties b/backend/framework/sdk/src/main/resources/i18n/system_zh_CN.properties index 3f6ea74d05..4453f4406a 100644 --- a/backend/framework/sdk/src/main/resources/i18n/system_zh_CN.properties +++ b/backend/framework/sdk/src/main/resources/i18n/system_zh_CN.properties @@ -160,20 +160,21 @@ plugin.file_name.not_blank=文件名不能为空 plugin.file_name.length_range=文件名长度必须在{min}和{max}之间 plugin.create_user.not_blank=创建人不能为空 plugin.create_user.length_range=创建人长度必须在{min}和{max}之间 -plugin.scenario.not_blank=插件使用场景PAI/PLATFORM不能为空 -plugin.scenario.length_range=插件使用场景PAI/PLATFORM长度必须在{min}和{max}之间 +plugin.scenario.not_blank=插件使用场景API/PLATFORM不能为空 +plugin.scenario.length_range=插件使用场景API/PLATFORM长度必须在{min}和{max}之间 plugin.exist=插件名称或文件名已存在 plugin.type.exist=插件类型已存在 plugin.script.exist=脚本id重复 plugin.script.format=脚本格式错误 # serviceIntegration -service_integration.id.not_blank=ID不能為空 -service_integration.id.length_range=ID長度必須在{min}和{max}之间 -service_integration.plugin_id.not_blank=插件的ID不能為空 -service_integration.plugin_id.length_range=插件的ID長度必須在{min}和{max}之间 -service_integration.organization_id.not_blank=组织ID不能為空 -service_integration.organization_id.length_range=组织ID長度必須在{min}和{max}之间 +service_integration.id.not_blank=ID不能为空 +service_integration.id.length_range=ID长度必须在{min}和{max}之间 +service_integration.plugin_id.not_blank=插件的ID不能为空 +service_integration.plugin_id.length_range=插件的ID长度必须在{min}和{max}之间 +service_integration.organization_id.not_blank=组织ID不能为空 +service_integration.organization_id.length_range=组织ID长度必须在{min}和{max}之间 service_integration_exist_error=服务集成配置已存在 +service_integration.configuration.not_blank=服务集成配置不能為空 # permission permission.system_plugin.name=插件 permission.system_organization_project.name=组织与项目 diff --git a/backend/framework/sdk/src/main/resources/i18n/system_zh_TW.properties b/backend/framework/sdk/src/main/resources/i18n/system_zh_TW.properties index 58f439753b..b688ca669e 100644 --- a/backend/framework/sdk/src/main/resources/i18n/system_zh_TW.properties +++ b/backend/framework/sdk/src/main/resources/i18n/system_zh_TW.properties @@ -174,6 +174,7 @@ service_integration.plugin_id.length_range=插件的ID长度必须在{min}和{ma service_integration.organization_id.not_blank=组织ID不能为空 service_integration.organization_id.length_range=组织ID长度必须在{min}和{max}之间 service_integration_exist_error=服務集成配置已存在 +service_integration.configuration.not_blank=服务集成配置不能為空 # permission permission.system_plugin.name=插件 permission.system_organization_project.name=組織與項目 diff --git a/backend/framework/sdk/src/test/java/io/metersphere/sdk/base/BaseTest.java b/backend/framework/sdk/src/test/java/io/metersphere/sdk/base/BaseTest.java index 8754f46f30..a19b4b6be8 100644 --- a/backend/framework/sdk/src/test/java/io/metersphere/sdk/base/BaseTest.java +++ b/backend/framework/sdk/src/test/java/io/metersphere/sdk/base/BaseTest.java @@ -40,6 +40,8 @@ import org.springframework.util.MultiValueMap; import java.io.File; import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.nio.charset.Charset; import java.nio.file.Files; import java.util.HashMap; import java.util.List; @@ -232,20 +234,24 @@ public abstract class BaseTest { } protected T getResultData(MvcResult mvcResult, Class clazz) throws Exception { - Object data = JSON.parseMap(mvcResult.getResponse().getContentAsString()).get("data"); + Object data = parseResponse(mvcResult).get("data"); return JSON.parseObject(JSON.toJSONString(data), clazz); } protected T getResultMessageDetail(MvcResult mvcResult, Class clazz) throws Exception { - Object data = JSON.parseMap(mvcResult.getResponse().getContentAsString()).get("messageDetail"); + Object data = parseResponse(mvcResult).get("messageDetail"); return JSON.parseObject(JSON.toJSONString(data), clazz); } protected List getResultDataArray(MvcResult mvcResult, Class clazz) throws Exception { - Object data = JSON.parseMap(mvcResult.getResponse().getContentAsString()).get("data"); + Object data = parseResponse(mvcResult).get("data"); return JSON.parseArray(JSON.toJSONString(data), clazz); } + private static Map parseResponse(MvcResult mvcResult) throws UnsupportedEncodingException { + return JSON.parseMap(mvcResult.getResponse().getContentAsString(Charset.defaultCharset())); + } + /** * 校验错误响应码 */ @@ -258,7 +264,7 @@ public abstract class BaseTest { } protected Pager> getPageResult(MvcResult mvcResult, Class clazz) throws Exception { - Map pagerResult = (Map) JSON.parseMap(mvcResult.getResponse().getContentAsString()).get("data"); + Map pagerResult = (Map) parseResponse(mvcResult).get("data"); List list = JSON.parseArray(JSON.toJSONString(pagerResult.get("list")), clazz); Pager pager = new Pager(); pager.setPageSize(Long.valueOf(pagerResult.get("pageSize").toString())); diff --git a/backend/framework/sdk/src/test/java/io/metersphere/sdk/base/param/NotEmptyParamGenerator.java b/backend/framework/sdk/src/test/java/io/metersphere/sdk/base/param/NotEmptyParamGenerator.java index 1ffc1b1bd2..6fb01f3dec 100644 --- a/backend/framework/sdk/src/test/java/io/metersphere/sdk/base/param/NotEmptyParamGenerator.java +++ b/backend/framework/sdk/src/test/java/io/metersphere/sdk/base/param/NotEmptyParamGenerator.java @@ -2,7 +2,7 @@ package io.metersphere.sdk.base.param; import java.lang.annotation.Annotation; import java.lang.reflect.Field; -import java.util.ArrayList; +import java.util.*; /** * @author jianxing @@ -14,6 +14,15 @@ public class NotEmptyParamGenerator extends ParamGenerator { */ @Override public Object invalidGenerate(Annotation annotation, Field field) { - return new ArrayList<>(0); + if (field.getType().equals(List.class)) { + return new ArrayList<>(0); + } + if (field.getType().equals(Set.class)) { + return new HashSet<>(0); + } + if (field.getType().equals(Map.class)) { + return new HashMap<>(0); + } + throw new RuntimeException("不支持该类型"); } } diff --git a/backend/services/system-setting/src/main/java/io/metersphere/system/controller/PluginController.java b/backend/services/system-setting/src/main/java/io/metersphere/system/controller/PluginController.java index a8d75649cd..80b75b9150 100644 --- a/backend/services/system-setting/src/main/java/io/metersphere/system/controller/PluginController.java +++ b/backend/services/system-setting/src/main/java/io/metersphere/system/controller/PluginController.java @@ -22,6 +22,7 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.util.List; /** @@ -86,7 +87,7 @@ public class PluginController { @Schema(description = "图片路径", requiredMode = Schema.RequiredMode.REQUIRED) @RequestParam("imagePath") String imagePath, - HttpServletResponse response) { - // todo + HttpServletResponse response) throws IOException { + pluginService.getPluginImg(pluginId, imagePath, response); } } \ No newline at end of file diff --git a/backend/services/system-setting/src/main/java/io/metersphere/system/controller/ServiceIntegrationController.java b/backend/services/system-setting/src/main/java/io/metersphere/system/controller/ServiceIntegrationController.java index cdccdc98a0..3e30f8d5d4 100644 --- a/backend/services/system-setting/src/main/java/io/metersphere/system/controller/ServiceIntegrationController.java +++ b/backend/services/system-setting/src/main/java/io/metersphere/system/controller/ServiceIntegrationController.java @@ -14,12 +14,13 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; +import jakarta.validation.constraints.NotEmpty; import org.apache.shiro.authz.annotation.RequiresPermissions; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import java.util.HashMap; import java.util.List; -import java.util.Map; /** * @author : jianxing @@ -32,6 +33,7 @@ public class ServiceIntegrationController { @Resource private ServiceIntegrationService serviceIntegrationService; + @GetMapping("/list/{organizationId}") @Operation(summary = "获取服务集成列表") @RequiresPermissions(PermissionConstants.SYSTEM_SERVICE_INTEGRATION_READ) @@ -59,24 +61,27 @@ public class ServiceIntegrationController { @Operation(summary = "删除服务集成") @RequiresPermissions(PermissionConstants.SYSTEM_SERVICE_INTEGRATION_DELETE) @Log(type = OperationLogType.DELETE, expression = "#msClass.deleteLog(#id)", msClass = ServiceIntegrationLogService.class) - public String delete(@PathVariable String id) { - return serviceIntegrationService.delete(id); + public void delete(@PathVariable String id) { + serviceIntegrationService.delete(id); } - @PostMapping("/validate") + @PostMapping("/validate/{pluginId}") @Operation(summary = "校验服务集成信息") @RequiresPermissions(PermissionConstants.SYSTEM_SERVICE_INTEGRATION_UPDATE) - public boolean validate(@Validated({Updated.class}) @RequestBody - @Schema(description = "配置的表单键值对", requiredMode = Schema.RequiredMode.REQUIRED) - Map serviceIntegrationInfo) { - return serviceIntegrationService.validate(serviceIntegrationInfo); + public void validate(@PathVariable String pluginId, + @Validated({Updated.class}) + @RequestBody + @NotEmpty + @Schema(description = "配置的表单键值对", requiredMode = Schema.RequiredMode.REQUIRED) + HashMap serviceIntegrationInfo) { + serviceIntegrationService.validate(pluginId, serviceIntegrationInfo); } @GetMapping("/validate/{id}") @Operation(summary = "校验服务集成信息") @RequiresPermissions(PermissionConstants.SYSTEM_SERVICE_INTEGRATION_UPDATE) - public boolean validate(@PathVariable String id) { - return serviceIntegrationService.validate(id); + public void validate(@PathVariable String id) { + serviceIntegrationService.validate(id); } @GetMapping("/script/{pluginId}") diff --git a/backend/services/system-setting/src/main/java/io/metersphere/system/dto/ServiceIntegrationDTO.java b/backend/services/system-setting/src/main/java/io/metersphere/system/dto/ServiceIntegrationDTO.java index 8328f2786a..471887d44e 100644 --- a/backend/services/system-setting/src/main/java/io/metersphere/system/dto/ServiceIntegrationDTO.java +++ b/backend/services/system-setting/src/main/java/io/metersphere/system/dto/ServiceIntegrationDTO.java @@ -20,7 +20,7 @@ public class ServiceIntegrationDTO implements Serializable { private String title; @Schema(description = "插件描述") - private String describe; + private String description; @Schema(description = "是否启用") private Boolean enable; diff --git a/backend/services/system-setting/src/main/java/io/metersphere/system/request/PluginUpdateRequest.java b/backend/services/system-setting/src/main/java/io/metersphere/system/request/PluginUpdateRequest.java index 3dd9f5ba7b..38c1c8e9ae 100644 --- a/backend/services/system-setting/src/main/java/io/metersphere/system/request/PluginUpdateRequest.java +++ b/backend/services/system-setting/src/main/java/io/metersphere/system/request/PluginUpdateRequest.java @@ -28,7 +28,7 @@ public class PluginUpdateRequest { private Boolean global; @Schema(description = "插件描述") - @Size(min = 1, max = 500, message = "{plugin.scenario.length_range}", groups = {Created.class, Updated.class}) + @Size(max = 500, message = "{plugin.scenario.length_range}", groups = {Created.class, Updated.class}) private String description; @Schema(hidden = true) diff --git a/backend/services/system-setting/src/main/java/io/metersphere/system/request/ServiceIntegrationUpdateRequest.java b/backend/services/system-setting/src/main/java/io/metersphere/system/request/ServiceIntegrationUpdateRequest.java index af5f32c05b..58728b1af8 100644 --- a/backend/services/system-setting/src/main/java/io/metersphere/system/request/ServiceIntegrationUpdateRequest.java +++ b/backend/services/system-setting/src/main/java/io/metersphere/system/request/ServiceIntegrationUpdateRequest.java @@ -1,12 +1,14 @@ package io.metersphere.system.request; + import io.metersphere.validation.groups.Created; import io.metersphere.validation.groups.Updated; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Size; import lombok.Data; + import java.io.Serializable; import java.util.Map; @@ -17,7 +19,7 @@ public class ServiceIntegrationUpdateRequest implements Serializable { @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank(message = "{service_integration.id.not_blank}", groups = {Updated.class}) - @Size(min = 1, max = 50, message = "{service_integration.id.length_range}", groups = {Created.class, Updated.class}) + @Size(min = 1, max = 50, message = "{service_integration.id.length_range}", groups = {Updated.class}) private String id; @Schema(description = "插件的ID", requiredMode = Schema.RequiredMode.REQUIRED) @@ -34,6 +36,6 @@ public class ServiceIntegrationUpdateRequest implements Serializable { private String organizationId; @Schema(description = "配置的表单键值对", requiredMode = Schema.RequiredMode.REQUIRED) - @NotNull(message = "{service_integration.configuration.not_blank}", groups = {Created.class}) - private Map configuration; + @NotEmpty(message = "{service_integration.configuration.not_blank}", groups = {Created.class}) + private Map configuration; } \ No newline at end of file diff --git a/backend/services/system-setting/src/main/java/io/metersphere/system/service/PluginService.java b/backend/services/system-setting/src/main/java/io/metersphere/system/service/PluginService.java index d2aa37c46c..0f5f4c96e2 100644 --- a/backend/services/system-setting/src/main/java/io/metersphere/system/service/PluginService.java +++ b/backend/services/system-setting/src/main/java/io/metersphere/system/service/PluginService.java @@ -4,6 +4,7 @@ package io.metersphere.system.service; import io.metersphere.plugin.sdk.api.MsPlugin; import io.metersphere.sdk.constants.KafkaPluginTopicType; import io.metersphere.sdk.constants.KafkaTopicConstants; +import io.metersphere.sdk.controller.handler.result.CommonResultCode; import io.metersphere.sdk.dto.OptionDTO; import io.metersphere.sdk.exception.MSException; import io.metersphere.sdk.service.PluginLoadService; @@ -15,6 +16,7 @@ import io.metersphere.system.mapper.ExtPluginMapper; import io.metersphere.system.mapper.PluginMapper; import io.metersphere.system.request.PluginUpdateRequest; import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; import org.apache.commons.collections.CollectionUtils; import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; @@ -23,10 +25,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.UUID; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.*; import static io.metersphere.system.controller.result.SystemResultCode.PLUGIN_EXIST; import static io.metersphere.system.controller.result.SystemResultCode.PLUGIN_TYPE_EXIST; @@ -138,8 +140,9 @@ public class PluginService { } /** - * 通知其他节点加载插件 - * 这里需要传一下 fileName,事务未提交,查询不到文件名 + * 通知其他节点加载插件 + * 这里需要传一下 fileName,事务未提交,查询不到文件名 + * * @param pluginId * @param fileName */ @@ -149,7 +152,8 @@ public class PluginService { } /** - * 通知其他节点卸载插件 + * 通知其他节点卸载插件 + * * @param pluginId */ public void notifiedPluginDelete(String pluginId) { @@ -201,4 +205,34 @@ public class PluginService { public String getScript(String pluginId, String scriptId) { return pluginScriptService.get(pluginId, scriptId); } + + public void getPluginImg(String pluginId, String filePath, HttpServletResponse response) throws IOException { + validateImageFileName(filePath); + InputStream inputStream = pluginLoadService.getResourceAsStream(pluginId, filePath); + writeImage(filePath, inputStream, response); + } + + public void validateImageFileName(String filename) { + Set imgSuffix = new HashSet<>() {{ + add("jpg"); + add("png"); + add("gif"); + add("jpeg"); + }}; + if (!imgSuffix.contains(StringUtils.substringAfterLast(filename, "."))) { + throw new MSException(CommonResultCode.FILE_NAME_ILLEGAL); + } + } + + public void writeImage(String filePath, InputStream in, HttpServletResponse response) throws IOException { + response.setContentType("image/" + StringUtils.substringAfterLast(filePath, ".")); + try (OutputStream out = response.getOutputStream()) { + byte[] buffer = new byte[1024]; + int bytesRead; + while ((bytesRead = in.read(buffer)) != -1) { + response.getOutputStream().write(buffer, 0, bytesRead); + } + out.flush(); + } + } } \ No newline at end of file diff --git a/backend/services/system-setting/src/main/java/io/metersphere/system/service/ServiceIntegrationService.java b/backend/services/system-setting/src/main/java/io/metersphere/system/service/ServiceIntegrationService.java index 13d83b956b..b5e2ba418e 100644 --- a/backend/services/system-setting/src/main/java/io/metersphere/system/service/ServiceIntegrationService.java +++ b/backend/services/system-setting/src/main/java/io/metersphere/system/service/ServiceIntegrationService.java @@ -1,8 +1,13 @@ package io.metersphere.system.service; +import io.metersphere.plugin.platform.api.AbstractPlatformPlugin; +import io.metersphere.plugin.platform.api.Platform; import io.metersphere.sdk.exception.MSException; +import io.metersphere.sdk.service.PlatformPluginService; +import io.metersphere.sdk.service.PluginLoadService; import io.metersphere.sdk.util.BeanUtils; import io.metersphere.sdk.util.JSON; +import io.metersphere.system.domain.Plugin; import io.metersphere.system.domain.ServiceIntegration; import io.metersphere.system.domain.ServiceIntegrationExample; import io.metersphere.system.dto.ServiceIntegrationDTO; @@ -10,13 +15,14 @@ import io.metersphere.system.mapper.ServiceIntegrationMapper; import io.metersphere.system.request.ServiceIntegrationUpdateRequest; import jakarta.annotation.Resource; import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.stream.Collectors; import static io.metersphere.system.controller.result.SystemResultCode.SERVICE_INTEGRATION_EXIST; @@ -27,12 +33,48 @@ import static io.metersphere.system.controller.result.SystemResultCode.SERVICE_I @Service @Transactional(rollbackFor = Exception.class) public class ServiceIntegrationService { - @Resource private ServiceIntegrationMapper serviceIntegrationMapper; + @Resource + private PluginLoadService pluginLoadService; + @Resource + private PlatformPluginService platformPluginService; + + public static final String PLUGIN_IMAGE_GET_PATH = "/plugin/image/%s?imagePath=%s"; public List list(String organizationId) { - return Arrays.asList(new ServiceIntegrationDTO()); + // 查询服务集成已配置数据 + Map serviceIntegrationMap = getServiceIntegrationByOrgId(organizationId).stream() + .collect(Collectors.toMap(ServiceIntegration::getPluginId, i -> i)); + + List plugins = platformPluginService.getPlatformPlugins(); + return plugins.stream().map(plugin -> { + AbstractPlatformPlugin msPluginInstance = pluginLoadService.getPlatformPluginInstance(plugin.getId()); + // 获取插件基础信息 + ServiceIntegrationDTO serviceIntegrationDTO = new ServiceIntegrationDTO(); + serviceIntegrationDTO.setTitle(msPluginInstance.getName()); + serviceIntegrationDTO.setEnable(false); + serviceIntegrationDTO.setConfig(false); + serviceIntegrationDTO.setDescription(msPluginInstance.getDescription()); + serviceIntegrationDTO.setLogo(String.format(PLUGIN_IMAGE_GET_PATH, plugin.getId(), msPluginInstance.getLogo())); + serviceIntegrationDTO.setPluginId(plugin.getId()); + ServiceIntegration serviceIntegration = serviceIntegrationMap.get(plugin.getId()); + if (serviceIntegration != null) { + serviceIntegrationDTO.setConfiguration(JSON.parseMap(new String(serviceIntegration.getConfiguration()))); + serviceIntegrationDTO.setId(serviceIntegration.getId()); + serviceIntegrationDTO.setEnable(serviceIntegration.getEnable()); + serviceIntegrationDTO.setConfig(true); + serviceIntegrationDTO.setOrganizationId(serviceIntegration.getOrganizationId()); + } + return serviceIntegrationDTO; + }).toList(); + } + + private List getServiceIntegrationByOrgId(String organizationId) { + ServiceIntegrationExample example = new ServiceIntegrationExample(); + example.createCriteria() + .andOrganizationIdEqualTo(organizationId); + return serviceIntegrationMapper.selectByExampleWithBLOBs(example); } public ServiceIntegration add(ServiceIntegrationUpdateRequest request) { @@ -47,6 +89,8 @@ public class ServiceIntegrationService { public ServiceIntegration update(ServiceIntegrationUpdateRequest request) { ServiceIntegration serviceIntegration = new ServiceIntegration(); + // 组织不能修改 + serviceIntegration.setOrganizationId(null); BeanUtils.copyBean(serviceIntegration, request); if (request.getConfiguration() != null) { serviceIntegration.setConfiguration(JSON.toJSONBytes(request.getConfiguration())); @@ -55,9 +99,8 @@ public class ServiceIntegrationService { return serviceIntegration; } - public String delete(String id) { + public void delete(String id) { serviceIntegrationMapper.deleteByPrimaryKey(id); - return id; } private void checkAddExist(ServiceIntegration serviceIntegration) { @@ -70,13 +113,15 @@ public class ServiceIntegrationService { } } - public boolean validate(Map serviceIntegrationInfo) { - return serviceIntegrationInfo == null; + public void validate(String pluginId, Map serviceIntegrationInfo) { + Platform platform = platformPluginService.getPlatform(pluginId, StringUtils.EMPTY, JSON.toJSONString(serviceIntegrationInfo)); + platform.validateIntegrationConfig(); } - public boolean validate(String id) { + public void validate(String id) { ServiceIntegration serviceIntegration = serviceIntegrationMapper.selectByPrimaryKey(id); - return serviceIntegration == null; + Platform platform = platformPluginService.getPlatform(serviceIntegration.getPluginId(), StringUtils.EMPTY); + platform.validateIntegrationConfig(); } public ServiceIntegration get(String id) { @@ -84,6 +129,7 @@ public class ServiceIntegrationService { } public Object getPluginScript(String pluginId) { - return new ServiceIntegration(); + AbstractPlatformPlugin platformPlugin = pluginLoadService.getImplInstance(pluginId, AbstractPlatformPlugin.class); + return pluginLoadService.getPluginScriptContent(pluginId, platformPlugin.getIntegrationScriptId()); } } \ No newline at end of file diff --git a/backend/services/system-setting/src/test/java/io/metersphere/system/controller/PluginControllerTests.java b/backend/services/system-setting/src/test/java/io/metersphere/system/controller/PluginControllerTests.java index 91b2a026b5..b7872341d9 100644 --- a/backend/services/system-setting/src/test/java/io/metersphere/system/controller/PluginControllerTests.java +++ b/backend/services/system-setting/src/test/java/io/metersphere/system/controller/PluginControllerTests.java @@ -29,6 +29,7 @@ import java.util.Arrays; import java.util.List; import java.util.Optional; +import static io.metersphere.sdk.controller.handler.result.CommonResultCode.FILE_NAME_ILLEGAL; import static io.metersphere.system.controller.result.SystemResultCode.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -260,8 +261,10 @@ public class PluginControllerTests extends BaseTest { @Order(5) public void getPluginImg() throws Exception { // @@请求成功 - mockMvc.perform(getRequestBuilder(PLUGIN_IMAGE, "pluginId", "/static/jira.jpg")) + mockMvc.perform(getRequestBuilder(PLUGIN_IMAGE, anotherAddPlugin.getId(), "/static/jira.jpg")) .andExpect(status().isOk()); + + assertErrorCode( this.requestGet(PLUGIN_IMAGE, anotherAddPlugin.getId(), "/static/jira.doc"), FILE_NAME_ILLEGAL); } @Test @@ -274,6 +277,7 @@ public class PluginControllerTests extends BaseTest { Assertions.assertNull(plugin); Assertions.assertEquals(new ArrayList<>(0), getOrgIdsByPlugId(addPlugin.getId())); Assertions.assertEquals(new ArrayList<>(0), getScriptIdsByPlugId(addPlugin.getId())); + this.requestGetWithOk(DEFAULT_DELETE, anotherAddPlugin.getId()); // @@校验日志 checkLog(addPlugin.getId(), OperationLogType.DELETE); diff --git a/backend/services/system-setting/src/test/java/io/metersphere/system/controller/ServiceIntegrationControllerTests.java b/backend/services/system-setting/src/test/java/io/metersphere/system/controller/ServiceIntegrationControllerTests.java index c9c4f50db6..0fd5fb9a22 100644 --- a/backend/services/system-setting/src/test/java/io/metersphere/system/controller/ServiceIntegrationControllerTests.java +++ b/backend/services/system-setting/src/test/java/io/metersphere/system/controller/ServiceIntegrationControllerTests.java @@ -1,22 +1,44 @@ package io.metersphere.system.controller; +import io.metersphere.plugin.platform.api.AbstractPlatformPlugin; +import io.metersphere.plugin.sdk.util.JSON; import io.metersphere.sdk.base.BaseTest; import io.metersphere.sdk.constants.PermissionConstants; import io.metersphere.sdk.log.constants.OperationLogType; +import io.metersphere.sdk.service.PluginLoadService; import io.metersphere.system.controller.param.ServiceIntegrationUpdateRequestDefinition; +import io.metersphere.system.domain.Organization; +import io.metersphere.system.domain.Plugin; import io.metersphere.system.domain.ServiceIntegration; +import io.metersphere.system.dto.ServiceIntegrationDTO; import io.metersphere.system.mapper.ServiceIntegrationMapper; +import io.metersphere.system.request.PluginUpdateRequest; import io.metersphere.system.request.ServiceIntegrationUpdateRequest; +import io.metersphere.system.service.OrganizationService; +import io.metersphere.system.service.PluginService; import jakarta.annotation.Resource; -import org.junit.jupiter.api.MethodOrderer; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestMethodOrder; +import lombok.Getter; +import lombok.Setter; +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.*; +import org.mockserver.client.MockServerClient; +import org.mockserver.model.Header; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.web.servlet.MvcResult; -import java.util.HashMap; +import java.io.File; +import java.io.FileInputStream; +import java.util.List; +import java.util.Map; + +import static io.metersphere.sdk.constants.InternalUserRole.ADMIN; +import static io.metersphere.system.controller.result.SystemResultCode.SERVICE_INTEGRATION_EXIST; +import static io.metersphere.system.service.ServiceIntegrationService.PLUGIN_IMAGE_GET_PATH; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; /** * @author jianxing @@ -29,41 +51,68 @@ public class ServiceIntegrationControllerTests extends BaseTest { private static final String BASE_PATH = "/service/integration/"; private static final String LIST = "/list/{0}"; private static final String VALIDATE_GET = "/validate/{0}"; - private static final String VALIDATE_POST = "/validate"; + private static final String VALIDATE_POST = "/validate/{0}"; private static final String SCRIPT_GET = "/script/{0}"; + private static ServiceIntegration addServiceIntegration; + private static Plugin plugin; + private static Organization defaultOrg; + @Resource private ServiceIntegrationMapper serviceIntegrationMapper; - private static ServiceIntegration addServiceIntegration; + @Resource + private OrganizationService organizationService; + @Resource + private PluginLoadService pluginLoadService; + @Resource + private PluginService pluginService; + @Resource + private MockServerClient mockServerClient; + @Value("${embedded.mockserver.host}") + private String mockServerHost; + @Value("${embedded.mockserver.port}") + private int mockServerHostPort; + @Override protected String getBasePath() { return BASE_PATH; } @Test - public void list() throws Exception { - // @@请求成功 - this.requestGetWithOkAndReturn(LIST, "orgId"); -// List serviceIntegrationList = getResultDataArray(mvcResult, ServiceIntegration.class); - // todo 校验数据是否正确 - // @@校验权限 - requestGetPermissionTest(PermissionConstants.SYSTEM_SERVICE_INTEGRATION_READ, LIST,"orgId"); + @Order(0) + public void listEmpty() throws Exception { + defaultOrg = organizationService.getDefault(); + // @@请求成功, 校验空数据,请求是否正常 + this.requestGetWithOkAndReturn(LIST, defaultOrg.getId()); } + @Test - @Order(0) + @Order(1) public void add() throws Exception { + plugin = addPlugin(); + + JiraIntegrationConfig integrationConfig = new JiraIntegrationConfig(); + Map integrationConfigMap = JSON.parseMap(JSON.toJSONString(integrationConfig)); + // @@请求成功 ServiceIntegrationUpdateRequest request = new ServiceIntegrationUpdateRequest(); request.setEnable(false); - request.setPluginId("pluginId"); - request.setConfiguration(new HashMap<>()); - request.setOrganizationId("orgId"); - // todo 填充数据 + request.setPluginId(plugin.getId()); + request.setConfiguration(integrationConfigMap); + request.setOrganizationId(defaultOrg.getId()); MvcResult mvcResult = this.requestPostWithOkAndReturn(DEFAULT_ADD, request); + // 校验请求成功数据 ServiceIntegration resultData = getResultData(mvcResult, ServiceIntegration.class); ServiceIntegration serviceIntegration = serviceIntegrationMapper.selectByPrimaryKey(resultData.getId()); - this.addServiceIntegration= serviceIntegration; - // todo 校验请求成功数据 + Assertions.assertEquals(JSON.parseMap(new String(serviceIntegration.getConfiguration())), integrationConfigMap); + Assertions.assertEquals(serviceIntegration.getEnable(), request.getEnable()); + Assertions.assertEquals(serviceIntegration.getPluginId(), request.getPluginId()); + Assertions.assertEquals(serviceIntegration.getOrganizationId(), request.getOrganizationId()); + this.addServiceIntegration = serviceIntegration; + + // @@重名校验异常 + assertErrorCode(this.requestPost(DEFAULT_ADD, request), SERVICE_INTEGRATION_EXIST); + // @@校验日志 checkLog(this.addServiceIntegration.getId(), OperationLogType.ADD); // @@异常参数校验 @@ -73,20 +122,32 @@ public class ServiceIntegrationControllerTests extends BaseTest { } @Test - @Order(1) + @Order(2) public void update() throws Exception { + JiraIntegrationConfig integrationConfig = new JiraIntegrationConfig(); + integrationConfig.setAddress(String.format("http://%s:%s", mockServerHost, mockServerHostPort)); + Map integrationConfigMap = JSON.parseMap(JSON.toJSONString(integrationConfig)); + // @@请求成功 ServiceIntegrationUpdateRequest request = new ServiceIntegrationUpdateRequest(); request.setId(addServiceIntegration.getId()); request.setEnable(false); - request.setPluginId("pluginId"); - request.setConfiguration(new HashMap<>()); - request.setOrganizationId("orgId"); - // todo 填充数据 + request.setPluginId(plugin.getId()); + request.setConfiguration(integrationConfigMap); + request.setOrganizationId(defaultOrg.getId()); this.requestPostWithOk(DEFAULT_UPDATE, request); // 校验请求成功数据 -// ServiceIntegration serviceIntegration = serviceIntegrationMapper.selectByPrimaryKey(request.getId()); - // todo 校验请求成功数据 + ServiceIntegration serviceIntegration = serviceIntegrationMapper.selectByPrimaryKey(request.getId()); + Assertions.assertEquals(JSON.parseMap(new String(serviceIntegration.getConfiguration())), integrationConfigMap); + Assertions.assertEquals(serviceIntegration.getEnable(), request.getEnable()); + Assertions.assertEquals(serviceIntegration.getPluginId(), request.getPluginId()); + // 验证组织修改无效 + Assertions.assertEquals(serviceIntegration.getOrganizationId(), defaultOrg.getId()); + + // 提升覆盖率 + request.setConfiguration(null); + this.requestPostWithOk(DEFAULT_UPDATE, request); + // @@校验日志 checkLog(request.getId(), OperationLogType.UPDATE); // @@异常参数校验 @@ -95,41 +156,132 @@ public class ServiceIntegrationControllerTests extends BaseTest { requestPostPermissionTest(PermissionConstants.SYSTEM_SERVICE_INTEGRATION_UPDATE, DEFAULT_UPDATE, request); } + @Test + @Order(3) + public void list() throws Exception { + // @@请求成功 + MvcResult mvcResult = this.requestGetWithOkAndReturn(LIST, defaultOrg.getId()); + // 校验请求成功数据 + List serviceIntegrationList = getResultDataArray(mvcResult, ServiceIntegrationDTO.class); + ServiceIntegrationDTO serviceIntegrationDTO = serviceIntegrationList.get(0); + ServiceIntegration serviceIntegration = serviceIntegrationMapper.selectByPrimaryKey(serviceIntegrationDTO.getId()); + Assertions.assertEquals(JSON.parseMap(new String(serviceIntegration.getConfiguration())), + serviceIntegrationDTO.getConfiguration()); + Assertions.assertEquals(serviceIntegration.getEnable(), serviceIntegrationDTO.getEnable()); + Assertions.assertEquals(serviceIntegration.getPluginId(), serviceIntegrationDTO.getPluginId()); + AbstractPlatformPlugin msPluginInstance = pluginLoadService.getPlatformPluginInstance(plugin.getId()); + Assertions.assertEquals(serviceIntegrationDTO.getDescription(), msPluginInstance.getDescription()); + Assertions.assertEquals(serviceIntegrationDTO.getOrganizationId(), defaultOrg.getId()); + Assertions.assertEquals(serviceIntegrationDTO.getTitle(), msPluginInstance.getName()); + Assertions.assertEquals(serviceIntegrationDTO.getConfig(), true); + Assertions.assertEquals(serviceIntegrationDTO.getLogo(), + String.format(PLUGIN_IMAGE_GET_PATH, plugin.getId(), msPluginInstance.getLogo())); + + // @@校验权限 + requestGetPermissionTest(PermissionConstants.SYSTEM_SERVICE_INTEGRATION_READ, LIST, defaultOrg.getId()); + } + + @Test + @Order(4) + public void validateGet() throws Exception { + mockServerClient + .when( + request() + .withMethod("GET") + .withPath("/rest/api/2/myself")) + .respond( + response() + .withStatusCode(200) + .withHeaders( + new Header("Content-Type", "application/json; charset=utf-8"), + new Header("Cache-Control", "public, max-age=86400")) + .withBody("{\"self\"") + ); + + // @@请求成功 + this.requestGetWithOk(VALIDATE_GET, addServiceIntegration.getId()); + // @@校验权限 + requestGetPermissionTest(PermissionConstants.SYSTEM_SERVICE_INTEGRATION_UPDATE, VALIDATE_GET, addServiceIntegration.getId()); + } + + @Test + @Order(5) + public void validatePost() throws Exception { + JiraIntegrationConfig integrationConfig = new JiraIntegrationConfig(); + integrationConfig.setAddress(String.format("http://%s:%s", mockServerHost, mockServerHostPort)); + Map integrationConfigMap = JSON.parseMap(JSON.toJSONString(integrationConfig)); + // @@请求成功 + this.requestPostWithOk(VALIDATE_POST, integrationConfigMap, plugin.getId()); + // @@校验权限 + requestPostPermissionTest(PermissionConstants.SYSTEM_SERVICE_INTEGRATION_UPDATE, VALIDATE_POST, integrationConfigMap, plugin.getId()); + } + + @Test + @Order(6) + public void getPluginScript() throws Exception { + // @@请求成功 + MvcResult mvcResult = this.requestGetWithOkAndReturn(SCRIPT_GET, plugin.getId()); + // 校验请求成功数据 + Assertions.assertTrue(StringUtils.isNotBlank(mvcResult.getResponse().getContentAsString())); + // @@校验权限 + requestGetPermissionTest(PermissionConstants.SYSTEM_SERVICE_INTEGRATION_READ, SCRIPT_GET, plugin.getId()); + } + @Test public void delete() throws Exception { // @@请求成功 this.requestGetWithOk(DEFAULT_DELETE, addServiceIntegration.getId()); - // todo 校验请求成功数据 + // 校验请求成功数据 + ServiceIntegration serviceIntegration = serviceIntegrationMapper.selectByPrimaryKey(addServiceIntegration.getId()); + Assertions.assertNull(serviceIntegration); + + // 清理插件 + deletePlugin(); + // @@校验日志 checkLog(addServiceIntegration.getId(), OperationLogType.DELETE); // @@校验权限 requestGetPermissionTest(PermissionConstants.SYSTEM_SERVICE_INTEGRATION_DELETE, DEFAULT_DELETE, addServiceIntegration.getId()); } - @Test - public void validateGet() throws Exception { - // @@请求成功 - this.requestGetWithOk(VALIDATE_GET, addServiceIntegration.getId()); - // todo 校验请求成功数据 - // @@校验权限 - requestGetPermissionTest(PermissionConstants.SYSTEM_SERVICE_INTEGRATION_UPDATE, VALIDATE_GET, addServiceIntegration.getId()); + + /** + * 添加插件,供测试使用 + * + * @return + * @throws Exception + */ + public Plugin addPlugin() throws Exception { + PluginUpdateRequest request = new PluginUpdateRequest(); + File jarFile = new File( + this.getClass().getClassLoader().getResource("file/metersphere-jira-plugin-3.x.jar") + .getPath() + ); + FileInputStream inputStream = new FileInputStream(jarFile); + MockMultipartFile mockMultipartFile = new MockMultipartFile(jarFile.getName(), inputStream); + request.setName("测试插件"); + request.setGlobal(true); + request.setEnable(true); + request.setCreateUser(ADMIN.name()); + return pluginService.add(request, mockMultipartFile); } - @Test - public void validatePost() throws Exception { - // @@请求成功 - this.requestPostWithOk(VALIDATE_POST, new HashMap<>()); - // todo 校验请求成功数据 - // @@校验权限 - requestPostPermissionTest(PermissionConstants.SYSTEM_SERVICE_INTEGRATION_UPDATE, VALIDATE_POST, new HashMap<>()); + /** + * 删除插件 + * @throws Exception + */ + public void deletePlugin() throws Exception { + this.requestGetWithOk(DEFAULT_DELETE, plugin.getId()); } - @Test - public void getPluginScript() throws Exception { - // @@请求成功 - this.requestGetWithOk(SCRIPT_GET, "pluginId"); - // todo 校验请求成功数据 - // @@校验权限 - requestGetPermissionTest(PermissionConstants.SYSTEM_SERVICE_INTEGRATION_READ, SCRIPT_GET, "pluginId"); + @Getter + @Setter + public class JiraIntegrationConfig { + private String account; + private String password; + private String token; + private String authType; + private String address; + private String version; } } \ No newline at end of file diff --git a/backend/services/system-setting/src/test/java/io/metersphere/system/controller/param/PluginUpdateRequestDefinition.java b/backend/services/system-setting/src/test/java/io/metersphere/system/controller/param/PluginUpdateRequestDefinition.java index f3e886bd79..d285ca31fb 100644 --- a/backend/services/system-setting/src/test/java/io/metersphere/system/controller/param/PluginUpdateRequestDefinition.java +++ b/backend/services/system-setting/src/test/java/io/metersphere/system/controller/param/PluginUpdateRequestDefinition.java @@ -16,6 +16,6 @@ public class PluginUpdateRequestDefinition { @Size(min = 1, max = 255, groups = {Created.class, Updated.class}) private String name; - @Size(min = 1, max = 500, groups = {Created.class, Updated.class}) + @Size(max = 500, groups = {Created.class, Updated.class}) private String description; } diff --git a/backend/services/system-setting/src/test/java/io/metersphere/system/controller/param/ServiceIntegrationUpdateRequestDefinition.java b/backend/services/system-setting/src/test/java/io/metersphere/system/controller/param/ServiceIntegrationUpdateRequestDefinition.java index 162002c6c6..b381fe49a1 100644 --- a/backend/services/system-setting/src/test/java/io/metersphere/system/controller/param/ServiceIntegrationUpdateRequestDefinition.java +++ b/backend/services/system-setting/src/test/java/io/metersphere/system/controller/param/ServiceIntegrationUpdateRequestDefinition.java @@ -14,7 +14,7 @@ import java.util.Map; public class ServiceIntegrationUpdateRequestDefinition { @Schema(description = "ID", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank(groups = {Updated.class}) - @Size(min = 1, max = 50, groups = {Created.class, Updated.class}) + @Size(min = 1, max = 50, groups = {Updated.class}) private String id; @Schema(description = "插件的ID", requiredMode = Schema.RequiredMode.REQUIRED) diff --git a/backend/services/system-setting/src/test/resources/file/metersphere-jira-plugin-3.x.jar b/backend/services/system-setting/src/test/resources/file/metersphere-jira-plugin-3.x.jar index f8b4cb2f6fa331dd107b8691cdc89814cd810311..c8a6ea2338f7f162367e6ade82cf0a7eac40ceb6 100644 GIT binary patch literal 41971 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 delta 1651 zcmZvc2{crD9LKL2GMe#hGa)8qiSmd+NOo$BA{-(qjVHSrlqH7IOk|(SQ{WcK`qLz5frV1=c7dXiI?eZU?xl#UGX? zr~rQo8LfOp$T)3=zrA=N>wex_{bzlxKMb_sktHJlZf)6N?U7I(00@Tx0Qy_4l{d-V z)-U9ocYqqv&pjx}Kb2t9V2q}+ZHEgG1k;LL53 z*(G=nWuq6WPHh<4oxle>7uy-*J&v<)vgA&a-@pR~U*GKrDk~)Vl$_cHG$n&YSI(zH#P81ebkm2N4z?T zJf0&f<)AcKD;#mPG1jvyT<@4h>{)VV``fCWZPC<{Gb-~~>*K0+8U79_R_T(N8A+N- zN`8O3+Sw>dv#eDbJ|8q`5ubHx9AZ>#ge><8-6VGf$k7D-TgRufbXOI!{^lPUj$b3r z7fIH=lw2H)M+x(ll<5n_BvQxglSmoEy^g{z&rP3Tf(;YaQeubJZ!g?)GzcX3`ZoKz zu7oY^_pyb&&pLFv}UjuR%BIS%%RN{Q?whUvtK7_o}D8uSEB6eLf_7Dza&CT z!TRhVsavlS|@GmJtcbK!vP0EGSF}b;li45;`Z=44sHdObn~;PFNry4eb4J zoJEp7!7kw%QqwNMF05KK!Ai476eL939T}@}zF`qx!>3pD;f67zJ-Xq>N?6s?a7?W$ zU`ybEq2WL1w}p2C&?dMGOiq{KG7+puH`4wU6u*`U1G6UWkoy&pKgV(*nUW!8Fbm~^ zh$Qdx!4N^er+-b(uLA*~Fh&&ODKrqAyM={w2MOg%!D?ZkYQB=}mdlF(0O{{tkS&ZU zjQOH3GZ~%7Qrs zsLfC*6k6nlbm@$}FuVZd8Gv34A%P$Afzb>MOacidEWsfL;x|D+5NLn{z%-Kkng0O{ CtDzYH