Merge branch 'master-jdk17' of https://gitee.com/zhijiantianya/yudao-cloud
# Conflicts: # yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClientConfig.java # yudao-module-infra/yudao-module-infra-server/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/s3/S3FileClientTest.java # yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/sms/SmsLogServiceImpl.javapull/205/head^2
commit
921398e437
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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<T>) {
|
||||
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<T>) {
|
||||
// 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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -6,8 +6,8 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
|
|||
import lombok.Data;
|
||||
import org.hibernate.validator.constraints.URL;
|
||||
|
||||
import javax.validation.constraints.AssertTrue;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* S3 文件客户端的配置类
|
||||
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -5,11 +5,11 @@ import cn.hutool.core.util.IdUtil;
|
|||
import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils;
|
||||
import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.S3FileClient;
|
||||
import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.S3FileClientConfig;
|
||||
import jakarta.validation.Validation;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import javax.validation.Validation;
|
||||
|
||||
@SuppressWarnings("resource")
|
||||
public class S3FileClientTest {
|
||||
|
||||
@Test
|
||||
|
@ -71,6 +71,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 +79,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 +121,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
|
||||
|
|
|
@ -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
|
||||
|
||||
}
|
||||
|
|
|
@ -22,4 +22,8 @@ public interface SmsLogMapper extends BaseMapperX<SmsLogDO> {
|
|||
.orderByDesc(SmsLogDO::getId));
|
||||
}
|
||||
|
||||
default SmsLogDO selectByApiSerialNo(String apiSerialNo) {
|
||||
return selectOne(SmsLogDO::getApiSerialNo, apiSerialNo);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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")); // 发送序列号
|
||||
|
|
|
@ -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);
|
||||
|
||||
/**
|
||||
|
|
|
@ -10,7 +10,8 @@ import cn.iocoder.yudao.module.system.enums.sms.SmsSendStatusEnum;
|
|||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
@ -63,10 +64,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()));
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -291,7 +291,7 @@ public class SmsSendServiceImplTest extends BaseMockitoUnitTest {
|
|||
// 调用
|
||||
smsSendService.receiveSmsStatus(channelCode, text);
|
||||
// 断言
|
||||
receiveResults.forEach(result -> smsLogService.updateSmsReceiveResult(eq(result.getLogId()), eq(result.getSuccess()),
|
||||
receiveResults.forEach(result -> smsLogService.updateSmsReceiveResult(eq(result.getLogId()), eq(result.getSerialNo()), eq(result.getSuccess()),
|
||||
eq(result.getReceiveTime()), eq(result.getErrorCode()), eq(result.getErrorCode())));
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue