# Conflicts:
#	yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/s3/S3FileClientTest.java
#	yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/sms/SmsLogServiceImpl.java
This commit is contained in:
YunaiV
2025-08-18 08:42:59 +08:00
24 changed files with 250 additions and 121 deletions

View File

@@ -49,8 +49,15 @@ public class HttpUtils {
return builder.build(); return builder.build();
} }
private String append(String base, Map<String, ?> query, boolean fragment) { public static String removeUrlQuery(String url) {
return append(base, query, null, fragment); if (!StrUtil.contains(url, '?')) {
return url;
}
UrlBuilder builder = UrlBuilder.of(url, Charset.defaultCharset());
// 移除 query、fragment
builder.setQuery(null);
builder.setFragment(null);
return builder.build();
} }
/** /**

View File

@@ -111,9 +111,6 @@ public class GlobalExceptionHandler {
if (ex instanceof AccessDeniedException) { if (ex instanceof AccessDeniedException) {
return accessDeniedExceptionHandler(request, (AccessDeniedException) ex); return accessDeniedExceptionHandler(request, (AccessDeniedException) ex);
} }
if (ex instanceof UncheckedExecutionException && ex.getCause() != ex) {
return allExceptionHandler(request, ex.getCause());
}
return defaultExceptionHandler(request, ex); return defaultExceptionHandler(request, ex);
} }
@@ -308,6 +305,12 @@ public class GlobalExceptionHandler {
*/ */
@ExceptionHandler(value = Exception.class) @ExceptionHandler(value = Exception.class)
public CommonResult<?> defaultExceptionHandler(HttpServletRequest req, Throwable ex) { 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); CommonResult<?> tableNotExistsResult = handleTableNotExists(ex);
if (tableNotExistsResult != null) { if (tableNotExistsResult != null) {

View File

@@ -42,4 +42,14 @@ public interface FileApi {
String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content, String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content,
String name, String directory, String type); String name, String directory, String type);
/**
* 生成文件预签名地址,用于读取
*
* @param url 完整的文件访问地址
* @param expirationSeconds 访问有效期,单位秒
* @return 文件预签名地址
*/
String presignGetUrl(@NotEmpty(message = "URL 不能为空") String url,
Integer expirationSeconds);
} }

View File

@@ -23,4 +23,9 @@ public class FileApiImpl implements FileApi {
return fileService.createFile(content, name, directory, type); return fileService.createFile(content, name, directory, type);
} }
@Override
public String presignGetUrl(String url, Integer expirationSeconds) {
return fileService.presignGetUrl(url, expirationSeconds);
}
} }

View File

