# Conflicts:
#	yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/filter/CacheRequestBodyWrapper.java
#	yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/mail/dto/MailSendSingleToUserReqDTO.java
#	yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/mail/vo/template/MailTemplateSendReqVO.java
#	yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailSendServiceImpl.java
#	yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/mail/MailLogServiceImplTest.java
This commit is contained in:
YunaiV 2025-08-17 15:00:52 +08:00
commit 93d0bf5ead
61 changed files with 1132 additions and 210 deletions

View File

@ -1259,14 +1259,16 @@ CREATE TABLE `system_mail_log` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
`user_id` bigint NULL DEFAULT NULL COMMENT '用户编号',
`user_type` tinyint NULL DEFAULT NULL COMMENT '用户类型',
`to_mail` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '接收邮箱地址',
`to_mails` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '接收邮箱地址',
`cc_mails` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '抄送邮箱地址',
`bcc_mails` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '密送邮箱地址',
`account_id` bigint NOT NULL COMMENT '邮箱账号编号',
`from_mail` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '发送邮箱地址',
`template_id` bigint NOT NULL COMMENT '模板编号',
`template_code` varchar(63) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '模板编码',
`template_nickname` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '模版发送人名称',
`template_title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '邮件标题',
`template_content` varchar(10240) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '邮件内容',
`template_content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '邮件内容',
`template_params` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '邮件参数',
`send_status` tinyint NOT NULL DEFAULT 0 COMMENT '发送状态',
`send_time` datetime NULL DEFAULT NULL COMMENT '发送时间',

View File

@ -15,6 +15,8 @@ public interface WebFilterOrderEnum {
int REQUEST_BODY_CACHE_FILTER = Integer.MIN_VALUE + 500;
int API_ENCRYPT_FILTER = REQUEST_BODY_CACHE_FILTER + 1;
// OrderedRequestContextFilter 默认为 -105用于国际化上下文等等
int TENANT_CONTEXT_FILTER = - 104; // 需要保证在 ApiAccessLogFilter 前面

View File

@ -0,0 +1,70 @@
package cn.iocoder.yudao.framework.encrypt.config;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
/**
* HTTP API 加解密配置
*
* @author 芋道源码
*/
@ConfigurationProperties(prefix = "yudao.api-encrypt")
@Validated
@Data
public class ApiEncryptProperties {
/**
* 是否开启
*/
@NotNull(message = "是否开启不能为空")
private Boolean enable;
/**
* 请求头响应头名称
*
* 1. 如果该请求头非空则表示请求参数已被前端加密后端需要解密
* 2. 如果该响应头非空则表示响应结果已被后端加密前端需要解密
*/
@NotEmpty(message = "请求头(响应头)名称不能为空")
private String header = "X-Api-Encrypt";
/**
* 对称加密算法用于请求/响应的加解密
*
* 目前支持
* 对称加密
* 1. {@link cn.hutool.crypto.symmetric.SymmetricAlgorithm#AES}
* 2. {@link cn.hutool.crypto.symmetric.SM4#ALGORITHM_NAME} 需要自己二次开发成本低
* 非对称加密
* 1. {@link cn.hutool.crypto.asymmetric.AsymmetricAlgorithm#RSA}
* 2. {@link cn.hutool.crypto.asymmetric.SM2} 需要自己二次开发成本低
*
* @see <a href="https://help.aliyun.com/zh/ssl-certificate/what-are-a-public-key-and-a-private-key">什么是公钥和私钥</a>
*/
@NotEmpty(message = "对称加密算法不能为空")
private String algorithm;
/**
* 请求的解密密钥
*
* 注意
* 1. 如果是对称加密后端对应的是密钥对应的前端也对应的也是密钥
* 2. 如果是非对称加密后端对应的是私钥对应的前端对应的是公钥重要
*/
@NotEmpty(message = "请求的解密密钥不能为空")
private String requestKey;
/**
* 响应的加密密钥
*
* 注意
* 1. 如果是对称加密后端对应的是密钥对应的前端也对应的也是密钥
* 2. 如果是非对称加密后端对应的是公钥对应的前端对应的是私钥重要
*/
@NotEmpty(message = "响应的加密密钥不能为空")
private String responseKey;
}

View File

@ -0,0 +1,34 @@
package cn.iocoder.yudao.framework.encrypt.config;
import cn.iocoder.yudao.framework.common.enums.WebFilterOrderEnum;
import cn.iocoder.yudao.framework.encrypt.core.filter.ApiEncryptFilter;
import cn.iocoder.yudao.framework.web.config.WebProperties;
import cn.iocoder.yudao.framework.web.core.handler.GlobalExceptionHandler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import static cn.iocoder.yudao.framework.web.config.YudaoWebAutoConfiguration.createFilterBean;
@AutoConfiguration
@Slf4j
@EnableConfigurationProperties(ApiEncryptProperties.class)
@ConditionalOnProperty(prefix = "yudao.api-encrypt", name = "enable", havingValue = "true")
public class YudaoApiEncryptAutoConfiguration {
@Bean
public FilterRegistrationBean<ApiEncryptFilter> apiEncryptFilter(WebProperties webProperties,
ApiEncryptProperties apiEncryptProperties,
RequestMappingHandlerMapping requestMappingHandlerMapping,
GlobalExceptionHandler globalExceptionHandler) {
ApiEncryptFilter filter = new ApiEncryptFilter(webProperties, apiEncryptProperties,
requestMappingHandlerMapping, globalExceptionHandler);
return createFilterBean(filter, WebFilterOrderEnum.API_ENCRYPT_FILTER);
}
}

View File

@ -0,0 +1,23 @@
package cn.iocoder.yudao.framework.encrypt.core.annotation;
import java.lang.annotation.*;
/**
* HTTP API 加解密注解
*/
@Documented
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiEncrypt {
/**
* 是否对请求参数进行解密默认 true
*/
boolean request() default true;
/**
* 是否对响应结果进行加密默认 true
*/
boolean response() default true;
}

View File

@ -0,0 +1,86 @@
package cn.iocoder.yudao.framework.encrypt.core.filter;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.asymmetric.AsymmetricDecryptor;
import cn.hutool.crypto.asymmetric.KeyType;
import cn.hutool.crypto.symmetric.SymmetricDecryptor;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
/**
* 解密请求 {@link HttpServletRequestWrapper} 实现类
*
* @author 芋道源码
*/
public class ApiDecryptRequestWrapper extends HttpServletRequestWrapper {
private final byte[] body;
public ApiDecryptRequestWrapper(HttpServletRequest request,
SymmetricDecryptor symmetricDecryptor,
AsymmetricDecryptor asymmetricDecryptor) throws IOException {
super(request);
// 读取 body允许 HEXBASE64 传输
String requestBody = StrUtil.utf8Str(
IoUtil.readBytes(request.getInputStream(), false));
// 解密 body
body = symmetricDecryptor != null ? symmetricDecryptor.decrypt(requestBody)
: asymmetricDecryptor.decrypt(requestBody, KeyType.PrivateKey);
}
@Override
public BufferedReader getReader() {
return new BufferedReader(new InputStreamReader(this.getInputStream()));
}
@Override
public int getContentLength() {
return body.length;
}
@Override
public long getContentLengthLong() {
return body.length;
}
@Override
public ServletInputStream getInputStream() {
ByteArrayInputStream stream = new ByteArrayInputStream(body);
return new ServletInputStream() {
@Override
public int read() {
return stream.read();
}
@Override
public int available() {
return body.length;
}
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener readListener) {
}
};
}
}

View File

@ -0,0 +1,152 @@
package cn.iocoder.yudao.framework.encrypt.core.filter;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.crypto.asymmetric.AsymmetricDecryptor;
import cn.hutool.crypto.asymmetric.AsymmetricEncryptor;
import cn.hutool.crypto.symmetric.SymmetricDecryptor;
import cn.hutool.crypto.symmetric.SymmetricEncryptor;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils;
import cn.iocoder.yudao.framework.encrypt.config.ApiEncryptProperties;
import cn.iocoder.yudao.framework.encrypt.core.annotation.ApiEncrypt;
import cn.iocoder.yudao.framework.web.config.WebProperties;
import cn.iocoder.yudao.framework.web.core.filter.ApiRequestFilter;
import cn.iocoder.yudao.framework.web.core.handler.GlobalExceptionHandler;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpMethod;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerExecutionChain;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import java.io.IOException;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
/**
* API 加密过滤器处理 {@link ApiEncrypt} 注解
*
* 1. 解密请求参数
* 2. 加密响应结果
*
* 疑问为什么不使用 SpringMVC RequestBodyAdvice ResponseBodyAdvice 机制呢
* 回答考虑到项目中会记录访问日志异常日志以及 HTTP API 签名等场景最好是全局级且提前做解析
*
* @author 芋道源码
*/
@Slf4j
public class ApiEncryptFilter extends ApiRequestFilter {
private final ApiEncryptProperties apiEncryptProperties;
private final RequestMappingHandlerMapping requestMappingHandlerMapping;
private final GlobalExceptionHandler globalExceptionHandler;
private final SymmetricDecryptor requestSymmetricDecryptor;
private final AsymmetricDecryptor requestAsymmetricDecryptor;
private final SymmetricEncryptor responseSymmetricEncryptor;
private final AsymmetricEncryptor responseAsymmetricEncryptor;
public ApiEncryptFilter(WebProperties webProperties,
ApiEncryptProperties apiEncryptProperties,
RequestMappingHandlerMapping requestMappingHandlerMapping,
GlobalExceptionHandler globalExceptionHandler) {
super(webProperties);
this.apiEncryptProperties = apiEncryptProperties;
this.requestMappingHandlerMapping = requestMappingHandlerMapping;
this.globalExceptionHandler = globalExceptionHandler;
if (StrUtil.equalsIgnoreCase(apiEncryptProperties.getAlgorithm(), "AES")) {
this.requestSymmetricDecryptor = SecureUtil.aes(StrUtil.utf8Bytes(apiEncryptProperties.getRequestKey()));
this.requestAsymmetricDecryptor = null;
this.responseSymmetricEncryptor = SecureUtil.aes(StrUtil.utf8Bytes(apiEncryptProperties.getResponseKey()));
this.responseAsymmetricEncryptor = null;
} else if (StrUtil.equalsIgnoreCase(apiEncryptProperties.getAlgorithm(), "RSA")) {
this.requestSymmetricDecryptor = null;
this.requestAsymmetricDecryptor = SecureUtil.rsa(apiEncryptProperties.getRequestKey(), null);
this.responseSymmetricEncryptor = null;
this.responseAsymmetricEncryptor = SecureUtil.rsa(null, apiEncryptProperties.getResponseKey());
} else {
// 补充说明如果要支持 SM2SM4 等算法可在此处增加对应实例的创建并添加相应的 Maven 依赖即可
throw new IllegalArgumentException("不支持的加密算法:" + apiEncryptProperties.getAlgorithm());
}
}
@Override
@SuppressWarnings("NullableProblems")
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 获取 @ApiEncrypt 注解
ApiEncrypt apiEncrypt = getApiEncrypt(request);
boolean requestEnable = apiEncrypt != null && apiEncrypt.request();
boolean responseEnable = apiEncrypt != null && apiEncrypt.response();
String encryptHeader = request.getHeader(apiEncryptProperties.getHeader());
if (!requestEnable && !responseEnable && StrUtil.isBlank(encryptHeader)) {
chain.doFilter(request, response);
return;
}
// 1. 解密请求
if (ObjectUtils.equalsAny(HttpMethod.valueOf(request.getMethod()),
HttpMethod.POST, HttpMethod.PUT, HttpMethod.DELETE)) {
try {
if (StrUtil.isNotBlank(encryptHeader)) {
request = new ApiDecryptRequestWrapper(request,
requestSymmetricDecryptor, requestAsymmetricDecryptor);
} else if (requestEnable) {
throw invalidParamException("请求未包含加密标头,请检查是否正确配置了加密标头");
}
} catch (Exception ex) {
CommonResult<?> result = globalExceptionHandler.allExceptionHandler(request, ex);
ServletUtils.writeJSON(response, result);
return;
}
}
// 2. 执行过滤器链
if (responseEnable) {
// 特殊仅包装最后执行目的Response 内容可以被重复读取
response = new ApiEncryptResponseWrapper(response);
}
chain.doFilter(request, response);
// 3. 加密响应真正执行
if (responseEnable) {
((ApiEncryptResponseWrapper) response).encrypt(apiEncryptProperties,
responseSymmetricEncryptor, responseAsymmetricEncryptor);
}
}
/**
* 获取 @ApiEncrypt 注解
*
* @param request 请求
*/
private ApiEncrypt getApiEncrypt(HttpServletRequest request) {
try {
HandlerExecutionChain mappingHandler = requestMappingHandlerMapping.getHandler(request);
if (mappingHandler == null) {
return null;
}
Object handler = mappingHandler.getHandler();
if (handler instanceof HandlerMethod handlerMethod) {
ApiEncrypt annotation = handlerMethod.getMethodAnnotation(ApiEncrypt.class);
if (annotation == null) {
annotation = handlerMethod.getBeanType().getAnnotation(ApiEncrypt.class);
}
return annotation;
}
} catch (Exception e) {
log.error("[getApiEncrypt][url({}/{}) 获取 @ApiEncrypt 注解失败]",
request.getRequestURI(), request.getMethod(), e);
}
return null;
}
}

