Merge remote-tracking branch 'origin/master'
commit
b836c28736
|
@ -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 '发送时间',
|
||||
|
|
|
@ -17,6 +17,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 前面
|
||||
|
|
|
@ -25,16 +25,16 @@ public class CommonResult<T> implements Serializable {
|
|||
* @see ErrorCode#getCode()
|
||||
*/
|
||||
private Integer code;
|
||||
/**
|
||||
* 返回数据
|
||||
*/
|
||||
private T data;
|
||||
/**
|
||||
* 错误提示,用户可阅读
|
||||
*
|
||||
* @see ErrorCode#getMsg() ()
|
||||
*/
|
||||
private String msg;
|
||||
/**
|
||||
* 返回数据
|
||||
*/
|
||||
private T data;
|
||||
|
||||
/**
|
||||
* 将传入的 result 对象,转换成另外一个泛型结果的对象
|
||||
|
|
|
@ -11,12 +11,12 @@ import java.util.List;
|
|||
@Data
|
||||
public final class PageResult<T> implements Serializable {
|
||||
|
||||
@Schema(description = "数据", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<T> list;
|
||||
|
||||
@Schema(description = "总量", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long total;
|
||||
|
||||
@Schema(description = "数据", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<T> list;
|
||||
|
||||
public PageResult() {
|
||||
}
|
||||
|
||||
|
|
|
@ -49,8 +49,15 @@ public class HttpUtils {
|
|||
return builder.build();
|
||||
}
|
||||
|
||||
private String append(String base, Map<String, ?> query, boolean fragment) {
|
||||
return append(base, query, null, fragment);
|
||||
public static String removeUrlQuery(String url) {
|
||||
if (!StrUtil.contains(url, '?')) {
|
||||
return url;
|
||||
}
|
||||
UrlBuilder builder = UrlBuilder.of(url, Charset.defaultCharset());
|
||||
// 移除 query、fragment
|
||||
builder.setQuery(null);
|
||||
builder.setFragment(null);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -96,7 +96,6 @@ public class EnvLoadBalancerClient implements ReactorServiceInstanceLoadBalancer
|
|||
List<ServiceInstance> chooseInstances = CollectionUtils.filterList(instances, instance -> StrUtil.isEmpty(EnvUtils.getTag(instance)));
|
||||
// 【重要】补充说明:如果希望在 chooseInstances 为空时,不允许打到有 tag 的实例,可以取消注释下面的代码
|
||||
if (CollUtil.isEmpty(chooseInstances)) {
|
||||
log.warn("[getInstanceResponseWithoutTag][serviceId({}) 没有不带 tag 的服务实例列表,直接使用所有服务实例列表]", serviceId);
|
||||
chooseInstances = instances;
|
||||
}
|
||||
|
||||
|
|
|
@ -21,7 +21,8 @@ import org.springframework.context.annotation.Bean;
|
|||
@AutoConfiguration
|
||||
@ConditionalOnClass(name = {
|
||||
"org.apache.skywalking.apm.toolkit.opentracing.SkywalkingTracer",
|
||||
"io.opentracing.Tracer"
|
||||
"io.opentracing.Tracer",
|
||||
"javax.servlet.Filter"
|
||||
})
|
||||
@EnableConfigurationProperties(TracerProperties.class)
|
||||
@ConditionalOnProperty(prefix = "yudao.tracer", value = "enable", matchIfMissing = true)
|
||||
|
|
|
@ -9,6 +9,7 @@ import cn.iocoder.yudao.framework.common.pojo.SortingField;
|
|||
import cn.iocoder.yudao.framework.mybatis.core.enums.DbTypeEnum;
|
||||
import com.baomidou.mybatisplus.annotation.DbType;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.OrderItem;
|
||||
import com.baomidou.mybatisplus.core.toolkit.StringPool;
|
||||
|
@ -47,16 +48,36 @@ public class MyBatisUtils {
|
|||
return page;
|
||||
}
|
||||
|
||||
@SuppressWarnings("PatternVariableCanBeUsed")
|
||||
public static <T> void addOrder(Wrapper<T> wrapper, Collection<SortingField> sortingFields) {
|
||||
if (CollUtil.isEmpty(sortingFields)) {
|
||||
return;
|
||||
}
|
||||
QueryWrapper<T> query = (QueryWrapper<T>) wrapper;
|
||||
for (SortingField sortingField : sortingFields) {
|
||||
query.orderBy(true,
|
||||
SortingField.ORDER_ASC.equals(sortingField.getOrder()),
|
||||
StrUtil.toUnderlineCase(sortingField.getField()));
|
||||
if (wrapper instanceof QueryWrapper) {
|
||||
QueryWrapper<T> query = (QueryWrapper<T>) wrapper;
|
||||
for (SortingField sortingField : sortingFields) {
|
||||
query.orderBy(true,
|
||||
SortingField.ORDER_ASC.equals(sortingField.getOrder()),
|
||||
StrUtil.toUnderlineCase(sortingField.getField()));
|
||||
}
|
||||
} else if (wrapper instanceof LambdaQueryWrapper) {
|
||||
// LambdaQueryWrapper 不直接支持字符串字段排序,使用 last 方法拼接 ORDER BY
|
||||
LambdaQueryWrapper<T> lambdaQuery = (LambdaQueryWrapper<T>) wrapper;
|
||||
StringBuilder orderBy = new StringBuilder();
|
||||
for (SortingField sortingField : sortingFields) {
|
||||
if (StrUtil.isNotEmpty(orderBy)) {
|
||||
orderBy.append(", ");
|
||||
}
|
||||
orderBy.append(StrUtil.toUnderlineCase(sortingField.getField()))
|
||||
.append(" ")
|
||||
.append(SortingField.ORDER_ASC.equals(sortingField.getOrder()) ? "ASC" : "DESC");
|
||||
}
|
||||
lambdaQuery.last("ORDER BY " + orderBy);
|
||||
// 另外个思路:https://blog.csdn.net/m0_59084856/article/details/138450913
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unsupported wrapper type: " + wrapper.getClass().getName());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
package cn.iocoder.yudao.framework.encrypt.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
}
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
|
||||
}
|
|
@ -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 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;
|
||||
|
||||
/**
|
||||
* 解密请求 {@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,允许 HEX、BASE64 传输
|
||||
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) {
|
||||
}
|
||||
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,154 @@
|
|||
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 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 javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
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 {
|
||||
// 补充说明:如果要支持 SM2、SM4 等算法,可在此处增加对应实例的创建,并添加相应的 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 请求
|
||||
*/
|
||||
@SuppressWarnings("PatternVariableCanBeUsed")
|
||||
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 handlerMethod = (HandlerMethod) handler;
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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 javax.servlet.ServletOutputStream;
|
||||
import javax.servlet.WriteListener;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.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);
|
||||
}
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* HTTP API 加密组件:支持 Request 和 Response 的加密、解密
|
||||
*/
|
||||
package cn.iocoder.yudao.framework.encrypt;
|
|
@ -8,7 +8,6 @@ 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() {
|
||||
|
|
|
@ -32,6 +32,7 @@ import org.springframework.web.bind.MissingServletRequestParameterException;
|
|||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
import org.springframework.web.servlet.NoHandlerFoundException;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
@ -92,6 +93,9 @@ public class GlobalExceptionHandler {
|
|||
if (ex instanceof ValidationException) {
|
||||
return validationException((ValidationException) ex);
|
||||
}
|
||||
if (ex instanceof MaxUploadSizeExceededException) {
|
||||
return maxUploadSizeExceededExceptionHandler((MaxUploadSizeExceededException) ex);
|
||||
}
|
||||
if (ex instanceof NoHandlerFoundException) {
|
||||
return noHandlerFoundExceptionHandler((NoHandlerFoundException) ex);
|
||||
}
|
||||
|
@ -107,9 +111,6 @@ public class GlobalExceptionHandler {
|
|||
if (ex instanceof AccessDeniedException) {
|
||||
return accessDeniedExceptionHandler(request, (AccessDeniedException) ex);
|
||||
}
|
||||
if (ex instanceof UncheckedExecutionException && ex.getCause() != ex) {
|
||||
return allExceptionHandler(request, ex.getCause());
|
||||
}
|
||||
return defaultExceptionHandler(request, ex);
|
||||
}
|
||||
|
||||
|
@ -209,6 +210,14 @@ public class GlobalExceptionHandler {
|
|||
return CommonResult.error(BAD_REQUEST);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理上传文件过大异常
|
||||
*/
|
||||
@ExceptionHandler(MaxUploadSizeExceededException.class)
|
||||
public CommonResult<?> maxUploadSizeExceededExceptionHandler(MaxUploadSizeExceededException ex) {
|
||||
return CommonResult.error(BAD_REQUEST.getCode(), "上传文件过大,请调整后重试");
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 SpringMVC 请求地址不存在
|
||||
*
|
||||
|
@ -296,6 +305,12 @@ public class GlobalExceptionHandler {
|
|||
*/
|
||||
@ExceptionHandler(value = Exception.class)
|
||||
public CommonResult<?> defaultExceptionHandler(HttpServletRequest req, Throwable ex) {
|
||||
// 特殊:如果是 ServiceException 的异常,则直接返回
|
||||
// 例如说:https://gitee.com/zhijiantianya/yudao-cloud/issues/ICSSRM、https://gitee.com/zhijiantianya/yudao-cloud/issues/ICT6FM
|
||||
if (ex.getCause() != null && ex.getCause() instanceof ServiceException) {
|
||||
return serviceExceptionHandler((ServiceException) ex.getCause());
|
||||
}
|
||||
|
||||
// 情况一:处理表不存在的异常
|
||||
CommonResult<?> tableNotExistsResult = handleTableNotExists(ex);
|
||||
if (tableNotExistsResult != null) {
|
||||
|
|
|
@ -4,4 +4,5 @@ cn.iocoder.yudao.framework.swagger.config.YudaoSwaggerAutoConfiguration
|
|||
cn.iocoder.yudao.framework.web.config.YudaoWebAutoConfiguration
|
||||
cn.iocoder.yudao.framework.apilog.config.YudaoApiLogRpcAutoConfiguration
|
||||
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
|
|
@ -69,11 +69,10 @@ spring:
|
|||
password: 123456
|
||||
|
||||
# Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优
|
||||
data:
|
||||
redis:
|
||||
host: 400-infra.server.iocoder.cn # 地址
|
||||
port: 6379 # 端口
|
||||
database: 1 # 数据库索引
|
||||
redis:
|
||||
host: 400-infra.server.iocoder.cn # 地址
|
||||
port: 6379 # 端口
|
||||
database: 1 # 数据库索引
|
||||
# password: 123456 # 密码,建议生产环境开启
|
||||
|
||||
--- #################### MQ 消息队列相关配置 ####################
|
||||
|
|
|
@ -78,12 +78,11 @@ spring:
|
|||
password: 123456
|
||||
|
||||
# Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优
|
||||
data:
|
||||
redis:
|
||||
host: 127.0.0.1 # 地址
|
||||
port: 6379 # 端口
|
||||
database: 0 # 数据库索引
|
||||
# password: 123456 # 密码,建议生产环境开启
|
||||
redis:
|
||||
host: 127.0.0.1 # 地址
|
||||
port: 6379 # 端口
|
||||
database: 0 # 数据库索引
|
||||
# password: 123456 # 密码,建议生产环境开启
|
||||
|
||||
--- #################### MQ 消息队列相关配置 ####################
|
||||
|
||||
|
|
|
@ -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, "动态表单不存在");
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -172,6 +172,11 @@ public class BpmProcessDefinitionInfoDO extends BaseDO {
|
|||
*/
|
||||
private Boolean allowCancelRunningProcess;
|
||||
|
||||
/**
|
||||
* 是否允许审批人撤回任务
|
||||
*/
|
||||
private Boolean allowWithdrawTask;
|
||||
|
||||
/**
|
||||
* 流程 ID 规则
|
||||
*/
|
||||
|
|
|
@ -797,9 +797,9 @@ public class BpmnModelUtils {
|
|||
|
||||
// 情况:StartEvent/EndEvent/UserTask/ServiceTask
|
||||
if (currentElement instanceof StartEvent
|
||||
|| currentElement instanceof EndEvent
|
||||
|| currentElement instanceof UserTask
|
||||
|| currentElement instanceof ServiceTask) {
|
||||
|| currentElement instanceof EndEvent
|
||||
|| currentElement instanceof UserTask
|
||||
|| currentElement instanceof ServiceTask) {
|
||||
// 添加节点
|
||||
FlowNode flowNode = (FlowNode) currentElement;
|
||||
resultElements.add(flowNode);
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理排它网关
|
||||
*
|
||||
|
@ -938,8 +981,8 @@ public class BpmnModelUtils {
|
|||
*/
|
||||
private static SequenceFlow findMatchSequenceFlowByExclusiveGateway(Gateway gateway, Map<String, Object> variables) {
|
||||
SequenceFlow matchSequenceFlow = CollUtil.findOne(gateway.getOutgoingFlows(),
|
||||
flow -> ObjUtil.notEqual(gateway.getDefaultFlow(), flow.getId())
|
||||
&& (evalConditionExpress(variables, flow.getConditionExpression())));
|
||||
flow -> ObjUtil.notEqual(gateway.getDefaultFlow(), flow.getId())
|
||||
&& (evalConditionExpress(variables, flow.getConditionExpression())));
|
||||
if (matchSequenceFlow == null) {
|
||||
matchSequenceFlow = CollUtil.findOne(gateway.getOutgoingFlows(),
|
||||
flow -> ObjUtil.equal(gateway.getDefaultFlow(), flow.getId()));
|
||||
|
|
|
@ -67,7 +67,6 @@ import java.util.*;
|
|||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*;
|
||||
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
|
||||
import static cn.iocoder.yudao.module.bpm.controller.admin.task.vo.instance.BpmApprovalDetailRespVO.ActivityNode;
|
||||
import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.*;
|
||||
import static cn.iocoder.yudao.module.bpm.framework.flowable.core.enums.BpmnModelConstants.START_USER_NODE_ID;
|
||||
|
@ -221,11 +220,6 @@ public class BpmProcessInstanceServiceImpl implements BpmProcessInstanceService
|
|||
List<ActivityNode> simulateActivityNodes = getSimulateApproveNodeList(startUserId, bpmnModel,
|
||||
processDefinitionInfo,
|
||||
processVariables, activities);
|
||||
// 3.3 如果是发起动作,activityId 为开始节点,不校验审批人自选节点
|
||||
if (ObjUtil.equals(reqVO.getActivityId(), BpmnModelConstants.START_USER_NODE_ID)) {
|
||||
simulateActivityNodes.removeIf(node ->
|
||||
BpmTaskCandidateStrategyEnum.APPROVE_USER_SELECT.getStrategy().equals(node.getCandidateStrategy()));
|
||||
}
|
||||
|
||||
// 4. 拼接最终数据
|
||||
return buildApprovalDetail(reqVO, bpmnModel, processDefinition, processDefinitionInfo, historicProcessInstance,
|
||||
|
@ -415,7 +409,9 @@ public class BpmProcessInstanceServiceImpl implements BpmProcessInstanceService
|
|||
endActivities.forEach(activity -> {
|
||||
// StartEvent:只处理 BPMN 的场景。因为,SIMPLE 情况下,已经有 START_USER_NODE 节点
|
||||
if (ELEMENT_EVENT_START.equals(activity.getActivityType())
|
||||
&& BpmModelTypeEnum.BPMN.getType().equals(processDefinitionInfo.getModelType())) {
|
||||
&& BpmModelTypeEnum.BPMN.getType().equals(processDefinitionInfo.getModelType())
|
||||
&& !CollUtil.contains(activities, // 特殊:如果已经存在用户手动创建的 START_USER_NODE_ID 节点,则忽略 StartEvent
|
||||
historicActivity -> historicActivity.getActivityId().equals(START_USER_NODE_ID))) {
|
||||
ActivityNodeTask startTask = new ActivityNodeTask().setId(BpmnModelConstants.START_USER_NODE_ID)
|
||||
.setAssignee(startUserId).setStatus(BpmTaskStatusEnum.APPROVE.getStatus());
|
||||
ActivityNode startNode = new ActivityNode().setId(startTask.getId())
|
||||
|
@ -555,7 +551,8 @@ public class BpmProcessInstanceServiceImpl implements BpmProcessInstanceService
|
|||
// 情况一:BPMN 设计器
|
||||
if (Objects.equals(BpmModelTypeEnum.BPMN.getType(), processDefinitionInfo.getModelType())) {
|
||||
List<FlowElement> flowElements = BpmnModelUtils.simulateProcess(bpmnModel, processVariables);
|
||||
return convertList(flowElements, flowElement -> buildNotRunApproveNodeForBpmn(startUserId, bpmnModel,
|
||||
return convertList(flowElements, flowElement -> buildNotRunApproveNodeForBpmn(
|
||||
startUserId, bpmnModel, flowElements,
|
||||
processDefinitionInfo, processVariables, flowElement, runActivityIds));
|
||||
}
|
||||
// 情况二:SIMPLE 设计器
|
||||
|
@ -563,7 +560,8 @@ public class BpmProcessInstanceServiceImpl implements BpmProcessInstanceService
|
|||
BpmSimpleModelNodeVO simpleModel = JsonUtils.parseObject(processDefinitionInfo.getSimpleModel(),
|
||||
BpmSimpleModelNodeVO.class);
|
||||
List<BpmSimpleModelNodeVO> simpleNodes = SimpleModelUtils.simulateProcess(simpleModel, processVariables);
|
||||
return convertList(simpleNodes, simpleNode -> buildNotRunApproveNodeForSimple(startUserId, bpmnModel,
|
||||
return convertList(simpleNodes, simpleNode -> buildNotRunApproveNodeForSimple(
|
||||
startUserId, bpmnModel,
|
||||
processDefinitionInfo, processVariables, simpleNode, runActivityIds));
|
||||
}
|
||||
throw new IllegalArgumentException("未知设计器类型:" + processDefinitionInfo.getModelType());
|
||||
|
@ -618,8 +616,9 @@ public class BpmProcessInstanceServiceImpl implements BpmProcessInstanceService
|
|||
return null;
|
||||
}
|
||||
|
||||
private ActivityNode buildNotRunApproveNodeForBpmn(Long startUserId, BpmnModel bpmnModel,
|
||||
BpmProcessDefinitionInfoDO processDefinitionInfo, Map<String, Object> processVariables,
|
||||
private ActivityNode buildNotRunApproveNodeForBpmn(Long startUserId, BpmnModel bpmnModel, List<FlowElement> flowElements,
|
||||
BpmProcessDefinitionInfoDO processDefinitionInfo,
|
||||
Map<String, Object> processVariables,
|
||||
FlowElement node, Set<String> runActivityIds) {
|
||||
if (runActivityIds.contains(node.getId())) {
|
||||
return null;
|
||||
|
@ -634,6 +633,10 @@ public class BpmProcessInstanceServiceImpl implements BpmProcessInstanceService
|
|||
|
||||
// 1. 开始节点
|
||||
if (node instanceof StartEvent) {
|
||||
if (CollUtil.contains(flowElements, // 特殊:如果已经存在用户手动创建的 START_USER_NODE_ID 节点,则忽略 StartEvent
|
||||
flowElement -> flowElement.getId().equals(START_USER_NODE_ID))) {
|
||||
return null;
|
||||
}
|
||||
return activityNode.setName(BpmSimpleModelNodeTypeEnum.START_USER_NODE.getName())
|
||||
.setNodeType(BpmSimpleModelNodeTypeEnum.START_USER_NODE.getType());
|
||||
}
|
||||
|
|
|
@ -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 事件相关方法 ==========
|
||||
|
||||
/**
|
||||
|
|
|
@ -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版本解决taskCreatedAfter、taskCreatedBefore问题,升级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());
|
||||
|
|
|
@ -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 = "转移线索";
|
||||
|
|
|
@ -106,14 +106,14 @@ 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());
|
||||
}
|
||||
|
||||
private void validateRelationDataExists(CrmClueSaveReqVO reqVO) {
|
||||
// 校验负责人
|
||||
if (Objects.nonNull(reqVO.getOwnerUserId()) &&
|
||||
Objects.isNull(adminUserApi.getUser(reqVO.getOwnerUserId()).getCheckedData())) {
|
||||
Objects.isNull(adminUserApi.getUser(reqVO.getOwnerUserId()))) {
|
||||
throw exception(USER_NOT_EXISTS);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -159,10 +159,10 @@ public class CrmContactServiceImpl implements CrmContactService {
|
|||
// 2. 删除联系人
|
||||
contactMapper.deleteById(id);
|
||||
|
||||
// 4.1 删除数据权限
|
||||
permissionService.deletePermission(CrmBizTypeEnum.CRM_CONTACT.getType(), id);
|
||||
// 4.2 删除商机关联
|
||||
// 4.1 删除商机关联
|
||||
contactBusinessService.deleteContactBusinessByContactId(id);
|
||||
// 4.2 删除数据权限
|
||||
permissionService.deletePermission(CrmBizTypeEnum.CRM_CONTACT.getType(), id);
|
||||
|
||||
// 记录操作日志上下文
|
||||
LogRecordContext.putVariable("contactName", contact.getName());
|
||||
|
|
|
@ -76,6 +76,11 @@ spring:
|
|||
--- #################### MQ 消息队列相关配置 ####################
|
||||
|
||||
--- #################### 定时任务相关配置 ####################
|
||||
xxl:
|
||||
job:
|
||||
enabled: false # 是否开启调度中心,默认为 true 开启
|
||||
admin:
|
||||
addresses: http://127.0.0.1:9090/xxl-job-admin # 调度中心部署跟地址
|
||||
|
||||
--- #################### 服务保障相关配置 ####################
|
||||
|
||||
|
|
|
@ -21,11 +21,10 @@ spring:
|
|||
schema-locations: classpath:/sql/create_tables.sql
|
||||
|
||||
# Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优
|
||||
data:
|
||||
redis:
|
||||
host: 127.0.0.1 # 地址
|
||||
port: 16379 # 端口(单元测试,使用 16379 端口)
|
||||
database: 0 # 数据库索引
|
||||
redis:
|
||||
host: 127.0.0.1 # 地址
|
||||
port: 16379 # 端口(单元测试,使用 16379 端口)
|
||||
database: 0 # 数据库索引
|
||||
|
||||
mybatis-plus:
|
||||
lazy-initialization: true # 单元测试,设置 MyBatis Mapper 延迟加载,加速每个单元测试
|
||||
|
|
|
@ -67,7 +67,7 @@ spring:
|
|||
password: 123456
|
||||
|
||||
# Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优
|
||||
data:
|
||||
redis:
|
||||
host: 400-infra.server.iocoder.cn # 地址
|
||||
port: 6379 # 端口
|
||||
database: 1 # 数据库索引
|
||||
|
@ -76,6 +76,11 @@ spring:
|
|||
--- #################### MQ 消息队列相关配置 ####################
|
||||
|
||||
--- #################### 定时任务相关配置 ####################
|
||||
xxl:
|
||||
job:
|
||||
enabled: false # 是否开启调度中心,默认为 true 开启
|
||||
admin:
|
||||
addresses: http://127.0.0.1:9090/xxl-job-admin # 调度中心部署跟地址
|
||||
|
||||
--- #################### 服务保障相关配置 ####################
|
||||
|
||||
|
|
|
@ -6,8 +6,10 @@ import cn.iocoder.yudao.module.infra.enums.ApiConstants;
|
|||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
|
@ -57,4 +59,16 @@ public interface FileApi {
|
|||
@Operation(summary = "保存文件,并返回文件的访问路径")
|
||||
CommonResult<String> createFile(@Valid @RequestBody FileCreateReqDTO createReqDTO);
|
||||
|
||||
/**
|
||||
* 生成文件预签名地址,用于读取
|
||||
*
|
||||
* @param url 完整的文件访问地址
|
||||
* @param expirationSeconds 访问有效期,单位秒
|
||||
* @return 文件预签名地址
|
||||
*/
|
||||
@GetMapping(PREFIX + "/presigned-url")
|
||||
@Operation(summary = "生成文件预签名地址,用于读取")
|
||||
CommonResult<String> presignGetUrl(@NotEmpty(message = "URL 不能为空") @RequestParam("url") String url,
|
||||
Integer expirationSeconds);
|
||||
|
||||
}
|
||||
|
|
|
@ -23,4 +23,9 @@ public class FileApiImpl implements FileApi {
|
|||
createReqDTO.getDirectory(), createReqDTO.getType()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommonResult<String> presignGetUrl(String url, Integer expirationSeconds) {
|
||||
return success(fileService.presignGetUrl(url, expirationSeconds));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -43,7 +43,7 @@ public class FileController {
|
|||
|
||||
@PostMapping("/upload")
|
||||
@Operation(summary = "上传文件", description = "模式一:后端上传文件")
|
||||
public CommonResult<String> uploadFile(FileUploadReqVO uploadReqVO) throws Exception {
|
||||
public CommonResult<String> uploadFile(@Valid FileUploadReqVO uploadReqVO) throws Exception {
|
||||
MultipartFile file = uploadReqVO.getFile();
|
||||
byte[] content = IoUtil.readBytes(file.getInputStream());
|
||||
return success(fileService.createFile(content, file.getOriginalFilename(),
|
||||
|
@ -51,7 +51,7 @@ public class FileController {
|
|||
}
|
||||
|
||||
@GetMapping("/presigned-url")
|
||||
@Operation(summary = "获取文件预签名地址", description = "模式二:前端上传文件:用于前端直接上传七牛、阿里云 OSS 等文件存储器")
|
||||
@Operation(summary = "获取文件预签名地址(上传)", description = "模式二:前端上传文件:用于前端直接上传七牛、阿里云 OSS 等文件存储器")
|
||||
@Parameters({
|
||||
@Parameter(name = "name", description = "文件名称", required = true),
|
||||
@Parameter(name = "directory", description = "文件目录")
|
||||
|
@ -59,7 +59,7 @@ public class FileController {
|
|||
public CommonResult<FilePresignedUrlRespVO> getFilePresignedUrl(
|
||||
@RequestParam("name") String name,
|
||||
@RequestParam(value = "directory", required = false) String directory) {
|
||||
return success(fileService.getFilePresignedUrl(name, directory));
|
||||
return success(fileService.presignPutUrl(name, directory));
|
||||
}
|
||||
|
||||
@PostMapping("/create")
|
||||
|
|
|
@ -42,7 +42,7 @@ public class AppFileController {
|
|||
}
|
||||
|
||||
@GetMapping("/presigned-url")
|
||||
@Operation(summary = "获取文件预签名地址", description = "模式二:前端上传文件:用于前端直接上传七牛、阿里云 OSS 等文件存储器")
|
||||
@Operation(summary = "获取文件预签名地址(上传)", description = "模式二:前端上传文件:用于前端直接上传七牛、阿里云 OSS 等文件存储器")
|
||||
@Parameters({
|
||||
@Parameter(name = "name", description = "文件名称", required = true),
|
||||
@Parameter(name = "directory", description = "文件目录")
|
||||
|
@ -50,7 +50,7 @@ public class AppFileController {
|
|||
public CommonResult<FilePresignedUrlRespVO> getFilePresignedUrl(
|
||||
@RequestParam("name") String name,
|
||||
@RequestParam(value = "directory", required = false) String directory) {
|
||||
return success(fileService.getFilePresignedUrl(name, directory));
|
||||
return success(fileService.presignPutUrl(name, directory));
|
||||
}
|
||||
|
||||
@PostMapping("/create")
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
package cn.iocoder.yudao.module.infra.framework.file.core.client;
|
||||
|
||||
import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.FilePresignedUrlRespDTO;
|
||||
|
||||
/**
|
||||
* 文件客户端
|
||||
*
|
||||
|
@ -42,13 +40,26 @@ public interface FileClient {
|
|||
*/
|
||||
byte[] getContent(String path) throws Exception;
|
||||
|
||||
// ========== 文件签名,目前仅 S3 支持 ==========
|
||||
|
||||
/**
|
||||
* 获得文件预签名地址
|
||||
* 获得文件预签名地址,用于上传
|
||||
*
|
||||
* @param path 相对路径
|
||||
* @return 文件预签名地址
|
||||
*/
|
||||
default FilePresignedUrlRespDTO getPresignedObjectUrl(String path) throws Exception {
|
||||
default String presignPutUrl(String path) {
|
||||
throw new UnsupportedOperationException("不支持的操作");
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件预签名地址,用于读取
|
||||
*
|
||||
* @param url 完整的文件访问地址
|
||||
* @param expirationSeconds 访问有效期,单位秒
|
||||
* @return 文件预签名地址
|
||||
*/
|
||||
default String presignGetUrl(String url, Integer expirationSeconds) {
|
||||
throw new UnsupportedOperationException("不支持的操作");
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package cn.iocoder.yudao.module.infra.framework.file.core.client.local;
|
||||
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.io.IORuntimeException;
|
||||
import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient;
|
||||
|
||||
import java.io.File;
|
||||
|
@ -38,7 +39,14 @@ public class LocalFileClient extends AbstractFileClient<LocalFileClientConfig> {
|
|||
@Override
|
||||
public byte[] getContent(String path) {
|
||||
String filePath = getFilePath(path);
|
||||
return FileUtil.readBytes(filePath);
|
||||
try {
|
||||
return FileUtil.readBytes(filePath);
|
||||
} catch (IORuntimeException ex) {
|
||||
if (ex.getMessage().startsWith("File not exist:")) {
|
||||
return null;
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
private String getFilePath(String path) {
|
||||
|
|
|
@ -1,29 +0,0 @@
|
|||
package cn.iocoder.yudao.module.infra.framework.file.core.client.s3;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 文件预签名地址 Response DTO
|
||||
*
|
||||
* @author owen
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class FilePresignedUrlRespDTO {
|
||||
|
||||
/**
|
||||
* 文件上传 URL(用于上传)
|
||||
*
|
||||
* 例如说:
|
||||
*/
|
||||
private String uploadUrl;
|
||||
|
||||
/**
|
||||
* 文件 URL(用于读取、下载等)
|
||||
*/
|
||||
private String url;
|
||||
|
||||
}
|
|
@ -1,8 +1,10 @@
|
|||
package cn.iocoder.yudao.module.infra.framework.file.core.client.s3;
|
||||
|
||||
import cn.hutool.core.io.IoUtil;
|
||||
import cn.hutool.core.util.BooleanUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
|
||||
import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient;
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
|
||||
|
@ -15,9 +17,11 @@ import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
|||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
|
||||
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
|
||||
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
|
@ -27,6 +31,8 @@ import java.time.Duration;
|
|||
*/
|
||||
public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
|
||||
|
||||
private static final Duration EXPIRATION_DEFAULT = Duration.ofHours(24);
|
||||
|
||||
private S3Client client;
|
||||
private S3Presigner presigner;
|
||||
|
||||
|
@ -75,7 +81,7 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
|
|||
// 上传文件
|
||||
client.putObject(putRequest, RequestBody.fromBytes(content));
|
||||
// 拼接返回路径
|
||||
return config.getDomain() + "/" + path;
|
||||
return presignGetUrl(path, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -97,23 +103,33 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
|
|||
}
|
||||
|
||||
@Override
|
||||
public FilePresignedUrlRespDTO getPresignedObjectUrl(String path) {
|
||||
Duration expiration = Duration.ofHours(24);
|
||||
return new FilePresignedUrlRespDTO(getPresignedUrl(path, expiration), config.getDomain() + "/" + path);
|
||||
public String presignPutUrl(String path) {
|
||||
return presigner.presignPutObject(PutObjectPresignRequest.builder()
|
||||
.signatureDuration(EXPIRATION_DEFAULT)
|
||||
.putObjectRequest(b -> b.bucket(config.getBucket()).key(path)).build())
|
||||
.url().toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成动态的预签名上传 URL
|
||||
*
|
||||
* @param path 相对路径
|
||||
* @param expiration 过期时间
|
||||
* @return 生成的上传 URL
|
||||
*/
|
||||
private String getPresignedUrl(String path, Duration expiration) {
|
||||
return presigner.presignPutObject(PutObjectPresignRequest.builder()
|
||||
.signatureDuration(expiration)
|
||||
.putObjectRequest(b -> b.bucket(config.getBucket()).key(path))
|
||||
.build()).url().toString();
|
||||
@Override
|
||||
public String presignGetUrl(String url, Integer expirationSeconds) {
|
||||
// 1. 将 url 转换为 path
|
||||
String path = StrUtil.removePrefix(url, config.getDomain() + "/");
|
||||
path = HttpUtils.removeUrlQuery(path);
|
||||
|
||||
// 2.1 情况一:公开访问:无需签名
|
||||
// 考虑到老版本的兼容,所以必须是 config.getEnablePublicAccess() 为 false 时,才进行签名
|
||||
if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) {
|
||||
return config.getDomain() + "/" + path;
|
||||
}
|
||||
|
||||
// 2.2 情况二:私有访问:生成 GET 预签名 URL
|
||||
String finalPath = path;
|
||||
Duration expiration = expirationSeconds != null ? Duration.ofSeconds(expirationSeconds) : EXPIRATION_DEFAULT;
|
||||
URL signedUrl = presigner.presignGetObject(GetObjectPresignRequest.builder()
|
||||
.signatureDuration(expiration)
|
||||
.getObjectRequest(b -> b.bucket(config.getBucket()).key(finalPath)).build())
|
||||
.url();
|
||||
return signedUrl.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -73,6 +73,15 @@ public class S3FileClientConfig implements FileClientConfig {
|
|||
@NotNull(message = "enablePathStyleAccess 不能为空")
|
||||
private Boolean enablePathStyleAccess;
|
||||
|
||||
/**
|
||||
* 是否公开访问
|
||||
*
|
||||
* true:公开访问,所有人都可以访问
|
||||
* false:私有访问,只有配置的 accessKey 才可以访问
|
||||
*/
|
||||
@NotNull(message = "是否公开访问不能为空")
|
||||
private Boolean enablePublicAccess;
|
||||
|
||||
@SuppressWarnings("RedundantIfStatement")
|
||||
@AssertTrue(message = "domain 不能为空")
|
||||
@JsonIgnore
|
||||
|
|
|
@ -80,9 +80,15 @@ public class FileTypeUtils {
|
|||
*/
|
||||
public static void writeAttachment(HttpServletResponse response, String filename, byte[] content) throws IOException {
|
||||
// 设置 header 和 contentType
|
||||
response.setHeader("Content-Disposition", "attachment;filename=" + HttpUtils.encodeUtf8(filename));
|
||||
String contentType = getMineType(content, filename);
|
||||
response.setContentType(contentType);
|
||||
// 设置内容显示、下载文件名:https://www.cnblogs.com/wq-9/articles/12165056.html
|
||||
if (StrUtil.containsIgnoreCase(contentType, "image/")) {
|
||||
// 参见 https://github.com/YunaiV/ruoyi-vue-pro/issues/692 讨论
|
||||
response.setHeader("Content-Disposition", "inline;filename=" + HttpUtils.encodeUtf8(filename));
|
||||
} else {
|
||||
response.setHeader("Content-Disposition", "attachment;filename=" + HttpUtils.encodeUtf8(filename));
|
||||
}
|
||||
// 针对 video 的特殊处理,解决视频地址在移动端播放的兼容性问题
|
||||
if (StrUtil.containsIgnoreCase(contentType, "video")) {
|
||||
response.setHeader("Content-Length", String.valueOf(content.length));
|
||||
|
|
|
@ -38,14 +38,22 @@ public interface FileService {
|
|||
String name, String directory, String type);
|
||||
|
||||
/**
|
||||
* 生成文件预签名地址信息
|
||||
* 生成文件预签名地址信息,用于上传
|
||||
*
|
||||
* @param name 文件名
|
||||
* @param directory 目录
|
||||
* @return 预签名地址信息
|
||||
*/
|
||||
FilePresignedUrlRespVO getFilePresignedUrl(@NotEmpty(message = "文件名不能为空") String name,
|
||||
String directory);
|
||||
FilePresignedUrlRespVO presignPutUrl(@NotEmpty(message = "文件名不能为空") String name,
|
||||
String directory);
|
||||
/**
|
||||
* 生成文件预签名地址信息,用于读取
|
||||
*
|
||||
* @param url 完整的文件访问地址
|
||||
* @param expirationSeconds 访问有效期,单位秒
|
||||
* @return 文件预签名地址
|
||||
*/
|
||||
String presignGetUrl(String url, Integer expirationSeconds);
|
||||
|
||||
/**
|
||||
* 创建文件
|
||||
|
|
|
@ -6,6 +6,7 @@ import cn.hutool.core.lang.Assert;
|
|||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FileCreateReqVO;
|
||||
import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO;
|
||||
|
@ -13,7 +14,6 @@ import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePresigned
|
|||
import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO;
|
||||
import cn.iocoder.yudao.module.infra.dal.mysql.file.FileMapper;
|
||||
import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient;
|
||||
import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.FilePresignedUrlRespDTO;
|
||||
import cn.iocoder.yudao.module.infra.framework.file.core.utils.FileTypeUtils;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import javax.annotation.Resource;
|
||||
|
@ -126,19 +126,27 @@ public class FileServiceImpl implements FileService {
|
|||
|
||||
@Override
|
||||
@SneakyThrows
|
||||
public FilePresignedUrlRespVO getFilePresignedUrl(String name, String directory) {
|
||||
public FilePresignedUrlRespVO presignPutUrl(String name, String directory) {
|
||||
// 1. 生成上传的 path,需要保证唯一
|
||||
String path = generateUploadPath(name, directory);
|
||||
|
||||
// 2. 获取文件预签名地址
|
||||
FileClient fileClient = fileConfigService.getMasterFileClient();
|
||||
FilePresignedUrlRespDTO presignedObjectUrl = fileClient.getPresignedObjectUrl(path);
|
||||
return BeanUtils.toBean(presignedObjectUrl, FilePresignedUrlRespVO.class,
|
||||
object -> object.setConfigId(fileClient.getId()).setPath(path));
|
||||
String uploadUrl = fileClient.presignPutUrl(path);
|
||||
String visitUrl = fileClient.presignGetUrl(path, null);
|
||||
return new FilePresignedUrlRespVO().setConfigId(fileClient.getId())
|
||||
.setPath(path).setUploadUrl(uploadUrl).setUrl(visitUrl);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String presignGetUrl(String url, Integer expirationSeconds) {
|
||||
FileClient fileClient = fileConfigService.getMasterFileClient();
|
||||
return fileClient.presignGetUrl(url, expirationSeconds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long createFile(FileCreateReqVO createReqVO) {
|
||||
createReqVO.setUrl(HttpUtils.removeUrlQuery(createReqVO.getUrl())); // 目的:移除私有桶情况下,URL 的签名参数
|
||||
FileDO file = BeanUtils.toBean(createReqVO, FileDO.class);
|
||||
fileMapper.insert(file);
|
||||
return file.getId();
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -99,6 +99,7 @@ async function handleDeleteBatch() {
|
|||
});
|
||||
try {
|
||||
await delete${simpleClassName}List(checkedIds.value);
|
||||
checkedIds.value = [];
|
||||
ElMessage.success($t('ui.actionMessage.deleteSuccess'));
|
||||
onRefresh();
|
||||
} finally {
|
||||
|
|
|
@ -79,6 +79,7 @@ async function handleDeleteBatch() {
|
|||
});
|
||||
try {
|
||||
await delete${subSimpleClassName}List(checkedIds.value);
|
||||
checkedIds.value = [];
|
||||
ElMessage.success($t('ui.actionMessage.deleteSuccess'));
|
||||
onRefresh();
|
||||
} finally {
|
||||
|
|
|
@ -7,6 +7,8 @@ import cn.iocoder.yudao.module.infra.framework.file.core.client.local.LocalFileC
|
|||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString;
|
||||
|
||||
public class LocalFileClientTest {
|
||||
|
||||
@Test
|
||||
|
@ -26,4 +28,18 @@ public class LocalFileClientTest {
|
|||
client.delete(path);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Disabled
|
||||
public void testGetContent_notFound() {
|
||||
// 创建客户端
|
||||
LocalFileClientConfig config = new LocalFileClientConfig();
|
||||
config.setDomain("http://127.0.0.1:48080");
|
||||
config.setBasePath("/Users/yunai/file_test");
|
||||
LocalFileClient client = new LocalFileClient(0L, config);
|
||||
client.init();
|
||||
// 上传文件
|
||||
byte[] content = client.getContent(randomString());
|
||||
System.out.println();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test;
|
|||
|
||||
import javax.validation.Validation;
|
||||
|
||||
@SuppressWarnings("resource")
|
||||
public class S3FileClientTest {
|
||||
|
||||
@Test
|
||||
|
@ -71,6 +72,7 @@ public class S3FileClientTest {
|
|||
config.setAccessSecret("kXM1l5ia1RvSX3QaOEcwI3RLz3Y2rmNszWonKZtP");
|
||||
config.setBucket("ruoyi-vue-pro");
|
||||
config.setDomain("http://test.yudao.iocoder.cn"); // 如果有自定义域名,则可以设置。http://static.yudao.iocoder.cn
|
||||
config.setEnablePathStyleAccess(false);
|
||||
// 默认上海的 endpoint
|
||||
config.setEndpoint("s3-cn-south-1.qiniucs.com");
|
||||
|
||||
|
@ -78,6 +80,32 @@ public class S3FileClientTest {
|
|||
testExecuteUpload(config);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Disabled // 七牛云存储(读私有桶),如果要集成测试,可以注释本行
|
||||
public void testQiniu_privateGet() {
|
||||
S3FileClientConfig config = new S3FileClientConfig();
|
||||
// 配置成你自己的
|
||||
// config.setAccessKey(System.getenv("QINIU_ACCESS_KEY"));
|
||||
// config.setAccessSecret(System.getenv("QINIU_SECRET_KEY"));
|
||||
config.setAccessKey("b7yvuhBSAGjmtPhMFcn9iMOxUOY_I06cA_p0ZUx8");
|
||||
config.setAccessSecret("kXM1l5ia1RvSX3QaOEcwI3RLz3Y2rmNszWonKZtP");
|
||||
config.setBucket("ruoyi-vue-pro-private");
|
||||
config.setDomain("http://t151glocd.hn-bkt.clouddn.com"); // 如果有自定义域名,则可以设置。http://static.yudao.iocoder.cn
|
||||
config.setEnablePathStyleAccess(false);
|
||||
// 默认上海的 endpoint
|
||||
config.setEndpoint("s3-cn-south-1.qiniucs.com");
|
||||
|
||||
// 校验配置
|
||||
ValidationUtils.validate(Validation.buildDefaultValidatorFactory().getValidator(), config);
|
||||
// 创建 Client
|
||||
S3FileClient client = new S3FileClient(0L, config);
|
||||
client.init();
|
||||
// 执行生成 URL 签名
|
||||
String path = "output.png";
|
||||
String presignedUrl = client.presignGetUrl(path, 300);
|
||||
System.out.println(presignedUrl);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Disabled // 华为云存储,如果要集成测试,可以注释本行
|
||||
public void testHuaweiCloud() throws Exception {
|
||||
|
@ -94,7 +122,7 @@ public class S3FileClientTest {
|
|||
testExecuteUpload(config);
|
||||
}
|
||||
|
||||
private void testExecuteUpload(S3FileClientConfig config) throws Exception {
|
||||
private void testExecuteUpload(S3FileClientConfig config) {
|
||||
// 校验配置
|
||||
ValidationUtils.validate(Validation.buildDefaultValidatorFactory().getValidator(), config);
|
||||
// 创建 Client
|
||||
|
|
|
@ -74,11 +74,10 @@ spring:
|
|||
validation-query: SELECT SERVER_STATUS() # TDengine 数据源的有效性检查 SQL
|
||||
|
||||
# Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优
|
||||
data:
|
||||
redis:
|
||||
host: 400-infra.server.iocoder.cn # 地址
|
||||
port: 6379 # 端口
|
||||
database: 1 # 数据库索引
|
||||
redis:
|
||||
host: 400-infra.server.iocoder.cn # 地址
|
||||
port: 6379 # 端口
|
||||
database: 1 # 数据库索引
|
||||
# password: 123456 # 密码,建议生产环境开启
|
||||
|
||||
--- #################### MQ 消息队列相关配置 ####################
|
||||
|
|
|
@ -83,11 +83,10 @@ spring:
|
|||
validation-query: SELECT SERVER_STATUS() # TDengine 数据源的有效性检查 SQL
|
||||
|
||||
# Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优
|
||||
data:
|
||||
redis:
|
||||
host: 127.0.0.1 # 地址
|
||||
port: 6379 # 端口
|
||||
database: 0 # 数据库索引
|
||||
redis:
|
||||
host: 127.0.0.1 # 地址
|
||||
port: 6379 # 端口
|
||||
database: 0 # 数据库索引
|
||||
# password: 123456 # 密码,建议生产环境开启
|
||||
|
||||
--- #################### MQ 消息队列相关配置 ####################
|
||||
|
|
|
@ -30,7 +30,7 @@ public interface CouponConvert {
|
|||
CouponRespDTO convert(CouponDO bean);
|
||||
|
||||
default CouponDO convert(CouponTemplateDO template, Long userId) {
|
||||
CouponDO couponDO = new CouponDO()
|
||||
CouponDO coupon = new CouponDO()
|
||||
.setTemplateId(template.getId())
|
||||
.setName(template.getName())
|
||||
.setTakeType(template.getTakeType())
|
||||
|
@ -44,13 +44,13 @@ public interface CouponConvert {
|
|||
.setStatus(CouponStatusEnum.UNUSED.getStatus())
|
||||
.setUserId(userId);
|
||||
if (CouponTemplateValidityTypeEnum.DATE.getType().equals(template.getValidityType())) {
|
||||
couponDO.setValidStartTime(template.getValidStartTime());
|
||||
couponDO.setValidEndTime(template.getValidEndTime());
|
||||
coupon.setValidStartTime(template.getValidStartTime());
|
||||
coupon.setValidEndTime(template.getValidEndTime());
|
||||
} else if (CouponTemplateValidityTypeEnum.TERM.getType().equals(template.getValidityType())) {
|
||||
couponDO.setValidStartTime(LocalDateTime.now().plusDays(template.getFixedStartTerm()));
|
||||
couponDO.setValidEndTime(LocalDateTime.now().plusDays(template.getFixedEndTerm()));
|
||||
coupon.setValidStartTime(LocalDateTime.now().plusDays(template.getFixedStartTerm()));
|
||||
coupon.setValidEndTime(coupon.getValidStartTime().plusDays(template.getFixedEndTerm()));
|
||||
}
|
||||
return couponDO;
|
||||
return coupon;
|
||||
}
|
||||
|
||||
CouponPageReqVO convert(AppCouponPageReqVO pageReqVO, Collection<Long> userIds);
|
||||
|
|
|
@ -43,6 +43,40 @@ public class TradeStatusSyncToWxaOrderHandler implements TradeOrderHandler {
|
|||
if (ObjUtil.notEqual(order.getPayChannelCode(), PayChannelEnum.WX_LITE.getCode())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 上传订单物流信息到微信小程序
|
||||
uploadWxaOrderShippingInfo(order);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterReceiveOrder(TradeOrderDO order) {
|
||||
// 注意:只有微信小程序支付的订单,才需要同步
|
||||
if (ObjUtil.notEqual(order.getPayChannelCode(), PayChannelEnum.WX_LITE.getCode())) {
|
||||
return;
|
||||
}
|
||||
PayOrderRespDTO payOrder = payOrderApi.getOrder(order.getPayOrderId()).getCheckedData();
|
||||
SocialWxaOrderNotifyConfirmReceiveReqDTO reqDTO = new SocialWxaOrderNotifyConfirmReceiveReqDTO()
|
||||
.setTransactionId(payOrder.getChannelOrderNo())
|
||||
.setReceivedTime(order.getReceiveTime());
|
||||
try {
|
||||
socialClientApi.notifyWxaOrderConfirmReceive(UserTypeEnum.MEMBER.getValue(), reqDTO);
|
||||
} catch (Exception ex) {
|
||||
log.error("[afterReceiveOrder][订单({}) 通知订单收货到微信小程序失败]", order, ex);
|
||||
}
|
||||
|
||||
// 如果是门店自提订单,上传订单物流信息到微信小程序
|
||||
// 原因是,门店自提订单没有 “afterDeliveryOrder” 阶段。可见 https://t.zsxq.com/KWD3u 反馈
|
||||
if (DeliveryTypeEnum.PICK_UP.getType().equals(order.getDeliveryType())) {
|
||||
uploadWxaOrderShippingInfo(order);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传订单物流信息到微信小程序
|
||||
*
|
||||
* @param order 订单
|
||||
*/
|
||||
private void uploadWxaOrderShippingInfo(TradeOrderDO order) {
|
||||
PayOrderRespDTO payOrder = payOrderApi.getOrder(order.getPayOrderId()).getCheckedData();
|
||||
SocialWxaOrderUploadShippingInfoReqDTO reqDTO = new SocialWxaOrderUploadShippingInfoReqDTO()
|
||||
.setTransactionId(payOrder.getChannelOrderNo())
|
||||
|
@ -59,29 +93,12 @@ public class TradeStatusSyncToWxaOrderHandler implements TradeOrderHandler {
|
|||
reqDTO.setLogisticsType(SocialWxaOrderUploadShippingInfoReqDTO.LOGISTICS_TYPE_VIRTUAL);
|
||||
}
|
||||
try {
|
||||
socialClientApi.uploadWxaOrderShippingInfo(UserTypeEnum.MEMBER.getValue(), reqDTO).checkError();
|
||||
socialClientApi.uploadWxaOrderShippingInfo(UserTypeEnum.MEMBER.getValue(), reqDTO);
|
||||
} catch (Exception ex) {
|
||||
log.error("[afterDeliveryOrder][订单({}) 上传订单物流信息到微信小程序失败]", order, ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterReceiveOrder(TradeOrderDO order) {
|
||||
// 注意:只有微信小程序支付的订单,才需要同步
|
||||
if (ObjUtil.notEqual(order.getPayChannelCode(), PayChannelEnum.WX_LITE.getCode())) {
|
||||
return;
|
||||
}
|
||||
PayOrderRespDTO payOrder = payOrderApi.getOrder(order.getPayOrderId()).getCheckedData();
|
||||
SocialWxaOrderNotifyConfirmReceiveReqDTO reqDTO = new SocialWxaOrderNotifyConfirmReceiveReqDTO()
|
||||
.setTransactionId(payOrder.getChannelOrderNo())
|
||||
.setReceivedTime(order.getReceiveTime());
|
||||
try {
|
||||
socialClientApi.notifyWxaOrderConfirmReceive(UserTypeEnum.MEMBER.getValue(), reqDTO).getCheckedData();
|
||||
} catch (Exception ex) {
|
||||
log.error("[afterReceiveOrder][订单({}) 通知订单收货到微信小程序失败]", order, ex);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO @芋艿:【设置路径】 https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/order-shipping/order-shipping.html#%E5%85%AD%E3%80%81%E6%B6%88%E6%81%AF%E8%B7%B3%E8%BD%AC%E8%B7%AF%E5%BE%84%E8%AE%BE%E7%BD%AE%E6%8E%A5%E5%8F%A3
|
||||
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -585,7 +585,7 @@ public class PayOrderServiceImpl implements PayOrderService {
|
|||
log.error("[expireOrder][order({}) 更新为支付关闭失败]", order.getId());
|
||||
return false;
|
||||
}
|
||||
log.info("[expireOrder][order({}) 更新为支付关闭失败]", order.getId());
|
||||
log.info("[expireOrder][order({}) 更新为支付关闭成功]", order.getId());
|
||||
return true;
|
||||
} catch (Throwable e) {
|
||||
log.error("[expireOrder][order({}) 过期订单异常]", order.getId(), e);
|
||||
|
|
|
@ -77,6 +77,11 @@ spring:
|
|||
--- #################### MQ 消息队列相关配置 ####################
|
||||
|
||||
--- #################### 定时任务相关配置 ####################
|
||||
xxl:
|
||||
job:
|
||||
enabled: false # 是否开启调度中心,默认为 true 开启
|
||||
admin:
|
||||
addresses: http://127.0.0.1:9090/xxl-job-admin # 调度中心部署跟地址
|
||||
|
||||
--- #################### 服务保障相关配置 ####################
|
||||
|
||||
|
|
|
@ -1,27 +1,48 @@
|
|||
package cn.iocoder.yudao.module.system.api.mail.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.Email;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Schema(description = "RPC 服务 - 邮件发送给 Admin 或者 Member 用户 Request DTO")
|
||||
/**
|
||||
* 邮件发送 Request DTO
|
||||
*
|
||||
* @author wangjingqi
|
||||
*/
|
||||
@Data
|
||||
public class MailSendSingleToUserReqDTO {
|
||||
|
||||
@Schema(description = "用户编号", example = "1024")
|
||||
/**
|
||||
* 用户编号
|
||||
*
|
||||
* 如果非空,则加载对应用户的邮箱,添加到 {@link #toMails} 中
|
||||
*/
|
||||
private Long userId;
|
||||
@Schema(description = "手机号", requiredMode = Schema.RequiredMode.REQUIRED, example = "15601691300")
|
||||
@Email
|
||||
private String mail;
|
||||
|
||||
@Schema(description = "邮件模板编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "USER_SEND")
|
||||
/**
|
||||
* 收件邮箱
|
||||
*/
|
||||
private List<@Email String> toMails;
|
||||
/**
|
||||
* 抄送邮箱
|
||||
*/
|
||||
private List<@Email String> ccMails;
|
||||
/**
|
||||
* 密送邮箱
|
||||
*/
|
||||
private List<@Email String> bccMails;
|
||||
|
||||
|
||||
/**
|
||||
* 邮件模板编号
|
||||
*/
|
||||
@NotNull(message = "邮件模板编号不能为空")
|
||||
private String templateCode;
|
||||
|
||||
@Schema(description = "邮件模板参数")
|
||||
/**
|
||||
* 邮件模板参数
|
||||
*/
|
||||
private Map<String, Object> templateParams;
|
||||
|
||||
}
|
||||
|
|
|
@ -19,13 +19,15 @@ public class MailSendApiImpl implements MailSendApi {
|
|||
|
||||
@Override
|
||||
public CommonResult<Long> sendSingleMailToAdmin(MailSendSingleToUserReqDTO reqDTO) {
|
||||
return success(mailSendService.sendSingleMailToAdmin(reqDTO.getMail(), reqDTO.getUserId(),
|
||||
return success(mailSendService.sendSingleMailToAdmin(reqDTO.getUserId(),
|
||||
reqDTO.getToMails(), reqDTO.getCcMails(), reqDTO.getBccMails(),
|
||||
reqDTO.getTemplateCode(), reqDTO.getTemplateParams()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommonResult<Long> sendSingleMailToMember(MailSendSingleToUserReqDTO reqDTO) {
|
||||
return success(mailSendService.sendSingleMailToMember(reqDTO.getMail(), reqDTO.getUserId(),
|
||||
return success(mailSendService.sendSingleMailToMember(reqDTO.getUserId(),
|
||||
reqDTO.getToMails(), reqDTO.getCcMails(), reqDTO.getBccMails(),
|
||||
reqDTO.getTemplateCode(), reqDTO.getTemplateParams()));
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
@ -21,16 +39,6 @@ tenant-id: {{adminTenantId}}
|
|||
"password": "admin123"
|
||||
}
|
||||
|
||||
### 请求 /login 接口 => 失败(租户不存在)
|
||||
POST {{baseUrl}}/system/auth/login
|
||||
Content-Type: application/json
|
||||
tenant-id: 2
|
||||
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "admin123"
|
||||
}
|
||||
|
||||
### 请求 /get-permission-info 接口 => 成功
|
||||
GET {{baseUrl}}/system/auth/get-permission-info
|
||||
Authorization: Bearer {{token}}
|
||||
|
|
|
@ -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')")
|
||||
|
|
|
@ -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()));
|
||||
}
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
|||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Schema(description = "管理后台 - 邮件日志 Response VO")
|
||||
|
@ -19,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;
|
||||
|
|
|
@ -5,15 +5,22 @@ import lombok.Data;
|
|||
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.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 = "模板编码不能为空")
|
||||
|
|
|
@ -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;
|
||||
|
||||
/**
|
||||
* 邮箱账号编号
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
@ -22,4 +22,8 @@ public interface SmsLogMapper extends BaseMapperX<SmsLogDO> {
|
|||
.orderByDesc(SmsLogDO::getId));
|
||||
}
|
||||
|
||||
default SmsLogDO selectByApiSerialNo(String apiSerialNo) {
|
||||
return selectOne(SmsLogDO::getApiSerialNo, apiSerialNo);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,212 @@
|
|||
package cn.iocoder.yudao.module.system.framework.captcha.core;
|
||||
|
||||
import cn.hutool.core.util.RandomUtil;
|
||||
import com.anji.captcha.model.common.RepCodeEnum;
|
||||
import com.anji.captcha.model.common.ResponseModel;
|
||||
import com.anji.captcha.model.vo.CaptchaVO;
|
||||
import com.anji.captcha.service.impl.AbstractCaptchaService;
|
||||
import com.anji.captcha.service.impl.CaptchaServiceFactory;
|
||||
import com.anji.captcha.util.AESUtil;
|
||||
import com.anji.captcha.util.ImageUtils;
|
||||
import com.anji.captcha.util.RandomUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.awt.*;
|
||||
import java.awt.geom.AffineTransform;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* 图片文字验证码
|
||||
*
|
||||
* @author Tsui
|
||||
* @since 2025/7/23 20:44
|
||||
*/
|
||||
public class PictureWordCaptchaServiceImpl extends AbstractCaptchaService {
|
||||
|
||||
/**
|
||||
* 验证码的基础字符
|
||||
*/
|
||||
private static final String CHARACTERS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
/**
|
||||
* 验证码长度
|
||||
*/
|
||||
private static final Integer LENGTH = 4;
|
||||
|
||||
private static final int WIDTH = 120;
|
||||
private static final int HEIGHT = 40;
|
||||
private static final int LINES = 10;
|
||||
|
||||
@Override
|
||||
public void init(Properties config) {
|
||||
super.init(config);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroy(Properties config) {
|
||||
logger.info("start-clear-history-data-{}", captchaType());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String captchaType() {
|
||||
return "pictureWord";
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseModel get(CaptchaVO captchaVO) {
|
||||
String text = generateRandomText(LENGTH);
|
||||
CaptchaVO imageData = getImageData(text);
|
||||
// pointJson 不传到前端,只做后端校验,测试时放开
|
||||
// imageData.setPointJson(text);
|
||||
return ResponseModel.successData(imageData);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseModel check(CaptchaVO captchaVO) {
|
||||
ResponseModel r = super.check(captchaVO);
|
||||
if (!validatedReq(r)) {
|
||||
return r;
|
||||
}
|
||||
|
||||
// 取出验证码
|
||||
String codeKey = String.format(REDIS_CAPTCHA_KEY, captchaVO.getToken());
|
||||
if (!CaptchaServiceFactory.getCache(cacheType).exists(codeKey)) {
|
||||
return ResponseModel.errorMsg(RepCodeEnum.API_CAPTCHA_INVALID);
|
||||
}
|
||||
// 正确的验证码
|
||||
String codeValue = CaptchaServiceFactory.getCache(cacheType).get(codeKey);
|
||||
String code = getCodeByCodeValue(codeValue);
|
||||
String secretKey = getSecretKeyByCodeValue(codeValue);
|
||||
// 验证码只用一次,即刻失效
|
||||
CaptchaServiceFactory.getCache(cacheType).delete(codeKey);
|
||||
|
||||
// 用户输入的验证码(CaptchaVO 中 没有预留字段,暂时用 pointJson 无需加解密)
|
||||
String userCode = captchaVO.getPointJson();
|
||||
if (!StringUtils.equalsIgnoreCase(code, userCode)) {
|
||||
afterValidateFail(captchaVO);
|
||||
return ResponseModel.errorMsg(RepCodeEnum.API_CAPTCHA_COORDINATE_ERROR);
|
||||
}
|
||||
|
||||
// 校验成功,将信息存入缓存
|
||||
String value;
|
||||
try {
|
||||
value = AESUtil.aesEncrypt(captchaVO.getToken().concat("---").concat(userCode), secretKey);
|
||||
} catch (Exception e) {
|
||||
logger.error("AES 加密失败", e);
|
||||
afterValidateFail(captchaVO);
|
||||
return ResponseModel.errorMsg(e.getMessage());
|
||||
}
|
||||
String secondKey = String.format(REDIS_SECOND_CAPTCHA_KEY, value);
|
||||
CaptchaServiceFactory.getCache(cacheType).set(secondKey, captchaVO.getToken(), EXPIRESIN_THREE);
|
||||
captchaVO.setResult(true);
|
||||
captchaVO.resetClientFlag();
|
||||
return ResponseModel.successData(captchaVO);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseModel verification(CaptchaVO captchaVO) {
|
||||
ResponseModel r = super.verification(captchaVO);
|
||||
if (!validatedReq(r)) {
|
||||
return r;
|
||||
}
|
||||
try {
|
||||
String codeKey = String.format(REDIS_SECOND_CAPTCHA_KEY, captchaVO.getCaptchaVerification());
|
||||
if (!CaptchaServiceFactory.getCache(cacheType).exists(codeKey)) {
|
||||
return ResponseModel.errorMsg(RepCodeEnum.API_CAPTCHA_INVALID);
|
||||
}
|
||||
// 二次校验取值后,即刻失效
|
||||
CaptchaServiceFactory.getCache(cacheType).delete(codeKey);
|
||||
} catch (Exception e) {
|
||||
logger.error("验证码解析失败", e);
|
||||
return ResponseModel.errorMsg(e.getMessage());
|
||||
}
|
||||
return ResponseModel.success();
|
||||
}
|
||||
|
||||
|
||||
private CaptchaVO getImageData(String text) {
|
||||
CaptchaVO dataVO = new CaptchaVO();
|
||||
BufferedImage image = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = image.createGraphics();
|
||||
|
||||
// 设置背景色
|
||||
g.setColor(getRandomColor(200, 250));
|
||||
g.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
// 绘制干扰线
|
||||
for (int i = 0; i < LINES; i++) {
|
||||
g.setColor(getRandomColor(100, 200));
|
||||
int x1 = RandomUtil.randomInt(WIDTH);
|
||||
int y1 = RandomUtil.randomInt(HEIGHT);
|
||||
int x2 = RandomUtil.randomInt(WIDTH);
|
||||
int y2 = RandomUtil.randomInt(HEIGHT);
|
||||
g.drawLine(x1, y1, x2, y2);
|
||||
}
|
||||
// 设置字体
|
||||
g.setFont(new Font("Arial", Font.BOLD, 24));
|
||||
// 绘制验证码文本
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
g.setColor(getRandomColor(20, 130));
|
||||
// 文字旋转
|
||||
AffineTransform affineTransform = new AffineTransform();
|
||||
int x = 20 + i * 20;
|
||||
int y = 24 + RandomUtil.randomInt(8);
|
||||
// 旋转范围 -45 ~ 45
|
||||
affineTransform.setToRotation(Math.toRadians(RandomUtil.randomInt(-45, 45)), x, y);
|
||||
g.setTransform(affineTransform);
|
||||
g.drawString(text.charAt(i) + "", x, y);
|
||||
}
|
||||
// 添加噪点
|
||||
for (int i = 0; i < 100; i++) {
|
||||
int x = RandomUtil.randomInt(WIDTH);
|
||||
int y = RandomUtil.randomInt(HEIGHT);
|
||||
image.setRGB(x, y, getRandomColor(0, 255).getRGB());
|
||||
}
|
||||
g.dispose();
|
||||
|
||||
String secretKey = null;
|
||||
if (captchaAesStatus) {
|
||||
secretKey = AESUtil.getKey();
|
||||
}
|
||||
dataVO.setSecretKey(secretKey);
|
||||
|
||||
dataVO.setOriginalImageBase64(ImageUtils.getImageToBase64Str(image).replaceAll("\r|\n", ""));
|
||||
dataVO.setToken(RandomUtils.getUUID());
|
||||
// dataVO.setSecretKey(secretKey);
|
||||
// 将坐标信息存入 redis 中
|
||||
String codeKey = String.format(REDIS_CAPTCHA_KEY, dataVO.getToken());
|
||||
CaptchaServiceFactory.getCache(cacheType).set(codeKey, getCodeValue(text, secretKey), EXPIRESIN_SECONDS);
|
||||
return dataVO;
|
||||
}
|
||||
|
||||
private String getCodeValue(String text, String secretKey) {
|
||||
return text + "," + secretKey;
|
||||
}
|
||||
|
||||
private String getCodeByCodeValue(String codeValue) {
|
||||
return codeValue.split(",")[0];
|
||||
}
|
||||
|
||||
private String getSecretKeyByCodeValue(String codeValue) {
|
||||
return codeValue.split(",")[1];
|
||||
}
|
||||
|
||||
private Color getRandomColor(int min, int max) {
|
||||
int minVal = Math.min(min, max);
|
||||
int maxVal = Math.max(min, max);
|
||||
int r = RandomUtil.randomInt(minVal, maxVal);
|
||||
int g = RandomUtil.randomInt(minVal, maxVal);
|
||||
int b = RandomUtil.randomInt(minVal, maxVal);
|
||||
return new Color(r, g, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成指定长度的随机字符串
|
||||
*
|
||||
* @param length 长度
|
||||
* @return {@link String}
|
||||
*/
|
||||
public static String generateRandomText(int length) {
|
||||
return RandomUtil.randomString(CHARACTERS, length);
|
||||
}
|
||||
|
||||
}
|
|
@ -119,6 +119,7 @@ public class TencentSmsClient extends AbstractSmsClient {
|
|||
return new SmsReceiveRespDTO()
|
||||
.setSuccess("SUCCESS".equals(statusObj.getStr("report_status"))) // 是否接收成功
|
||||
.setErrorCode(statusObj.getStr("errmsg")) // 状态报告编码
|
||||
.setErrorMsg(statusObj.getStr("description")) // 状态报告描述
|
||||
.setMobile(statusObj.getStr("mobile")) // 手机号
|
||||
.setReceiveTime(statusObj.getLocalDateTime("user_receive_time", null)) // 状态报告时间
|
||||
.setSerialNo(statusObj.getStr("sid")); // 发送序列号
|
||||
|
|
|
@ -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;
|
||||
/**
|
||||
* 邮件账号编号
|
||||
*/
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -36,6 +36,13 @@ public interface DeptService {
|
|||
*/
|
||||
void deleteDept(Long id);
|
||||
|
||||
/**
|
||||
* 批量删除部门
|
||||
*
|
||||
* @param ids 部门编号数组
|
||||
*/
|
||||
void deleteDeptList(List<Long> ids);
|
||||
|
||||
/**
|
||||
* 获得部门信息
|
||||
*
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
|
||||
/**
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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);
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
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;
|
||||
|
@ -18,6 +20,8 @@ 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);
|
||||
|
@ -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 参数列表
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ import java.util.Map;
|
|||
* 短信日志 Service 接口
|
||||
*
|
||||
* @author zzf
|
||||
* @date 13:48 2021/3/2
|
||||
* @since 13:48 2021/3/2
|
||||
*/
|
||||
public interface SmsLogService {
|
||||
|
||||
|
@ -49,12 +49,13 @@ public interface SmsLogService {
|
|||
* 更新日志的接收结果
|
||||
*
|
||||
* @param id 日志编号
|
||||
* @param apiSerialNo 发送编号
|
||||
* @param success 是否接收成功
|
||||
* @param receiveTime 用户接收时间
|
||||
* @param apiReceiveCode API 接收结果的编码
|
||||
* @param apiReceiveMsg API 接收结果的说明
|
||||
*/
|
||||
void updateSmsReceiveResult(Long id, Boolean success,
|
||||
void updateSmsReceiveResult(Long id, String apiSerialNo, Boolean success,
|
||||
LocalDateTime receiveTime, String apiReceiveCode, String apiReceiveMsg);
|
||||
|
||||
/**
|
||||
|
|
|
@ -63,10 +63,17 @@ public class SmsLogServiceImpl implements SmsLogService {
|
|||
}
|
||||
|
||||
@Override
|
||||
public void updateSmsReceiveResult(Long id, Boolean success, LocalDateTime receiveTime,
|
||||
public void updateSmsReceiveResult(Long id, String apiSerialNo, Boolean success, LocalDateTime receiveTime,
|
||||
String apiReceiveCode, String apiReceiveMsg) {
|
||||
SmsReceiveStatusEnum receiveStatus = Objects.equals(success, true) ?
|
||||
SmsReceiveStatusEnum.SUCCESS : SmsReceiveStatusEnum.FAILURE;
|
||||
if (id == null || id == 0) {
|
||||
SmsLogDO log = smsLogMapper.selectByApiSerialNo(apiSerialNo);
|
||||
if (log == null) {
|
||||
return;
|
||||
}
|
||||
id = log.getId();
|
||||
}
|
||||
smsLogMapper.updateById(SmsLogDO.builder().id(id).receiveStatus(receiveStatus.getStatus())
|
||||
.receiveTime(receiveTime).apiReceiveCode(apiReceiveCode).apiReceiveMsg(apiReceiveMsg).build());
|
||||
}
|
||||
|
|
|
@ -184,7 +184,7 @@ public class SmsSendServiceImpl implements SmsSendService {
|
|||
return;
|
||||
}
|
||||
// 更新短信日志的接收结果. 因为量一般不大,所以先使用 for 循环更新
|
||||
receiveResults.forEach(result -> smsLogService.updateSmsReceiveResult(result.getLogId(),
|
||||
receiveResults.forEach(result -> smsLogService.updateSmsReceiveResult(result.getLogId(), result.getSerialNo(),
|
||||
result.getSuccess(), result.getReceiveTime(), result.getErrorCode(), result.getErrorMsg()));
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
cn.iocoder.yudao.module.system.framework.captcha.core.PictureWordCaptchaServiceImpl
|
|
@ -135,7 +135,7 @@ aj:
|
|||
cache-type: redis # 缓存 local/redis...
|
||||
cache-number: 1000 # local 缓存的阈值,达到这个值,清除缓存
|
||||
timing-clear: 180 # local定时清除过期缓存(单位秒),设置为0代表不执行
|
||||
type: blockPuzzle # 验证码类型 default两种都实例化。 blockPuzzle 滑块拼图 clickWord 文字点选
|
||||
type: blockPuzzle # 验证码类型 default 三种都实例化。blockPuzzle 滑块拼图、clickWord 文字点选、pictureWord 文本输入
|
||||
water-mark: 芋道源码 # 右下角水印文字(我的水印),可使用 https://tool.chinaz.com/tools/unicode.aspx 中文转 Unicode,Linux 可能需要转 unicode
|
||||
interference-options: 0 # 滑动干扰项(0/1/2)
|
||||
req-frequency-limit-enable: false # 接口请求次数一分钟限制是否开启 true|false
|
||||
|
@ -158,6 +158,13 @@ yudao:
|
|||
enable: false
|
||||
exclude-urls: # 如下 url,仅仅是为了演示,去掉配置也没关系
|
||||
- ${management.endpoints.web.base-path}/** # 不处理 Actuator 的请求
|
||||
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 案例】响应加密的公钥
|
||||
swagger:
|
||||
title: 管理后台
|
||||
description: 提供管理员管理的所有功能
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 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());
|
||||
|
@ -132,48 +142,50 @@ public class MailLogServiceImplTest extends BaseDbUnitTest {
|
|||
|
||||
@Test
|
||||
public void testGetMailLogPage() {
|
||||
// mock 数据
|
||||
MailLogDO dbMailLog = randomPojo(MailLogDO.class, o -> { // 等会查询到
|
||||
o.setUserId(1L);
|
||||
o.setUserType(UserTypeEnum.ADMIN.getValue());
|
||||
o.setToMail("768@qq.com");
|
||||
o.setAccountId(10L);
|
||||
o.setTemplateId(100L);
|
||||
o.setSendStatus(MailSendStatusEnum.INIT.getStatus());
|
||||
o.setSendTime(buildTime(2023, 2, 10));
|
||||
o.setTemplateParams(randomTemplateParams());
|
||||
});
|
||||
mailLogMapper.insert(dbMailLog);
|
||||
// 测试 userId 不匹配
|
||||
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")));
|
||||
// 测试 accountId 不匹配
|
||||
mailLogMapper.insert(cloneIgnoreId(dbMailLog, o -> o.setAccountId(11L)));
|
||||
// 测试 templateId 不匹配
|
||||
mailLogMapper.insert(cloneIgnoreId(dbMailLog, o -> o.setTemplateId(101L)));
|
||||
// 测试 sendStatus 不匹配
|
||||
mailLogMapper.insert(cloneIgnoreId(dbMailLog, o -> o.setSendStatus(MailSendStatusEnum.SUCCESS.getStatus())));
|
||||
// 测试 sendTime 不匹配
|
||||
mailLogMapper.insert(cloneIgnoreId(dbMailLog, o -> o.setSendTime(buildTime(2023, 3, 10))));
|
||||
// 准备参数
|
||||
MailLogPageReqVO reqVO = new MailLogPageReqVO();
|
||||
reqVO.setUserId(1L);
|
||||
reqVO.setUserType(UserTypeEnum.ADMIN.getValue());
|
||||
reqVO.setToMail("768");
|
||||
reqVO.setAccountId(10L);
|
||||
reqVO.setTemplateId(100L);
|
||||
reqVO.setSendStatus(MailSendStatusEnum.INIT.getStatus());
|
||||
reqVO.setSendTime((buildBetweenTime(2023, 2, 1, 2023, 2, 15)));
|
||||
// mock 数据
|
||||
MailLogDO dbMailLog = randomPojo(MailLogDO.class, o -> { // 等会查询到
|
||||
o.setUserId(1L);
|
||||
o.setUserType(UserTypeEnum.ADMIN.getValue());
|
||||
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());
|
||||
o.setSendTime(buildTime(2023, 2, 10));
|
||||
o.setTemplateParams(randomTemplateParams());
|
||||
});
|
||||
mailLogMapper.insert(dbMailLog);
|
||||
// 测试 userId 不匹配
|
||||
mailLogMapper.insert(cloneIgnoreId(dbMailLog, o -> o.setUserId(2L)));
|
||||
// 测试 userType 不匹配
|
||||
mailLogMapper.insert(cloneIgnoreId(dbMailLog, o -> o.setUserType(UserTypeEnum.MEMBER.getValue())));
|
||||
// 测试 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 不匹配
|
||||
mailLogMapper.insert(cloneIgnoreId(dbMailLog, o -> o.setTemplateId(101L)));
|
||||
// 测试 sendStatus 不匹配
|
||||
mailLogMapper.insert(cloneIgnoreId(dbMailLog, o -> o.setSendStatus(MailSendStatusEnum.SUCCESS.getStatus())));
|
||||
// 测试 sendTime 不匹配
|
||||
mailLogMapper.insert(cloneIgnoreId(dbMailLog, o -> o.setSendTime(buildTime(2023, 3, 10))));
|
||||
// 准备参数
|
||||
MailLogPageReqVO reqVO = new MailLogPageReqVO();
|
||||
reqVO.setUserId(1L);
|
||||
reqVO.setUserType(UserTypeEnum.ADMIN.getValue());
|
||||
// reqVO.setToMail("768@qq.com");
|
||||
reqVO.setAccountId(10L);
|
||||
reqVO.setTemplateId(100L);
|
||||
reqVO.setSendStatus(MailSendStatusEnum.INIT.getStatus());
|
||||
reqVO.setSendTime((buildBetweenTime(2023, 2, 1, 2023, 2, 15)));
|
||||
|
||||
// 调用
|
||||
PageResult<MailLogDO> pageResult = mailLogService.getMailLogPage(reqVO);
|
||||
// 断言
|
||||
assertEquals(1, pageResult.getTotal());
|
||||
assertEquals(1, pageResult.getList().size());
|
||||
assertPojoEquals(dbMailLog, pageResult.getList().get(0));
|
||||
// 调用
|
||||
PageResult<MailLogDO> pageResult = mailLogService.getMailLogPage(reqVO);
|
||||
// 断言
|
||||
assertEquals(1, pageResult.getTotal());
|
||||
assertEquals(1, pageResult.getList().size());
|
||||
assertPojoEquals(dbMailLog, pageResult.getList().get(0));
|
||||
}
|
||||
|
||||
private static Map<String, Object> randomTemplateParams() {
|
||||
|
|
|
@ -21,6 +21,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;
|
||||
|
||||
|
@ -28,8 +29,7 @@ import static cn.hutool.core.util.RandomUtil.randomEle;
|
|||
import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
|
||||
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*;
|
||||
import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
|
@ -67,14 +67,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 的方法
|
||||
|
@ -95,61 +99,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() {
|
||||
|
@ -160,6 +130,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());
|
||||
|
@ -178,23 +150,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();
|
||||
|
@ -202,6 +180,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());
|
||||
|
@ -220,15 +200,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());
|
||||
}
|
||||
|
||||
|
@ -257,18 +240,35 @@ 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);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDoSendMail_success() {
|
||||
try (MockedStatic<MailUtil> mailUtilMock = mockStatic(MailUtil.class)) {
|
||||
try (final MockedStatic<MailUtil> mailUtilMock = mockStatic(MailUtil.class)) {
|
||||
// 准备参数
|
||||
MailSendMessage message = randomPojo(MailSendMessage.class, o -> o.setNickname("芋艿"));
|
||||
// mock 方法(获得邮箱账号)
|
||||
|
@ -279,16 +279,17 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest {
|
|||
// mock 方法(发送邮件)
|
||||
String messageId = randomString();
|
||||
mailUtilMock.when(() -> MailUtil.send(
|
||||
argThat(mailAccount -> {
|
||||
assertEquals("芋艿 <7685@qq.com>", mailAccount.getFrom());
|
||||
assertTrue(mailAccount.isAuth());
|
||||
assertEquals(account.getUsername(), mailAccount.getUser());
|
||||
assertEquals(account.getPassword(), mailAccount.getPass());
|
||||
assertEquals(account.getHost(), mailAccount.getHost());
|
||||
assertEquals(account.getPort(), mailAccount.getPort());
|
||||
assertEquals(account.getSslEnable(), mailAccount.isSslEnable());
|
||||
return true;
|
||||
}), eq(message.getMail()), eq(message.getTitle()), eq(message.getContent()), eq(true)))
|
||||
argThat(mailAccount -> {
|
||||
assertEquals("芋艿 <7685@qq.com>", mailAccount.getFrom());
|
||||
assertTrue(mailAccount.isAuth());
|
||||
assertEquals(account.getUsername(), mailAccount.getUser());
|
||||
assertArrayEquals(account.getPassword().toCharArray(), mailAccount.getPass().toCharArray());
|
||||
assertEquals(account.getHost(), mailAccount.getHost());
|
||||
assertEquals(account.getPort(), mailAccount.getPort());
|
||||
assertEquals(account.getSslEnable(), mailAccount.isSslEnable());
|
||||
return true;
|
||||
}), eq(message.getToMails()), eq(message.getCcMails()), eq(message.getBccMails()),
|
||||
eq(message.getTitle()), eq(message.getContent()), eq(true)))
|
||||
.thenReturn(messageId);
|
||||
|
||||
// 调用
|
||||
|
@ -314,13 +315,13 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest {
|
|||
assertEquals("芋艿 <7685@qq.com>", mailAccount.getFrom());
|
||||
assertTrue(mailAccount.isAuth());
|
||||
assertEquals(account.getUsername(), mailAccount.getUser());
|
||||
assertEquals(account.getPassword(), mailAccount.getPass());
|
||||
assertArrayEquals(account.getPassword().toCharArray(), mailAccount.getPass().toCharArray());
|
||||
assertEquals(account.getHost(), mailAccount.getHost());
|
||||
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);
|
||||
|
|
|
@ -153,13 +153,14 @@ public class SmsLogServiceImplTest extends BaseDbUnitTest {
|
|||
smsLogMapper.insert(dbSmsLog);
|
||||
// 准备参数
|
||||
Long id = dbSmsLog.getId();
|
||||
String apiSerialNo = dbSmsLog.getApiSerialNo();
|
||||
Boolean success = randomBoolean();
|
||||
LocalDateTime receiveTime = randomLocalDateTime();
|
||||
String apiReceiveCode = randomString();
|
||||
String apiReceiveMsg = randomString();
|
||||
|
||||
// 调用
|
||||
smsLogService.updateSmsReceiveResult(id, success, receiveTime, apiReceiveCode, apiReceiveMsg);
|
||||
smsLogService.updateSmsReceiveResult(id, apiSerialNo, success, receiveTime, apiReceiveCode, apiReceiveMsg);
|
||||
// 断言
|
||||
dbSmsLog = smsLogMapper.selectById(id);
|
||||
assertEquals(success ? SmsReceiveStatusEnum.SUCCESS.getStatus()
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue