Commit e2edd7a49f3c9bb864ebd61c1656944bf541d168

Authored by 芋道源码
Committed by Gitee
2 parents 8e219244 2cad35f3

!1404 feat:【framework 框架】增加 api 加解密能力

Merge pull request !1404 from 芋道源码/develop
Showing 49 changed files with 1110 additions and 199 deletions
sql/mysql/ruoyi-vue-pro.sql
... ... @@ -1259,14 +1259,16 @@ CREATE TABLE `system_mail_log` (
1259 1259 `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
1260 1260 `user_id` bigint NULL DEFAULT NULL COMMENT '用户编号',
1261 1261 `user_type` tinyint NULL DEFAULT NULL COMMENT '用户类型',
1262   - `to_mail` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '接收邮箱地址',
  1262 + `to_mails` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '接收邮箱地址',
  1263 + `cc_mails` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '抄送邮箱地址',
  1264 + `bcc_mails` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '密送邮箱地址',
1263 1265 `account_id` bigint NOT NULL COMMENT '邮箱账号编号',
1264 1266 `from_mail` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '发送邮箱地址',
1265 1267 `template_id` bigint NOT NULL COMMENT '模板编号',
1266 1268 `template_code` varchar(63) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '模板编码',
1267 1269 `template_nickname` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '模版发送人名称',
1268 1270 `template_title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '邮件标题',
1269   - `template_content` varchar(10240) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '邮件内容',
  1271 + `template_content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '邮件内容',
1270 1272 `template_params` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '邮件参数',
1271 1273 `send_status` tinyint NOT NULL DEFAULT 0 COMMENT '发送状态',
1272 1274 `send_time` datetime NULL DEFAULT NULL COMMENT '发送时间',
... ...
yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/enums/WebFilterOrderEnum.java
... ... @@ -15,6 +15,8 @@ public interface WebFilterOrderEnum {
15 15  
16 16 int REQUEST_BODY_CACHE_FILTER = Integer.MIN_VALUE + 500;
17 17  
  18 + int API_ENCRYPT_FILTER = REQUEST_BODY_CACHE_FILTER + 1;
  19 +
18 20 // OrderedRequestContextFilter 默认为 -105,用于国际化上下文等等
19 21  
20 22 int TENANT_CONTEXT_FILTER = - 104; // 需要保证在 ApiAccessLogFilter 前面
... ...
yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/config/ApiEncryptProperties.java 0 → 100644
  1 +package cn.iocoder.yudao.framework.encrypt.config;
  2 +
  3 +import jakarta.validation.constraints.NotEmpty;
  4 +import jakarta.validation.constraints.NotNull;
  5 +import lombok.Data;
  6 +import org.springframework.boot.context.properties.ConfigurationProperties;
  7 +import org.springframework.validation.annotation.Validated;
  8 +
  9 +/**
  10 + * HTTP API 加解密配置
  11 + *
  12 + * @author 芋道源码
  13 + */
  14 +@ConfigurationProperties(prefix = "yudao.api-encrypt")
  15 +@Validated
  16 +@Data
  17 +public class ApiEncryptProperties {
  18 +
  19 + /**
  20 + * 是否开启
  21 + */
  22 + @NotNull(message = "是否开启不能为空")
  23 + private Boolean enable;
  24 +
  25 + /**
  26 + * 请求头(响应头)名称
  27 + *
  28 + * 1. 如果该请求头非空,则表示请求参数已被「前端」加密,「后端」需要解密
  29 + * 2. 如果该响应头非空,则表示响应结果已被「后端」加密,「前端」需要解密
  30 + */
  31 + @NotEmpty(message = "请求头(响应头)名称不能为空")
  32 + private String header = "X-Api-Encrypt";
  33 +
  34 + /**
  35 + * 对称加密算法,用于请求/响应的加解密
  36 + *
  37 + * 目前支持
  38 + * 【对称加密】:
  39 + * 1. {@link cn.hutool.crypto.symmetric.SymmetricAlgorithm#AES}
  40 + * 2. {@link cn.hutool.crypto.symmetric.SM4#ALGORITHM_NAME} (需要自己二次开发,成本低)
  41 + * 【非对称加密】
  42 + * 1. {@link cn.hutool.crypto.asymmetric.AsymmetricAlgorithm#RSA}
  43 + * 2. {@link cn.hutool.crypto.asymmetric.SM2} (需要自己二次开发,成本低)
  44 + *
  45 + * @see <a href="https://help.aliyun.com/zh/ssl-certificate/what-are-a-public-key-and-a-private-key">什么是公钥和私钥?</a>
  46 + */
  47 + @NotEmpty(message = "对称加密算法不能为空")
  48 + private String algorithm;
  49 +
  50 + /**
  51 + * 请求的解密密钥
  52 + *
  53 + * 注意:
  54 + * 1. 如果是【对称加密】时,它「后端」对应的是“密钥”。对应的,「前端」也对应的也是“密钥”。
  55 + * 2. 如果是【非对称加密】时,它「后端」对应的是“私钥”。对应的,「前端」对应的是“公钥”。(重要!!!)
  56 + */
  57 + @NotEmpty(message = "请求的解密密钥不能为空")
  58 + private String requestKey;
  59 +
  60 + /**
  61 + * 响应的加密密钥
  62 + *
  63 + * 注意:
  64 + * 1. 如果是【对称加密】时,它「后端」对应的是“密钥”。对应的,「前端」也对应的也是“密钥”。
  65 + * 2. 如果是【非对称加密】时,它「后端」对应的是“公钥”。对应的,「前端」对应的是“私钥”。(重要!!!)
  66 + */
  67 + @NotEmpty(message = "响应的加密密钥不能为空")
  68 + private String responseKey;
  69 +
  70 +}
... ...
yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/config/YudaoApiEncryptAutoConfiguration.java 0 → 100644
  1 +package cn.iocoder.yudao.framework.encrypt.config;
  2 +
  3 +import cn.iocoder.yudao.framework.common.enums.WebFilterOrderEnum;
  4 +import cn.iocoder.yudao.framework.encrypt.core.filter.ApiEncryptFilter;
  5 +import cn.iocoder.yudao.framework.web.config.WebProperties;
  6 +import cn.iocoder.yudao.framework.web.core.handler.GlobalExceptionHandler;
  7 +import lombok.extern.slf4j.Slf4j;
  8 +import org.springframework.boot.autoconfigure.AutoConfiguration;
  9 +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
  10 +import org.springframework.boot.context.properties.EnableConfigurationProperties;
  11 +import org.springframework.boot.web.servlet.FilterRegistrationBean;
  12 +import org.springframework.context.annotation.Bean;
  13 +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
  14 +
  15 +import static cn.iocoder.yudao.framework.web.config.YudaoWebAutoConfiguration.createFilterBean;
  16 +
  17 +@AutoConfiguration
  18 +@Slf4j
  19 +@EnableConfigurationProperties(ApiEncryptProperties.class)
  20 +@ConditionalOnProperty(prefix = "yudao.api-encrypt", name = "enable", havingValue = "true")
  21 +public class YudaoApiEncryptAutoConfiguration {
  22 +
  23 + @Bean
  24 + public FilterRegistrationBean<ApiEncryptFilter> apiEncryptFilter(WebProperties webProperties,
  25 + ApiEncryptProperties apiEncryptProperties,
  26 + RequestMappingHandlerMapping requestMappingHandlerMapping,
  27 + GlobalExceptionHandler globalExceptionHandler) {
  28 + ApiEncryptFilter filter = new ApiEncryptFilter(webProperties, apiEncryptProperties,
  29 + requestMappingHandlerMapping, globalExceptionHandler);
  30 + return createFilterBean(filter, WebFilterOrderEnum.API_ENCRYPT_FILTER);
  31 +
  32 + }
  33 +
  34 +}
... ...
yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/core/annotation/ApiEncrypt.java 0 → 100644
  1 +package cn.iocoder.yudao.framework.encrypt.core.annotation;
  2 +
  3 +import java.lang.annotation.*;
  4 +
  5 +/**
  6 + * HTTP API 加解密注解
  7 + */
  8 +@Documented
  9 +@Target({ElementType.TYPE, ElementType.METHOD})
  10 +@Retention(RetentionPolicy.RUNTIME)
  11 +public @interface ApiEncrypt {
  12 +
  13 + /**
  14 + * 是否对请求参数进行解密,默认 true
  15 + */
  16 + boolean request() default true;
  17 +
  18 + /**
  19 + * 是否对响应结果进行加密,默认 true
  20 + */
  21 + boolean response() default true;
  22 +
  23 +}
... ...
yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/core/filter/ApiDecryptRequestWrapper.java 0 → 100644
  1 +package cn.iocoder.yudao.framework.encrypt.core.filter;
  2 +
  3 +import cn.hutool.core.io.IoUtil;
  4 +import cn.hutool.core.util.StrUtil;
  5 +import cn.hutool.crypto.asymmetric.AsymmetricDecryptor;
  6 +import cn.hutool.crypto.asymmetric.KeyType;
  7 +import cn.hutool.crypto.symmetric.SymmetricDecryptor;
  8 +import jakarta.servlet.ReadListener;
  9 +import jakarta.servlet.ServletInputStream;
  10 +import jakarta.servlet.http.HttpServletRequest;
  11 +import jakarta.servlet.http.HttpServletRequestWrapper;
  12 +
  13 +import java.io.BufferedReader;
  14 +import java.io.ByteArrayInputStream;
  15 +import java.io.IOException;
  16 +import java.io.InputStreamReader;
  17 +
  18 +/**
  19 + * 解密请求 {@link HttpServletRequestWrapper} 实现类
  20 + *
  21 + * @author 芋道源码
  22 + */
  23 +public class ApiDecryptRequestWrapper extends HttpServletRequestWrapper {
  24 +
  25 + private final byte[] body;
  26 +
  27 + public ApiDecryptRequestWrapper(HttpServletRequest request,
  28 + SymmetricDecryptor symmetricDecryptor,
  29 + AsymmetricDecryptor asymmetricDecryptor) throws IOException {
  30 + super(request);
  31 + // 读取 body,允许 HEX、BASE64 传输
  32 + String requestBody = StrUtil.utf8Str(
  33 + IoUtil.readBytes(request.getInputStream(), false));
  34 +
  35 + // 解密 body
  36 + body = symmetricDecryptor != null ? symmetricDecryptor.decrypt(requestBody)
  37 + : asymmetricDecryptor.decrypt(requestBody, KeyType.PrivateKey);
  38 + }
  39 +
  40 + @Override
  41 + public BufferedReader getReader() {
  42 + return new BufferedReader(new InputStreamReader(this.getInputStream()));
  43 + }
  44 +
  45 + @Override
  46 + public int getContentLength() {
  47 + return body.length;
  48 + }
  49 +
  50 + @Override
  51 + public long getContentLengthLong() {
  52 + return body.length;
  53 + }
  54 +
  55 + @Override
  56 + public ServletInputStream getInputStream() {
  57 + ByteArrayInputStream stream = new ByteArrayInputStream(body);
  58 + return new ServletInputStream() {
  59 +
  60 + @Override
  61 + public int read() {
  62 + return stream.read();
  63 + }
  64 +
  65 + @Override
  66 + public int available() {
  67 + return body.length;
  68 + }
  69 +
  70 + @Override
  71 + public boolean isFinished() {
  72 + return false;
  73 + }
  74 +
  75 + @Override
  76 + public boolean isReady() {
  77 + return false;
  78 + }
  79 +
  80 + @Override
  81 + public void setReadListener(ReadListener readListener) {
  82 + }
  83 +
  84 + };
  85 + }
  86 +}
... ...
yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/core/filter/ApiEncryptFilter.java 0 → 100644
  1 +package cn.iocoder.yudao.framework.encrypt.core.filter;
  2 +
  3 +import cn.hutool.core.util.StrUtil;
  4 +import cn.hutool.crypto.SecureUtil;
  5 +import cn.hutool.crypto.asymmetric.AsymmetricDecryptor;
  6 +import cn.hutool.crypto.asymmetric.AsymmetricEncryptor;
  7 +import cn.hutool.crypto.symmetric.SymmetricDecryptor;
  8 +import cn.hutool.crypto.symmetric.SymmetricEncryptor;
  9 +import cn.iocoder.yudao.framework.common.pojo.CommonResult;
  10 +import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
  11 +import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils;
  12 +import cn.iocoder.yudao.framework.encrypt.config.ApiEncryptProperties;
  13 +import cn.iocoder.yudao.framework.encrypt.core.annotation.ApiEncrypt;
  14 +import cn.iocoder.yudao.framework.web.config.WebProperties;
  15 +import cn.iocoder.yudao.framework.web.core.filter.ApiRequestFilter;
  16 +import cn.iocoder.yudao.framework.web.core.handler.GlobalExceptionHandler;
  17 +import jakarta.servlet.FilterChain;
  18 +import jakarta.servlet.ServletException;
  19 +import jakarta.servlet.http.HttpServletRequest;
  20 +import jakarta.servlet.http.HttpServletResponse;
  21 +import lombok.extern.slf4j.Slf4j;
  22 +import org.springframework.http.HttpMethod;
  23 +import org.springframework.web.method.HandlerMethod;
  24 +import org.springframework.web.servlet.HandlerExecutionChain;
  25 +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
  26 +
  27 +import java.io.IOException;
  28 +
  29 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
  30 +
  31 +/**
  32 + * API 加密过滤器,处理 {@link ApiEncrypt} 注解。
  33 + *
  34 + * 1. 解密请求参数
  35 + * 2. 加密响应结果
  36 + *
  37 + * 疑问:为什么不使用 SpringMVC 的 RequestBodyAdvice 或 ResponseBodyAdvice 机制呢?
  38 + * 回答:考虑到项目中会记录访问日志、异常日志,以及 HTTP API 签名等场景,最好是全局级、且提前做解析!!!
  39 + *
  40 + * @author 芋道源码
  41 + */
  42 +@Slf4j
  43 +public class ApiEncryptFilter extends ApiRequestFilter {
  44 +
  45 + private final ApiEncryptProperties apiEncryptProperties;
  46 +
  47 + private final RequestMappingHandlerMapping requestMappingHandlerMapping;
  48 +
  49 + private final GlobalExceptionHandler globalExceptionHandler;
  50 +
  51 + private final SymmetricDecryptor requestSymmetricDecryptor;
  52 + private final AsymmetricDecryptor requestAsymmetricDecryptor;
  53 +
  54 + private final SymmetricEncryptor responseSymmetricEncryptor;
  55 + private final AsymmetricEncryptor responseAsymmetricEncryptor;
  56 +
  57 + public ApiEncryptFilter(WebProperties webProperties,
  58 + ApiEncryptProperties apiEncryptProperties,
  59 + RequestMappingHandlerMapping requestMappingHandlerMapping,
  60 + GlobalExceptionHandler globalExceptionHandler) {
  61 + super(webProperties);
  62 + this.apiEncryptProperties = apiEncryptProperties;
  63 + this.requestMappingHandlerMapping = requestMappingHandlerMapping;
  64 + this.globalExceptionHandler = globalExceptionHandler;
  65 + if (StrUtil.equalsIgnoreCase(apiEncryptProperties.getAlgorithm(), "AES")) {
  66 + this.requestSymmetricDecryptor = SecureUtil.aes(StrUtil.utf8Bytes(apiEncryptProperties.getRequestKey()));
  67 + this.requestAsymmetricDecryptor = null;
  68 + this.responseSymmetricEncryptor = SecureUtil.aes(StrUtil.utf8Bytes(apiEncryptProperties.getResponseKey()));
  69 + this.responseAsymmetricEncryptor = null;
  70 + } else if (StrUtil.equalsIgnoreCase(apiEncryptProperties.getAlgorithm(), "RSA")) {
  71 + this.requestSymmetricDecryptor = null;
  72 + this.requestAsymmetricDecryptor = SecureUtil.rsa(apiEncryptProperties.getRequestKey(), null);
  73 + this.responseSymmetricEncryptor = null;
  74 + this.responseAsymmetricEncryptor = SecureUtil.rsa(null, apiEncryptProperties.getResponseKey());
  75 + } else {
  76 + // 补充说明:如果要支持 SM2、SM4 等算法,可在此处增加对应实例的创建,并添加相应的 Maven 依赖即可。
  77 + throw new IllegalArgumentException("不支持的加密算法:" + apiEncryptProperties.getAlgorithm());
  78 + }
  79 + }
  80 +
  81 + @Override
  82 + @SuppressWarnings("NullableProblems")
  83 + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
  84 + throws ServletException, IOException {
  85 + // 获取 @ApiEncrypt 注解
  86 + ApiEncrypt apiEncrypt = getApiEncrypt(request);
  87 + boolean requestEnable = apiEncrypt != null && apiEncrypt.request();
  88 + boolean responseEnable = apiEncrypt != null && apiEncrypt.response();
  89 + String encryptHeader = request.getHeader(apiEncryptProperties.getHeader());
  90 + if (!requestEnable && !responseEnable && StrUtil.isBlank(encryptHeader)) {
  91 + chain.doFilter(request, response);
  92 + return;
  93 + }
  94 +
  95 + // 1. 解密请求
  96 + if (ObjectUtils.equalsAny(HttpMethod.valueOf(request.getMethod()),
  97 + HttpMethod.POST, HttpMethod.PUT, HttpMethod.DELETE)) {
  98 + try {
  99 + if (StrUtil.isNotBlank(encryptHeader)) {
  100 + request = new ApiDecryptRequestWrapper(request,
  101 + requestSymmetricDecryptor, requestAsymmetricDecryptor);
  102 + } else if (requestEnable) {
  103 + throw invalidParamException("请求未包含加密标头,请检查是否正确配置了加密标头");
  104 + }
  105 + } catch (Exception ex) {
  106 + CommonResult<?> result = globalExceptionHandler.allExceptionHandler(request, ex);
  107 + ServletUtils.writeJSON(response, result);
  108 + return;
  109 + }
  110 + }
  111 +
  112 + // 2. 执行过滤器链
  113 + if (responseEnable) {
  114 + // 特殊:仅包装,最后执行。目的:Response 内容可以被重复读取!!!
  115 + response = new ApiEncryptResponseWrapper(response);
  116 + }
  117 + chain.doFilter(request, response);
  118 +
  119 + // 3. 加密响应(真正执行)
  120 + if (responseEnable) {
  121 + ((ApiEncryptResponseWrapper) response).encrypt(apiEncryptProperties,
  122 + responseSymmetricEncryptor, responseAsymmetricEncryptor);
  123 + }
  124 + }
  125 +
  126 + /**
  127 + * 获取 @ApiEncrypt 注解
  128 + *
  129 + * @param request 请求
  130 + */
  131 + private ApiEncrypt getApiEncrypt(HttpServletRequest request) {
  132 + try {
  133 + HandlerExecutionChain mappingHandler = requestMappingHandlerMapping.getHandler(request);
  134 + if (mappingHandler == null) {
  135 + return null;
  136 + }
  137 + Object handler = mappingHandler.getHandler();
  138 + if (handler instanceof HandlerMethod handlerMethod) {
  139 + ApiEncrypt annotation = handlerMethod.getMethodAnnotation(ApiEncrypt.class);
  140 + if (annotation == null) {
  141 + annotation = handlerMethod.getBeanType().getAnnotation(ApiEncrypt.class);
  142 + }
  143 + return annotation;
  144 + }
  145 + } catch (Exception e) {
  146 + log.error("[getApiEncrypt][url({}/{}) 获取 @ApiEncrypt 注解失败]",
  147 + request.getRequestURI(), request.getMethod(), e);
  148 + }
  149 + return null;
  150 + }
  151 +
  152 +}
... ...
yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/core/filter/ApiEncryptResponseWrapper.java 0 → 100644
  1 +package cn.iocoder.yudao.framework.encrypt.core.filter;
  2 +
  3 +import cn.hutool.crypto.asymmetric.AsymmetricEncryptor;
  4 +import cn.hutool.crypto.asymmetric.KeyType;
  5 +import cn.hutool.crypto.symmetric.SymmetricEncryptor;
  6 +import cn.iocoder.yudao.framework.encrypt.config.ApiEncryptProperties;
  7 +import jakarta.servlet.ServletOutputStream;
  8 +import jakarta.servlet.WriteListener;
  9 +import jakarta.servlet.http.HttpServletResponse;
  10 +import jakarta.servlet.http.HttpServletResponseWrapper;
  11 +
  12 +import java.io.ByteArrayOutputStream;
  13 +import java.io.IOException;
  14 +import java.io.OutputStreamWriter;
  15 +import java.io.PrintWriter;
  16 +
  17 +/**
  18 + * 加密响应 {@link HttpServletResponseWrapper} 实现类
  19 + *
  20 + * @author 芋道源码
  21 + */
  22 +public class ApiEncryptResponseWrapper extends HttpServletResponseWrapper {
  23 +
  24 + private final ByteArrayOutputStream byteArrayOutputStream;
  25 + private final ServletOutputStream servletOutputStream;
  26 + private final PrintWriter printWriter;
  27 +
  28 + public ApiEncryptResponseWrapper(HttpServletResponse response) {
  29 + super(response);
  30 + this.byteArrayOutputStream = new ByteArrayOutputStream();
  31 + this.servletOutputStream = this.getOutputStream();
  32 + this.printWriter = new PrintWriter(new OutputStreamWriter(byteArrayOutputStream));
  33 + }
  34 +
  35 + public void encrypt(ApiEncryptProperties properties,
  36 + SymmetricEncryptor symmetricEncryptor,
  37 + AsymmetricEncryptor asymmetricEncryptor) throws IOException {
  38 + // 1.1 清空 body
  39 + HttpServletResponse response = (HttpServletResponse) this.getResponse();
  40 + response.resetBuffer();
  41 + // 1.2 获取 body
  42 + this.flushBuffer();
  43 + byte[] body = byteArrayOutputStream.toByteArray();
  44 +
  45 + // 2. 加密 body
  46 + String encryptedBody = symmetricEncryptor != null ? symmetricEncryptor.encryptBase64(body)
  47 + : asymmetricEncryptor.encryptBase64(body, KeyType.PublicKey);
  48 + response.getWriter().write(encryptedBody);
  49 +
  50 + // 3. 添加加密 header 标识
  51 + this.addHeader(properties.getHeader(), "true");
  52 + // 特殊:特殊:https://juejin.cn/post/6867327674675625992
  53 + this.addHeader("Access-Control-Expose-Headers", properties.getHeader());
  54 + }
  55 +
  56 + @Override
  57 + public PrintWriter getWriter() {
  58 + return printWriter;
  59 + }
  60 +
  61 + @Override
  62 + public void flushBuffer() throws IOException {
  63 + if (servletOutputStream != null) {
  64 + servletOutputStream.flush();
  65 + }
  66 + if (printWriter != null) {
  67 + printWriter.flush();
  68 + }
  69 + }
  70 +
  71 + @Override
  72 + public void reset() {
  73 + byteArrayOutputStream.reset();
  74 + }
  75 +
  76 + @Override
  77 + public ServletOutputStream getOutputStream() {
  78 + return new ServletOutputStream() {
  79 +
  80 + @Override
  81 + public boolean isReady() {
  82 + return false;
  83 + }
  84 +
  85 + @Override
  86 + public void setWriteListener(WriteListener writeListener) {
  87 + }
  88 +
  89 + @Override
  90 + public void write(int b) {
  91 + byteArrayOutputStream.write(b);
  92 + }
  93 +
  94 + @Override
  95 + @SuppressWarnings("NullableProblems")
  96 + public void write(byte[] b) throws IOException {
  97 + byteArrayOutputStream.write(b);
  98 + }
  99 +
  100 + @Override
  101 + @SuppressWarnings("NullableProblems")
  102 + public void write(byte[] b, int off, int len) {
  103 + byteArrayOutputStream.write(b, off, len);
  104 + }
  105 +
  106 + };
  107 + }
  108 +
  109 +}
0 110 \ No newline at end of file
... ...
yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/package-info.java 0 → 100644
  1 +/**
  2 + * HTTP API 加密组件:支持 Request 和 Response 的加密、解密
  3 + */
  4 +package cn.iocoder.yudao.framework.encrypt;
0 5 \ No newline at end of file
... ...
yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/filter/CacheRequestBodyWrapper.java
1 1 package cn.iocoder.yudao.framework.web.core.filter;
2 2  
3 3 import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils;
4   -
5 4 import jakarta.servlet.ReadListener;
6 5 import jakarta.servlet.ServletInputStream;
7 6 import jakarta.servlet.http.HttpServletRequest;
8 7 import jakarta.servlet.http.HttpServletRequestWrapper;
  8 +
9 9 import java.io.BufferedReader;
10 10 import java.io.ByteArrayInputStream;
11   -import java.io.IOException;
12 11 import java.io.InputStreamReader;
13 12  
14 13 /**
... ... @@ -29,12 +28,22 @@ public class CacheRequestBodyWrapper extends HttpServletRequestWrapper {
29 28 }
30 29  
31 30 @Override
32   - public BufferedReader getReader() throws IOException {
  31 + public BufferedReader getReader() {
33 32 return new BufferedReader(new InputStreamReader(this.getInputStream()));
34 33 }
35 34  
36 35 @Override
37   - public ServletInputStream getInputStream() throws IOException {
  36 + public int getContentLength() {
  37 + return body.length;
  38 + }
  39 +
  40 + @Override
  41 + public long getContentLengthLong() {
  42 + return body.length;
  43 + }
  44 +
  45 + @Override
  46 + public ServletInputStream getInputStream() {
38 47 final ByteArrayInputStream inputStream = new ByteArrayInputStream(body);
39 48 // 返回 ServletInputStream
40 49 return new ServletInputStream() {
... ...
yudao-framework/yudao-spring-boot-starter-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
... ... @@ -3,4 +3,5 @@ cn.iocoder.yudao.framework.jackson.config.YudaoJacksonAutoConfiguration
3 3 cn.iocoder.yudao.framework.swagger.config.YudaoSwaggerAutoConfiguration
4 4 cn.iocoder.yudao.framework.web.config.YudaoWebAutoConfiguration
5 5 cn.iocoder.yudao.framework.xss.config.YudaoXssAutoConfiguration
6   -cn.iocoder.yudao.framework.banner.config.YudaoBannerAutoConfiguration
7 6 \ No newline at end of file
  7 +cn.iocoder.yudao.framework.banner.config.YudaoBannerAutoConfiguration
  8 +cn.iocoder.yudao.framework.encrypt.config.YudaoApiEncryptAutoConfiguration
8 9 \ No newline at end of file
... ...
yudao-framework/yudao-spring-boot-starter-web/src/test/java/cn/iocoder/yudao/framework/encrypt/ApiEncryptTest.java 0 → 100644
  1 +package cn.iocoder.yudao.framework.encrypt;
  2 +
  3 +import cn.hutool.core.util.RandomUtil;
  4 +import cn.hutool.core.util.StrUtil;
  5 +import cn.hutool.crypto.SecureUtil;
  6 +import cn.hutool.crypto.asymmetric.AsymmetricAlgorithm;
  7 +import cn.hutool.crypto.asymmetric.KeyType;
  8 +import cn.hutool.crypto.asymmetric.RSA;
  9 +import cn.hutool.crypto.symmetric.SymmetricAlgorithm;
  10 +import org.junit.jupiter.api.Test;
  11 +
  12 +import java.util.Objects;
  13 +
  14 +/**
  15 + * 各种 API 加解密的测试类:不是单测,而是方便大家生成密钥、加密、解密等操作。
  16 + *
  17 + * @author 芋道源码
  18 + */
  19 +@SuppressWarnings("ConstantValue")
  20 +public class ApiEncryptTest {
  21 +
  22 + @Test
  23 + public void testGenerateAsymmetric() {
  24 + String asymmetricAlgorithm = AsymmetricAlgorithm.RSA.getValue();
  25 +// String asymmetricAlgorithm = "SM2";
  26 +// String asymmetricAlgorithm = SM4.ALGORITHM_NAME;
  27 +// String asymmetricAlgorithm = SymmetricAlgorithm.AES.getValue();
  28 + String requestClientKey = null;
  29 + String requestServerKey = null;
  30 + String responseClientKey = null;
  31 + String responseServerKey = null;
  32 + if (Objects.equals(asymmetricAlgorithm, AsymmetricAlgorithm.RSA.getValue())) {
  33 + // 请求的密钥
  34 + RSA requestRsa = SecureUtil.rsa();
  35 + requestClientKey = requestRsa.getPublicKeyBase64();
  36 + requestServerKey = requestRsa.getPrivateKeyBase64();
  37 + // 响应的密钥
  38 + RSA responseRsa = new RSA();
  39 + responseClientKey = responseRsa.getPrivateKeyBase64();
  40 + responseServerKey = responseRsa.getPublicKeyBase64();
  41 + } else if (Objects.equals(asymmetricAlgorithm, SymmetricAlgorithm.AES.getValue())) {
  42 + // AES 密钥可选 32、24、16 位
  43 + // 请求的密钥(前后端密钥一致)
  44 + requestClientKey = RandomUtil.randomNumbers(32);
  45 + requestServerKey = requestClientKey;
  46 + // 响应的密钥(前后端密钥一致)
  47 + responseClientKey = RandomUtil.randomNumbers(32);
  48 + responseServerKey = responseClientKey;
  49 + }
  50 +
  51 + // 打印结果
  52 + System.out.println("requestClientKey = " + requestClientKey);
  53 + System.out.println("requestServerKey = " + requestServerKey);
  54 + System.out.println("responseClientKey = " + responseClientKey);
  55 + System.out.println("responseServerKey = " + responseServerKey);
  56 + }
  57 +
  58 + @Test
  59 + public void testEncrypt_aes() {
  60 + String key = "52549111389893486934626385991395";
  61 + String body = "{\n" +
  62 + " \"username\": \"admin\",\n" +
  63 + " \"password\": \"admin123\",\n" +
  64 + " \"uuid\": \"3acd87a09a4f48fb9118333780e94883\",\n" +
  65 + " \"code\": \"1024\"\n" +
  66 + "}";
  67 + String encrypt = SecureUtil.aes(StrUtil.utf8Bytes(key))
  68 + .encryptBase64(body);
  69 + System.out.println("encrypt = " + encrypt);
  70 + }
  71 +
  72 + @Test
  73 + public void testEncrypt_rsa() {
  74 + String key = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCls2rIpnGdYnLFgz1XU13GbNQ5DloyPpvW00FPGjqn5Z6JpK+kDtVlnkhwR87iRrE5Vf2WNqRX6vzbLSgveIQY8e8oqGCb829myjf1MuI+ZzN4ghf/7tEYhZJGPI9AbfxFqBUzm+kR3/HByAI22GLT96WM26QiMK8n3tIP/yiLswIDAQAB";
  75 + String body = "{\n" +
  76 + " \"username\": \"admin\",\n" +
  77 + " \"password\": \"admin123\",\n" +
  78 + " \"uuid\": \"3acd87a09a4f48fb9118333780e94883\",\n" +
  79 + " \"code\": \"1024\"\n" +
  80 + "}";
  81 + String encrypt = SecureUtil.rsa(null, key)
  82 + .encryptBase64(body, KeyType.PublicKey);
  83 + System.out.println("encrypt = " + encrypt);
  84 + }
  85 +
  86 +}
... ...
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/controller/admin/definition/vo/model/BpmModelMetaInfoVO.java
... ... @@ -72,6 +72,9 @@ public class BpmModelMetaInfoVO {
72 72 @Schema(description = "允许撤销审批中的申请", example = "true")
73 73 private Boolean allowCancelRunningProcess;
74 74  
  75 + @Schema(description = "允许允许审批人撤回任务", example = "false")
  76 + private Boolean allowWithdrawTask;
  77 +
75 78 @Schema(description = "流程 ID 规则", example = "{}")
76 79 private ProcessIdRule processIdRule;
77 80  
... ...
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/controller/admin/task/BpmTaskController.java
... ... @@ -219,6 +219,14 @@ public class BpmTaskController {
219 219 return success(true);
220 220 }
221 221  
  222 + @PutMapping("/withdraw")
  223 + @Operation(summary = "撤回任务")
  224 + @PreAuthorize("@ss.hasPermission('bpm:task:update')")
  225 + public CommonResult<Boolean> withdrawTask(@RequestParam("taskId") String taskId) {
  226 + taskService.withdrawTask(getLoginUserId(), taskId);
  227 + return success(true);
  228 + }
  229 +
222 230 @GetMapping("/list-by-parent-task-id")
223 231 @Operation(summary = "获得指定父级任务的子任务列表") // 目前用于,减签的时候,获得子任务列表
224 232 @Parameter(name = "parentTaskId", description = "父级任务编号", required = true)
... ...
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/dal/dataobject/definition/BpmProcessDefinitionInfoDO.java
... ... @@ -173,6 +173,11 @@ public class BpmProcessDefinitionInfoDO extends BaseDO {
173 173 private Boolean allowCancelRunningProcess;
174 174  
175 175 /**
  176 + * 是否允许审批人撤回任务
  177 + */
  178 + private Boolean allowWithdrawTask;
  179 +
  180 + /**
176 181 * 流程 ID 规则
177 182 */
178 183 @TableField(typeHandler = JacksonTypeHandler.class)
... ...
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/enums/ErrorCodeConstants.java
... ... @@ -60,6 +60,10 @@ public interface ErrorCodeConstants {
60 60 ErrorCode TASK_TRANSFER_FAIL_USER_NOT_EXISTS = new ErrorCode(1_009_005_014, "任务转办失败,转办人不存在");
61 61 ErrorCode TASK_SIGNATURE_NOT_EXISTS = new ErrorCode(1_009_005_015, "签名不能为空!");
62 62 ErrorCode TASK_REASON_REQUIRE = new ErrorCode(1_009_005_016, "审批意见不能为空!");
  63 + ErrorCode TASK_WITHDRAW_FAIL_PROCESS_NOT_RUNNING = new ErrorCode(1_009_005_017, "撤回失败,流程实例未运行!");
  64 + ErrorCode TASK_WITHDRAW_FAIL_TASK_NOT_EXISTS = new ErrorCode(1_009_005_018, "撤回失败,未查询到用户已办任务!");
  65 + ErrorCode TASK_WITHDRAW_FAIL_NOT_ALLOW = new ErrorCode(1_009_005_019, "撤回失败,此流程不允许撤回操作!");
  66 + ErrorCode TASK_WITHDRAW_FAIL_NEXT_TASK_NOT_ALLOW = new ErrorCode(1_009_005_019, "撤回失败,下一节点不满足撤回条件!");
63 67  
64 68 // ========== 动态表单模块 1-009-010-000 ==========
65 69 ErrorCode FORM_NOT_EXISTS = new ErrorCode(1_009_010_000, "动态表单不存在");
... ...
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/enums/task/BpmReasonEnum.java
... ... @@ -35,6 +35,7 @@ public enum BpmReasonEnum {
35 35 APPROVE_TYPE_AUTO_APPROVE("非人工审核,自动通过"),
36 36 APPROVE_TYPE_AUTO_REJECT("非人工审核,自动不通过"),
37 37 CANCEL_BY_PROCESS_CLEAN("进程清理自动取消"),
  38 + CANCEL_BY_WITHDRAW("前一任务撤回,系统自动取消"),
38 39 ;
39 40  
40 41 private final String reason;
... ...
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/BpmnModelUtils.java
... ... @@ -852,7 +852,7 @@ public class BpmnModelUtils {
852 852 } else if (flowNode instanceof ScriptTask) {
853 853 skipExpression = ((ScriptTask) flowNode).getSkipExpression();
854 854 }
855   -
  855 +
856 856 if (StrUtil.isEmpty(skipExpression)) {
857 857 return false;
858 858 }
... ... @@ -909,6 +909,49 @@ public class BpmnModelUtils {
909 909 }
910 910  
911 911 /**
  912 + * 查找起始节点下一个用户任务列表列表
  913 + *
  914 + * @param source 起始节点
  915 + * @return 结果
  916 + */
  917 + public static List<UserTask> getNextUserTasks(FlowElement source) {
  918 + return getNextUserTasks(source, null, null);
  919 + }
  920 +
  921 + /**
  922 + * 查找起始节点下一个用户任务列表列表
  923 + * @param source 起始节点
  924 + * @param hasSequenceFlow 已经经过的连线的 ID,用于判断线路是否重复
  925 + * @param userTaskList 用户任务列表
  926 + * @return 结果
  927 + */
  928 + public static List<UserTask> getNextUserTasks(FlowElement source, Set<String> hasSequenceFlow, List<UserTask> userTaskList) {
  929 + hasSequenceFlow = Optional.ofNullable(hasSequenceFlow).orElse(new HashSet<>());
  930 + userTaskList = Optional.ofNullable(userTaskList).orElse(new ArrayList<>());
  931 + // 获取出口连线
  932 + List<SequenceFlow> sequenceFlows = getElementOutgoingFlows(source);
  933 + if (!sequenceFlows.isEmpty()) {
  934 + for (SequenceFlow sequenceFlow : sequenceFlows) {
  935 + // 如果发现连线重复,说明循环了,跳过这个循环
  936 + if (hasSequenceFlow.contains(sequenceFlow.getId())) {
  937 + continue;
  938 + }
  939 + // 添加已经走过的连线
  940 + hasSequenceFlow.add(sequenceFlow.getId());
  941 + FlowElement targetFlowElement = sequenceFlow.getTargetFlowElement();
  942 + if (targetFlowElement instanceof UserTask) {
  943 + // 若节点为用户任务,加入到结果列表中
  944 + userTaskList.add((UserTask) targetFlowElement);
  945 + } else {
  946 + // 若节点非用户任务,继续递归查找下一个节点
  947 + getNextUserTasks(targetFlowElement, hasSequenceFlow, userTaskList);
  948 + }
  949 + }
  950 + }
  951 + return userTaskList;
  952 + }
  953 +
  954 + /**
912 955 * 处理排它网关
913 956 *
914 957 * @param gateway 排他网关
... ...
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmTaskService.java
... ... @@ -250,6 +250,14 @@ public interface BpmTaskService {
250 250 */
251 251 void copyTask(Long userId, @Valid BpmTaskCopyReqVO reqVO);
252 252  
  253 + /**
  254 + * 撤回任务
  255 + *
  256 + * @param userId 用户编号
  257 + * @param taskId 任务编号
  258 + */
  259 + void withdrawTask(Long userId, String taskId);
  260 +
253 261 // ========== Event 事件相关方法 ==========
254 262  
255 263 /**
... ...
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmTaskServiceImpl.java
... ... @@ -196,7 +196,7 @@ public class BpmTaskServiceImpl implements BpmTaskService {
196 196 /**
197 197 * 获得用户指定 processInstanceId 流程编号下的首个“待办”(未审批、且可审核)的任务
198 198 *
199   - * @param userId 用户编号
  199 + * @param userId 用户编号
200 200 * @param processInstanceId 流程编号
201 201 * @return 任务
202 202 */
... ... @@ -599,15 +599,15 @@ public class BpmTaskServiceImpl implements BpmTaskService {
599 599  
600 600 /**
601 601 * 校验选择的下一个节点的审批人,是否合法
602   - *
  602 + * <p>
603 603 * 1. 是否有漏选:没有选择审批人
604 604 * 2. 是否有多选:非下一个节点
605 605 *
606 606 * @param taskDefinitionKey 当前任务节点标识
607   - * @param variables 流程变量
608   - * @param bpmnModel 流程模型
609   - * @param nextAssignees 下一个节点审批人集合(参数)
610   - * @param processInstance 流程实例
  607 + * @param variables 流程变量
  608 + * @param bpmnModel 流程模型
  609 + * @param nextAssignees 下一个节点审批人集合(参数)
  610 + * @param processInstance 流程实例
611 611 */
612 612 @SuppressWarnings("unchecked")
613 613 private Map<String, Object> validateAndSetNextAssignees(String taskDefinitionKey, Map<String, Object> variables, BpmnModel bpmnModel,
... ... @@ -659,7 +659,7 @@ public class BpmTaskServiceImpl implements BpmTaskService {
659 659 approveUserSelectAssignees = new HashMap<>();
660 660 }
661 661 approveUserSelectAssignees.put(nextFlowNode.getId(), assignees);
662   - Map<String,List<Long>> existingApproveUserSelectAssignees = (Map<String,List<Long>>) variables.get(
  662 + Map<String, List<Long>> existingApproveUserSelectAssignees = (Map<String, List<Long>>) variables.get(
663 663 BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_APPROVE_USER_SELECT_ASSIGNEES);
664 664 if (CollUtil.isNotEmpty(existingApproveUserSelectAssignees)) {
665 665 approveUserSelectAssignees.putAll(existingApproveUserSelectAssignees);
... ... @@ -1177,6 +1177,63 @@ public class BpmTaskServiceImpl implements BpmTaskService {
1177 1177 processInstanceCopyService.createProcessInstanceCopy(reqVO.getCopyUserIds(), reqVO.getReason(), reqVO.getId());
1178 1178 }
1179 1179  
  1180 + @Override
  1181 + @Transactional(rollbackFor = Exception.class)
  1182 + public void withdrawTask(Long userId, String taskId) {
  1183 + // 1.1 查询本人已办任务
  1184 + HistoricTaskInstance taskInstance = historyService.createHistoricTaskInstanceQuery()
  1185 + .taskId(taskId).taskAssignee(userId.toString()).finished().singleResult();
  1186 + if (ObjUtil.isNull(taskInstance)) {
  1187 + throw exception(TASK_WITHDRAW_FAIL_TASK_NOT_EXISTS);
  1188 + }
  1189 + // 1.2 校验流程是否结束
  1190 + ProcessInstance processInstance = processInstanceService.getProcessInstance(taskInstance.getProcessInstanceId());
  1191 + if (ObjUtil.isNull(processInstance)) {
  1192 + throw exception(TASK_WITHDRAW_FAIL_PROCESS_NOT_RUNNING);
  1193 + }
  1194 + // 1.3 判断此流程是否允许撤回
  1195 + BpmProcessDefinitionInfoDO processDefinitionInfo = bpmProcessDefinitionService.getProcessDefinitionInfo(
  1196 + processInstance.getProcessDefinitionId());
  1197 + if (ObjUtil.isNull(processDefinitionInfo) || !Boolean.TRUE.equals(processDefinitionInfo.getAllowWithdrawTask())) {
  1198 + throw exception(TASK_WITHDRAW_FAIL_NOT_ALLOW);
  1199 + }
  1200 + // 1.4 判断下一个节点是否被审批过,如果是则无法撤回
  1201 + BpmnModel bpmnModel = modelService.getBpmnModelByDefinitionId(taskInstance.getProcessDefinitionId());
  1202 + UserTask userTask = (UserTask) BpmnModelUtils.getFlowElementById(bpmnModel, taskInstance.getTaskDefinitionKey());
  1203 + List<String> nextUserTaskKeys = convertList(BpmnModelUtils.getNextUserTasks(userTask), UserTask::getId);
  1204 + if (CollUtil.isEmpty(nextUserTaskKeys)) {
  1205 + throw exception(TASK_WITHDRAW_FAIL_NEXT_TASK_NOT_ALLOW);
  1206 + }
  1207 + // TODO @芋艿:是否选择升级flowable版本解决taskCreatedAfter、taskCreatedBefore问题,升级7.1.0可以;包括 todo 和 done 那边的查询哇??? 是的!
  1208 + long nextUserTaskFinishedCount = historyService.createHistoricTaskInstanceQuery()
  1209 + .processInstanceId(processInstance.getProcessInstanceId()).taskDefinitionKeys(nextUserTaskKeys)
  1210 + .taskCreatedAfter(taskInstance.getEndTime()).finished().count();
  1211 + if (nextUserTaskFinishedCount > 0) {
  1212 + throw exception(TASK_WITHDRAW_FAIL_NEXT_TASK_NOT_ALLOW);
  1213 + }
  1214 + // 1.5 获取需要撤回的运行任务
  1215 + List<Task> runningTasks = taskService.createTaskQuery().processInstanceId(processInstance.getProcessInstanceId())
  1216 + .taskDefinitionKeys(nextUserTaskKeys).active().list();
  1217 + if (CollUtil.isEmpty(runningTasks)) {
  1218 + throw exception(TASK_WITHDRAW_FAIL_NEXT_TASK_NOT_ALLOW);
  1219 + }
  1220 +
  1221 + // 2.1 取消当前任务
  1222 + List<String> withdrawExecutionIds = new ArrayList<>();
  1223 + for (Task task : runningTasks) {
  1224 + // 标记撤回任务为取消
  1225 + taskService.addComment(task.getId(), taskInstance.getProcessInstanceId(), BpmCommentTypeEnum.CANCEL.getType(),
  1226 + BpmCommentTypeEnum.CANCEL.formatComment("前一节点撤回"));
  1227 + updateTaskStatusAndReason(task.getId(), BpmTaskStatusEnum.CANCEL.getStatus(), BpmReasonEnum.CANCEL_BY_WITHDRAW.getReason());
  1228 + withdrawExecutionIds.add(task.getExecutionId());
  1229 + }
  1230 + // 2.2 执行撤回操作
  1231 + runtimeService.createChangeActivityStateBuilder()
  1232 + .processInstanceId(processInstance.getProcessInstanceId())
  1233 + .moveExecutionsToSingleActivityId(withdrawExecutionIds, taskInstance.getTaskDefinitionKey())
  1234 + .changeState();
  1235 + }
  1236 +
1180 1237 /**
1181 1238 * 校验任务是否能被减签
1182 1239 *
... ... @@ -1223,7 +1280,7 @@ public class BpmTaskServiceImpl implements BpmTaskService {
1223 1280 }
1224 1281  
1225 1282 // 2. 任务前置通知
1226   - if (ObjUtil.isNotNull(processDefinitionInfo.getTaskBeforeTriggerSetting())){
  1283 + if (ObjUtil.isNotNull(processDefinitionInfo.getTaskBeforeTriggerSetting())) {
1227 1284 BpmModelMetaInfoVO.HttpRequestSetting setting = processDefinitionInfo.getTaskBeforeTriggerSetting();
1228 1285 BpmHttpRequestUtils.executeBpmHttpRequest(processInstance,
1229 1286 setting.getUrl(), setting.getHeader(), setting.getBody(), true, setting.getResponse());
... ... @@ -1350,7 +1407,7 @@ public class BpmTaskServiceImpl implements BpmTaskService {
1350 1407 .taskVariableValueEquals(BpmnVariableConstants.TASK_VARIABLE_STATUS, BpmTaskStatusEnum.APPROVE.getStatus())
1351 1408 .finished();
1352 1409 if (BpmAutoApproveTypeEnum.APPROVE_ALL.getType().equals(processDefinitionInfo.getAutoApprovalType())
1353   - && sameAssigneeQuery.count() > 0) {
  1410 + && sameAssigneeQuery.count() > 0) {
1354 1411 getSelf().approveTask(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId())
1355 1412 .setReason(BpmAutoApproveTypeEnum.APPROVE_ALL.getName()));
1356 1413 return;
... ... @@ -1362,7 +1419,7 @@ public class BpmTaskServiceImpl implements BpmTaskService {
1362 1419 return;
1363 1420 }
1364 1421 List<String> sourceTaskIds = convertList(BpmnModelUtils.getElementIncomingFlows( // 获取所有上一个节点
1365   - BpmnModelUtils.getFlowElementById(bpmnModel, task.getTaskDefinitionKey())),
  1422 + BpmnModelUtils.getFlowElementById(bpmnModel, task.getTaskDefinitionKey())),
1366 1423 SequenceFlow::getSourceRef);
1367 1424 if (sameAssigneeQuery.taskDefinitionKeys(sourceTaskIds).count() > 0) {
1368 1425 getSelf().approveTask(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId())
... ... @@ -1387,7 +1444,7 @@ public class BpmTaskServiceImpl implements BpmTaskService {
1387 1444 PROCESS_INSTANCE_VARIABLE_SKIP_START_USER_NODE, String.class));
1388 1445 if (userTaskElement.getId().equals(START_USER_NODE_ID)
1389 1446 && (skipStartUserNodeFlag == null // 目的:一般是“主流程”,发起人节点,自动通过审核
1390   - || BooleanUtil.isTrue(skipStartUserNodeFlag)) // 目的:一般是“子流程”,发起人节点,按配置自动通过审核
  1447 + || BooleanUtil.isTrue(skipStartUserNodeFlag)) // 目的:一般是“子流程”,发起人节点,按配置自动通过审核
1391 1448 && ObjUtil.notEqual(returnTaskFlag, Boolean.TRUE)) {
1392 1449 getSelf().approveTask(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId())
1393 1450 .setReason(BpmReasonEnum.ASSIGN_START_USER_APPROVE_WHEN_SKIP_START_USER_NODE.getReason()));
... ... @@ -1456,7 +1513,7 @@ public class BpmTaskServiceImpl implements BpmTaskService {
1456 1513 }
1457 1514  
1458 1515 // 任务后置通知
1459   - if (ObjUtil.isNotNull(processDefinitionInfo.getTaskAfterTriggerSetting())){
  1516 + if (ObjUtil.isNotNull(processDefinitionInfo.getTaskAfterTriggerSetting())) {
1460 1517 BpmModelMetaInfoVO.HttpRequestSetting setting = processDefinitionInfo.getTaskAfterTriggerSetting();
1461 1518 BpmHttpRequestUtils.executeBpmHttpRequest(processInstance,
1462 1519 setting.getUrl(), setting.getHeader(), setting.getBody(), true, setting.getResponse());
... ...
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/LogRecordConstants.java
... ... @@ -14,7 +14,7 @@ public interface LogRecordConstants {
14 14 String CRM_CLUE_CREATE_SUB_TYPE = "创建线索";
15 15 String CRM_CLUE_CREATE_SUCCESS = "创建了线索{{#clue.name}}";
16 16 String CRM_CLUE_UPDATE_SUB_TYPE = "更新线索";
17   - String CRM_CLUE_UPDATE_SUCCESS = "更新了线索【{{#clueName}}】: {_DIFF{#updateReq}}";
  17 + String CRM_CLUE_UPDATE_SUCCESS = "更新了线索【{{#clueName}}】: {_DIFF{#updateReqVO}}";
18 18 String CRM_CLUE_DELETE_SUB_TYPE = "删除线索";
19 19 String CRM_CLUE_DELETE_SUCCESS = "删除了线索【{{#clueName}}】";
20 20 String CRM_CLUE_TRANSFER_SUB_TYPE = "转移线索";
... ...
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/clue/CrmClueServiceImpl.java
... ... @@ -106,7 +106,7 @@ public class CrmClueServiceImpl implements CrmClueService {
106 106  
107 107 // 3. 记录操作日志上下文
108 108 updateReqVO.setOwnerUserId(oldClue.getOwnerUserId()); // 避免操作日志出现“删除负责人”的情况
109   - LogRecordContext.putVariable(DiffParseFunction.OLD_OBJECT, BeanUtils.toBean(oldClue, CrmCustomerSaveReqVO.class));
  109 + LogRecordContext.putVariable(DiffParseFunction.OLD_OBJECT, BeanUtils.toBean(oldClue, CrmClueSaveReqVO.class));
110 110 LogRecordContext.putVariable("clueName", oldClue.getName());
111 111 }
112 112  
... ...
yudao-module-mp/src/main/java/cn/iocoder/yudao/module/mp/controller/admin/message/vo/message/MpMessagePageReqVO.java
... ... @@ -28,6 +28,9 @@ public class MpMessagePageReqVO extends PageParam {
28 28 @Schema(description = "公众号粉丝标识", example = "o6_bmjrPTlm6_2sgVt7hMZOPfL2M")
29 29 private String openid;
30 30  
  31 + @Schema(description = "公众号粉丝 UserId", example = "1")
  32 + private String userId;
  33 +
31 34 @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
32 35 @Schema(description = "创建时间")
33 36 private LocalDateTime[] createTime;
... ...
yudao-module-mp/src/main/java/cn/iocoder/yudao/module/mp/dal/mysql/message/MpMessageMapper.java
... ... @@ -15,6 +15,7 @@ public interface MpMessageMapper extends BaseMapperX&lt;MpMessageDO&gt; {
15 15 .eqIfPresent(MpMessageDO::getAccountId, reqVO.getAccountId())
16 16 .eqIfPresent(MpMessageDO::getType, reqVO.getType())
17 17 .eqIfPresent(MpMessageDO::getOpenid, reqVO.getOpenid())
  18 + .eqIfPresent(MpMessageDO::getUserId, reqVO.getUserId())
18 19 .betweenIfPresent(MpMessageDO::getCreateTime, reqVO.getCreateTime())
19 20 .orderByDesc(MpMessageDO::getId));
20 21 }
... ...
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/framework/pay/core/client/impl/alipay/AbstractAlipayPayClient.java
... ... @@ -353,7 +353,7 @@ public abstract class AbstractAlipayPayClient extends AbstractPayClient&lt;AlipayPa
353 353 } else if (Objects.equals(config.getMode(), MODE_CERTIFICATE)) {
354 354 // 由于 rsaCertCheckV1 的第二个参数是 path,所以不能这么调用!!!通过阅读源码,发现可以采用如下方式!
355 355 X509Certificate cert = AntCertificationUtil.getCertFromContent(config.getAlipayPublicCertContent());
356   - String publicKey = Base64.encodeBase64String(cert.getEncoded());
  356 + String publicKey = Base64.encodeBase64String(cert.getPublicKey().getEncoded());
357 357 verify = AlipaySignature.rsaCheckV1(params, publicKey,
358 358 StandardCharsets.UTF_8.name(), config.getSignType());
359 359 } else {
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/mail/MailSendApiImpl.java
... ... @@ -21,13 +21,15 @@ public class MailSendApiImpl implements MailSendApi {
21 21  
22 22 @Override
23 23 public Long sendSingleMailToAdmin(MailSendSingleToUserReqDTO reqDTO) {
24   - return mailSendService.sendSingleMailToAdmin(reqDTO.getMail(), reqDTO.getUserId(),
  24 + return mailSendService.sendSingleMailToAdmin(reqDTO.getUserId(),
  25 + reqDTO.getToMails(), reqDTO.getCcMails(), reqDTO.getBccMails(),
25 26 reqDTO.getTemplateCode(), reqDTO.getTemplateParams());
26 27 }
27 28  
28 29 @Override
29 30 public Long sendSingleMailToMember(MailSendSingleToUserReqDTO reqDTO) {
30   - return mailSendService.sendSingleMailToMember(reqDTO.getMail(), reqDTO.getUserId(),
  31 + return mailSendService.sendSingleMailToMember(reqDTO.getUserId(),
  32 + reqDTO.getToMails(), reqDTO.getCcMails(), reqDTO.getBccMails(),
31 33 reqDTO.getTemplateCode(), reqDTO.getTemplateParams());
32 34 }
33 35  
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/mail/dto/MailSendSingleToUserReqDTO.java
... ... @@ -4,6 +4,8 @@ import lombok.Data;
4 4  
5 5 import jakarta.validation.constraints.Email;
6 6 import jakarta.validation.constraints.NotNull;
  7 +
  8 +import java.util.List;
7 9 import java.util.Map;
8 10  
9 11 /**
... ... @@ -16,13 +18,24 @@ public class MailSendSingleToUserReqDTO {
16 18  
17 19 /**
18 20 * 用户编号
  21 + *
  22 + * 如果非空,则加载对应用户的邮箱,添加到 {@link #toMails} 中
19 23 */
20 24 private Long userId;
  25 +
  26 + /**
  27 + * 收件邮箱
  28 + */
  29 + private List<@Email String> toMails;
21 30 /**
22   - * 邮箱
  31 + * 抄送邮箱
23 32 */
24   - @Email
25   - private String mail;
  33 + private List<@Email String> ccMails;
  34 + /**
  35 + * 密送邮箱
  36 + */
  37 + private List<@Email String> bccMails;
  38 +
26 39  
27 40 /**
28 41 * 邮件模板编号
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/AuthController.http
... ... @@ -11,6 +11,24 @@ tag: Yunai.local
11 11 "code": "1024"
12 12 }
13 13  
  14 +### 请求 /login 接口【加密 AES】 => 成功
  15 +POST {{baseUrl}}/system/auth/login
  16 +Content-Type: application/json
  17 +tenant-id: {{adminTenantId}}
  18 +tag: Yunai.local
  19 +X-API-ENCRYPT: true
  20 +
  21 +WvSX9MOrenyGfBhEM0g1/hHgq8ocktMZ9OwAJ6MOG5FUrzYF/rG5JF1eMptQM1wT73VgDS05l/37WeRtad+JrqChAul/sR/SdOsUKqjBhvvQx1JVhzxr6s8uUP67aKTSZ6Psv7O32ELxXrzSaQvG5CInzz3w6sLtbNNLd1kXe6Q=
  22 +
  23 +### 请求 /login 接口【加密 RSA】 => 成功
  24 +POST {{baseUrl}}/system/auth/login
  25 +Content-Type: application/json
  26 +tenant-id: {{adminTenantId}}
  27 +tag: Yunai.local
  28 +X-API-ENCRYPT: true
  29 +
  30 +e7QZTork9ZV5CmgZvSd+cHZk3xdUxKtowLM02kOha+gxHK2H/daU8nVBYS3+bwuDRy5abf+Pz1QJJGVAEd27wwrXBmupOOA/bhpuzzDwcRuJRD+z+YgiNoEXFDRHERxPYlPqAe9zAHtihD0ceub1AjybQsEsROew4C3Q602XYW0=
  31 +
14 32 ### 请求 /login 接口 => 成功(无验证码)
15 33 POST {{baseUrl}}/system/auth/login
16 34 Content-Type: application/json
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/dept/DeptController.java
... ... @@ -56,6 +56,15 @@ public class DeptController {
56 56 return success(true);
57 57 }
58 58  
  59 + @DeleteMapping("/delete-list")
  60 + @Operation(summary = "批量删除部门")
  61 + @Parameter(name = "ids", description = "编号列表", required = true)
  62 + @PreAuthorize("@ss.hasPermission('system:dept:delete')")
  63 + public CommonResult<Boolean> deleteDeptList(@RequestParam("ids") List<Long> ids) {
  64 + deptService.deleteDeptList(ids);
  65 + return success(true);
  66 + }
  67 +
59 68 @GetMapping("/list")
60 69 @Operation(summary = "获取部门列表")
61 70 @PreAuthorize("@ss.hasPermission('system:dept:query')")
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/mail/MailTemplateController.java
... ... @@ -91,7 +91,8 @@ public class MailTemplateController {
91 91 @Operation(summary = "发送短信")
92 92 @PreAuthorize("@ss.hasPermission('system:mail-template:send-mail')")
93 93 public CommonResult<Long> sendMail(@Valid @RequestBody MailTemplateSendReqVO sendReqVO) {
94   - return success(mailSendService.sendSingleMailToAdmin(sendReqVO.getMail(), getLoginUserId(),
  94 + return success(mailSendService.sendSingleMailToAdmin(getLoginUserId(),
  95 + sendReqVO.getToMails(), sendReqVO.getCcMails(), sendReqVO.getBccMails(),
95 96 sendReqVO.getTemplateCode(), sendReqVO.getTemplateParams()));
96 97 }
97 98  
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/mail/vo/log/MailLogRespVO.java
... ... @@ -2,13 +2,11 @@ package cn.iocoder.yudao.module.system.controller.admin.mail.vo.log;
2 2  
3 3 import io.swagger.v3.oas.annotations.media.Schema;
4 4 import lombok.Data;
5   -import org.springframework.format.annotation.DateTimeFormat;
6 5  
7 6 import java.time.LocalDateTime;
  7 +import java.util.List;
8 8 import java.util.Map;
9 9  
10   -import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
11   -
12 10 @Schema(description = "管理后台 - 邮件日志 Response VO")
13 11 @Data
14 12 public class MailLogRespVO {
... ... @@ -22,8 +20,14 @@ public class MailLogRespVO {
22 20 @Schema(description = "用户类型,参见 UserTypeEnum 枚举", example = "2")
23 21 private Byte userType;
24 22  
25   - @Schema(description = "接收邮箱地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "76854@qq.com")
26   - private String toMail;
  23 + @Schema(description = "接收邮箱地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "user1@example.com, user2@example.com")
  24 + private List<String> toMails;
  25 +
  26 + @Schema(description = "抄送邮箱地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "user3@example.com, user4@example.com")
  27 + private List<String> ccMails;
  28 +
  29 + @Schema(description = "密送邮箱地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "user5@example.com, user6@example.com")
  30 + private List<String> bccMails;
27 31  
28 32 @Schema(description = "邮箱账号编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "18107")
29 33 private Long accountId;
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/mail/vo/template/MailTemplateSendReqVO.java
... ... @@ -5,15 +5,22 @@ import lombok.Data;
5 5  
6 6 import jakarta.validation.constraints.NotEmpty;
7 7 import jakarta.validation.constraints.NotNull;
  8 +import java.util.List;
8 9 import java.util.Map;
9 10  
10 11 @Schema(description = "管理后台 - 邮件发送 Req VO")
11 12 @Data
12 13 public class MailTemplateSendReqVO {
13 14  
14   - @Schema(description = "接收邮箱", requiredMode = Schema.RequiredMode.REQUIRED, example = "7685413@qq.com")
  15 + @Schema(description = "接收邮箱", requiredMode = Schema.RequiredMode.REQUIRED, example = "[user1@example.com, user2@example.com]")
15 16 @NotEmpty(message = "接收邮箱不能为空")
16   - private String mail;
  17 + private List<String> toMails;
  18 +
  19 + @Schema(description = "抄送邮箱", requiredMode = Schema.RequiredMode.REQUIRED, example = "[user3@example.com, user4@example.com]")
  20 + private List<String> ccMails;
  21 +
  22 + @Schema(description = "密送邮箱", requiredMode = Schema.RequiredMode.REQUIRED, example = "[user5@example.com, user6@example.com]")
  23 + private List<String> bccMails;
17 24  
18 25 @Schema(description = "模板编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "test_01")
19 26 @NotNull(message = "模板编码不能为空")
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/mail/MailLogDO.java
... ... @@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.system.dal.dataobject.mail;
2 2  
3 3 import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
4 4 import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
  5 +import cn.iocoder.yudao.framework.mybatis.core.type.StringListTypeHandler;
5 6 import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
6 7 import cn.iocoder.yudao.module.system.enums.mail.MailSendStatusEnum;
7 8 import com.baomidou.mybatisplus.annotation.KeySequence;
... ... @@ -12,6 +13,7 @@ import lombok.*;
12 13  
13 14 import java.io.Serializable;
14 15 import java.time.LocalDateTime;
  16 +import java.util.List;
15 17 import java.util.Map;
16 18  
17 19 /**
... ... @@ -47,10 +49,22 @@ public class MailLogDO extends BaseDO implements Serializable {
47 49 * 枚举 {@link UserTypeEnum}
48 50 */
49 51 private Integer userType;
  52 +
  53 + /**
  54 + * 接收邮箱地址
  55 + */
  56 + @TableField(typeHandler = StringListTypeHandler.class)
  57 + private List<String> toMails;
50 58 /**
51 59 * 接收邮箱地址
52 60 */
53   - private String toMail;
  61 + @TableField(typeHandler = StringListTypeHandler.class)
  62 + private List<String> ccMails;
  63 + /**
  64 + * 密送邮箱地址
  65 + */
  66 + @TableField(typeHandler = StringListTypeHandler.class)
  67 + private List<String> bccMails;
54 68  
55 69 /**
56 70 * 邮箱账号编号
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/mail/MailLogMapper.java
1 1 package cn.iocoder.yudao.module.system.dal.mysql.mail;
2 2  
  3 +import cn.hutool.core.util.StrUtil;
3 4 import cn.iocoder.yudao.framework.common.pojo.PageResult;
4 5 import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
5 6 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
  7 +import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
6 8 import cn.iocoder.yudao.module.system.controller.admin.mail.vo.log.MailLogPageReqVO;
7 9 import cn.iocoder.yudao.module.system.dal.dataobject.mail.MailLogDO;
8 10 import org.apache.ibatis.annotations.Mapper;
... ... @@ -14,11 +16,12 @@ public interface MailLogMapper extends BaseMapperX&lt;MailLogDO&gt; {
14 16 return selectPage(reqVO, new LambdaQueryWrapperX<MailLogDO>()
15 17 .eqIfPresent(MailLogDO::getUserId, reqVO.getUserId())
16 18 .eqIfPresent(MailLogDO::getUserType, reqVO.getUserType())
17   - .likeIfPresent(MailLogDO::getToMail, reqVO.getToMail())
18 19 .eqIfPresent(MailLogDO::getAccountId, reqVO.getAccountId())
19 20 .eqIfPresent(MailLogDO::getTemplateId, reqVO.getTemplateId())
20 21 .eqIfPresent(MailLogDO::getSendStatus, reqVO.getSendStatus())
21 22 .betweenIfPresent(MailLogDO::getSendTime, reqVO.getSendTime())
  23 + .apply(StrUtil.isNotBlank(reqVO.getToMail()),
  24 + MyBatisUtils.findInSet("to_mails", reqVO.getToMail()))
22 25 .orderByDesc(MailLogDO::getId));
23 26 }
24 27  
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/AliyunSmsClient.java
... ... @@ -136,15 +136,20 @@ public class AliyunSmsClient extends AbstractSmsClient {
136 136 .map(entry -> percentCode(entry.getKey()) + "=" + percentCode(String.valueOf(entry.getValue())))
137 137 .collect(Collectors.joining("&"));
138 138  
139   - // 2.1 请求 Header
  139 + // 2. 请求 Body
  140 + String requestBody = ""; // 短信 API 为 RPC 接口,query parameters 在 uri 中拼接,因此 request body 如果没有特殊要求,设置为空
  141 + String hashedRequestPayload = DigestUtil.sha256Hex(requestBody);
  142 +
  143 + // 3.1 请求 Header
140 144 TreeMap<String, String> headers = new TreeMap<>();
141 145 headers.put("host", HOST);
142 146 headers.put("x-acs-version", VERSION);
143 147 headers.put("x-acs-action", apiName);
144 148 headers.put("x-acs-date", FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ss'Z'", TimeZone.getTimeZone("GMT")).format(new Date()));
145 149 headers.put("x-acs-signature-nonce", IdUtil.randomUUID());
  150 + headers.put("x-acs-content-sha256", hashedRequestPayload);
146 151  
147   - // 2.2 构建签名 Header
  152 + // 3.2 构建签名 Header
148 153 StringBuilder canonicalHeaders = new StringBuilder(); // 构造请求头,多个规范化消息头,按照消息头名称(小写)的字符代码顺序以升序排列后拼接在一起
149 154 StringBuilder signedHeadersBuilder = new StringBuilder(); // 已签名消息头列表,多个请求头名称(小写)按首字母升序排列并以英文分号(;)分隔
150 155 headers.entrySet().stream().filter(entry -> entry.getKey().toLowerCase().startsWith("x-acs-")
... ... @@ -157,13 +162,13 @@ public class AliyunSmsClient extends AbstractSmsClient {
157 162 });
158 163 String signedHeaders = signedHeadersBuilder.substring(0, signedHeadersBuilder.length() - 1);
159 164  
160   - // 3. 请求 Body
161   - String requestBody = ""; // 短信 API 为 RPC 接口,query parameters 在 uri 中拼接,因此 request body 如果没有特殊要求,设置为空。
162   - String hashedRequestBody = DigestUtil.sha256Hex(requestBody);
163   -
164 165 // 4. 构建 Authorization 签名
165   - String canonicalRequest = "POST" + "\n" + "/" + "\n" + queryString + "\n"
166   - + canonicalHeaders + "\n" + signedHeaders + "\n" + hashedRequestBody;
  166 + String canonicalRequest = "POST" + "\n" +
  167 + "/" + "\n" +
  168 + queryString + "\n" +
  169 + canonicalHeaders + "\n" +
  170 + signedHeaders + "\n" +
  171 + hashedRequestPayload;
167 172 String hashedCanonicalRequest = DigestUtil.sha256Hex(canonicalRequest);
168 173 String stringToSign = "ACS3-HMAC-SHA256" + "\n" + hashedCanonicalRequest;
169 174 String signature = SecureUtil.hmacSha256(properties.getApiSecret()).digestHex(stringToSign); // 计算签名
... ... @@ -184,7 +189,7 @@ public class AliyunSmsClient extends AbstractSmsClient {
184 189 @SneakyThrows
185 190 private static String percentCode(String str) {
186 191 Assert.notNull(str, "str 不能为空");
187   - return HttpUtils.encodeUtf8(str)
  192 + return URLEncoder.encode(str, StandardCharsets.UTF_8.name())
188 193 .replace("+", "%20") // 加号 "+" 被替换为 "%20"
189 194 .replace("*", "%2A") // 星号 "*" 被替换为 "%2A"
190 195 .replace("%7E", "~"); // 波浪号 "%7E" 被替换为 "~"
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/mq/message/mail/MailSendMessage.java
... ... @@ -5,6 +5,9 @@ import lombok.Data;
5 5 import jakarta.validation.constraints.NotEmpty;
6 6 import jakarta.validation.constraints.NotNull;
7 7  
  8 +import java.util.Collection;
  9 +import java.util.List;
  10 +
8 11 /**
9 12 * 邮箱发送消息
10 13 *
... ... @@ -21,8 +24,16 @@ public class MailSendMessage {
21 24 /**
22 25 * 接收邮件地址
23 26 */
24   - @NotNull(message = "接收邮件地址不能为空")
25   - private String mail;
  27 + @NotEmpty(message = "接收邮件地址不能为空")
  28 + private Collection<String> toMails;
  29 + /**
  30 + * 抄送邮件地址
  31 + */
  32 + private Collection<String> ccMails;
  33 + /**
  34 + * 密送邮件地址
  35 + */
  36 + private Collection<String> bccMails;
26 37 /**
27 38 * 邮件账号编号
28 39 */
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/mq/producer/mail/MailProducer.java
... ... @@ -7,6 +7,11 @@ import org.springframework.stereotype.Component;
7 7  
8 8 import jakarta.annotation.Resource;
9 9  
  10 +import java.util.Collection;
  11 +import java.util.List;
  12 +
  13 +import static java.util.Collections.singletonList;
  14 +
10 15 /**
11 16 * Mail 邮件相关消息的 Producer
12 17 *
... ... @@ -24,17 +29,22 @@ public class MailProducer {
24 29 * 发送 {@link MailSendMessage} 消息
25 30 *
26 31 * @param sendLogId 发送日志编码
27   - * @param mail 接收邮件地址
  32 + * @param toMails 接收邮件地址
  33 + * @param ccMails 抄送邮件地址
  34 + * @param bccMails 密送邮件地址
28 35 * @param accountId 邮件账号编号
29   - * @param nickname 邮件发件人
30   - * @param title 邮件标题
31   - * @param content 邮件内容
  36 + * @param nickname 邮件发件人
  37 + * @param title 邮件标题
  38 + * @param content 邮件内容
32 39 */
33   - public void sendMailSendMessage(Long sendLogId, String mail, Long accountId,
34   - String nickname, String title, String content) {
  40 + public void sendMailSendMessage(Long sendLogId,
  41 + Collection<String> toMails, Collection<String> ccMails, Collection<String> bccMails,
  42 + Long accountId, String nickname, String title, String content) {
35 43 MailSendMessage message = new MailSendMessage()
36   - .setLogId(sendLogId).setMail(mail).setAccountId(accountId)
37   - .setNickname(nickname).setTitle(title).setContent(content);
  44 + .setLogId(sendLogId)
  45 + .setToMails(toMails).setCcMails(ccMails).setBccMails(bccMails)
  46 + .setAccountId(accountId).setNickname(nickname)
  47 + .setTitle(title).setContent(content);
38 48 applicationContext.publishEvent(message);
39 49 }
40 50  
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptService.java
... ... @@ -37,6 +37,13 @@ public interface DeptService {
37 37 void deleteDept(Long id);
38 38  
39 39 /**
  40 + * 批量删除部门
  41 + *
  42 + * @param ids 部门编号数组
  43 + */
  44 + void deleteDeptList(List<Long> ids);
  45 +
  46 + /**
40 47 * 获得部门信息
41 48 *
42 49 * @param id 部门编号
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImpl.java
... ... @@ -88,6 +88,21 @@ public class DeptServiceImpl implements DeptService {
88 88 deptMapper.deleteById(id);
89 89 }
90 90  
  91 + @Override
  92 + @CacheEvict(cacheNames = RedisKeyConstants.DEPT_CHILDREN_ID_LIST,
  93 + allEntries = true) // allEntries 清空所有缓存,因为操作一个部门,涉及到多个缓存
  94 + public void deleteDeptList(List<Long> ids) {
  95 + // 校验是否有子部门
  96 + for (Long id : ids) {
  97 + if (deptMapper.selectCountByParentId(id) > 0) {
  98 + throw exception(DEPT_EXITS_CHILDREN);
  99 + }
  100 + }
  101 +
  102 + // 批量删除部门
  103 + deptMapper.deleteByIds(ids);
  104 + }
  105 +
91 106 @VisibleForTesting
92 107 void validateDeptExists(Long id) {
93 108 if (id == null) {
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailLogService.java
... ... @@ -6,6 +6,8 @@ import cn.iocoder.yudao.module.system.dal.dataobject.mail.MailAccountDO;
6 6 import cn.iocoder.yudao.module.system.dal.dataobject.mail.MailLogDO;
7 7 import cn.iocoder.yudao.module.system.dal.dataobject.mail.MailTemplateDO;
8 8  
  9 +import java.util.Collection;
  10 +import java.util.List;
9 11 import java.util.Map;
10 12  
11 13 /**
... ... @@ -35,18 +37,21 @@ public interface MailLogService {
35 37 /**
36 38 * 创建邮件日志
37 39 *
38   - * @param userId 用户编码
39   - * @param userType 用户类型
40   - * @param toMail 收件人邮件
41   - * @param account 邮件账号信息
42   - * @param template 模版信息
  40 + * @param userId 用户编码
  41 + * @param userType 用户类型
  42 + * @param toMails 收件人邮件
  43 + * @param ccMails 收件人邮件
  44 + * @param bccMails 收件人邮件
  45 + * @param account 邮件账号信息
  46 + * @param template 模版信息
43 47 * @param templateContent 模版内容
44   - * @param templateParams 模版参数
45   - * @param isSend 是否发送成功
  48 + * @param templateParams 模版参数
  49 + * @param isSend 是否发送成功
46 50 * @return 日志编号
47 51 */
48   - Long createMailLog(Long userId, Integer userType, String toMail,
49   - MailAccountDO account, MailTemplateDO template ,
  52 + Long createMailLog(Long userId, Integer userType,
  53 + Collection<String> toMails, Collection<String> ccMails, Collection<String> bccMails,
  54 + MailAccountDO account, MailTemplateDO template,
50 55 String templateContent, Map<String, Object> templateParams, Boolean isSend);
51 56  
52 57 /**
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailLogServiceImpl.java
1 1 package cn.iocoder.yudao.module.system.service.mail;
2 2  
  3 +import cn.hutool.core.collection.ListUtil;
3 4 import cn.iocoder.yudao.framework.common.pojo.PageResult;
4 5 import cn.iocoder.yudao.module.system.controller.admin.mail.vo.log.MailLogPageReqVO;
5 6 import cn.iocoder.yudao.module.system.dal.dataobject.mail.MailAccountDO;
... ... @@ -12,8 +13,7 @@ import org.springframework.validation.annotation.Validated;
12 13  
13 14 import jakarta.annotation.Resource;
14 15 import java.time.LocalDateTime;
15   -import java.util.Map;
16   -import java.util.Objects;
  16 +import java.util.*;
17 17  
18 18 import static cn.hutool.core.exceptions.ExceptionUtil.getRootCauseMessage;
19 19  
... ... @@ -41,7 +41,8 @@ public class MailLogServiceImpl implements MailLogService {
41 41 }
42 42  
43 43 @Override
44   - public Long createMailLog(Long userId, Integer userType, String toMail,
  44 + public Long createMailLog(Long userId, Integer userType,
  45 + Collection<String> toMails, Collection<String> ccMails, Collection<String> bccMails,
45 46 MailAccountDO account, MailTemplateDO template,
46 47 String templateContent, Map<String, Object> templateParams, Boolean isSend) {
47 48 MailLogDO.MailLogDOBuilder logDOBuilder = MailLogDO.builder();
... ... @@ -49,7 +50,8 @@ public class MailLogServiceImpl implements MailLogService {
49 50 logDOBuilder.sendStatus(Objects.equals(isSend, true) ? MailSendStatusEnum.INIT.getStatus()
50 51 : MailSendStatusEnum.IGNORE.getStatus())
51 52 // 用户信息
52   - .userId(userId).userType(userType).toMail(toMail)
  53 + .userId(userId).userType(userType)
  54 + .toMails(ListUtil.toList(toMails)).ccMails(ListUtil.toList(ccMails)).bccMails(ListUtil.toList(bccMails))
53 55 .accountId(account.getId()).fromMail(account.getMail())
54 56 // 模板相关字段
55 57 .templateId(template.getId()).templateCode(template.getCode()).templateNickname(template.getNickname())
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailSendService.java
1 1 package cn.iocoder.yudao.module.system.service.mail;
2 2  
  3 +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
3 4 import cn.iocoder.yudao.module.system.mq.message.mail.MailSendMessage;
4 5  
  6 +import java.util.Collection;
5 7 import java.util.Map;
6 8  
7 9 /**
... ... @@ -15,38 +17,53 @@ public interface MailSendService {
15 17 /**
16 18 * 发送单条邮件给管理后台的用户
17 19 *
18   - * @param mail 邮箱
19 20 * @param userId 用户编码
  21 + * @param toMails 收件邮箱
  22 + * @param ccMails 抄送邮箱
  23 + * @param bccMails 密送邮箱
20 24 * @param templateCode 邮件模版编码
21 25 * @param templateParams 邮件模版参数
22 26 * @return 发送日志编号
23 27 */
24   - Long sendSingleMailToAdmin(String mail, Long userId,
25   - String templateCode, Map<String, Object> templateParams);
  28 + default Long sendSingleMailToAdmin(Long userId,
  29 + Collection<String> toMails, Collection<String> ccMails, Collection<String> bccMails,
  30 + String templateCode, Map<String, Object> templateParams) {
  31 + return sendSingleMail(toMails, ccMails, bccMails, userId, UserTypeEnum.ADMIN.getValue(),
  32 + templateCode, templateParams);
  33 + }
26 34  
27 35 /**
28 36 * 发送单条邮件给用户 APP 的用户
29 37 *
30   - * @param mail 邮箱
31 38 * @param userId 用户编码
  39 + * @param toMails 收件邮箱
  40 + * @param ccMails 抄送邮箱
  41 + * @param bccMails 密送邮箱
32 42 * @param templateCode 邮件模版编码
33 43 * @param templateParams 邮件模版参数
34 44 * @return 发送日志编号
35 45 */
36   - Long sendSingleMailToMember(String mail, Long userId,
37   - String templateCode, Map<String, Object> templateParams);
  46 + default Long sendSingleMailToMember(Long userId,
  47 + Collection<String> toMails, Collection<String> ccMails, Collection<String> bccMails,
  48 + String templateCode, Map<String, Object> templateParams) {
  49 + return sendSingleMail(toMails, ccMails, bccMails, userId, UserTypeEnum.MEMBER.getValue(),
  50 + templateCode, templateParams);
  51 + }
38 52  
39 53 /**
40   - * 发送单条邮件给用户
  54 + * 发送单条邮件
41 55 *
42   - * @param mail 邮箱
43   - * @param userId 用户编码
  56 + * @param toMails 收件邮箱
  57 + * @param ccMails 抄送邮箱
  58 + * @param bccMails 密送邮箱
  59 + * @param userId 用户编号
44 60 * @param userType 用户类型
45 61 * @param templateCode 邮件模版编码
46 62 * @param templateParams 邮件模版参数
47 63 * @return 发送日志编号
48 64 */
49   - Long sendSingleMail(String mail, Long userId, Integer userType,
  65 + Long sendSingleMail(Collection<String> toMails, Collection<String> ccMails, Collection<String> bccMails,
  66 + Long userId, Integer userType,
50 67 String templateCode, Map<String, Object> templateParams);
51 68  
52 69 /**
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailSendServiceImpl.java
1 1 package cn.iocoder.yudao.module.system.service.mail;
2 2  
  3 +import cn.hutool.core.collection.CollUtil;
  4 +import cn.hutool.core.lang.Validator;
3 5 import cn.hutool.core.util.StrUtil;
4 6 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
5 7 import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
... ... @@ -13,10 +15,13 @@ import cn.iocoder.yudao.module.system.service.user.AdminUserService;
13 15 import com.google.common.annotations.VisibleForTesting;
14 16 import jakarta.annotation.Resource;
15 17 import lombok.extern.slf4j.Slf4j;
16   -import org.dromara.hutool.extra.mail.*;
  18 +import org.dromara.hutool.extra.mail.MailAccount;
  19 +import org.dromara.hutool.extra.mail.MailUtil;
17 20 import org.springframework.stereotype.Service;
18 21 import org.springframework.validation.annotation.Validated;
19 22  
  23 +import java.util.Collection;
  24 +import java.util.LinkedHashSet;
20 25 import java.util.Map;
21 26  
22 27 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
... ... @@ -49,56 +54,67 @@ public class MailSendServiceImpl implements MailSendService {
49 54 private MailProducer mailProducer;
50 55  
51 56 @Override
52   - public Long sendSingleMailToAdmin(String mail, Long userId,
53   - String templateCode, Map<String, Object> templateParams) {
54   - // 如果 mail 为空,则加载用户编号对应的邮箱
55   - if (StrUtil.isEmpty(mail)) {
56   - AdminUserDO user = adminUserService.getUser(userId);
57   - if (user != null) {
58   - mail = user.getEmail();
59   - }
60   - }
61   - // 执行发送
62   - return sendSingleMail(mail, userId, UserTypeEnum.ADMIN.getValue(), templateCode, templateParams);
63   - }
64   -
65   - @Override
66   - public Long sendSingleMailToMember(String mail, Long userId,
67   - String templateCode, Map<String, Object> templateParams) {
68   - // 如果 mail 为空,则加载用户编号对应的邮箱
69   - if (StrUtil.isEmpty(mail)) {
70   - mail = memberService.getMemberUserEmail(userId);
71   - }
72   - // 执行发送
73   - return sendSingleMail(mail, userId, UserTypeEnum.MEMBER.getValue(), templateCode, templateParams);
74   - }
75   -
76   - @Override
77   - public Long sendSingleMail(String mail, Long userId, Integer userType,
  57 + public Long sendSingleMail(Collection<String> toMails, Collection<String> ccMails, Collection<String> bccMails,
  58 + Long userId, Integer userType,
78 59 String templateCode, Map<String, Object> templateParams) {
79   - // 校验邮箱模版是否合法
  60 + // 1.1 校验邮箱模版是否合法
80 61 MailTemplateDO template = validateMailTemplate(templateCode);
81   - // 校验邮箱账号是否合法
  62 + // 1.2 校验邮箱账号是否合法
82 63 MailAccountDO account = validateMailAccount(template.getAccountId());
83   -
84   - // 校验邮箱是否存在
85   - mail = validateMail(mail);
  64 + // 1.3 校验邮件参数是否缺失
86 65 validateTemplateParams(template, templateParams);
87 66  
  67 + // 2. 组装邮箱
  68 + String userMail = getUserMail(userId, userType);
  69 + Collection<String> toMailSet = new LinkedHashSet<>();
  70 + Collection<String> ccMailSet = new LinkedHashSet<>();
  71 + Collection<String> bccMailSet = new LinkedHashSet<>();
  72 + if (Validator.isEmail(userMail)) {
  73 + toMailSet.add(userMail);
  74 + }
  75 + if (CollUtil.isNotEmpty(toMails)) {
  76 + toMails.stream().filter(Validator::isEmail).forEach(toMailSet::add);
  77 + }
  78 + if (CollUtil.isNotEmpty(ccMails)) {
  79 + ccMails.stream().filter(Validator::isEmail).forEach(ccMailSet::add);
  80 + }
  81 + if (CollUtil.isNotEmpty(bccMails)) {
  82 + bccMails.stream().filter(Validator::isEmail).forEach(bccMailSet::add);
  83 + }
  84 + if (CollUtil.isEmpty(toMailSet)) {
  85 + throw exception(MAIL_SEND_MAIL_NOT_EXISTS);
  86 + }
  87 +
88 88 // 创建发送日志。如果模板被禁用,则不发送短信,只记录日志
89 89 Boolean isSend = CommonStatusEnum.ENABLE.getStatus().equals(template.getStatus());
90 90 String title = mailTemplateService.formatMailTemplateContent(template.getTitle(), templateParams);
91 91 String content = mailTemplateService.formatMailTemplateContent(template.getContent(), templateParams);
92   - Long sendLogId = mailLogService.createMailLog(userId, userType, mail,
  92 + Long sendLogId = mailLogService.createMailLog(userId, userType, toMailSet, ccMailSet, bccMailSet,
93 93 account, template, content, templateParams, isSend);
94 94 // 发送 MQ 消息,异步执行发送短信
95 95 if (isSend) {
96   - mailProducer.sendMailSendMessage(sendLogId, mail, account.getId(),
97   - template.getNickname(), title, content);
  96 + mailProducer.sendMailSendMessage(sendLogId, toMailSet, ccMailSet, bccMailSet,
  97 + account.getId(), template.getNickname(), title, content);
98 98 }
99 99 return sendLogId;
100 100 }
101 101  
  102 + private String getUserMail(Long userId, Integer userType) {
  103 + if (userId == null || userType == null) {
  104 + return null;
  105 + }
  106 + if (UserTypeEnum.ADMIN.getValue().equals(userType)) {
  107 + AdminUserDO user = adminUserService.getUser(userId);
  108 + if (user != null) {
  109 + return user.getEmail();
  110 + }
  111 + }
  112 + if (UserTypeEnum.MEMBER.getValue().equals(userType)) {
  113 + return memberService.getMemberUserEmail(userId);
  114 + }
  115 + return null;
  116 + }
  117 +
102 118 @Override
103 119 public void doSendMail(MailSendMessage message) {
104 120 // 1. 创建发送账号
... ... @@ -106,7 +122,7 @@ public class MailSendServiceImpl implements MailSendService {
106 122 MailAccount mailAccount = buildMailAccount(account, message.getNickname());
107 123 // 2. 发送邮件
108 124 try {
109   - String messageId = MailUtil.send(mailAccount, message.getMail(),
  125 + String messageId = MailUtil.send(mailAccount, message.getToMails(), message.getCcMails(), message.getBccMails(),
110 126 message.getTitle(), message.getContent(), true);
111 127 // 3. 更新结果(成功)
112 128 mailLogService.updateMailSendResult(message.getLogId(), messageId, null);
... ... @@ -146,16 +162,8 @@ public class MailSendServiceImpl implements MailSendService {
146 162 return account;
147 163 }
148 164  
149   - @VisibleForTesting
150   - String validateMail(String mail) {
151   - if (StrUtil.isEmpty(mail)) {
152   - throw exception(MAIL_SEND_MAIL_NOT_EXISTS);
153   - }
154   - return mail;
155   - }
156   -
157 165 /**
158   - * 校验邮件参数是否确实
  166 + * 校验邮件参数是否缺失
159 167 *
160 168 * @param template 邮箱模板
161 169 * @param templateParams 参数列表
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/permission/MenuServiceImpl.java
... ... @@ -255,9 +255,6 @@ public class MenuServiceImpl implements MenuService {
255 255 return;
256 256 }
257 257 // 如果 id 为空,说明不用比较是否为相同 id 的菜单
258   - if (id == null) {
259   - throw exception(MENU_NAME_DUPLICATE);
260   - }
261 258 if (!menu.getId().equals(id)) {
262 259 throw exception(MENU_NAME_DUPLICATE);
263 260 }
... ...
yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImplTest.java
... ... @@ -105,12 +105,40 @@ public class DeptServiceImplTest extends BaseDbUnitTest {
105 105 }
106 106  
107 107 @Test
108   - public void testValidateDeptExists_notFound() {
  108 + public void testDeleteDeptList_success() {
  109 + // mock 数据
  110 + DeptDO deptDO1 = randomPojo(DeptDO.class);
  111 + deptMapper.insert(deptDO1);
  112 + DeptDO deptDO2 = randomPojo(DeptDO.class);
  113 + deptMapper.insert(deptDO2);
109 114 // 准备参数
110   - Long id = randomLongId();
  115 + List<Long> ids = Arrays.asList(deptDO1.getId(), deptDO2.getId());
  116 +
  117 + // 调用
  118 + deptService.deleteDeptList(ids);
  119 + // 校验数据不存在了
  120 + assertNull(deptMapper.selectById(deptDO1.getId()));
  121 + assertNull(deptMapper.selectById(deptDO2.getId()));
  122 + }
  123 +
  124 + @Test
  125 + public void testDeleteDeptList_exitsChildren() {
  126 + // mock 数据
  127 + DeptDO parentDept = randomPojo(DeptDO.class);
  128 + deptMapper.insert(parentDept);
  129 + DeptDO childrenDeptDO = randomPojo(DeptDO.class, o -> {
  130 + o.setParentId(parentDept.getId());
  131 + o.setStatus(randomCommonStatus());
  132 + });
  133 + deptMapper.insert(childrenDeptDO);
  134 + DeptDO anotherDept = randomPojo(DeptDO.class);
  135 + deptMapper.insert(anotherDept);
  136 +
  137 + // 准备参数(包含有子部门的 parentDept)
  138 + List<Long> ids = Arrays.asList(parentDept.getId(), anotherDept.getId());
111 139  
112 140 // 调用, 并断言异常
113   - assertServiceException(() -> deptService.validateDeptExists(id), DEPT_NOT_FOUND);
  141 + assertServiceException(() -> deptService.deleteDeptList(ids), DEPT_EXITS_CHILDREN);
114 142 }
115 143  
116 144 @Test
... ...
yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/mail/MailLogServiceImplTest.java
... ... @@ -10,10 +10,12 @@ import cn.iocoder.yudao.module.system.dal.dataobject.mail.MailLogDO;
10 10 import cn.iocoder.yudao.module.system.dal.dataobject.mail.MailTemplateDO;
11 11 import cn.iocoder.yudao.module.system.dal.mysql.mail.MailLogMapper;
12 12 import cn.iocoder.yudao.module.system.enums.mail.MailSendStatusEnum;
  13 +import org.assertj.core.util.Lists;
13 14 import org.junit.jupiter.api.Test;
14 15 import org.springframework.context.annotation.Import;
15 16  
16 17 import jakarta.annotation.Resource;
  18 +import java.util.Collection;
17 19 import java.util.Map;
18 20  
19 21 import static cn.hutool.core.util.RandomUtil.randomEle;
... ... @@ -43,7 +45,9 @@ public class MailLogServiceImplTest extends BaseDbUnitTest {
43 45 // 准备参数
44 46 Long userId = randomLongId();
45 47 Integer userType = randomEle(UserTypeEnum.values()).getValue();
46   - String toMail = randomEmail();
  48 + Collection<String> toMails = Lists.newArrayList(randomEmail(), randomEmail());
  49 + Collection<String> ccMails = Lists.newArrayList(randomEmail());
  50 + Collection<String> bccMails = Lists.newArrayList(randomEmail());
47 51 MailAccountDO account = randomPojo(MailAccountDO.class);
48 52 MailTemplateDO template = randomPojo(MailTemplateDO.class);
49 53 String templateContent = randomString();
... ... @@ -52,14 +56,20 @@ public class MailLogServiceImplTest extends BaseDbUnitTest {
52 56 // mock 方法
53 57  
54 58 // 调用
55   - Long logId = mailLogService.createMailLog(userId, userType, toMail, account, template, templateContent, templateParams, isSend);
  59 + Long logId = mailLogService.createMailLog(userId, userType, toMails, ccMails, bccMails,
  60 + account, template, templateContent, templateParams, isSend);
56 61 // 断言
57 62 MailLogDO log = mailLogMapper.selectById(logId);
58 63 assertNotNull(log);
59 64 assertEquals(MailSendStatusEnum.INIT.getStatus(), log.getSendStatus());
60 65 assertEquals(userId, log.getUserId());
61 66 assertEquals(userType, log.getUserType());
62   - assertEquals(toMail, log.getToMail());
  67 + assertEquals(toMails.size(), log.getToMails().size());
  68 + assertTrue(log.getToMails().containsAll(toMails));
  69 + assertEquals(ccMails.size(), log.getCcMails().size());
  70 + assertTrue(log.getCcMails().containsAll(ccMails));
  71 + assertEquals(bccMails.size(), log.getBccMails().size());
  72 + assertTrue(log.getBccMails().containsAll(bccMails));
63 73 assertEquals(account.getId(), log.getAccountId());
64 74 assertEquals(account.getMail(), log.getFromMail());
65 75 assertEquals(template.getId(), log.getTemplateId());
... ... @@ -136,7 +146,9 @@ public class MailLogServiceImplTest extends BaseDbUnitTest {
136 146 MailLogDO dbMailLog = randomPojo(MailLogDO.class, o -> { // 等会查询到
137 147 o.setUserId(1L);
138 148 o.setUserType(UserTypeEnum.ADMIN.getValue());
139   - o.setToMail("768@qq.com");
  149 + o.setToMails(Lists.newArrayList("768@qq.com"));
  150 + o.setCcMails(Lists.newArrayList());
  151 + o.setBccMails(Lists.newArrayList());
140 152 o.setAccountId(10L);
141 153 o.setTemplateId(100L);
142 154 o.setSendStatus(MailSendStatusEnum.INIT.getStatus());
... ... @@ -148,8 +160,8 @@ public class MailLogServiceImplTest extends BaseDbUnitTest {
148 160 mailLogMapper.insert(cloneIgnoreId(dbMailLog, o -> o.setUserId(2L)));
149 161 // 测试 userType 不匹配
150 162 mailLogMapper.insert(cloneIgnoreId(dbMailLog, o -> o.setUserType(UserTypeEnum.MEMBER.getValue())));
151   - // 测试 toMail 不匹配
152   - mailLogMapper.insert(cloneIgnoreId(dbMailLog, o -> o.setToMail("788@.qq.com")));
  163 + // 测试 toMails 不匹配(特殊:find_in_set 无法单测)
  164 +// mailLogMapper.insert(cloneIgnoreId(dbMailLog, o -> o.setToMails(Lists.newArrayList("788@qq.com"))));
153 165 // 测试 accountId 不匹配
154 166 mailLogMapper.insert(cloneIgnoreId(dbMailLog, o -> o.setAccountId(11L)));
155 167 // 测试 templateId 不匹配
... ... @@ -162,7 +174,7 @@ public class MailLogServiceImplTest extends BaseDbUnitTest {
162 174 MailLogPageReqVO reqVO = new MailLogPageReqVO();
163 175 reqVO.setUserId(1L);
164 176 reqVO.setUserType(UserTypeEnum.ADMIN.getValue());
165   - reqVO.setToMail("768");
  177 +// reqVO.setToMail("768@qq.com");
166 178 reqVO.setAccountId(10L);
167 179 reqVO.setTemplateId(100L);
168 180 reqVO.setSendStatus(MailSendStatusEnum.INIT.getStatus());
... ...
yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/mail/MailSendServiceImplTest.java
... ... @@ -20,6 +20,7 @@ import org.mockito.InjectMocks;
20 20 import org.mockito.Mock;
21 21 import org.mockito.MockedStatic;
22 22  
  23 +import java.util.Collection;
23 24 import java.util.HashMap;
24 25 import java.util.Map;
25 26  
... ... @@ -65,14 +66,18 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest {
65 66 }
66 67  
67 68 @Test
68   - public void testSendSingleMailToAdmin() {
  69 + public void testSendSingleMail_success() {
69 70 // 准备参数
70 71 Long userId = randomLongId();
71 72 String templateCode = RandomUtils.randomString();
72 73 Map<String, Object> templateParams = MapUtil.<String, Object>builder().put("code", "1234")
73 74 .put("op", "login").build();
  75 + Collection<String> toMails = Lists.newArrayList("admin@test.com");
  76 + Collection<String> ccMails = Lists.newArrayList("cc@test.com");
  77 + Collection<String> bccMails = Lists.newArrayList("bcc@test.com");
  78 +
74 79 // mock adminUserService 的方法
75   - AdminUserDO user = randomPojo(AdminUserDO.class, o -> o.setMobile("15601691300"));
  80 + AdminUserDO user = randomPojo(AdminUserDO.class, o -> o.setEmail("admin@example.com"));
76 81 when(adminUserService.getUser(eq(userId))).thenReturn(user);
77 82  
78 83 // mock MailTemplateService 的方法
... ... @@ -93,61 +98,27 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest {
93 98 when(mailAccountService.getMailAccountFromCache(eq(template.getAccountId()))).thenReturn(account);
94 99 // mock MailLogService 的方法
95 100 Long mailLogId = randomLongId();
96   - when(mailLogService.createMailLog(eq(userId), eq(UserTypeEnum.ADMIN.getValue()), eq(user.getEmail()),
  101 + when(mailLogService.createMailLog(eq(userId), eq(UserTypeEnum.ADMIN.getValue()),
  102 + argThat(toMailSet -> toMailSet.contains(user.getEmail()) && toMailSet.contains("admin@test.com")),
  103 + argThat(ccMailSet -> ccMailSet.contains("cc@test.com")),
  104 + argThat(bccMailSet -> bccMailSet.contains("bcc@test.com")),
97 105 eq(account), eq(template), eq(content), eq(templateParams), eq(true))).thenReturn(mailLogId);
98 106  
99 107 // 调用
100   - Long resultMailLogId = mailSendService.sendSingleMailToAdmin(null, userId, templateCode, templateParams);
  108 + Long resultMailLogId = mailSendService.sendSingleMail(toMails, ccMails, bccMails, userId,
  109 + UserTypeEnum.ADMIN.getValue(), templateCode, templateParams);
101 110 // 断言
102 111 assertEquals(mailLogId, resultMailLogId);
103 112 // 断言调用
104   - verify(mailProducer).sendMailSendMessage(eq(mailLogId), eq(user.getEmail()),
105   - eq(account.getId()), eq(template.getNickname()), eq(title), eq(content));
106   - }
107   -
108   - @Test
109   - public void testSendSingleMailToMember() {
110   - // 准备参数
111   - Long userId = randomLongId();
112   - String templateCode = RandomUtils.randomString();
113   - Map<String, Object> templateParams = MapUtil.<String, Object>builder().put("code", "1234")
114   - .put("op", "login").build();
115   - // mock memberService 的方法
116   - String mail = randomEmail();
117   - when(memberService.getMemberUserEmail(eq(userId))).thenReturn(mail);
118   -
119   - // mock MailTemplateService 的方法
120   - MailTemplateDO template = randomPojo(MailTemplateDO.class, o -> {
121   - o.setStatus(CommonStatusEnum.ENABLE.getStatus());
122   - o.setContent("验证码为{code}, 操作为{op}");
123   - o.setParams(Lists.newArrayList("code", "op"));
124   - });
125   - when(mailTemplateService.getMailTemplateByCodeFromCache(eq(templateCode))).thenReturn(template);
126   - String title = RandomUtils.randomString();
127   - when(mailTemplateService.formatMailTemplateContent(eq(template.getTitle()), eq(templateParams)))
128   - .thenReturn(title);
129   - String content = RandomUtils.randomString();
130   - when(mailTemplateService.formatMailTemplateContent(eq(template.getContent()), eq(templateParams)))
131   - .thenReturn(content);
132   - // mock MailAccountService 的方法
133   - MailAccountDO account = randomPojo(MailAccountDO.class);
134   - when(mailAccountService.getMailAccountFromCache(eq(template.getAccountId()))).thenReturn(account);
135   - // mock MailLogService 的方法
136   - Long mailLogId = randomLongId();
137   - when(mailLogService.createMailLog(eq(userId), eq(UserTypeEnum.MEMBER.getValue()), eq(mail),
138   - eq(account), eq(template), eq(content), eq(templateParams), eq(true))).thenReturn(mailLogId);
139   -
140   - // 调用
141   - Long resultMailLogId = mailSendService.sendSingleMailToMember(null, userId, templateCode, templateParams);
142   - // 断言
143   - assertEquals(mailLogId, resultMailLogId);
144   - // 断言调用
145   - verify(mailProducer).sendMailSendMessage(eq(mailLogId), eq(mail),
  113 + verify(mailProducer).sendMailSendMessage(eq(mailLogId),
  114 + argThat(toMailSet -> toMailSet.contains(user.getEmail()) && toMailSet.contains("admin@test.com")),
  115 + argThat(ccMailSet -> ccMailSet.contains("cc@test.com")),
  116 + argThat(bccMailSet -> bccMailSet.contains("bcc@test.com")),
146 117 eq(account.getId()), eq(template.getNickname()), eq(title), eq(content));
147 118 }
148 119  
149 120 /**
150   - * 发送成功,当短信模板开启时
  121 + * 发送成功,当邮件模板开启时
151 122 */
152 123 @Test
153 124 public void testSendSingleMail_successWhenMailTemplateEnable() {
... ... @@ -158,6 +129,8 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest {
158 129 String templateCode = RandomUtils.randomString();
159 130 Map<String, Object> templateParams = MapUtil.<String, Object>builder().put("code", "1234")
160 131 .put("op", "login").build();
  132 + Collection<String> toMails = Lists.newArrayList(mail);
  133 +
161 134 // mock MailTemplateService 的方法
162 135 MailTemplateDO template = randomPojo(MailTemplateDO.class, o -> {
163 136 o.setStatus(CommonStatusEnum.ENABLE.getStatus());
... ... @@ -176,23 +149,29 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest {
176 149 when(mailAccountService.getMailAccountFromCache(eq(template.getAccountId()))).thenReturn(account);
177 150 // mock MailLogService 的方法
178 151 Long mailLogId = randomLongId();
179   - when(mailLogService.createMailLog(eq(userId), eq(userType), eq(mail),
  152 + when(mailLogService.createMailLog(eq(userId), eq(userType),
  153 + argThat(toMailSet -> toMailSet.contains(mail)),
  154 + argThat(Collection::isEmpty),
  155 + argThat(Collection::isEmpty),
180 156 eq(account), eq(template), eq(content), eq(templateParams), eq(true))).thenReturn(mailLogId);
181 157  
182 158 // 调用
183   - Long resultMailLogId = mailSendService.sendSingleMail(mail, userId, userType, templateCode, templateParams);
  159 + Long resultMailLogId = mailSendService.sendSingleMail(toMails, null, null, userId, userType, templateCode, templateParams);
184 160 // 断言
185 161 assertEquals(mailLogId, resultMailLogId);
186 162 // 断言调用
187   - verify(mailProducer).sendMailSendMessage(eq(mailLogId), eq(mail),
  163 + verify(mailProducer).sendMailSendMessage(eq(mailLogId),
  164 + argThat(toMailSet -> toMailSet.contains(mail)),
  165 + argThat(Collection::isEmpty),
  166 + argThat(Collection::isEmpty),
188 167 eq(account.getId()), eq(template.getNickname()), eq(title), eq(content));
189 168 }
190 169  
191 170 /**
192   - * 发送成功,当短信模板关闭时
  171 + * 发送成功,当邮件模板关闭时
193 172 */
194 173 @Test
195   - public void testSendSingleMail_successWhenSmsTemplateDisable() {
  174 + public void testSendSingleMail_successWhenMailTemplateDisable() {
196 175 // 准备参数
197 176 String mail = randomEmail();
198 177 Long userId = randomLongId();
... ... @@ -200,6 +179,8 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest {
200 179 String templateCode = RandomUtils.randomString();
201 180 Map<String, Object> templateParams = MapUtil.<String, Object>builder().put("code", "1234")
202 181 .put("op", "login").build();
  182 + Collection<String> toMails = Lists.newArrayList(mail);
  183 +
203 184 // mock MailTemplateService 的方法
204 185 MailTemplateDO template = randomPojo(MailTemplateDO.class, o -> {
205 186 o.setStatus(CommonStatusEnum.DISABLE.getStatus());
... ... @@ -218,15 +199,18 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest {
218 199 when(mailAccountService.getMailAccountFromCache(eq(template.getAccountId()))).thenReturn(account);
219 200 // mock MailLogService 的方法
220 201 Long mailLogId = randomLongId();
221   - when(mailLogService.createMailLog(eq(userId), eq(userType), eq(mail),
  202 + when(mailLogService.createMailLog(eq(userId), eq(userType),
  203 + argThat(toMailSet -> toMailSet.contains(mail)),
  204 + argThat(Collection::isEmpty),
  205 + argThat(Collection::isEmpty),
222 206 eq(account), eq(template), eq(content), eq(templateParams), eq(false))).thenReturn(mailLogId);
223 207  
224 208 // 调用
225   - Long resultMailLogId = mailSendService.sendSingleMail(mail, userId, userType, templateCode, templateParams);
  209 + Long resultMailLogId = mailSendService.sendSingleMail(toMails, null, null, userId, userType, templateCode, templateParams);
226 210 // 断言
227 211 assertEquals(mailLogId, resultMailLogId);
228 212 // 断言调用
229   - verify(mailProducer, times(0)).sendMailSendMessage(anyLong(), anyString(),
  213 + verify(mailProducer, times(0)).sendMailSendMessage(anyLong(), any(), any(), any(),
230 214 anyLong(), anyString(), anyString(), anyString());
231 215 }
232 216  
... ... @@ -255,12 +239,29 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest {
255 239 }
256 240  
257 241 @Test
258   - public void testValidateMail_notExists() {
  242 + public void testSendSingleMail_noValidEmail() {
259 243 // 准备参数
260   - // mock 方法
  244 + Long userId = randomLongId();
  245 + String templateCode = RandomUtils.randomString();
  246 + Map<String, Object> templateParams = MapUtil.<String, Object>builder().put("code", "1234")
  247 + .put("op", "login").build();
  248 + Collection<String> toMails = Lists.newArrayList("invalid-email"); // 非法邮箱
  249 +
  250 + // mock MailTemplateService 的方法
  251 + MailTemplateDO template = randomPojo(MailTemplateDO.class, o -> {
  252 + o.setStatus(CommonStatusEnum.ENABLE.getStatus());
  253 + o.setContent("验证码为{code}, 操作为{op}");
  254 + o.setParams(Lists.newArrayList("code", "op"));
  255 + });
  256 + when(mailTemplateService.getMailTemplateByCodeFromCache(eq(templateCode))).thenReturn(template);
  257 +
  258 + // mock MailAccountService 的方法
  259 + MailAccountDO account = randomPojo(MailAccountDO.class);
  260 + when(mailAccountService.getMailAccountFromCache(eq(template.getAccountId()))).thenReturn(account);
261 261  
262 262 // 调用,并断言异常
263   - assertServiceException(() -> mailSendService.validateMail(null),
  263 + assertServiceException(() -> mailSendService.sendSingleMail(toMails, null, null, userId,
  264 + UserTypeEnum.ADMIN.getValue(), templateCode, templateParams),
264 265 MAIL_SEND_MAIL_NOT_EXISTS);
265 266 }
266 267  
... ... @@ -286,7 +287,8 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest {
286 287 assertEquals(account.getPort(), mailAccount.getPort());
287 288 assertEquals(account.getSslEnable(), mailAccount.isSslEnable());
288 289 return true;
289   - }), eq(message.getMail()), eq(message.getTitle()), eq(message.getContent()), eq(true)))
  290 + }), eq(message.getToMails()), eq(message.getCcMails()), eq(message.getBccMails()),
  291 + eq(message.getTitle()), eq(message.getContent()), eq(true)))
290 292 .thenReturn(messageId);
291 293  
292 294 // 调用
... ... @@ -317,7 +319,8 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest {
317 319 assertEquals(account.getPort(), mailAccount.getPort());
318 320 assertEquals(account.getSslEnable(), mailAccount.isSslEnable());
319 321 return true;
320   - }), eq(message.getMail()), eq(message.getTitle()), eq(message.getContent()), eq(true))).thenThrow(e);
  322 + }), eq(message.getToMails()), eq(message.getCcMails()), eq(message.getBccMails()),
  323 + eq(message.getTitle()), eq(message.getContent()), eq(true))).thenThrow(e);
321 324  
322 325 // 调用
323 326 mailSendService.doSendMail(message);
... ...
yudao-module-system/src/test/resources/sql/create_tables.sql
... ... @@ -553,7 +553,9 @@ CREATE TABLE IF NOT EXISTS &quot;system_mail_log&quot; (
553 553 "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
554 554 "user_id" bigint,
555 555 "user_type" varchar,
556   - "to_mail" varchar NOT NULL,
  556 + "to_mails" varchar NOT NULL,
  557 + "cc_mails" varchar,
  558 + "bcc_mails" varchar,
557 559 "account_id" bigint NOT NULL,
558 560 "from_mail" varchar NOT NULL,
559 561 "template_id" bigint NOT NULL,
... ...
yudao-server/src/main/resources/application.yaml
... ... @@ -245,6 +245,13 @@ yudao:
245 245 security:
246 246 permit-all_urls:
247 247 - /admin-api/mp/open/** # 微信公众号开放平台,微信回调接口,不需要登录
  248 + api-encrypt:
  249 + enable: true # 是否开启 API 加密
  250 + algorithm: AES # 加密算法,支持 AES、RSA 等
  251 + request-key: 52549111389893486934626385991395 # 【AES 案例】请求加密的秘钥,,必须 16、24、32 位
  252 + response-key: 96103715984234343991809655248883 # 【AES 案例】响应加密的秘钥,AES 案例,必须 16、24、32 位
  253 +# request-key: MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAKWzasimcZ1icsWDPVdTXcZs1DkOWjI+m9bTQU8aOqflnomkr6QO1WWeSHBHzuJGsTlV/ZY2pFfq/NstKC94hBjx7yioYJvzb2bKN/Uy4j5nM3iCF//u0RiFkkY8j0Bt/EWoFTOb6RHf8cHIAjbYYtP3pYzbpCIwryfe0g//KIuzAgMBAAECgYADDjZrYcpZjR2xr7RbXmGtzYbyUGXwZEAqa3XaWBD51J2iSyOkAlQEDjGmxGQ3vvb4qDHHadWI+3/TKNeDXJUO+xTVJrnismK5BsHyC6dfxlIK/5BAuknryTca/3UoA1yomS9ZlF3Q0wcecaDoEnSmZEaTrp9T3itPAz4KnGjv5QJBAN5mNcfu6iJ5ktNvEdzqcxkKwbXb9Nq1SLnmTvt+d5TPX7eQ9fCwtOfVu5iBLhhZzb5PJ7pSN3Zt6rl5/jPOGv0CQQC+vETX9oe1wbxZSv6/RBGy0Xow6GndbJwvd89PcAJ2h+OJXWtg/rRHB3t9EQm7iis0XbZTapj19E4U6l8EibhvAkEA1CvYpRwmHKu1SqdM+GBnW/2qHlBwwXJvpoK02TOm674HR/4w0+YRQJfkd7LOAgcyxJuJgDTNmtt0MmzS+iNoFQJAMVSUIZ77XoDq69U/qcw7H5qaFcgmiUQr6QL9tTftCyb+LGri+MUnby96OtCLSdvkbLjIDS8GvKYhA7vSM2RDNQJBAKGyVVnFFIrbK3yuwW71yvxQEGoGxlgvZSezZ4vGgqTxrr9HvAtvWLwR6rpe6ybR/x8uUtoW7NRBWgpiIFwjvY4= # 【RSA 案例】请求解密的私钥
  254 +# response-key: MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDh/CHyBcS/zEfVyINVA7+c9Xxl0CPdxPMK1OIjxaLy/7BLfbkoEpI8onQtjuzfpuxCraDem9bu3BMF0pMH95HytI3Vi0kGjaV+WLIalwgc2w37oA2sbsmKzQOP7SDLO5s2QJNAD7kVwd+Q5rqaLu2MO0xVv+0IUJhn83hClC0L5wIDAQAB # 【RSA 案例】响应加密的公钥
248 255 websocket:
249 256 enable: true # websocket的开关
250 257 path: /infra/ws # 路径
... ...