View File

@ -0,0 +1,109 @@
package cn.iocoder.yudao.framework.encrypt.core.filter;
import cn.hutool.crypto.asymmetric.AsymmetricEncryptor;
import cn.hutool.crypto.asymmetric.KeyType;
import cn.hutool.crypto.symmetric.SymmetricEncryptor;
import cn.iocoder.yudao.framework.encrypt.config.ApiEncryptProperties;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.WriteListener;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpServletResponseWrapper;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
/**
* 加密响应 {@link HttpServletResponseWrapper} 实现类
*
* @author 芋道源码
*/
public class ApiEncryptResponseWrapper extends HttpServletResponseWrapper {
private final ByteArrayOutputStream byteArrayOutputStream;
private final ServletOutputStream servletOutputStream;
private final PrintWriter printWriter;
public ApiEncryptResponseWrapper(HttpServletResponse response) {
super(response);
this.byteArrayOutputStream = new ByteArrayOutputStream();
this.servletOutputStream = this.getOutputStream();
this.printWriter = new PrintWriter(new OutputStreamWriter(byteArrayOutputStream));
}
public void encrypt(ApiEncryptProperties properties,
SymmetricEncryptor symmetricEncryptor,
AsymmetricEncryptor asymmetricEncryptor) throws IOException {
// 1.1 清空 body
HttpServletResponse response = (HttpServletResponse) this.getResponse();
response.resetBuffer();
// 1.2 获取 body
this.flushBuffer();
byte[] body = byteArrayOutputStream.toByteArray();
// 2. 加密 body
String encryptedBody = symmetricEncryptor != null ? symmetricEncryptor.encryptBase64(body)
: asymmetricEncryptor.encryptBase64(body, KeyType.PublicKey);
response.getWriter().write(encryptedBody);
// 3. 添加加密 header 标识
this.addHeader(properties.getHeader(), "true");
// 特殊特殊https://juejin.cn/post/6867327674675625992
this.addHeader("Access-Control-Expose-Headers", properties.getHeader());
}
@Override
public PrintWriter getWriter() {
return printWriter;
}
@Override
public void flushBuffer() throws IOException {
if (servletOutputStream != null) {
servletOutputStream.flush();
}
if (printWriter != null) {
printWriter.flush();
}
}
@Override
public void reset() {
byteArrayOutputStream.reset();
}
@Override
public ServletOutputStream getOutputStream() {
return new ServletOutputStream() {
@Override
public boolean isReady() {
return false;
}
@Override
public void setWriteListener(WriteListener writeListener) {
}
@Override
public void write(int b) {
byteArrayOutputStream.write(b);
}
@Override
@SuppressWarnings("NullableProblems")
public void write(byte[] b) throws IOException {
byteArrayOutputStream.write(b);
}
@Override
@SuppressWarnings("NullableProblems")
public void write(byte[] b, int off, int len) {
byteArrayOutputStream.write(b, off, len);
}
};
}
}

View File

@ -0,0 +1,4 @@
/**
* HTTP API 加密组件支持 Request Response 的加密解密
*/
package cn.iocoder.yudao.framework.encrypt;

View File

@ -1,14 +1,13 @@
package cn.iocoder.yudao.framework.web.core.filter;
import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
/**
@ -29,12 +28,22 @@ public class CacheRequestBodyWrapper extends HttpServletRequestWrapper {
}
@Override
public BufferedReader getReader() throws IOException {
public BufferedReader getReader() {
return new BufferedReader(new InputStreamReader(this.getInputStream()));
}
@Override
public ServletInputStream getInputStream() throws IOException {
public int getContentLength() {
return body.length;
}
@Override
public long getContentLengthLong() {
return body.length;
}
@Override
public ServletInputStream getInputStream() {
final ByteArrayInputStream inputStream = new ByteArrayInputStream(body);
// 返回 ServletInputStream
return new ServletInputStream() {

View File

@ -3,4 +3,5 @@ cn.iocoder.yudao.framework.jackson.config.YudaoJacksonAutoConfiguration
cn.iocoder.yudao.framework.swagger.config.YudaoSwaggerAutoConfiguration
cn.iocoder.yudao.framework.web.config.YudaoWebAutoConfiguration
cn.iocoder.yudao.framework.xss.config.YudaoXssAutoConfiguration
cn.iocoder.yudao.framework.banner.config.YudaoBannerAutoConfiguration
cn.iocoder.yudao.framework.banner.config.YudaoBannerAutoConfiguration
cn.iocoder.yudao.framework.encrypt.config.YudaoApiEncryptAutoConfiguration

View File

@ -0,0 +1,86 @@
package cn.iocoder.yudao.framework.encrypt;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.crypto.asymmetric.AsymmetricAlgorithm;
import cn.hutool.crypto.asymmetric.KeyType;
import cn.hutool.crypto.asymmetric.RSA;
import cn.hutool.crypto.symmetric.SymmetricAlgorithm;
import org.junit.jupiter.api.Test;
import java.util.Objects;
/**
* 各种 API 加解密的测试类不是单测而是方便大家生成密钥加密解密等操作
*
* @author 芋道源码
*/
@SuppressWarnings("ConstantValue")
public class ApiEncryptTest {
@Test
public void testGenerateAsymmetric() {
String asymmetricAlgorithm = AsymmetricAlgorithm.RSA.getValue();
// String asymmetricAlgorithm = "SM2";
// String asymmetricAlgorithm = SM4.ALGORITHM_NAME;
// String asymmetricAlgorithm = SymmetricAlgorithm.AES.getValue();
String requestClientKey = null;
String requestServerKey = null;
String responseClientKey = null;
String responseServerKey = null;
if (Objects.equals(asymmetricAlgorithm, AsymmetricAlgorithm.RSA.getValue())) {
// 请求的密钥
RSA requestRsa = SecureUtil.rsa();
requestClientKey = requestRsa.getPublicKeyBase64();
requestServerKey = requestRsa.getPrivateKeyBase64();
// 响应的密钥
RSA responseRsa = new RSA();
responseClientKey = responseRsa.getPrivateKeyBase64();
responseServerKey = responseRsa.getPublicKeyBase64();
} else if (Objects.equals(asymmetricAlgorithm, SymmetricAlgorithm.AES.getValue())) {
// AES 密钥可选 322416
// 请求的密钥前后端密钥一致
requestClientKey = RandomUtil.randomNumbers(32);
requestServerKey = requestClientKey;
// 响应的密钥前后端密钥一致
responseClientKey = RandomUtil.randomNumbers(32);
responseServerKey = responseClientKey;
}
// 打印结果
System.out.println("requestClientKey = " + requestClientKey);
System.out.println("requestServerKey = " + requestServerKey);
System.out.println("responseClientKey = " + responseClientKey);
System.out.println("responseServerKey = " + responseServerKey);
}
@Test
public void testEncrypt_aes() {
String key = "52549111389893486934626385991395";
String body = "{\n" +
" \"username\": \"admin\",\n" +
" \"password\": \"admin123\",\n" +
" \"uuid\": \"3acd87a09a4f48fb9118333780e94883\",\n" +
" \"code\": \"1024\"\n" +
"}";
String encrypt = SecureUtil.aes(StrUtil.utf8Bytes(key))
.encryptBase64(body);
System.out.println("encrypt = " + encrypt);
}
@Test
public void testEncrypt_rsa() {
String key = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCls2rIpnGdYnLFgz1XU13GbNQ5DloyPpvW00FPGjqn5Z6JpK+kDtVlnkhwR87iRrE5Vf2WNqRX6vzbLSgveIQY8e8oqGCb829myjf1MuI+ZzN4ghf/7tEYhZJGPI9AbfxFqBUzm+kR3/HByAI22GLT96WM26QiMK8n3tIP/yiLswIDAQAB";
String body = "{\n" +
" \"username\": \"admin\",\n" +
" \"password\": \"admin123\",\n" +
" \"uuid\": \"3acd87a09a4f48fb9118333780e94883\",\n" +
" \"code\": \"1024\"\n" +
"}";
String encrypt = SecureUtil.rsa(null, key)
.encryptBase64(body, KeyType.PublicKey);
System.out.println("encrypt = " + encrypt);
}
}

View File