@@ -51,7 +51,7 @@ public class FileController {
} }
@GetMapping("/presigned-url") @GetMapping("/presigned-url")
@Operation(summary = "获取文件预签名地址", description = "模式二:前端上传文件:用于前端直接上传七牛、阿里云 OSS 等文件存储器") @Operation(summary = "获取文件预签名地址(上传)", description = "模式二:前端上传文件:用于前端直接上传七牛、阿里云 OSS 等文件存储器")
@Parameters({ @Parameters({
@Parameter(name = "name", description = "文件名称", required = true), @Parameter(name = "name", description = "文件名称", required = true),
@Parameter(name = "directory", description = "文件目录") @Parameter(name = "directory", description = "文件目录")
@@ -59,7 +59,7 @@ public class FileController {
public CommonResult<FilePresignedUrlRespVO> getFilePresignedUrl( public CommonResult<FilePresignedUrlRespVO> getFilePresignedUrl(
@RequestParam("name") String name, @RequestParam("name") String name,
@RequestParam(value = "directory", required = false) String directory) { @RequestParam(value = "directory", required = false) String directory) {
return success(fileService.getFilePresignedUrl(name, directory)); return success(fileService.presignPutUrl(name, directory));
} }
@PostMapping("/create") @PostMapping("/create")

View File

@@ -42,7 +42,7 @@ public class AppFileController {
} }
@GetMapping("/presigned-url") @GetMapping("/presigned-url")
@Operation(summary = "获取文件预签名地址", description = "模式二:前端上传文件:用于前端直接上传七牛、阿里云 OSS 等文件存储器") @Operation(summary = "获取文件预签名地址(上传)", description = "模式二:前端上传文件:用于前端直接上传七牛、阿里云 OSS 等文件存储器")
@Parameters({ @Parameters({
@Parameter(name = "name", description = "文件名称", required = true), @Parameter(name = "name", description = "文件名称", required = true),
@Parameter(name = "directory", description = "文件目录") @Parameter(name = "directory", description = "文件目录")
@@ -50,7 +50,7 @@ public class AppFileController {
public CommonResult<FilePresignedUrlRespVO> getFilePresignedUrl( public CommonResult<FilePresignedUrlRespVO> getFilePresignedUrl(
@RequestParam("name") String name, @RequestParam("name") String name,
@RequestParam(value = "directory", required = false) String directory) { @RequestParam(value = "directory", required = false) String directory) {
return success(fileService.getFilePresignedUrl(name, directory)); return success(fileService.presignPutUrl(name, directory));
} }
@PostMapping("/create") @PostMapping("/create")

View File

@@ -1,7 +1,5 @@
package cn.iocoder.yudao.module.infra.framework.file.core.client; 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; byte[] getContent(String path) throws Exception;
// ========== 文件签名,目前仅 S3 支持 ==========
/** /**
* 获得文件预签名地址 * 获得文件预签名地址,用于上传
* *
* @param path 相对路径 * @param path 相对路径
* @return 文件预签名地址 * @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("不支持的操作"); throw new UnsupportedOperationException("不支持的操作");
} }

View File

@@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.infra.framework.file.core.client.local; package cn.iocoder.yudao.module.infra.framework.file.core.client.local;
import cn.hutool.core.io.FileUtil; import cn.hutool.core.io.FileUtil;
import cn.hutool.core.io.IORuntimeException;
import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient; import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient;
import java.io.File; import java.io.File;
@@ -38,7 +39,14 @@ public class LocalFileClient extends AbstractFileClient<LocalFileClientConfig> {
@Override @Override
public byte[] getContent(String path) { public byte[] getContent(String path) {
String filePath = getFilePath(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) { private String getFilePath(String path) {

View File

@@ -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;
}

View File

@@ -1,8 +1,10 @@
package cn.iocoder.yudao.module.infra.framework.file.core.client.s3; package cn.iocoder.yudao.module.infra.framework.file.core.client.s3;
import cn.hutool.core.io.IoUtil; import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpUtil; 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 cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; 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.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner; 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 software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
import java.net.URI; import java.net.URI;
import java.net.URL;
import java.time.Duration; import java.time.Duration;
/** /**
@@ -27,6 +31,8 @@ import java.time.Duration;
*/ */
public class S3FileClient extends AbstractFileClient<S3FileClientConfig> { public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
private static final Duration EXPIRATION_DEFAULT = Duration.ofHours(24);
private S3Client client; private S3Client client;
private S3Presigner presigner; private S3Presigner presigner;
@@ -75,7 +81,7 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
// 上传文件 // 上传文件
client.putObject(putRequest, RequestBody.fromBytes(content)); client.putObject(putRequest, RequestBody.fromBytes(content));
// 拼接返回路径 // 拼接返回路径
return config.getDomain() + "/" + path; return presignGetUrl(path, null);
} }
@Override @Override
@@ -97,23 +103,33 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
} }
@Override @Override
public FilePresignedUrlRespDTO getPresignedObjectUrl(String path) { public String presignPutUrl(String path) {
Duration expiration = Duration.ofHours(24); return presigner.presignPutObject(PutObjectPresignRequest.builder()
return new FilePresignedUrlRespDTO(getPresignedUrl(path, expiration), config.getDomain() + "/" + path); .signatureDuration(EXPIRATION_DEFAULT)
.putObjectRequest(b -> b.bucket(config.getBucket()).key(path)).build())
.url().toString();
} }
/** @Override
* 生成动态的预签名上传 URL public String presignGetUrl(String url, Integer expirationSeconds) {
* // 1. 将 url 转换为 path
* @param path 相对路径 String path = StrUtil.removePrefix(url, config.getDomain() + "/");
* @param expiration 过期时间 path = HttpUtils.removeUrlQuery(path);
* @return 生成的上传 URL
*/ // 2.1 情况一:公开访问:无需签名
private String getPresignedUrl(String path, Duration expiration) { // 考虑到老版本的兼容,所以必须是 config.getEnablePublicAccess() 为 false 时,才进行签名
return presigner.presignPutObject(PutObjectPresignRequest.builder() 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) .signatureDuration(expiration)
.putObjectRequest(b -> b.bucket(config.getBucket()).key(path)) .getObjectRequest(b -> b.bucket(config.getBucket()).key(finalPath)).build())
.build()).url().toString(); .url();
return signedUrl.toString();
} }
/** /**

View File

@@ -73,6 +73,15 @@ public class S3FileClientConfig implements FileClientConfig {
@NotNull(message = "enablePathStyleAccess 不能为空") @NotNull(message = "enablePathStyleAccess 不能为空")
private Boolean enablePathStyleAccess; private Boolean enablePathStyleAccess;
/**
* 是否公开访问
*
* true公开访问所有人都可以访问
* false私有访问只有配置的 accessKey 才可以访问
*/
@NotNull(message = "是否公开访问不能为空")
private Boolean enablePublicAccess;
@SuppressWarnings("RedundantIfStatement") @SuppressWarnings("RedundantIfStatement")
@AssertTrue(message = "domain 不能为空") @AssertTrue(message = "domain 不能为空")
@JsonIgnore @JsonIgnore

View File

@@ -80,9 +80,15 @@ public class FileTypeUtils {
*/ */
public static void writeAttachment(HttpServletResponse response, String filename, byte[] content) throws IOException { public static void writeAttachment(HttpServletResponse response, String filename, byte[] content) throws IOException {
// 设置 header 和 contentType // 设置 header 和 contentType
response.setHeader("Content-Disposition", "attachment;filename=" + HttpUtils.encodeUtf8(filename));
String contentType = getMineType(content, filename); String contentType = getMineType(content, filename);
response.setContentType(contentType); 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 的特殊处理,解决视频地址在移动端播放的兼容性问题 // 针对 video 的特殊处理,解决视频地址在移动端播放的兼容性问题
if (StrUtil.containsIgnoreCase(contentType, "video")) { if (StrUtil.containsIgnoreCase(contentType, "video")) {
response.setHeader("Content-Length", String.valueOf(content.length)); response.setHeader("Content-Length", String.valueOf(content.length));

View File

@@ -37,14 +37,22 @@ public interface FileService {
String name, String directory, String type); String name, String directory, String type);
/** /**
* 生成文件预签名地址信息 * 生成文件预签名地址信息,用于上传
* *
* @param name 文件名 * @param name 文件名
* @param directory 目录 * @param directory 目录
* @return 预签名地址信息 * @return 预签名地址信息
*/ */
FilePresignedUrlRespVO getFilePresignedUrl(@NotEmpty(message = "文件名不能为空") String name, FilePresignedUrlRespVO presignPutUrl(@NotEmpty(message = "文件名不能为空") String name,
String directory); String directory);
/**
* 生成文件预签名地址信息,用于读取
*
* @param url 完整的文件访问地址
* @param expirationSeconds 访问有效期,单位秒
* @return 文件预签名地址
*/
String presignGetUrl(String url, Integer expirationSeconds);
/** /**
* 创建文件 * 创建文件

View File

@@ -6,6 +6,7 @@ import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil; import cn.hutool.crypto.digest.DigestUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult; 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.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.FileCreateReqVO;
import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO; 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.dataobject.file.FileDO;
import cn.iocoder.yudao.module.infra.dal.mysql.file.FileMapper; 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.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 cn.iocoder.yudao.module.infra.framework.file.core.utils.FileTypeUtils;
import com.google.common.annotations.VisibleForTesting; import com.google.common.annotations.VisibleForTesting;
import lombok.SneakyThrows; import lombok.SneakyThrows;
@@ -126,19 +126,27 @@ public class FileServiceImpl implements FileService {
@Override @Override
@SneakyThrows @SneakyThrows
public FilePresignedUrlRespVO getFilePresignedUrl(String name, String directory) { public FilePresignedUrlRespVO presignPutUrl(String name, String directory) {
// 1. 生成上传的 path需要保证唯一 // 1. 生成上传的 path需要保证唯一
String path = generateUploadPath(name, directory); String path = generateUploadPath(name, directory);
// 2. 获取文件预签名地址 // 2. 获取文件预签名地址
FileClient fileClient = fileConfigService.getMasterFileClient(); FileClient fileClient = fileConfigService.getMasterFileClient();
FilePresignedUrlRespDTO presignedObjectUrl = fileClient.getPresignedObjectUrl(path); String uploadUrl = fileClient.presignPutUrl(path);
return BeanUtils.toBean(presignedObjectUrl, FilePresignedUrlRespVO.class, String visitUrl = fileClient.presignGetUrl(path, null);
object -> object.setConfigId(fileClient.getId()).setPath(path)); 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 @Override
public Long createFile(FileCreateReqVO createReqVO) { public Long createFile(FileCreateReqVO createReqVO) {
createReqVO.setUrl(HttpUtils.removeUrlQuery(createReqVO.getUrl())); // 目的移除私有桶情况下URL 的签名参数
FileDO file = BeanUtils.toBean(createReqVO, FileDO.class); FileDO file = BeanUtils.toBean(createReqVO, FileDO.class);
fileMapper.insert(file); fileMapper.insert(file);
return file.getId(); return file.getId();

View File

@@ -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.Disabled;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString;
public class LocalFileClientTest { public class LocalFileClientTest {
@Test @Test
@@ -26,4 +28,18 @@ public class LocalFileClientTest {
client.delete(path); 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();
}
} }

View File

@@ -5,11 +5,11 @@ import cn.hutool.core.util.IdUtil;
import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils; 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.S3FileClient;
import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.S3FileClientConfig; 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.Disabled;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import javax.validation.Validation; @SuppressWarnings("resource")
public class S3FileClientTest { public class S3FileClientTest {
@Test @Test
@@ -71,6 +71,7 @@ public class S3FileClientTest {
config.setAccessSecret("kXM1l5ia1RvSX3QaOEcwI3RLz3Y2rmNszWonKZtP"); config.setAccessSecret("kXM1l5ia1RvSX3QaOEcwI3RLz3Y2rmNszWonKZtP");
config.setBucket("ruoyi-vue-pro"); config.setBucket("ruoyi-vue-pro");
config.setDomain("http://test.yudao.iocoder.cn"); // 如果有自定义域名则可以设置。http://static.yudao.iocoder.cn config.setDomain("http://test.yudao.iocoder.cn"); // 如果有自定义域名则可以设置。http://static.yudao.iocoder.cn
config.setEnablePathStyleAccess(false);
// 默认上海的 endpoint // 默认上海的 endpoint
config.setEndpoint("s3-cn-south-1.qiniucs.com"); config.setEndpoint("s3-cn-south-1.qiniucs.com");
@@ -78,6 +79,32 @@ public class S3FileClientTest {
testExecuteUpload(config); 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 @Test
@Disabled // 华为云存储,如果要集成测试,可以注释本行 @Disabled // 华为云存储,如果要集成测试,可以注释本行
public void testHuaweiCloud() throws Exception { public void testHuaweiCloud() throws Exception {
@@ -94,7 +121,7 @@ public class S3FileClientTest {
testExecuteUpload(config); testExecuteUpload(config);
} }
private void testExecuteUpload(S3FileClientConfig config) throws Exception { private void testExecuteUpload(S3FileClientConfig config) {
// 校验配置 // 校验配置
ValidationUtils.validate(Validation.buildDefaultValidatorFactory().getValidator(), config); ValidationUtils.validate(Validation.buildDefaultValidatorFactory().getValidator(), config);
// 创建 Client // 创建 Client

View File

@@ -541,14 +541,23 @@ public abstract class AbstractWxPayClient extends AbstractPayClient<WxPayClientC
* @see <a href="https://github.com/binarywang/weixin-java-pay-demo/blob/master/src/main/java/com/github/binarywang/demo/wx/pay/controller/WxPayV3Controller.java#L202-L221">官方示例</a> * @see <a href="https://github.com/binarywang/weixin-java-pay-demo/blob/master/src/main/java/com/github/binarywang/demo/wx/pay/controller/WxPayV3Controller.java#L202-L221">官方示例</a>
*/ */
private SignatureHeader getRequestHeader(Map<String, String> headers) { private SignatureHeader getRequestHeader(Map<String, String> headers) {
// 参见 https://gitee.com/zhijiantianya/yudao-cloud/issues/ICSFL6
return SignatureHeader.builder() return SignatureHeader.builder()
.signature(headers.get("wechatpay-signature")) .signature(getHeaderValue(headers, "Wechatpay-Signature", "wechatpay-signature"))
.nonce(headers.get("wechatpay-nonce")) .nonce(getHeaderValue(headers, "Wechatpay-Nonce", "wechatpay-nonce"))
.serial(headers.get("wechatpay-serial")) .serial(getHeaderValue(headers, "Wechatpay-Serial", "wechatpay-serial"))
.timeStamp(headers.get("wechatpay-timestamp")) .timeStamp(getHeaderValue(headers, "Wechatpay-Timestamp", "wechatpay-timestamp"))
.build(); .build();
} }
private String getHeaderValue(Map<String, String> headers, String capitalizedKey, String lowercaseKey) {
String value = headers.get(capitalizedKey);
if (value != null) {
return value;
}
return headers.get(lowercaseKey);
}
// TODO @芋艿:可能是 wxjava 的 bughttps://github.com/binarywang/WxJava/issues/1557 // TODO @芋艿:可能是 wxjava 的 bughttps://github.com/binarywang/WxJava/issues/1557
private void fixV3HttpClientConnectionPoolShutDown() { private void fixV3HttpClientConnectionPoolShutDown() {
client.getConfig().setApiV3HttpClient(null); client.getConfig().setApiV3HttpClient(null);

View File

@@ -22,4 +22,8 @@ public interface SmsLogMapper extends BaseMapperX<SmsLogDO> {
.orderByDesc(SmsLogDO::getId)); .orderByDesc(SmsLogDO::getId));
} }
default SmsLogDO selectByApiSerialNo(String apiSerialNo) {
return selectOne(SmsLogDO::getApiSerialNo, apiSerialNo);
}
} }

View File

@@ -119,6 +119,7 @@ public class TencentSmsClient extends AbstractSmsClient {
return new SmsReceiveRespDTO() return new SmsReceiveRespDTO()
.setSuccess("SUCCESS".equals(statusObj.getStr("report_status"))) // 是否接收成功 .setSuccess("SUCCESS".equals(statusObj.getStr("report_status"))) // 是否接收成功
.setErrorCode(statusObj.getStr("errmsg")) // 状态报告编码 .setErrorCode(statusObj.getStr("errmsg")) // 状态报告编码
.setErrorMsg(statusObj.getStr("description")) // 状态报告描述
.setMobile(statusObj.getStr("mobile")) // 手机号 .setMobile(statusObj.getStr("mobile")) // 手机号
.setReceiveTime(statusObj.getLocalDateTime("user_receive_time", null)) // 状态报告时间 .setReceiveTime(statusObj.getLocalDateTime("user_receive_time", null)) // 状态报告时间
.setSerialNo(statusObj.getStr("sid")); // 发送序列号 .setSerialNo(statusObj.getStr("sid")); // 发送序列号

View File

@@ -12,7 +12,7 @@ import java.util.Map;
* 短信日志 Service 接口 * 短信日志 Service 接口
* *
* @author zzf * @author zzf
* @date 13:48 2021/3/2 * @since 13:48 2021/3/2
*/ */
public interface SmsLogService { public interface SmsLogService {
@@ -49,12 +49,13 @@ public interface SmsLogService {
* 更新日志的接收结果 * 更新日志的接收结果
* *
* @param id 日志编号 * @param id 日志编号
* @param apiSerialNo 发送编号
* @param success 是否接收成功 * @param success 是否接收成功
* @param receiveTime 用户接收时间 * @param receiveTime 用户接收时间
* @param apiReceiveCode API 接收结果的编码 * @param apiReceiveCode API 接收结果的编码
* @param apiReceiveMsg API 接收结果的说明 * @param apiReceiveMsg API 接收结果的说明
*/ */
void updateSmsReceiveResult(Long id, Boolean success, void updateSmsReceiveResult(Long id, String apiSerialNo, Boolean success,
LocalDateTime receiveTime, String apiReceiveCode, String apiReceiveMsg); LocalDateTime receiveTime, String apiReceiveCode, String apiReceiveMsg);
/** /**

View File

@@ -10,7 +10,8 @@ import cn.iocoder.yudao.module.system.enums.sms.SmsSendStatusEnum;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import javax.annotation.Resource; import jakarta.annotation.Resource;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
@@ -63,10 +64,17 @@ public class SmsLogServiceImpl implements SmsLogService {
} }
@Override @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) { String apiReceiveCode, String apiReceiveMsg) {
SmsReceiveStatusEnum receiveStatus = Objects.equals(success, true) ? SmsReceiveStatusEnum receiveStatus = Objects.equals(success, true) ?
SmsReceiveStatusEnum.SUCCESS : SmsReceiveStatusEnum.FAILURE; 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()) smsLogMapper.updateById(SmsLogDO.builder().id(id).receiveStatus(receiveStatus.getStatus())
.receiveTime(receiveTime).apiReceiveCode(apiReceiveCode).apiReceiveMsg(apiReceiveMsg).build()); .receiveTime(receiveTime).apiReceiveCode(apiReceiveCode).apiReceiveMsg(apiReceiveMsg).build());
} }

View File

@@ -184,7 +184,7 @@ public class SmsSendServiceImpl implements SmsSendService {
return; return;
} }
// 更新短信日志的接收结果. 因为量一般不大,所以先使用 for 循环更新 // 更新短信日志的接收结果. 因为量一般不大,所以先使用 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())); result.getSuccess(), result.getReceiveTime(), result.getErrorCode(), result.getErrorMsg()));
} }

View File

@@ -41,47 +41,47 @@ public class SmsLogServiceImplTest extends BaseDbUnitTest {
@Test @Test
public void testGetSmsLogPage() { public void testGetSmsLogPage() {
// mock 数据 // mock 数据
SmsLogDO dbSmsLog = randomSmsLogDO(o -> { // 等会查询到 SmsLogDO dbSmsLog = randomSmsLogDO(o -> { // 等会查询到
o.setChannelId(1L); o.setChannelId(1L);
o.setTemplateId(10L); o.setTemplateId(10L);
o.setMobile("15601691300"); o.setMobile("15601691300");
o.setSendStatus(SmsSendStatusEnum.INIT.getStatus()); o.setSendStatus(SmsSendStatusEnum.INIT.getStatus());
o.setSendTime(buildTime(2020, 11, 11)); o.setSendTime(buildTime(2020, 11, 11));
o.setReceiveStatus(SmsReceiveStatusEnum.INIT.getStatus()); o.setReceiveStatus(SmsReceiveStatusEnum.INIT.getStatus());
o.setReceiveTime(buildTime(2021, 11, 11)); o.setReceiveTime(buildTime(2021, 11, 11));
}); });
smsLogMapper.insert(dbSmsLog); smsLogMapper.insert(dbSmsLog);
// 测试 channelId 不匹配 // 测试 channelId 不匹配
smsLogMapper.insert(cloneIgnoreId(dbSmsLog, o -> o.setChannelId(2L))); smsLogMapper.insert(cloneIgnoreId(dbSmsLog, o -> o.setChannelId(2L)));
// 测试 templateId 不匹配 // 测试 templateId 不匹配
smsLogMapper.insert(cloneIgnoreId(dbSmsLog, o -> o.setTemplateId(20L))); smsLogMapper.insert(cloneIgnoreId(dbSmsLog, o -> o.setTemplateId(20L)));
// 测试 mobile 不匹配 // 测试 mobile 不匹配
smsLogMapper.insert(cloneIgnoreId(dbSmsLog, o -> o.setMobile("18818260999"))); smsLogMapper.insert(cloneIgnoreId(dbSmsLog, o -> o.setMobile("18818260999")));
// 测试 sendStatus 不匹配 // 测试 sendStatus 不匹配
smsLogMapper.insert(cloneIgnoreId(dbSmsLog, o -> o.setSendStatus(SmsSendStatusEnum.IGNORE.getStatus()))); smsLogMapper.insert(cloneIgnoreId(dbSmsLog, o -> o.setSendStatus(SmsSendStatusEnum.IGNORE.getStatus())));
// 测试 sendTime 不匹配 // 测试 sendTime 不匹配
smsLogMapper.insert(cloneIgnoreId(dbSmsLog, o -> o.setSendTime(buildTime(2020, 12, 12)))); smsLogMapper.insert(cloneIgnoreId(dbSmsLog, o -> o.setSendTime(buildTime(2020, 12, 12))));
// 测试 receiveStatus 不匹配 // 测试 receiveStatus 不匹配
smsLogMapper.insert(cloneIgnoreId(dbSmsLog, o -> o.setReceiveStatus(SmsReceiveStatusEnum.SUCCESS.getStatus()))); smsLogMapper.insert(cloneIgnoreId(dbSmsLog, o -> o.setReceiveStatus(SmsReceiveStatusEnum.SUCCESS.getStatus())));
// 测试 receiveTime 不匹配 // 测试 receiveTime 不匹配
smsLogMapper.insert(cloneIgnoreId(dbSmsLog, o -> o.setReceiveTime(buildTime(2021, 12, 12)))); smsLogMapper.insert(cloneIgnoreId(dbSmsLog, o -> o.setReceiveTime(buildTime(2021, 12, 12))));
// 准备参数 // 准备参数
SmsLogPageReqVO reqVO = new SmsLogPageReqVO(); SmsLogPageReqVO reqVO = new SmsLogPageReqVO();
reqVO.setChannelId(1L); reqVO.setChannelId(1L);
reqVO.setTemplateId(10L); reqVO.setTemplateId(10L);
reqVO.setMobile("156"); reqVO.setMobile("156");
reqVO.setSendStatus(SmsSendStatusEnum.INIT.getStatus()); reqVO.setSendStatus(SmsSendStatusEnum.INIT.getStatus());
reqVO.setSendTime(buildBetweenTime(2020, 11, 1, 2020, 11, 30)); reqVO.setSendTime(buildBetweenTime(2020, 11, 1, 2020, 11, 30));
reqVO.setReceiveStatus(SmsReceiveStatusEnum.INIT.getStatus()); reqVO.setReceiveStatus(SmsReceiveStatusEnum.INIT.getStatus());
reqVO.setReceiveTime(buildBetweenTime(2021, 11, 1, 2021, 11, 30)); reqVO.setReceiveTime(buildBetweenTime(2021, 11, 1, 2021, 11, 30));
// 调用 // 调用
PageResult<SmsLogDO> pageResult = smsLogService.getSmsLogPage(reqVO); PageResult<SmsLogDO> pageResult = smsLogService.getSmsLogPage(reqVO);
// 断言 // 断言
assertEquals(1, pageResult.getTotal()); assertEquals(1, pageResult.getTotal());
assertEquals(1, pageResult.getList().size()); assertEquals(1, pageResult.getList().size());
assertPojoEquals(dbSmsLog, pageResult.getList().get(0)); assertPojoEquals(dbSmsLog, pageResult.getList().get(0));
} }
@Test @Test
@@ -153,13 +153,14 @@ public class SmsLogServiceImplTest extends BaseDbUnitTest {
smsLogMapper.insert(dbSmsLog); smsLogMapper.insert(dbSmsLog);
// 准备参数 // 准备参数
Long id = dbSmsLog.getId(); Long id = dbSmsLog.getId();
String apiSerialNo = dbSmsLog.getApiSerialNo();
Boolean success = randomBoolean(); Boolean success = randomBoolean();
LocalDateTime receiveTime = randomLocalDateTime(); LocalDateTime receiveTime = randomLocalDateTime();
String apiReceiveCode = randomString(); String apiReceiveCode = randomString();
String apiReceiveMsg = randomString(); String apiReceiveMsg = randomString();
// 调用 // 调用
smsLogService.updateSmsReceiveResult(id, success, receiveTime, apiReceiveCode, apiReceiveMsg); smsLogService.updateSmsReceiveResult(id, apiSerialNo, success, receiveTime, apiReceiveCode, apiReceiveMsg);
// 断言 // 断言
dbSmsLog = smsLogMapper.selectById(id); dbSmsLog = smsLogMapper.selectById(id);
assertEquals(success ? SmsReceiveStatusEnum.SUCCESS.getStatus() assertEquals(success ? SmsReceiveStatusEnum.SUCCESS.getStatus()

View File

@@ -291,7 +291,7 @@ public class SmsSendServiceImplTest extends BaseMockitoUnitTest {
// 调用 // 调用
smsSendService.receiveSmsStatus(channelCode, text); 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()))); eq(result.getReceiveTime()), eq(result.getErrorCode()), eq(result.getErrorCode())));
} }