@ -72,6 +72,9 @@ public class BpmModelMetaInfoVO {
@Schema(description = "允许撤销审批中的申请", example = "true")
private Boolean allowCancelRunningProcess;
@Schema(description = "允许允许审批人撤回任务", example = "false")
private Boolean allowWithdrawTask;
@Schema(description = "流程 ID 规则", example = "{}")
private ProcessIdRule processIdRule;

View File

@ -219,6 +219,14 @@ public class BpmTaskController {
return success(true);
}
@PutMapping("/withdraw")
@Operation(summary = "撤回任务")
@PreAuthorize("@ss.hasPermission('bpm:task:update')")
public CommonResult<Boolean> withdrawTask(@RequestParam("taskId") String taskId) {
taskService.withdrawTask(getLoginUserId(), taskId);
return success(true);
}
@GetMapping("/list-by-parent-task-id")
@Operation(summary = "获得指定父级任务的子任务列表") // 目前用于减签的时候获得子任务列表
@Parameter(name = "parentTaskId", description = "父级任务编号", required = true)

View File

@ -172,6 +172,11 @@ public class BpmProcessDefinitionInfoDO extends BaseDO {
*/
private Boolean allowCancelRunningProcess;
/**
* 是否允许审批人撤回任务
*/
private Boolean allowWithdrawTask;
/**
* 流程 ID 规则
*/

View File

@ -60,6 +60,10 @@ public interface ErrorCodeConstants {
ErrorCode TASK_TRANSFER_FAIL_USER_NOT_EXISTS = new ErrorCode(1_009_005_014, "任务转办失败,转办人不存在");
ErrorCode TASK_SIGNATURE_NOT_EXISTS = new ErrorCode(1_009_005_015, "签名不能为空!");
ErrorCode TASK_REASON_REQUIRE = new ErrorCode(1_009_005_016, "审批意见不能为空!");
ErrorCode TASK_WITHDRAW_FAIL_PROCESS_NOT_RUNNING = new ErrorCode(1_009_005_017, "撤回失败,流程实例未运行!");
ErrorCode TASK_WITHDRAW_FAIL_TASK_NOT_EXISTS = new ErrorCode(1_009_005_018, "撤回失败,未查询到用户已办任务!");
ErrorCode TASK_WITHDRAW_FAIL_NOT_ALLOW = new ErrorCode(1_009_005_019, "撤回失败,此流程不允许撤回操作!");
ErrorCode TASK_WITHDRAW_FAIL_NEXT_TASK_NOT_ALLOW = new ErrorCode(1_009_005_019, "撤回失败,下一节点不满足撤回条件!");
// ========== 动态表单模块 1-009-010-000 ==========
ErrorCode FORM_NOT_EXISTS = new ErrorCode(1_009_010_000, "动态表单不存在");

View File

@ -35,6 +35,7 @@ public enum BpmReasonEnum {
APPROVE_TYPE_AUTO_APPROVE("非人工审核,自动通过"),
APPROVE_TYPE_AUTO_REJECT("非人工审核,自动不通过"),
CANCEL_BY_PROCESS_CLEAN("进程清理自动取消"),
CANCEL_BY_WITHDRAW("前一任务撤回,系统自动取消"),
;
private final String reason;

View File

@ -852,7 +852,7 @@ public class BpmnModelUtils {
} else if (flowNode instanceof ScriptTask) {
skipExpression = ((ScriptTask) flowNode).getSkipExpression();
}
if (StrUtil.isEmpty(skipExpression)) {
return false;
}
@ -908,6 +908,49 @@ public class BpmnModelUtils {
return nextFlowNodes;
}
/**
* 查找起始节点下一个用户任务列表列表
*
* @param source 起始节点
* @return 结果
*/
public static List<UserTask> getNextUserTasks(FlowElement source) {
return getNextUserTasks(source, null, null);
}
/**
* 查找起始节点下一个用户任务列表列表
* @param source 起始节点
* @param hasSequenceFlow 已经经过的连线的 ID用于判断线路是否重复
* @param userTaskList 用户任务列表
* @return 结果
*/
public static List<UserTask> getNextUserTasks(FlowElement source, Set<String> hasSequenceFlow, List<UserTask> userTaskList) {
hasSequenceFlow = Optional.ofNullable(hasSequenceFlow).orElse(new HashSet<>());
userTaskList = Optional.ofNullable(userTaskList).orElse(new ArrayList<>());
// 获取出口连线
List<SequenceFlow> sequenceFlows = getElementOutgoingFlows(source);
if (!sequenceFlows.isEmpty()) {
for (SequenceFlow sequenceFlow : sequenceFlows) {
// 如果发现连线重复说明循环了跳过这个循环
if (hasSequenceFlow.contains(sequenceFlow.getId())) {
continue;
}
// 添加已经走过的连线
hasSequenceFlow.add(sequenceFlow.getId());
FlowElement targetFlowElement = sequenceFlow.getTargetFlowElement();
if (targetFlowElement instanceof UserTask) {
// 若节点为用户任务加入到结果列表中
userTaskList.add((UserTask) targetFlowElement);
} else {
// 若节点非用户任务继续递归查找下一个节点
getNextUserTasks(targetFlowElement, hasSequenceFlow, userTaskList);
}
}
}
return userTaskList;
}
/**
* 处理排它网关
*

View File

@ -250,6 +250,14 @@ public interface BpmTaskService {
*/
void copyTask(Long userId, @Valid BpmTaskCopyReqVO reqVO);
/**
* 撤回任务
*
* @param userId 用户编号
* @param taskId 任务编号
*/
void withdrawTask(Long userId, String taskId);
// ========== Event 事件相关方法 ==========
/**

View File

@ -196,7 +196,7 @@ public class BpmTaskServiceImpl implements BpmTaskService {
/**
* 获得用户指定 processInstanceId 流程编号下的首个待办未审批且可审核的任务
*
* @param userId 用户编号
* @param userId 用户编号
* @param processInstanceId 流程编号
* @return 任务
*/
@ -599,15 +599,15 @@ public class BpmTaskServiceImpl implements BpmTaskService {
/**
* 校验选择的下一个节点的审批人是否合法
*
* <p>
* 1. 是否有漏选没有选择审批人
* 2. 是否有多选非下一个节点
*
* @param taskDefinitionKey 当前任务节点标识
* @param variables 流程变量
* @param bpmnModel 流程模型
* @param nextAssignees 下一个节点审批人集合参数
* @param processInstance 流程实例
* @param variables 流程变量
* @param bpmnModel 流程模型
* @param nextAssignees 下一个节点审批人集合参数
* @param processInstance 流程实例
*/
@SuppressWarnings("unchecked")
private Map<String, Object> validateAndSetNextAssignees(String taskDefinitionKey, Map<String, Object> variables, BpmnModel bpmnModel,
@ -659,7 +659,7 @@ public class BpmTaskServiceImpl implements BpmTaskService {
approveUserSelectAssignees = new HashMap<>();
}
approveUserSelectAssignees.put(nextFlowNode.getId(), assignees);
Map<String,List<Long>> existingApproveUserSelectAssignees = (Map<String,List<Long>>) variables.get(
Map<String, List<Long>> existingApproveUserSelectAssignees = (Map<String, List<Long>>) variables.get(
BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_APPROVE_USER_SELECT_ASSIGNEES);
if (CollUtil.isNotEmpty(existingApproveUserSelectAssignees)) {
approveUserSelectAssignees.putAll(existingApproveUserSelectAssignees);
@ -1177,6 +1177,63 @@ public class BpmTaskServiceImpl implements BpmTaskService {
processInstanceCopyService.createProcessInstanceCopy(reqVO.getCopyUserIds(), reqVO.getReason(), reqVO.getId());
}
@Override
@Transactional(rollbackFor = Exception.class)
public void withdrawTask(Long userId, String taskId) {
// 1.1 查询本人已办任务
HistoricTaskInstance taskInstance = historyService.createHistoricTaskInstanceQuery()
.taskId(taskId).taskAssignee(userId.toString()).finished().singleResult();
if (ObjUtil.isNull(taskInstance)) {
throw exception(TASK_WITHDRAW_FAIL_TASK_NOT_EXISTS);
}
// 1.2 校验流程是否结束
ProcessInstance processInstance = processInstanceService.getProcessInstance(taskInstance.getProcessInstanceId());
if (ObjUtil.isNull(processInstance)) {
throw exception(TASK_WITHDRAW_FAIL_PROCESS_NOT_RUNNING);
}
// 1.3 判断此流程是否允许撤回
BpmProcessDefinitionInfoDO processDefinitionInfo = bpmProcessDefinitionService.getProcessDefinitionInfo(
processInstance.getProcessDefinitionId());
if (ObjUtil.isNull(processDefinitionInfo) || !Boolean.TRUE.equals(processDefinitionInfo.getAllowWithdrawTask())) {
throw exception(TASK_WITHDRAW_FAIL_NOT_ALLOW);
}
// 1.4 判断下一个节点是否被审批过如果是则无法撤回
BpmnModel bpmnModel = modelService.getBpmnModelByDefinitionId(taskInstance.getProcessDefinitionId());
UserTask userTask = (UserTask) BpmnModelUtils.getFlowElementById(bpmnModel, taskInstance.getTaskDefinitionKey());
List<String> nextUserTaskKeys = convertList(BpmnModelUtils.getNextUserTasks(userTask), UserTask::getId);
if (CollUtil.isEmpty(nextUserTaskKeys)) {
throw exception(TASK_WITHDRAW_FAIL_NEXT_TASK_NOT_ALLOW);
}
// TODO @芋艿是否选择升级flowable版本解决taskCreatedAftertaskCreatedBefore问题升级7.1.0可以包括 todo done 那边的查询哇 是的
long nextUserTaskFinishedCount = historyService.createHistoricTaskInstanceQuery()
.processInstanceId(processInstance.getProcessInstanceId()).taskDefinitionKeys(nextUserTaskKeys)
.taskCreatedAfter(taskInstance.getEndTime()).finished().count();
if (nextUserTaskFinishedCount > 0) {
throw exception(TASK_WITHDRAW_FAIL_NEXT_TASK_NOT_ALLOW);
}
// 1.5 获取需要撤回的运行任务
List<Task> runningTasks = taskService.createTaskQuery().processInstanceId(processInstance.getProcessInstanceId())
.taskDefinitionKeys(nextUserTaskKeys).active().list();
if (CollUtil.isEmpty(runningTasks)) {
throw exception(TASK_WITHDRAW_FAIL_NEXT_TASK_NOT_ALLOW);
}
// 2.1 取消当前任务
List<String> withdrawExecutionIds = new ArrayList<>();
for (Task task : runningTasks) {
// 标记撤回任务为取消
taskService.addComment(task.getId(), taskInstance.getProcessInstanceId(), BpmCommentTypeEnum.CANCEL.getType(),
BpmCommentTypeEnum.CANCEL.formatComment("前一节点撤回"));
updateTaskStatusAndReason(task.getId(), BpmTaskStatusEnum.CANCEL.getStatus(), BpmReasonEnum.CANCEL_BY_WITHDRAW.getReason());
withdrawExecutionIds.add(task.getExecutionId());
}
// 2.2 执行撤回操作
runtimeService.createChangeActivityStateBuilder()
.processInstanceId(processInstance.getProcessInstanceId())
.moveExecutionsToSingleActivityId(withdrawExecutionIds, taskInstance.getTaskDefinitionKey())
.changeState();
}
/**
* 校验任务是否能被减签
*
@ -1223,7 +1280,7 @@ public class BpmTaskServiceImpl implements BpmTaskService {
}
// 2. 任务前置通知
if (ObjUtil.isNotNull(processDefinitionInfo.getTaskBeforeTriggerSetting())){
if (ObjUtil.isNotNull(processDefinitionInfo.getTaskBeforeTriggerSetting())) {
BpmModelMetaInfoVO.HttpRequestSetting setting = processDefinitionInfo.getTaskBeforeTriggerSetting();
BpmHttpRequestUtils.executeBpmHttpRequest(processInstance,
setting.getUrl(), setting.getHeader(), setting.getBody(), true, setting.getResponse());
@ -1350,7 +1407,7 @@ public class BpmTaskServiceImpl implements BpmTaskService {
.taskVariableValueEquals(BpmnVariableConstants.TASK_VARIABLE_STATUS, BpmTaskStatusEnum.APPROVE.getStatus())
.finished();
if (BpmAutoApproveTypeEnum.APPROVE_ALL.getType().equals(processDefinitionInfo.getAutoApprovalType())
&& sameAssigneeQuery.count() > 0) {
&& sameAssigneeQuery.count() > 0) {
getSelf().approveTask(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId())
.setReason(BpmAutoApproveTypeEnum.APPROVE_ALL.getName()));
return;
@ -1362,7 +1419,7 @@ public class BpmTaskServiceImpl implements BpmTaskService {
return;
}
List<String> sourceTaskIds = convertList(BpmnModelUtils.getElementIncomingFlows( // 获取所有上一个节点
BpmnModelUtils.getFlowElementById(bpmnModel, task.getTaskDefinitionKey())),
BpmnModelUtils.getFlowElementById(bpmnModel, task.getTaskDefinitionKey())),
SequenceFlow::getSourceRef);
if (sameAssigneeQuery.taskDefinitionKeys(sourceTaskIds).count() > 0) {
getSelf().approveTask(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId())
@ -1387,7 +1444,7 @@ public class BpmTaskServiceImpl implements BpmTaskService {
PROCESS_INSTANCE_VARIABLE_SKIP_START_USER_NODE, String.class));
if (userTaskElement.getId().equals(START_USER_NODE_ID)
&& (skipStartUserNodeFlag == null // 目的一般是主流程发起人节点自动通过审核
|| BooleanUtil.isTrue(skipStartUserNodeFlag)) // 目的一般是子流程发起人节点按配置自动通过审核
|| BooleanUtil.isTrue(skipStartUserNodeFlag)) // 目的一般是子流程发起人节点按配置自动通过审核
&& ObjUtil.notEqual(returnTaskFlag, Boolean.TRUE)) {
getSelf().approveTask(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId())
.setReason(BpmReasonEnum.ASSIGN_START_USER_APPROVE_WHEN_SKIP_START_USER_NODE.getReason()));
@ -1456,7 +1513,7 @@ public class BpmTaskServiceImpl implements BpmTaskService {
}
// 任务后置通知
if (ObjUtil.isNotNull(processDefinitionInfo.getTaskAfterTriggerSetting())){
if (ObjUtil.isNotNull(processDefinitionInfo.getTaskAfterTriggerSetting())) {
BpmModelMetaInfoVO.HttpRequestSetting setting = processDefinitionInfo.getTaskAfterTriggerSetting();
BpmHttpRequestUtils.executeBpmHttpRequest(processInstance,
setting.getUrl(), setting.getHeader(), setting.getBody(), true, setting.getResponse());

View File

@ -14,7 +14,7 @@ public interface LogRecordConstants {
String CRM_CLUE_CREATE_SUB_TYPE = "创建线索";
String CRM_CLUE_CREATE_SUCCESS = "创建了线索{{#clue.name}}";
String CRM_CLUE_UPDATE_SUB_TYPE = "更新线索";
String CRM_CLUE_UPDATE_SUCCESS = "更新了线索【{{#clueName}}】: {_DIFF{#updateReq}}";
String CRM_CLUE_UPDATE_SUCCESS = "更新了线索【{{#clueName}}】: {_DIFF{#updateReqVO}}";
String CRM_CLUE_DELETE_SUB_TYPE = "删除线索";
String CRM_CLUE_DELETE_SUCCESS = "删除了线索【{{#clueName}}】";
String CRM_CLUE_TRANSFER_SUB_TYPE = "转移线索";

View File

@ -106,7 +106,7 @@ public class CrmClueServiceImpl implements CrmClueService {
// 3. 记录操作日志上下文
updateReqVO.setOwnerUserId(oldClue.getOwnerUserId()); // 避免操作日志出现删除负责人的情况
LogRecordContext.putVariable(DiffParseFunction.OLD_OBJECT, BeanUtils.toBean(oldClue, CrmCustomerSaveReqVO.class));
LogRecordContext.putVariable(DiffParseFunction.OLD_OBJECT, BeanUtils.toBean(oldClue, CrmClueSaveReqVO.class));
LogRecordContext.putVariable("clueName", oldClue.getName());
}

View File

@ -170,6 +170,7 @@
await this.#[[$modal]]#.confirm('是否确认删除?')
try {
await ${simpleClassName}Api.delete${subSimpleClassName}List(this.checkedIds);
this.checkedIds = [];
await this.getList();
this.#[[$modal]]#.msgSuccess("删除成功");
} catch {}

View File

@ -338,6 +338,7 @@ export default {
await this.#[[$modal]]#.confirm('是否确认删除?')
try {
await ${simpleClassName}Api.delete${simpleClassName}List(this.checkedIds);
this.checkedIds = [];
await this.getList();
this.#[[$modal]]#.msgSuccess("删除成功");
} catch {}

View File

@ -209,6 +209,7 @@ const handleDeleteBatch = async () => {
// 删除的二次确认
await message.delConfirm()
await ${simpleClassName}Api.delete${subSimpleClassName}List(checkedIds.value);
checkedIds.value = [];
message.success(t('common.delSuccess'))
await getList();
} catch {}

View File

@ -366,6 +366,7 @@ const handleDeleteBatch = async () => {
// 删除的二次确认
await message.delConfirm()
await ${simpleClassName}Api.delete${simpleClassName}List(checkedIds.value);
checkedIds.value = [];
message.success(t('common.delSuccess'))
await getList();
} catch {}

View File

@ -168,6 +168,7 @@ async function handleDeleteBatch() {
});
try {
await delete${simpleClassName}List(checkedIds.value);
checkedIds.value = [];
message.success( $t('ui.actionMessage.deleteSuccess') );
await getList();
} finally {

View File

@ -92,6 +92,7 @@ async function handleDeleteBatch() {
});
try {
await delete${subSimpleClassName}List(checkedIds.value);
checkedIds.value = [];
message.success( $t('ui.actionMessage.deleteSuccess') );
await getList();
} finally {

View File

@ -102,6 +102,7 @@ async function handleDeleteBatch() {
});
try {
await delete${simpleClassName}List(checkedIds.value);
checkedIds.value = [];
message.success({
content: $t('ui.actionMessage.deleteSuccess'),
key: 'action_key_msg',

View File

@ -82,6 +82,7 @@ async function handleDeleteBatch() {
});
try {
await delete${subSimpleClassName}List(checkedIds.value);
checkedIds.value = [];
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.id]),
key: 'action_key_msg',

View File

@ -163,6 +163,7 @@ async function handleDeleteBatch() {
});
try {
await delete${simpleClassName}List(checkedIds.value);
checkedIds.value = [];
ElMessage.success($t('ui.actionMessage.deleteSuccess'));
await getList();
} finally {

View File

@ -87,6 +87,7 @@ async function handleDeleteBatch() {
});
try {
await delete${subSimpleClassName}List(checkedIds.value);
checkedIds.value = [];
ElMessage.success($t('ui.actionMessage.deleteSuccess'));
await getList();
} finally {

View File

@ -99,6 +99,7 @@ async function handleDeleteBatch() {
});
try {
await delete${simpleClassName}List(checkedIds.value);
checkedIds.value = [];
ElMessage.success($t('ui.actionMessage.deleteSuccess'));
onRefresh();
} finally {

View File

@ -79,6 +79,7 @@ async function handleDeleteBatch() {
});
try {
await delete${subSimpleClassName}List(checkedIds.value);
checkedIds.value = [];
ElMessage.success($t('ui.actionMessage.deleteSuccess'));
onRefresh();
} finally {

View File

@ -28,6 +28,9 @@ public class MpMessagePageReqVO extends PageParam {
@Schema(description = "公众号粉丝标识", example = "o6_bmjrPTlm6_2sgVt7hMZOPfL2M")
private String openid;
@Schema(description = "公众号粉丝 UserId", example = "1")
private String userId;
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
@Schema(description = "创建时间")
private LocalDateTime[] createTime;

View File

@ -15,6 +15,7 @@ public interface MpMessageMapper extends BaseMapperX<MpMessageDO> {
.eqIfPresent(MpMessageDO::getAccountId, reqVO.getAccountId())
.eqIfPresent(MpMessageDO::getType, reqVO.getType())
.eqIfPresent(MpMessageDO::getOpenid, reqVO.getOpenid())
.eqIfPresent(MpMessageDO::getUserId, reqVO.getUserId())
.betweenIfPresent(MpMessageDO::getCreateTime, reqVO.getCreateTime())
.orderByDesc(MpMessageDO::getId));
}

View File

@ -353,7 +353,7 @@ public abstract class AbstractAlipayPayClient extends AbstractPayClient<AlipayPa
} else if (Objects.equals(config.getMode(), MODE_CERTIFICATE)) {
// 由于 rsaCertCheckV1 的第二个参数是 path所以不能这么调用通过阅读源码发现可以采用如下方式
X509Certificate cert = AntCertificationUtil.getCertFromContent(config.getAlipayPublicCertContent());
String publicKey = Base64.encodeBase64String(cert.getEncoded());
String publicKey = Base64.encodeBase64String(cert.getPublicKey().getEncoded());
verify = AlipaySignature.rsaCheckV1(params, publicKey,
StandardCharsets.UTF_8.name(), config.getSignType());
} else {

View File

@ -21,13 +21,15 @@ public class MailSendApiImpl implements MailSendApi {
@Override
public Long sendSingleMailToAdmin(MailSendSingleToUserReqDTO reqDTO) {
return mailSendService.sendSingleMailToAdmin(reqDTO.getMail(), reqDTO.getUserId(),
return mailSendService.sendSingleMailToAdmin(reqDTO.getUserId(),
reqDTO.getToMails(), reqDTO.getCcMails(), reqDTO.getBccMails(),
reqDTO.getTemplateCode(), reqDTO.getTemplateParams());
}
@Override
public Long sendSingleMailToMember(MailSendSingleToUserReqDTO reqDTO) {
return mailSendService.sendSingleMailToMember(reqDTO.getMail(), reqDTO.getUserId(),
return mailSendService.sendSingleMailToMember(reqDTO.getUserId(),
reqDTO.getToMails(), reqDTO.getCcMails(), reqDTO.getBccMails(),
reqDTO.getTemplateCode(), reqDTO.getTemplateParams());
}

View File

@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.system.api.mail.dto;
import lombok.Data;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotNull;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.Map;
/**
@ -16,13 +18,24 @@ public class MailSendSingleToUserReqDTO {
/**
* 用户编号
*
* 如果非空则加载对应用户的邮箱添加到 {@link #toMails}
*/
private Long userId;
/**
* 邮箱
* 收件邮箱
*/
@Email
private String mail;
private List<@Email String> toMails;
/**
* 抄送邮箱
*/
private List<@Email String> ccMails;
/**
* 密送邮箱
*/
private List<@Email String> bccMails;
/**
* 邮件模板编号

View File

@ -11,6 +11,24 @@ tag: Yunai.local
"code": "1024"
}
### 请求 /login 接口【加密 AES】 => 成功
POST {{baseUrl}}/system/auth/login
Content-Type: application/json
tenant-id: {{adminTenantId}}
tag: Yunai.local
X-API-ENCRYPT: true
WvSX9MOrenyGfBhEM0g1/hHgq8ocktMZ9OwAJ6MOG5FUrzYF/rG5JF1eMptQM1wT73VgDS05l/37WeRtad+JrqChAul/sR/SdOsUKqjBhvvQx1JVhzxr6s8uUP67aKTSZ6Psv7O32ELxXrzSaQvG5CInzz3w6sLtbNNLd1kXe6Q=
### 请求 /login 接口【加密 RSA】 => 成功
POST {{baseUrl}}/system/auth/login
Content-Type: application/json
tenant-id: {{adminTenantId}}
tag: Yunai.local
X-API-ENCRYPT: true
e7QZTork9ZV5CmgZvSd+cHZk3xdUxKtowLM02kOha+gxHK2H/daU8nVBYS3+bwuDRy5abf+Pz1QJJGVAEd27wwrXBmupOOA/bhpuzzDwcRuJRD+z+YgiNoEXFDRHERxPYlPqAe9zAHtihD0ceub1AjybQsEsROew4C3Q602XYW0=
### 请求 /login 接口 => 成功(无验证码)
POST {{baseUrl}}/system/auth/login
Content-Type: application/json

View File

@ -56,6 +56,15 @@ public class DeptController {
return success(true);
}
@DeleteMapping("/delete-list")
@Operation(summary = "批量删除部门")
@Parameter(name = "ids", description = "编号列表", required = true)
@PreAuthorize("@ss.hasPermission('system:dept:delete')")
public CommonResult<Boolean> deleteDeptList(@RequestParam("ids") List<Long> ids) {
deptService.deleteDeptList(ids);
return success(true);
}
@GetMapping("/list")
@Operation(summary = "获取部门列表")
@PreAuthorize("@ss.hasPermission('system:dept:query')")

View File

@ -91,7 +91,8 @@ public class MailTemplateController {
@Operation(summary = "发送短信")
@PreAuthorize("@ss.hasPermission('system:mail-template:send-mail')")
public CommonResult<Long> sendMail(@Valid @RequestBody MailTemplateSendReqVO sendReqVO) {
return success(mailSendService.sendSingleMailToAdmin(sendReqVO.getMail(), getLoginUserId(),
return success(mailSendService.sendSingleMailToAdmin(getLoginUserId(),
sendReqVO.getToMails(), sendReqVO.getCcMails(), sendReqVO.getBccMails(),
sendReqVO.getTemplateCode(), sendReqVO.getTemplateParams()));
}

View File

@ -2,13 +2,11 @@ package cn.iocoder.yudao.module.system.controller.admin.mail.vo.log;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
@Schema(description = "管理后台 - 邮件日志 Response VO")
@Data
public class MailLogRespVO {
@ -22,8 +20,14 @@ public class MailLogRespVO {
@Schema(description = "用户类型,参见 UserTypeEnum 枚举", example = "2")
private Byte userType;
@Schema(description = "接收邮箱地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "76854@qq.com")
private String toMail;
@Schema(description = "接收邮箱地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "user1@example.com, user2@example.com")
private List<String> toMails;
@Schema(description = "抄送邮箱地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "user3@example.com, user4@example.com")
private List<String> ccMails;
@Schema(description = "密送邮箱地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "user5@example.com, user6@example.com")
private List<String> bccMails;
@Schema(description = "邮箱账号编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "18107")
private Long accountId;

View File

@ -3,17 +3,24 @@ package cn.iocoder.yudao.module.system.controller.admin.mail.vo.template;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.Map;
@Schema(description = "管理后台 - 邮件发送 Req VO")
@Data
public class MailTemplateSendReqVO {
@Schema(description = "接收邮箱", requiredMode = Schema.RequiredMode.REQUIRED, example = "7685413@qq.com")
@Schema(description = "接收邮箱", requiredMode = Schema.RequiredMode.REQUIRED, example = "[user1@example.com, user2@example.com]")
@NotEmpty(message = "接收邮箱不能为空")
private String mail;
private List<String> toMails;
@Schema(description = "抄送邮箱", requiredMode = Schema.RequiredMode.REQUIRED, example = "[user3@example.com, user4@example.com]")
private List<String> ccMails;
@Schema(description = "密送邮箱", requiredMode = Schema.RequiredMode.REQUIRED, example = "[user5@example.com, user6@example.com]")
private List<String> bccMails;
@Schema(description = "模板编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "test_01")
@NotNull(message = "模板编码不能为空")

View File

@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.system.dal.dataobject.mail;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.framework.mybatis.core.type.StringListTypeHandler;
import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
import cn.iocoder.yudao.module.system.enums.mail.MailSendStatusEnum;
import com.baomidou.mybatisplus.annotation.KeySequence;
@ -12,6 +13,7 @@ import lombok.*;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
@ -47,10 +49,22 @@ public class MailLogDO extends BaseDO implements Serializable {
* 枚举 {@link UserTypeEnum}
*/
private Integer userType;
/**
* 接收邮箱地址
*/
private String toMail;
@TableField(typeHandler = StringListTypeHandler.class)
private List<String> toMails;
/**
* 接收邮箱地址
*/
@TableField(typeHandler = StringListTypeHandler.class)
private List<String> ccMails;
/**
* 密送邮箱地址
*/
@TableField(typeHandler = StringListTypeHandler.class)
private List<String> bccMails;
/**
* 邮箱账号编号

View File

@ -1,8 +1,10 @@
package cn.iocoder.yudao.module.system.dal.mysql.mail;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
import cn.iocoder.yudao.module.system.controller.admin.mail.vo.log.MailLogPageReqVO;
import cn.iocoder.yudao.module.system.dal.dataobject.mail.MailLogDO;
import org.apache.ibatis.annotations.Mapper;
@ -14,11 +16,12 @@ public interface MailLogMapper extends BaseMapperX<MailLogDO> {
return selectPage(reqVO, new LambdaQueryWrapperX<MailLogDO>()
.eqIfPresent(MailLogDO::getUserId, reqVO.getUserId())
.eqIfPresent(MailLogDO::getUserType, reqVO.getUserType())
.likeIfPresent(MailLogDO::getToMail, reqVO.getToMail())
.eqIfPresent(MailLogDO::getAccountId, reqVO.getAccountId())
.eqIfPresent(MailLogDO::getTemplateId, reqVO.getTemplateId())
.eqIfPresent(MailLogDO::getSendStatus, reqVO.getSendStatus())
.betweenIfPresent(MailLogDO::getSendTime, reqVO.getSendTime())
.apply(StrUtil.isNotBlank(reqVO.getToMail()),
MyBatisUtils.findInSet("to_mails", reqVO.getToMail()))
.orderByDesc(MailLogDO::getId));
}

View File

@ -136,15 +136,20 @@ public class AliyunSmsClient extends AbstractSmsClient {
.map(entry -> percentCode(entry.getKey()) + "=" + percentCode(String.valueOf(entry.getValue())))
.collect(Collectors.joining("&"));
// 2.1 请求 Header
// 2. 请求 Body
String requestBody = ""; // 短信 API RPC 接口query parameters uri 中拼接因此 request body 如果没有特殊要求设置为空
String hashedRequestPayload = DigestUtil.sha256Hex(requestBody);
// 3.1 请求 Header
TreeMap<String, String> headers = new TreeMap<>();
headers.put("host", HOST);
headers.put("x-acs-version", VERSION);
headers.put("x-acs-action", apiName);
headers.put("x-acs-date", FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ss'Z'", TimeZone.getTimeZone("GMT")).format(new Date()));
headers.put("x-acs-signature-nonce", IdUtil.randomUUID());
headers.put("x-acs-content-sha256", hashedRequestPayload);
// 2.2 构建签名 Header
// 3.2 构建签名 Header
StringBuilder canonicalHeaders = new StringBuilder(); // 构造请求头多个规范化消息头按照消息头名称小写的字符代码顺序以升序排列后拼接在一起
StringBuilder signedHeadersBuilder = new StringBuilder(); // 已签名消息头列表多个请求头名称小写按首字母升序排列并以英文分号;分隔
headers.entrySet().stream().filter(entry -> entry.getKey().toLowerCase().startsWith("x-acs-")
@ -157,13 +162,13 @@ public class AliyunSmsClient extends AbstractSmsClient {
});
String signedHeaders = signedHeadersBuilder.substring(0, signedHeadersBuilder.length() - 1);
// 3. 请求 Body
String requestBody = ""; // 短信 API RPC 接口query parameters uri 中拼接因此 request body 如果没有特殊要求设置为空
String hashedRequestBody = DigestUtil.sha256Hex(requestBody);
// 4. 构建 Authorization 签名
String canonicalRequest = "POST" + "\n" + "/" + "\n" + queryString + "\n"
+ canonicalHeaders + "\n" + signedHeaders + "\n" + hashedRequestBody;
String canonicalRequest = "POST" + "\n" +
"/" + "\n" +
queryString + "\n" +
canonicalHeaders + "\n" +
signedHeaders + "\n" +
hashedRequestPayload;
String hashedCanonicalRequest = DigestUtil.sha256Hex(canonicalRequest);
String stringToSign = "ACS3-HMAC-SHA256" + "\n" + hashedCanonicalRequest;
String signature = SecureUtil.hmacSha256(properties.getApiSecret()).digestHex(stringToSign); // 计算签名
@ -184,7 +189,7 @@ public class AliyunSmsClient extends AbstractSmsClient {
@SneakyThrows
private static String percentCode(String str) {
Assert.notNull(str, "str 不能为空");
return HttpUtils.encodeUtf8(str)
return URLEncoder.encode(str, StandardCharsets.UTF_8.name())
.replace("+", "%20") // 加号 "+" 被替换为 "%20"
.replace("*", "%2A") // 星号 "*" 被替换为 "%2A"
.replace("%7E", "~"); // 波浪号 "%7E" 被替换为 "~"

View File

@ -5,6 +5,9 @@ import lombok.Data;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.util.Collection;
import java.util.List;
/**
* 邮箱发送消息
*
@ -21,8 +24,16 @@ public class MailSendMessage {
/**
* 接收邮件地址
*/
@NotNull(message = "接收邮件地址不能为空")
private String mail;
@NotEmpty(message = "接收邮件地址不能为空")
private Collection<String> toMails;
/**
* 抄送邮件地址
*/
private Collection<String> ccMails;
/**
* 密送邮件地址
*/
private Collection<String> bccMails;
/**
* 邮件账号编号
*/

View File

@ -7,6 +7,11 @@ import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.Collection;
import java.util.List;
import static java.util.Collections.singletonList;
/**
* Mail 邮件相关消息的 Producer
*
@ -24,17 +29,22 @@ public class MailProducer {
* 发送 {@link MailSendMessage} 消息
*
* @param sendLogId 发送日志编码
* @param mail 接收邮件地址
* @param toMails 接收邮件地址
* @param ccMails 抄送邮件地址
* @param bccMails 密送邮件地址
* @param accountId 邮件账号编号
* @param nickname 邮件发件人
* @param title 邮件标题
* @param content 邮件内容
* @param nickname 邮件发件人
* @param title 邮件标题
* @param content 邮件内容
*/
public void sendMailSendMessage(Long sendLogId, String mail, Long accountId,
String nickname, String title, String content) {
public void sendMailSendMessage(Long sendLogId,
Collection<String> toMails, Collection<String> ccMails, Collection<String> bccMails,
Long accountId, String nickname, String title, String content) {
MailSendMessage message = new MailSendMessage()
.setLogId(sendLogId).setMail(mail).setAccountId(accountId)
.setNickname(nickname).setTitle(title).setContent(content);
.setLogId(sendLogId)
.setToMails(toMails).setCcMails(ccMails).setBccMails(bccMails)
.setAccountId(accountId).setNickname(nickname)
.setTitle(title).setContent(content);
applicationContext.publishEvent(message);
}

View File

@ -36,6 +36,13 @@ public interface DeptService {
*/
void deleteDept(Long id);
/**
* 批量删除部门
*
* @param ids 部门编号数组
*/
void deleteDeptList(List<Long> ids);
/**
* 获得部门信息
*

View File

@ -88,6 +88,21 @@ public class DeptServiceImpl implements DeptService {
deptMapper.deleteById(id);
}
@Override
@CacheEvict(cacheNames = RedisKeyConstants.DEPT_CHILDREN_ID_LIST,
allEntries = true) // allEntries 清空所有缓存因为操作一个部门涉及到多个缓存
public void deleteDeptList(List<Long> ids) {
// 校验是否有子部门
for (Long id : ids) {
if (deptMapper.selectCountByParentId(id) > 0) {
throw exception(DEPT_EXITS_CHILDREN);
}
}
// 批量删除部门
deptMapper.deleteByIds(ids);
}
@VisibleForTesting
void validateDeptExists(Long id) {
if (id == null) {

View File

@ -6,6 +6,8 @@ import cn.iocoder.yudao.module.system.dal.dataobject.mail.MailAccountDO;
import cn.iocoder.yudao.module.system.dal.dataobject.mail.MailLogDO;
import cn.iocoder.yudao.module.system.dal.dataobject.mail.MailTemplateDO;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
@ -35,18 +37,21 @@ public interface MailLogService {
/**
* 创建邮件日志
*
* @param userId 用户编码
* @param userType 用户类型
* @param toMail 收件人邮件
* @param account 邮件账号信息
* @param template 模版信息
* @param userId 用户编码
* @param userType 用户类型
* @param toMails 收件人邮件
* @param ccMails 收件人邮件
* @param bccMails 收件人邮件
* @param account 邮件账号信息
* @param template 模版信息
* @param templateContent 模版内容
* @param templateParams 模版参数
* @param isSend 是否发送成功
* @param templateParams 模版参数
* @param isSend 是否发送成功
* @return 日志编号
*/
Long createMailLog(Long userId, Integer userType, String toMail,
MailAccountDO account, MailTemplateDO template ,
Long createMailLog(Long userId, Integer userType,
Collection<String> toMails, Collection<String> ccMails, Collection<String> bccMails,
MailAccountDO account, MailTemplateDO template,
String templateContent, Map<String, Object> templateParams, Boolean isSend);
/**

View File

@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.system.service.mail;
import cn.hutool.core.collection.ListUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.system.controller.admin.mail.vo.log.MailLogPageReqVO;
import cn.iocoder.yudao.module.system.dal.dataobject.mail.MailAccountDO;
@ -12,8 +13,7 @@ import org.springframework.validation.annotation.Validated;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Objects;
import java.util.*;
import static cn.hutool.core.exceptions.ExceptionUtil.getRootCauseMessage;
@ -41,7 +41,8 @@ public class MailLogServiceImpl implements MailLogService {
}
@Override
public Long createMailLog(Long userId, Integer userType, String toMail,
public Long createMailLog(Long userId, Integer userType,
Collection<String> toMails, Collection<String> ccMails, Collection<String> bccMails,
MailAccountDO account, MailTemplateDO template,
String templateContent, Map<String, Object> templateParams, Boolean isSend) {
MailLogDO.MailLogDOBuilder logDOBuilder = MailLogDO.builder();
@ -49,7 +50,8 @@ public class MailLogServiceImpl implements MailLogService {
logDOBuilder.sendStatus(Objects.equals(isSend, true) ? MailSendStatusEnum.INIT.getStatus()
: MailSendStatusEnum.IGNORE.getStatus())
// 用户信息
.userId(userId).userType(userType).toMail(toMail)
.userId(userId).userType(userType)
.toMails(ListUtil.toList(toMails)).ccMails(ListUtil.toList(ccMails)).bccMails(ListUtil.toList(bccMails))
.accountId(account.getId()).fromMail(account.getMail())
// 模板相关字段
.templateId(template.getId()).templateCode(template.getCode()).templateNickname(template.getNickname())

View File

@ -1,7 +1,9 @@
package cn.iocoder.yudao.module.system.service.mail;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.module.system.mq.message.mail.MailSendMessage;
import java.util.Collection;
import java.util.Map;
/**
@ -15,38 +17,53 @@ public interface MailSendService {
/**
* 发送单条邮件给管理后台的用户
*
* @param mail 邮箱
* @param userId 用户编码
* @param toMails 收件邮箱
* @param ccMails 抄送邮箱
* @param bccMails 密送邮箱
* @param templateCode 邮件模版编码
* @param templateParams 邮件模版参数
* @return 发送日志编号
*/
Long sendSingleMailToAdmin(String mail, Long userId,
String templateCode, Map<String, Object> templateParams);
default Long sendSingleMailToAdmin(Long userId,
Collection<String> toMails, Collection<String> ccMails, Collection<String> bccMails,
String templateCode, Map<String, Object> templateParams) {
return sendSingleMail(toMails, ccMails, bccMails, userId, UserTypeEnum.ADMIN.getValue(),
templateCode, templateParams);
}
/**
* 发送单条邮件给用户 APP 的用户
*
* @param mail 邮箱
* @param userId 用户编码
* @param toMails 收件邮箱
* @param ccMails 抄送邮箱
* @param bccMails 密送邮箱
* @param templateCode 邮件模版编码
* @param templateParams 邮件模版参数
* @return 发送日志编号
*/
Long sendSingleMailToMember(String mail, Long userId,
String templateCode, Map<String, Object> templateParams);
default Long sendSingleMailToMember(Long userId,
Collection<String> toMails, Collection<String> ccMails, Collection<String> bccMails,
String templateCode, Map<String, Object> templateParams) {
return sendSingleMail(toMails, ccMails, bccMails, userId, UserTypeEnum.MEMBER.getValue(),
templateCode, templateParams);
}
/**
* 发送单条邮件给用户
* 发送单条邮件
*
* @param mail 邮箱
* @param userId 用户编码
* @param toMails 收件邮箱
* @param ccMails 抄送邮箱
* @param bccMails 密送邮箱
* @param userId 用户编号
* @param userType 用户类型
* @param templateCode 邮件模版编码
* @param templateParams 邮件模版参数
* @return 发送日志编号
*/
Long sendSingleMail(String mail, Long userId, Integer userType,
Long sendSingleMail(Collection<String> toMails, Collection<String> ccMails, Collection<String> bccMails,
Long userId, Integer userType,
String templateCode, Map<String, Object> templateParams);
/**

View File

@ -1,8 +1,8 @@
package cn.iocoder.yudao.module.system.service.mail;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.lang.Validator;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.mail.MailAccount;
import cn.hutool.extra.mail.MailUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.module.system.dal.dataobject.mail.MailAccountDO;
@ -13,11 +13,15 @@ import cn.iocoder.yudao.module.system.mq.producer.mail.MailProducer;
import cn.iocoder.yudao.module.system.service.member.MemberService;
import cn.iocoder.yudao.module.system.service.user.AdminUserService;
import com.google.common.annotations.VisibleForTesting;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.dromara.hutool.extra.mail.MailAccount;
import org.dromara.hutool.extra.mail.MailUtil;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import javax.annotation.Resource;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
@ -50,56 +54,67 @@ public class MailSendServiceImpl implements MailSendService {
private MailProducer mailProducer;
@Override
public Long sendSingleMailToAdmin(String mail, Long userId,
String templateCode, Map<String, Object> templateParams) {
// 如果 mail 为空则加载用户编号对应的邮箱
if (StrUtil.isEmpty(mail)) {
AdminUserDO user = adminUserService.getUser(userId);
if (user != null) {
mail = user.getEmail();
}
}
// 执行发送
return sendSingleMail(mail, userId, UserTypeEnum.ADMIN.getValue(), templateCode, templateParams);
}
@Override
public Long sendSingleMailToMember(String mail, Long userId,
String templateCode, Map<String, Object> templateParams) {
// 如果 mail 为空则加载用户编号对应的邮箱
if (StrUtil.isEmpty(mail)) {
mail = memberService.getMemberUserEmail(userId);
}
// 执行发送
return sendSingleMail(mail, userId, UserTypeEnum.MEMBER.getValue(), templateCode, templateParams);
}
@Override
public Long sendSingleMail(String mail, Long userId, Integer userType,
public Long sendSingleMail(Collection<String> toMails, Collection<String> ccMails, Collection<String> bccMails,
Long userId, Integer userType,
String templateCode, Map<String, Object> templateParams) {
// 校验邮箱模版是否合法
// 1.1 校验邮箱模版是否合法
MailTemplateDO template = validateMailTemplate(templateCode);
// 校验邮箱账号是否合法
// 1.2 校验邮箱账号是否合法
MailAccountDO account = validateMailAccount(template.getAccountId());
// 校验邮箱是否存在
mail = validateMail(mail);
// 1.3 校验邮件参数是否缺失
validateTemplateParams(template, templateParams);
// 2. 组装邮箱
String userMail = getUserMail(userId, userType);
Collection<String> toMailSet = new LinkedHashSet<>();
Collection<String> ccMailSet = new LinkedHashSet<>();
Collection<String> bccMailSet = new LinkedHashSet<>();
if (Validator.isEmail(userMail)) {
toMailSet.add(userMail);
}
if (CollUtil.isNotEmpty(toMails)) {
toMails.stream().filter(Validator::isEmail).forEach(toMailSet::add);
}
if (CollUtil.isNotEmpty(ccMails)) {
ccMails.stream().filter(Validator::isEmail).forEach(ccMailSet::add);
}
if (CollUtil.isNotEmpty(bccMails)) {
bccMails.stream().filter(Validator::isEmail).forEach(bccMailSet::add);
}
if (CollUtil.isEmpty(toMailSet)) {
throw exception(MAIL_SEND_MAIL_NOT_EXISTS);
}
// 创建发送日志如果模板被禁用则不发送短信只记录日志
Boolean isSend = CommonStatusEnum.ENABLE.getStatus().equals(template.getStatus());
String title = mailTemplateService.formatMailTemplateContent(template.getTitle(), templateParams);
String content = mailTemplateService.formatMailTemplateContent(template.getContent(), templateParams);
Long sendLogId = mailLogService.createMailLog(userId, userType, mail,
Long sendLogId = mailLogService.createMailLog(userId, userType, toMailSet, ccMailSet, bccMailSet,
account, template, content, templateParams, isSend);
// 发送 MQ 消息异步执行发送短信
if (isSend) {
mailProducer.sendMailSendMessage(sendLogId, mail, account.getId(),
template.getNickname(), title, content);
mailProducer.sendMailSendMessage(sendLogId, toMailSet, ccMailSet, bccMailSet,
account.getId(), template.getNickname(), title, content);
}
return sendLogId;
}
private String getUserMail(Long userId, Integer userType) {
if (userId == null || userType == null) {
return null;
}
if (UserTypeEnum.ADMIN.getValue().equals(userType)) {
AdminUserDO user = adminUserService.getUser(userId);
if (user != null) {
return user.getEmail();
}
}
if (UserTypeEnum.MEMBER.getValue().equals(userType)) {
return memberService.getMemberUserEmail(userId);
}
return null;
}
@Override
public void doSendMail(MailSendMessage message) {
// 1. 创建发送账号
@ -107,7 +122,7 @@ public class MailSendServiceImpl implements MailSendService {
MailAccount mailAccount = buildMailAccount(account, message.getNickname());
// 2. 发送邮件
try {
String messageId = MailUtil.send(mailAccount, message.getMail(),
String messageId = MailUtil.send(mailAccount, message.getToMails(), message.getCcMails(), message.getBccMails(),
message.getTitle(), message.getContent(), true);
// 3. 更新结果成功
mailLogService.updateMailSendResult(message.getLogId(), messageId, null);
@ -120,7 +135,7 @@ public class MailSendServiceImpl implements MailSendService {
private MailAccount buildMailAccount(MailAccountDO account, String nickname) {
String from = StrUtil.isNotEmpty(nickname) ? nickname + " <" + account.getMail() + ">" : account.getMail();
return new MailAccount().setFrom(from).setAuth(true)
.setUser(account.getUsername()).setPass(account.getPassword())
.setUser(account.getUsername()).setPass(account.getPassword().toCharArray())
.setHost(account.getHost()).setPort(account.getPort())
.setSslEnable(account.getSslEnable()).setStarttlsEnable(account.getStarttlsEnable());
}
@ -147,16 +162,8 @@ public class MailSendServiceImpl implements MailSendService {
return account;
}
@VisibleForTesting
String validateMail(String mail) {
if (StrUtil.isEmpty(mail)) {
throw exception(MAIL_SEND_MAIL_NOT_EXISTS);
}
return mail;
}
/**
* 校验邮件参数是否确实
* 校验邮件参数是否缺失
*
* @param template 邮箱模板
* @param templateParams 参数列表

View File

@ -255,9 +255,6 @@ public class MenuServiceImpl implements MenuService {
return;
}
// 如果 id 为空说明不用比较是否为相同 id 的菜单
if (id == null) {
throw exception(MENU_NAME_DUPLICATE);
}
if (!menu.getId().equals(id)) {
throw exception(MENU_NAME_DUPLICATE);
}

View File

@ -105,12 +105,40 @@ public class DeptServiceImplTest extends BaseDbUnitTest {
}
@Test
public void testValidateDeptExists_notFound() {
public void testDeleteDeptList_success() {
// mock 数据
DeptDO deptDO1 = randomPojo(DeptDO.class);
deptMapper.insert(deptDO1);
DeptDO deptDO2 = randomPojo(DeptDO.class);
deptMapper.insert(deptDO2);
// 准备参数
Long id = randomLongId();
List<Long> ids = Arrays.asList(deptDO1.getId(), deptDO2.getId());
// 调用
deptService.deleteDeptList(ids);
// 校验数据不存在了
assertNull(deptMapper.selectById(deptDO1.getId()));
assertNull(deptMapper.selectById(deptDO2.getId()));
}
@Test
public void testDeleteDeptList_exitsChildren() {
// mock 数据
DeptDO parentDept = randomPojo(DeptDO.class);
deptMapper.insert(parentDept);
DeptDO childrenDeptDO = randomPojo(DeptDO.class, o -> {
o.setParentId(parentDept.getId());
o.setStatus(randomCommonStatus());
});
deptMapper.insert(childrenDeptDO);
DeptDO anotherDept = randomPojo(DeptDO.class);
deptMapper.insert(anotherDept);
// 准备参数包含有子部门的 parentDept
List<Long> ids = Arrays.asList(parentDept.getId(), anotherDept.getId());
// 调用, 并断言异常
assertServiceException(() -> deptService.validateDeptExists(id), DEPT_NOT_FOUND);
assertServiceException(() -> deptService.deleteDeptList(ids), DEPT_EXITS_CHILDREN);
}
@Test

View File

@ -10,10 +10,12 @@ import cn.iocoder.yudao.module.system.dal.dataobject.mail.MailLogDO;
import cn.iocoder.yudao.module.system.dal.dataobject.mail.MailTemplateDO;
import cn.iocoder.yudao.module.system.dal.mysql.mail.MailLogMapper;
import cn.iocoder.yudao.module.system.enums.mail.MailSendStatusEnum;
import org.assertj.core.util.Lists;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Import;
import javax.annotation.Resource;
import jakarta.annotation.Resource;
import java.util.Collection;
import java.util.Map;
import static cn.hutool.core.util.RandomUtil.randomEle;
@ -43,7 +45,9 @@ public class MailLogServiceImplTest extends BaseDbUnitTest {
// 准备参数
Long userId = randomLongId();
Integer userType = randomEle(UserTypeEnum.values()).getValue();
String toMail = randomEmail();
Collection<String> toMails = Lists.newArrayList(randomEmail(), randomEmail());
Collection<String> ccMails = Lists.newArrayList(randomEmail());
Collection<String> bccMails = Lists.newArrayList(randomEmail());
MailAccountDO account = randomPojo(MailAccountDO.class);
MailTemplateDO template = randomPojo(MailTemplateDO.class);
String templateContent = randomString();
@ -52,14 +56,20 @@ public class MailLogServiceImplTest extends BaseDbUnitTest {
// mock 方法
// 调用
Long logId = mailLogService.createMailLog(userId, userType, toMail, account, template, templateContent, templateParams, isSend);
Long logId = mailLogService.createMailLog(userId, userType, toMails, ccMails, bccMails,
account, template, templateContent, templateParams, isSend);
// 断言
MailLogDO log = mailLogMapper.selectById(logId);
assertNotNull(log);
assertEquals(MailSendStatusEnum.INIT.getStatus(), log.getSendStatus());
assertEquals(userId, log.getUserId());
assertEquals(userType, log.getUserType());
assertEquals(toMail, log.getToMail());
assertEquals(toMails.size(), log.getToMails().size());
assertTrue(log.getToMails().containsAll(toMails));
assertEquals(ccMails.size(), log.getCcMails().size());
assertTrue(log.getCcMails().containsAll(ccMails));
assertEquals(bccMails.size(), log.getBccMails().size());
assertTrue(log.getBccMails().containsAll(bccMails));
assertEquals(account.getId(), log.getAccountId());
assertEquals(account.getMail(), log.getFromMail());
assertEquals(template.getId(), log.getTemplateId());
@ -136,7 +146,9 @@ public class MailLogServiceImplTest extends BaseDbUnitTest {
MailLogDO dbMailLog = randomPojo(MailLogDO.class, o -> { // 等会查询到
o.setUserId(1L);
o.setUserType(UserTypeEnum.ADMIN.getValue());
o.setToMail("768@qq.com");
o.setToMails(Lists.newArrayList("768@qq.com"));
o.setCcMails(Lists.newArrayList());
o.setBccMails(Lists.newArrayList());
o.setAccountId(10L);
o.setTemplateId(100L);
o.setSendStatus(MailSendStatusEnum.INIT.getStatus());
@ -148,8 +160,8 @@ public class MailLogServiceImplTest extends BaseDbUnitTest {
mailLogMapper.insert(cloneIgnoreId(dbMailLog, o -> o.setUserId(2L)));
// 测试 userType 不匹配
mailLogMapper.insert(cloneIgnoreId(dbMailLog, o -> o.setUserType(UserTypeEnum.MEMBER.getValue())));
// 测试 toMail 不匹配
mailLogMapper.insert(cloneIgnoreId(dbMailLog, o -> o.setToMail("788@.qq.com")));
// 测试 toMails 不匹配特殊find_in_set 无法单测
// mailLogMapper.insert(cloneIgnoreId(dbMailLog, o -> o.setToMails(Lists.newArrayList("788@qq.com"))));
// 测试 accountId 不匹配
mailLogMapper.insert(cloneIgnoreId(dbMailLog, o -> o.setAccountId(11L)));
// 测试 templateId 不匹配
@ -162,7 +174,7 @@ public class MailLogServiceImplTest extends BaseDbUnitTest {
MailLogPageReqVO reqVO = new MailLogPageReqVO();
reqVO.setUserId(1L);
reqVO.setUserType(UserTypeEnum.ADMIN.getValue());
reqVO.setToMail("768");
// reqVO.setToMail("768@qq.com");
reqVO.setAccountId(10L);
reqVO.setTemplateId(100L);
reqVO.setSendStatus(MailSendStatusEnum.INIT.getStatus());

View File

@ -20,6 +20,7 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
@ -65,14 +66,18 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest {
}
@Test
public void testSendSingleMailToAdmin() {
public void testSendSingleMail_success() {
// 准备参数
Long userId = randomLongId();
String templateCode = RandomUtils.randomString();
Map<String, Object> templateParams = MapUtil.<String, Object>builder().put("code", "1234")
.put("op", "login").build();
Collection<String> toMails = Lists.newArrayList("admin@test.com");
Collection<String> ccMails = Lists.newArrayList("cc@test.com");
Collection<String> bccMails = Lists.newArrayList("bcc@test.com");
// mock adminUserService 的方法
AdminUserDO user = randomPojo(AdminUserDO.class, o -> o.setMobile("15601691300"));
AdminUserDO user = randomPojo(AdminUserDO.class, o -> o.setEmail("admin@example.com"));
when(adminUserService.getUser(eq(userId))).thenReturn(user);
// mock MailTemplateService 的方法
@ -93,61 +98,27 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest {
when(mailAccountService.getMailAccountFromCache(eq(template.getAccountId()))).thenReturn(account);
// mock MailLogService 的方法
Long mailLogId = randomLongId();
when(mailLogService.createMailLog(eq(userId), eq(UserTypeEnum.ADMIN.getValue()), eq(user.getEmail()),
when(mailLogService.createMailLog(eq(userId), eq(UserTypeEnum.ADMIN.getValue()),
argThat(toMailSet -> toMailSet.contains(user.getEmail()) && toMailSet.contains("admin@test.com")),
argThat(ccMailSet -> ccMailSet.contains("cc@test.com")),
argThat(bccMailSet -> bccMailSet.contains("bcc@test.com")),
eq(account), eq(template), eq(content), eq(templateParams), eq(true))).thenReturn(mailLogId);
// 调用
Long resultMailLogId = mailSendService.sendSingleMailToAdmin(null, userId, templateCode, templateParams);
Long resultMailLogId = mailSendService.sendSingleMail(toMails, ccMails, bccMails, userId,
UserTypeEnum.ADMIN.getValue(), templateCode, templateParams);
// 断言
assertEquals(mailLogId, resultMailLogId);
// 断言调用
verify(mailProducer).sendMailSendMessage(eq(mailLogId), eq(user.getEmail()),
eq(account.getId()), eq(template.getNickname()), eq(title), eq(content));
}
@Test
public void testSendSingleMailToMember() {
// 准备参数
Long userId = randomLongId();
String templateCode = RandomUtils.randomString();
Map<String, Object> templateParams = MapUtil.<String, Object>builder().put("code", "1234")
.put("op", "login").build();
// mock memberService 的方法
String mail = randomEmail();
when(memberService.getMemberUserEmail(eq(userId))).thenReturn(mail);
// mock MailTemplateService 的方法
MailTemplateDO template = randomPojo(MailTemplateDO.class, o -> {
o.setStatus(CommonStatusEnum.ENABLE.getStatus());
o.setContent("验证码为{code}, 操作为{op}");
o.setParams(Lists.newArrayList("code", "op"));
});
when(mailTemplateService.getMailTemplateByCodeFromCache(eq(templateCode))).thenReturn(template);
String title = RandomUtils.randomString();
when(mailTemplateService.formatMailTemplateContent(eq(template.getTitle()), eq(templateParams)))
.thenReturn(title);
String content = RandomUtils.randomString();
when(mailTemplateService.formatMailTemplateContent(eq(template.getContent()), eq(templateParams)))
.thenReturn(content);
// mock MailAccountService 的方法
MailAccountDO account = randomPojo(MailAccountDO.class);
when(mailAccountService.getMailAccountFromCache(eq(template.getAccountId()))).thenReturn(account);
// mock MailLogService 的方法
Long mailLogId = randomLongId();
when(mailLogService.createMailLog(eq(userId), eq(UserTypeEnum.MEMBER.getValue()), eq(mail),
eq(account), eq(template), eq(content), eq(templateParams), eq(true))).thenReturn(mailLogId);
// 调用
Long resultMailLogId = mailSendService.sendSingleMailToMember(null, userId, templateCode, templateParams);
// 断言
assertEquals(mailLogId, resultMailLogId);
// 断言调用
verify(mailProducer).sendMailSendMessage(eq(mailLogId), eq(mail),
verify(mailProducer).sendMailSendMessage(eq(mailLogId),
argThat(toMailSet -> toMailSet.contains(user.getEmail()) && toMailSet.contains("admin@test.com")),
argThat(ccMailSet -> ccMailSet.contains("cc@test.com")),
argThat(bccMailSet -> bccMailSet.contains("bcc@test.com")),
eq(account.getId()), eq(template.getNickname()), eq(title), eq(content));
}
/**
* 发送成功短信模板开启时
* 发送成功当邮件模板开启时
*/
@Test
public void testSendSingleMail_successWhenMailTemplateEnable() {
@ -158,6 +129,8 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest {
String templateCode = RandomUtils.randomString();
Map<String, Object> templateParams = MapUtil.<String, Object>builder().put("code", "1234")
.put("op", "login").build();
Collection<String> toMails = Lists.newArrayList(mail);
// mock MailTemplateService 的方法
MailTemplateDO template = randomPojo(MailTemplateDO.class, o -> {
o.setStatus(CommonStatusEnum.ENABLE.getStatus());
@ -176,23 +149,29 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest {
when(mailAccountService.getMailAccountFromCache(eq(template.getAccountId()))).thenReturn(account);
// mock MailLogService 的方法
Long mailLogId = randomLongId();
when(mailLogService.createMailLog(eq(userId), eq(userType), eq(mail),
when(mailLogService.createMailLog(eq(userId), eq(userType),
argThat(toMailSet -> toMailSet.contains(mail)),
argThat(Collection::isEmpty),
argThat(Collection::isEmpty),
eq(account), eq(template), eq(content), eq(templateParams), eq(true))).thenReturn(mailLogId);
// 调用
Long resultMailLogId = mailSendService.sendSingleMail(mail, userId, userType, templateCode, templateParams);
Long resultMailLogId = mailSendService.sendSingleMail(toMails, null, null, userId, userType, templateCode, templateParams);
// 断言
assertEquals(mailLogId, resultMailLogId);
// 断言调用
verify(mailProducer).sendMailSendMessage(eq(mailLogId), eq(mail),
verify(mailProducer).sendMailSendMessage(eq(mailLogId),
argThat(toMailSet -> toMailSet.contains(mail)),
argThat(Collection::isEmpty),
argThat(Collection::isEmpty),
eq(account.getId()), eq(template.getNickname()), eq(title), eq(content));
}
/**
* 发送成功短信模板关闭时
* 发送成功邮件模板关闭时
*/
@Test
public void testSendSingleMail_successWhenSmsTemplateDisable() {
public void testSendSingleMail_successWhenMailTemplateDisable() {
// 准备参数
String mail = randomEmail();
Long userId = randomLongId();
@ -200,6 +179,8 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest {
String templateCode = RandomUtils.randomString();
Map<String, Object> templateParams = MapUtil.<String, Object>builder().put("code", "1234")
.put("op", "login").build();
Collection<String> toMails = Lists.newArrayList(mail);
// mock MailTemplateService 的方法
MailTemplateDO template = randomPojo(MailTemplateDO.class, o -> {
o.setStatus(CommonStatusEnum.DISABLE.getStatus());
@ -218,15 +199,18 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest {
when(mailAccountService.getMailAccountFromCache(eq(template.getAccountId()))).thenReturn(account);
// mock MailLogService 的方法
Long mailLogId = randomLongId();
when(mailLogService.createMailLog(eq(userId), eq(userType), eq(mail),
when(mailLogService.createMailLog(eq(userId), eq(userType),
argThat(toMailSet -> toMailSet.contains(mail)),
argThat(Collection::isEmpty),
argThat(Collection::isEmpty),
eq(account), eq(template), eq(content), eq(templateParams), eq(false))).thenReturn(mailLogId);
// 调用
Long resultMailLogId = mailSendService.sendSingleMail(mail, userId, userType, templateCode, templateParams);
Long resultMailLogId = mailSendService.sendSingleMail(toMails, null, null, userId, userType, templateCode, templateParams);
// 断言
assertEquals(mailLogId, resultMailLogId);
// 断言调用
verify(mailProducer, times(0)).sendMailSendMessage(anyLong(), anyString(),
verify(mailProducer, times(0)).sendMailSendMessage(anyLong(), any(), any(), any(),
anyLong(), anyString(), anyString(), anyString());
}
@ -255,12 +239,29 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest {
}
@Test
public void testValidateMail_notExists() {
public void testSendSingleMail_noValidEmail() {
// 准备参数
// mock 方法
Long userId = randomLongId();
String templateCode = RandomUtils.randomString();
Map<String, Object> templateParams = MapUtil.<String, Object>builder().put("code", "1234")
.put("op", "login").build();
Collection<String> toMails = Lists.newArrayList("invalid-email"); // 非法邮箱
// mock MailTemplateService 的方法
MailTemplateDO template = randomPojo(MailTemplateDO.class, o -> {
o.setStatus(CommonStatusEnum.ENABLE.getStatus());
o.setContent("验证码为{code}, 操作为{op}");
o.setParams(Lists.newArrayList("code", "op"));
});
when(mailTemplateService.getMailTemplateByCodeFromCache(eq(templateCode))).thenReturn(template);
// mock MailAccountService 的方法
MailAccountDO account = randomPojo(MailAccountDO.class);
when(mailAccountService.getMailAccountFromCache(eq(template.getAccountId()))).thenReturn(account);
// 调用并断言异常
assertServiceException(() -> mailSendService.validateMail(null),
assertServiceException(() -> mailSendService.sendSingleMail(toMails, null, null, userId,
UserTypeEnum.ADMIN.getValue(), templateCode, templateParams),
MAIL_SEND_MAIL_NOT_EXISTS);
}
@ -286,7 +287,8 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest {
assertEquals(account.getPort(), mailAccount.getPort());
assertEquals(account.getSslEnable(), mailAccount.isSslEnable());
return true;
}), eq(message.getMail()), eq(message.getTitle()), eq(message.getContent()), eq(true)))
}), eq(message.getToMails()), eq(message.getCcMails()), eq(message.getBccMails()),
eq(message.getTitle()), eq(message.getContent()), eq(true)))
.thenReturn(messageId);
// 调用
@ -317,7 +319,8 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest {
assertEquals(account.getPort(), mailAccount.getPort());
assertEquals(account.getSslEnable(), mailAccount.isSslEnable());
return true;
}), eq(message.getMail()), eq(message.getTitle()), eq(message.getContent()), eq(true))).thenThrow(e);
}), eq(message.getToMails()), eq(message.getCcMails()), eq(message.getBccMails()),
eq(message.getTitle()), eq(message.getContent()), eq(true))).thenThrow(e);
// 调用
mailSendService.doSendMail(message);

View File

@ -553,7 +553,9 @@ CREATE TABLE IF NOT EXISTS "system_mail_log" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
"user_id" bigint,
"user_type" varchar,
"to_mail" varchar NOT NULL,
"to_mails" varchar NOT NULL,
"cc_mails" varchar,
"bcc_mails" varchar,
"account_id" bigint NOT NULL,
"from_mail" varchar NOT NULL,
"template_id" bigint NOT NULL,

View File

@ -245,6 +245,13 @@ yudao:
security:
permit-all_urls:
- /admin-api/mp/open/** # 微信公众号开放平台,微信回调接口,不需要登录
api-encrypt:
enable: true # 是否开启 API 加密
algorithm: AES # 加密算法,支持 AES、RSA 等
request-key: 52549111389893486934626385991395 # 【AES 案例】请求加密的秘钥,,必须 16、24、32 位
response-key: 96103715984234343991809655248883 # 【AES 案例】响应加密的秘钥AES 案例,必须 16、24、32 位
# 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 案例】请求解密的私钥
# response-key: MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDh/CHyBcS/zEfVyINVA7+c9Xxl0CPdxPMK1OIjxaLy/7BLfbkoEpI8onQtjuzfpuxCraDem9bu3BMF0pMH95HytI3Vi0kGjaV+WLIalwgc2w37oA2sbsmKzQOP7SDLO5s2QJNAD7kVwd+Q5rqaLu2MO0xVv+0IUJhn83hClC0L5wIDAQAB # 【RSA 案例】响应加密的公钥
websocket:
enable: true # websocket的开关
path: /infra/ws # 路径