Merge branch 'develop' of https://gitee.com/zhijiantianya/ruoyi-vue-pro into master-jdk17

This commit is contained in:
YunaiV
2025-05-02 21:09:43 +08:00
24 changed files with 418 additions and 220 deletions

View File

@@ -1,11 +1,10 @@
package cn.iocoder.yudao.module.infra.api.file;
import cn.iocoder.yudao.module.infra.service.file.FileService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import jakarta.annotation.Resource;
/**
* 文件 API 实现类
*
@@ -19,8 +18,8 @@ public class FileApiImpl implements FileApi {
private FileService fileService;
@Override
public String createFile(String name, String path, byte[] content) {
return fileService.createFile(name, path, content);
public String createFile(byte[] content, String name, String directory, String type) {
return fileService.createFile(content, name, directory, type);
}
}

View File

@@ -11,6 +11,7 @@ import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO;
import cn.iocoder.yudao.module.infra.service.file.FileService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.annotation.security.PermitAll;
@@ -41,14 +42,21 @@ public class FileController {
@Operation(summary = "上传文件", description = "模式一:后端上传文件")
public CommonResult<String> uploadFile(FileUploadReqVO uploadReqVO) throws Exception {
MultipartFile file = uploadReqVO.getFile();
String path = uploadReqVO.getPath();
return success(fileService.createFile(file.getOriginalFilename(), path, IoUtil.readBytes(file.getInputStream())));
byte[] content = IoUtil.readBytes(file.getInputStream());
return success(fileService.createFile(content, file.getOriginalFilename(),
uploadReqVO.getDirectory(), file.getContentType()));
}
@GetMapping("/presigned-url")
@Operation(summary = "获取文件预签名地址", description = "模式二:前端上传文件:用于前端直接上传七牛、阿里云 OSS 等文件存储器")
public CommonResult<FilePresignedUrlRespVO> getFilePresignedUrl(@RequestParam("path") String path) throws Exception {
return success(fileService.getFilePresignedUrl(path));
@Parameters({
@Parameter(name = "name", description = "文件名称", required = true),
@Parameter(name = "directory", description = "文件目录")
})
public CommonResult<FilePresignedUrlRespVO> getFilePresignedUrl(
@RequestParam("name") String name,
@RequestParam(value = "directory", required = false) String directory) {
return success(fileService.getFilePresignedUrl(name, directory));
}
@PostMapping("/create")

View File

@@ -14,7 +14,8 @@ public class FilePresignedUrlRespVO {
@Schema(description = "配置编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "11")
private Long configId;
@Schema(description = "文件上传 URL", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://s3.cn-south-1.qiniucs.com/ruoyi-vue-pro/758d3a5387507358c7236de4c8f96de1c7f5097ff6a7722b34772fb7b76b140f.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=3TvrJ70gl2Gt6IBe7_IZT1F6i_k0iMuRtyEv4EyS%2F20240217%2Fcn-south-1%2Fs3%2Faws4_request&X-Amz-Date=20240217T123222Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=a29f33770ab79bf523ccd4034d0752ac545f3c2a3b17baa1eb4e280cfdccfda5")
@Schema(description = "文件上传 URL", requiredMode = Schema.RequiredMode.REQUIRED,
example = "https://s3.cn-south-1.qiniucs.com/ruoyi-vue-pro/758d3a5387507358c7236de4c8f96de1c7f5097ff6a7722b34772fb7b76b140f.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=3TvrJ70gl2Gt6IBe7_IZT1F6i_k0iMuRtyEv4EyS%2F20240217%2Fcn-south-1%2Fs3%2Faws4_request&X-Amz-Date=20240217T123222Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=a29f33770ab79bf523ccd4034d0752ac545f3c2a3b17baa1eb4e280cfdccfda5")
private String uploadUrl;
/**
@@ -26,4 +27,12 @@ public class FilePresignedUrlRespVO {
example = "https://test.yudao.iocoder.cn/758d3a5387507358c7236de4c8f96de1c7f5097ff6a7722b34772fb7b76b140f.png")
private String url;
/**
* 为什么要返回 path 字段?
*
* 前端上传完文件后,需要调用 createFile 记录下 path 路径
*/
@Schema(description = "文件路径", requiredMode = Schema.RequiredMode.REQUIRED, example = "xxx.png")
private String path;
}

View File

@@ -14,7 +14,7 @@ public class FileUploadReqVO {
@NotNull(message = "文件附件不能为空")
private MultipartFile file;
@Schema(description = "文件附件", example = "yudaoyuanma.png")
private String path;
@Schema(description = "文件目录", example = "XXX/YYY")
private String directory;
}

View File

@@ -7,6 +7,8 @@ import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePresigned
import cn.iocoder.yudao.module.infra.controller.app.file.vo.AppFileUploadReqVO;
import cn.iocoder.yudao.module.infra.service.file.FileService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.annotation.security.PermitAll;
@@ -33,15 +35,21 @@ public class AppFileController {
@PermitAll
public CommonResult<String> uploadFile(AppFileUploadReqVO uploadReqVO) throws Exception {
MultipartFile file = uploadReqVO.getFile();
String path = uploadReqVO.getPath();
return success(fileService.createFile(file.getOriginalFilename(), path, IoUtil.readBytes(file.getInputStream())));
byte[] content = IoUtil.readBytes(file.getInputStream());
return success(fileService.createFile(content, file.getOriginalFilename(),
uploadReqVO.getDirectory(), file.getContentType()));
}
@GetMapping("/presigned-url")
@Operation(summary = "获取文件预签名地址", description = "模式二:前端上传文件:用于前端直接上传七牛、阿里云 OSS 等文件存储器")
@PermitAll
public CommonResult<FilePresignedUrlRespVO> getFilePresignedUrl(@RequestParam("path") String path) throws Exception {
return success(fileService.getFilePresignedUrl(path));
@Parameters({
@Parameter(name = "name", description = "文件名称", required = true),
@Parameter(name = "directory", description = "文件目录")
})
public CommonResult<FilePresignedUrlRespVO> getFilePresignedUrl(
@RequestParam("name") String name,
@RequestParam(value = "directory", required = false) String directory) {
return success(fileService.getFilePresignedUrl(name, directory));
}
@PostMapping("/create")

View File

@@ -14,7 +14,7 @@ public class AppFileUploadReqVO {
@NotNull(message = "文件附件不能为空")
private MultipartFile file;
@Schema(description = "文件附件", example = "yudaoyuanma.png")
private String path;
@Schema(description = "文件目录", example = "XXX/YYY")
private String directory;
}

View File

@@ -26,12 +26,6 @@ public class FtpFileClient extends AbstractFileClient<FtpFileClientConfig> {
@Override
protected void doInit() {
// 把配置的 \ 替换成 /, 如果路径配置 \a\test, 替换成 /a/test, 替换方法已经处理 null 情况
config.setBasePath(StrUtil.replace(config.getBasePath(), StrUtil.BACKSLASH, StrUtil.SLASH));
// ftp的路径是 / 结尾
if (!config.getBasePath().endsWith(StrUtil.SLASH)) {
config.setBasePath(config.getBasePath() + StrUtil.SLASH);
}
// 初始化 Ftp 对象
this.ftp = new Ftp(config.getHost(), config.getPort(), config.getUsername(), config.getPassword(),
CharsetUtil.CHARSET_UTF_8, null, null, FtpMode.valueOf(config.getMode()));
@@ -43,8 +37,8 @@ public class FtpFileClient extends AbstractFileClient<FtpFileClientConfig> {
String filePath = getFilePath(path);
String fileName = FileUtil.getName(filePath);
String dir = StrUtil.removeSuffix(filePath, fileName);
ftp.reconnectIfTimeout();
boolean success = ftp.upload(dir, fileName, new ByteArrayInputStream(content));
reconnectIfTimeout();
boolean success = ftp.upload(dir, fileName, new ByteArrayInputStream(content)); // 不需要主动创建目录ftp 内部已经处理(见源码)
if (!success) {
throw new FtpException(StrUtil.format("上传文件到目标目录 ({}) 失败", filePath));
}
@@ -55,7 +49,7 @@ public class FtpFileClient extends AbstractFileClient<FtpFileClientConfig> {
@Override
public void delete(String path) {
String filePath = getFilePath(path);
ftp.reconnectIfTimeout();
reconnectIfTimeout();
ftp.delFile(filePath);
}
@@ -65,13 +59,17 @@ public class FtpFileClient extends AbstractFileClient<FtpFileClientConfig> {
String fileName = FileUtil.getName(filePath);
String dir = StrUtil.removeSuffix(filePath, fileName);
ByteArrayOutputStream out = new ByteArrayOutputStream();
ftp.reconnectIfTimeout();
reconnectIfTimeout();
ftp.download(dir, fileName, out);
return out.toByteArray();
}
private String getFilePath(String path) {
return config.getBasePath() + path;
return config.getBasePath() + StrUtil.SLASH + path;
}
private synchronized void reconnectIfTimeout() {
ftp.reconnectIfTimeout();
}
}

View File

@@ -18,10 +18,6 @@ public class LocalFileClient extends AbstractFileClient<LocalFileClientConfig> {
@Override
protected void doInit() {
// 补全风格。例如说 Linux 是 /Windows 是 \
if (!config.getBasePath().endsWith(File.separator)) {
config.setBasePath(config.getBasePath() + File.separator);
}
}
@Override
@@ -46,7 +42,7 @@ public class LocalFileClient extends AbstractFileClient<LocalFileClientConfig> {
}
private String getFilePath(String path) {
return config.getBasePath() + path;
return config.getBasePath() + File.separator + path;
}
}

View File

@@ -22,10 +22,6 @@ public class SftpFileClient extends AbstractFileClient<SftpFileClientConfig> {
@Override
protected void doInit() {
// 补全风格。例如说 Linux 是 /Windows 是 \
if (!config.getBasePath().endsWith(File.separator)) {
config.setBasePath(config.getBasePath() + File.separator);
}
// 初始化 Ftp 对象
this.sftp = new Sftp(config.getHost(), config.getPort(), config.getUsername(), config.getPassword());
}
@@ -35,6 +31,8 @@ public class SftpFileClient extends AbstractFileClient<SftpFileClientConfig> {
// 执行写入
String filePath = getFilePath(path);
File file = FileUtils.createTempFile(content);
reconnectIfTimeout();
sftp.mkDirs(FileUtil.getParent(filePath, 1)); // 需要创建父目录,不然会报错
sftp.upload(filePath, file);
// 拼接返回路径
return super.formatFileUrl(config.getDomain(), path);
@@ -43,6 +41,7 @@ public class SftpFileClient extends AbstractFileClient<SftpFileClientConfig> {
@Override
public void delete(String path) {
String filePath = getFilePath(path);
reconnectIfTimeout();
sftp.delFile(filePath);
}
@@ -50,12 +49,17 @@ public class SftpFileClient extends AbstractFileClient<SftpFileClientConfig> {
public byte[] getContent(String path) {
String filePath = getFilePath(path);
File destFile = FileUtils.createTempFile();
reconnectIfTimeout();
sftp.download(filePath, destFile);
return FileUtil.readBytes(destFile);
}
private String getFilePath(String path) {
return config.getBasePath() + path;
return config.getBasePath() + File.separator + path;
}
private synchronized void reconnectIfTimeout() {
sftp.reconnectIfTimeout();
}
}

View File

@@ -6,7 +6,10 @@ import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
import com.alibaba.ttl.TransmittableThreadLocal;
import jakarta.servlet.http.HttpServletResponse;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.tika.Tika;
import org.apache.tika.mime.MimeTypeException;
import org.apache.tika.mime.MimeTypes;
import java.io.IOException;
@@ -15,12 +18,13 @@ import java.io.IOException;
*
* @author 芋道源码
*/
@Slf4j
public class FileTypeUtils {
private static final ThreadLocal<Tika> TIKA = TransmittableThreadLocal.withInitial(Tika::new);
/**
* 获得文件的 mineType对于docjar等文件会有误差
* 获得文件的 mineType对于 docjar 等文件会有误差
*
* @param data 文件内容
* @return mineType 无法识别时会返回“application/octet-stream”
@@ -31,7 +35,7 @@ public class FileTypeUtils {
}
/**
* 已知文件名获取文件类型在某些情况下比通过字节数组准确例如使用jar文件时通过名字更为准确
* 已知文件名,获取文件类型,在某些情况下比通过字节数组准确,例如使用 jar 文件时,通过名字更为准确
*
* @param name 文件名
* @return mineType 无法识别时会返回“application/octet-stream”
@@ -51,6 +55,23 @@ public class FileTypeUtils {
return TIKA.get().detect(data, name);
}
/**
* 根据 mineType 获得文件后缀
*
* 注意:如果获取不到,或者发生异常,都返回 null
*
* @param mineType 类型
* @return 后缀,例如说 .pdf
*/
public static String getExtension(String mineType) {
try {
return MimeTypes.getDefaultMimeTypes().forName(mineType).getExtension();
} catch (MimeTypeException e) {
log.warn("[getExtension][获取文件后缀({}) 失败]", mineType, e);
return null;
}
}
/**
* 返回附件
*

View File

@@ -5,6 +5,7 @@ import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FileCreateReq
import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO;
import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePresignedUrlRespVO;
import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO;
import jakarta.validation.constraints.NotEmpty;
/**
* 文件 Service 接口
@@ -24,12 +25,24 @@ public interface FileService {
/**
* 保存文件,并返回文件的访问路径
*
* @param name 文件名称
* @param path 文件路径
* @param content 文件内容
* @param name 文件名称,允许空
* @param directory 目录,允许空
* @param type 文件的 MIME 类型,允许空
* @return 文件路径
*/
String createFile(String name, String path, byte[] content);
String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content,
String name, String directory, String type);
/**
* 生成文件预签名地址信息
*
* @param name 文件名
* @param directory 目录
* @return 预签名地址信息
*/
FilePresignedUrlRespVO getFilePresignedUrl(@NotEmpty(message = "文件名不能为空") String name,
String directory);
/**
* 创建文件
@@ -55,12 +68,4 @@ public interface FileService {
*/
byte[] getFileContent(Long configId, String path) throws Exception;
/**
* 生成文件预签名地址信息
*
* @param path 文件路径
* @return 预签名地址信息
*/
FilePresignedUrlRespVO getFilePresignedUrl(String path) throws Exception;
}

View File

@@ -1,22 +1,26 @@
package cn.iocoder.yudao.module.infra.service.file;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.io.FileUtil;
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.io.FileUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
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.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.FilePresignedUrlRespVO;
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 jakarta.annotation.Resource;
import lombok.SneakyThrows;
import org.springframework.stereotype.Service;
import static cn.hutool.core.date.DatePattern.PURE_DATE_PATTERN;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_NOT_EXISTS;
@@ -28,6 +32,20 @@ import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_NOT_EX
@Service
public class FileServiceImpl implements FileService {
/**
* 上传文件的前缀是否包含日期yyyyMMdd
*
* 目的:按照日期,进行分目录
*/
static boolean PATH_PREFIX_DATE_ENABLE = true;
/**
* 上传文件的后缀,是否包含时间戳
*
* 目的:保证文件的唯一性,避免覆盖
* 定制:可按需调整成 UUID、或者其他方式
*/
static boolean PATH_SUFFIX_TIMESTAMP_ENABLE = true;
@Resource
private FileConfigService fileConfigService;
@@ -41,34 +59,82 @@ public class FileServiceImpl implements FileService {
@Override
@SneakyThrows
public String createFile(String name, String path, byte[] content) {
// 计算默认的 path 名
String type = FileTypeUtils.getMineType(content, name);
if (StrUtil.isEmpty(path)) {
path = FileUtils.generatePath(content, name);
public String createFile(byte[] content, String name, String directory, String type) {
// 1.1 处理 type 为空的情况
if (StrUtil.isEmpty(type)) {
type = FileTypeUtils.getMineType(content, name);
}
// 如果 name 为空,则使用 path 填充
// 1.2 处理 name 为空的情况
if (StrUtil.isEmpty(name)) {
name = path;
name = DigestUtil.sha256Hex(content);
}
if (StrUtil.isEmpty(FileUtil.extName(name))) {
// 如果 name 没有后缀 type则补充后缀
String extension = FileTypeUtils.getExtension(type);
if (StrUtil.isNotEmpty(extension)) {
name = name + extension;
}
}
// 上传到文件存储器
// 2.1 生成上传的 path需要保证唯一
String path = generateUploadPath(name, directory);
// 2.2 上传到文件存储器
FileClient client = fileConfigService.getMasterFileClient();
Assert.notNull(client, "客户端(master) 不能为空");
String url = client.upload(content, path, type);
// 保存到数据库
FileDO file = new FileDO();
file.setConfigId(client.getId());
file.setName(name);
file.setPath(path);
file.setUrl(url);
file.setType(type);
file.setSize(content.length);
fileMapper.insert(file);
// 3. 保存到数据库
fileMapper.insert(new FileDO().setConfigId(client.getId())
.setName(name).setPath(path).setUrl(url)
.setType(type).setSize(content.length));
return url;
}
@VisibleForTesting
String generateUploadPath(String name, String directory) {
// 1. 生成前缀、后缀
String prefix = null;
if (PATH_PREFIX_DATE_ENABLE) {
prefix = LocalDateTimeUtil.format(LocalDateTimeUtil.now(), PURE_DATE_PATTERN);
}
String suffix = null;
if (PATH_SUFFIX_TIMESTAMP_ENABLE) {
suffix = String.valueOf(System.currentTimeMillis());
}
// 2.1 先拼接 suffix 后缀
if (StrUtil.isNotEmpty(suffix)) {
String ext = FileUtil.extName(name);
if (StrUtil.isNotEmpty(ext)) {
name = FileUtil.mainName(name) + StrUtil.C_UNDERLINE + suffix + StrUtil.DOT + ext;
} else {
name = name + StrUtil.C_UNDERLINE + suffix;
}
}
// 2.2 再拼接 prefix 前缀
if (StrUtil.isNotEmpty(prefix)) {
name = prefix + StrUtil.SLASH + name;
}
// 2.3 最后拼接 directory 目录
if (StrUtil.isNotEmpty(directory)) {
name = directory + StrUtil.SLASH + name;
}
return name;
}
@Override
@SneakyThrows
public FilePresignedUrlRespVO getFilePresignedUrl(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));
}
@Override
public Long createFile(FileCreateReqVO createReqVO) {
FileDO file = BeanUtils.toBean(createReqVO, FileDO.class);
@@ -105,12 +171,4 @@ public class FileServiceImpl implements FileService {
return client.getContent(path);
}
@Override
public FilePresignedUrlRespVO getFilePresignedUrl(String path) throws Exception {
FileClient fileClient = fileConfigService.getMasterFileClient();
FilePresignedUrlRespDTO presignedObjectUrl = fileClient.getPresignedObjectUrl(path);
return BeanUtils.toBean(presignedObjectUrl, FilePresignedUrlRespVO.class,
object -> object.setConfigId(fileClient.getId()));
}
}

View File

@@ -8,8 +8,23 @@ import cn.iocoder.yudao.module.infra.framework.file.core.client.ftp.FtpFileClien
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
/**
* {@link FtpFileClient} 集成测试
*
* @author 芋道源码
*/
public class FtpFileClientTest {
// docker run -d \
// -p 2121:21 -p 30000-30009:30000-30009 \
// -e FTP_USER=foo \
// -e FTP_PASS=pass \
// -e PASV_ADDRESS=127.0.0.1 \
// -e PASV_MIN_PORT=30000 \
// -e PASV_MAX_PORT=30009 \
// -v $(pwd)/ftp-data:/home/vsftpd \
// fauria/vsftpd
@Test
@Disabled
public void test() {
@@ -17,10 +32,10 @@ public class FtpFileClientTest {
FtpFileClientConfig config = new FtpFileClientConfig();
config.setDomain("http://127.0.0.1:48080");
config.setBasePath("/home/ftp");
config.setHost("kanchai.club");
config.setPort(221);
config.setUsername("");
config.setPassword("");
config.setHost("127.0.0.1");
config.setPort(2121);
config.setUsername("foo");
config.setPassword("pass");
config.setMode(FtpMode.Passive.name());
FtpFileClient client = new FtpFileClient(0L, config);
client.init();

View File

@@ -7,19 +7,29 @@ import cn.iocoder.yudao.module.infra.framework.file.core.client.sftp.SftpFileCli
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
/**
* {@link SftpFileClient} 集成测试
*
* @author 芋道源码
*/
public class SftpFileClientTest {
// docker run -p 2222:22 -d \
// -v $(pwd)/sftp-data:/home/foo/upload \
// atmoz/sftp \
// foo:pass:1001
@Test
@Disabled
public void test() {
// 创建客户端
SftpFileClientConfig config = new SftpFileClientConfig();
config.setDomain("http://127.0.0.1:48080");
config.setBasePath("/home/ftp");
config.setHost("kanchai.club");
config.setPort(222);
config.setUsername("");
config.setPassword("");
config.setBasePath("/upload"); // 注意,这个是相对路径,不是实际 linux 上的路径!!!
config.setHost("127.0.0.1");
config.setPort(2222);
config.setUsername("foo");
config.setPassword("pass");
SftpFileClient client = new SftpFileClient(0L, config);
client.init();
// 上传文件

View File

@@ -3,19 +3,20 @@ package cn.iocoder.yudao.module.infra.service.file;
import cn.hutool.core.io.resource.ResourceUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.framework.test.core.util.AssertUtils;
import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO;
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 jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import jakarta.annotation.Resource;
import java.time.LocalDateTime;
import java.util.concurrent.atomic.AtomicReference;
import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildTime;
import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
@@ -29,7 +30,7 @@ import static org.mockito.Mockito.*;
public class FileServiceImplTest extends BaseDbUnitTest {
@Resource
private FileService fileService;
private FileServiceImpl fileService;
@Resource
private FileMapper fileMapper;
@@ -37,6 +38,12 @@ public class FileServiceImplTest extends BaseDbUnitTest {
@MockBean
private FileConfigService fileConfigService;
@BeforeEach
public void setUp() {
FileServiceImpl.PATH_PREFIX_DATE_ENABLE = true;
FileServiceImpl.PATH_SUFFIX_TIMESTAMP_ENABLE = true;
}
@Test
public void testGetFilePage() {
// mock 数据
@@ -70,28 +77,69 @@ public class FileServiceImplTest extends BaseDbUnitTest {
AssertUtils.assertPojoEquals(dbFile, pageResult.getList().get(0));
}
/**
* content、name、directory、type 都非空
*/
@Test
public void testCreateFile_success() throws Exception {
public void testCreateFile_success_01() throws Exception {
// 准备参数
String path = randomString();
byte[] content = ResourceUtil.readBytes("file/erweima.jpg");
String name = "单测文件名";
String directory = randomString();
String type = "image/jpeg";
// mock Master 文件客户端
FileClient client = mock(FileClient.class);
when(fileConfigService.getMasterFileClient()).thenReturn(client);
String url = randomString();
when(client.upload(same(content), same(path), eq("image/jpeg"))).thenReturn(url);
AtomicReference<String> pathRef = new AtomicReference<>();
when(client.upload(same(content), argThat(path -> {
assertTrue(path.matches(directory + "/\\d{8}/" + name + "_\\d+.jpg"));
pathRef.set(path);
return true;
}), eq(type))).thenReturn(url);
when(client.getId()).thenReturn(10L);
String name = "单测文件名";
// 调用
String result = fileService.createFile(name, path, content);
String result = fileService.createFile(content, name, directory, type);
// 断言
assertEquals(result, url);
// 校验数据
FileDO file = fileMapper.selectOne(FileDO::getPath, path);
FileDO file = fileMapper.selectOne(FileDO::getUrl, url);
assertEquals(10L, file.getConfigId());
assertEquals(path, file.getPath());
assertEquals(pathRef.get(), file.getPath());
assertEquals(url, file.getUrl());
assertEquals("image/jpeg", file.getType());
assertEquals(type, file.getType());
assertEquals(content.length, file.getSize());
}
/**
* content 非空,其它都空
*/
@Test
public void testCreateFile_success_02() throws Exception {
// 准备参数
byte[] content = ResourceUtil.readBytes("file/erweima.jpg");
// mock Master 文件客户端
String type = "image/jpeg";
FileClient client = mock(FileClient.class);
when(fileConfigService.getMasterFileClient()).thenReturn(client);
String url = randomString();
AtomicReference<String> pathRef = new AtomicReference<>();
when(client.upload(same(content), argThat(path -> {
assertTrue(path.matches("\\d{8}/6318848e882d8a7e7e82789d87608f684ee52d41966bfc8cad3ce15aad2b970e_\\d+\\.jpg"));
pathRef.set(path);
return true;
}), eq(type))).thenReturn(url);
when(client.getId()).thenReturn(10L);
// 调用
String result = fileService.createFile(content, null, null, null);
// 断言
assertEquals(result, url);
// 校验数据
FileDO file = fileMapper.selectOne(FileDO::getUrl, url);
assertEquals(10L, file.getConfigId());
assertEquals(pathRef.get(), file.getPath());
assertEquals(url, file.getUrl());
assertEquals(type, file.getType());
assertEquals(content.length, file.getSize());
}
@@ -140,4 +188,122 @@ public class FileServiceImplTest extends BaseDbUnitTest {
assertSame(result, content);
}
@Test
public void testGenerateUploadPath_AllEnabled() {
// 准备参数
String name = "test.jpg";
String directory = "avatar";
FileServiceImpl.PATH_PREFIX_DATE_ENABLE = true;
FileServiceImpl.PATH_SUFFIX_TIMESTAMP_ENABLE = true;
// 调用
String path = fileService.generateUploadPath(name, directory);
// 断言
// 格式为avatar/yyyyMMdd/test_timestamp.jpg
assertTrue(path.startsWith(directory + "/"));
// 包含日期格式8 位数字,如 20240517
assertTrue(path.matches(directory + "/\\d{8}/test_\\d+\\.jpg"));
}
@Test
public void testGenerateUploadPath_PrefixEnabled_SuffixDisabled() {
// 准备参数
String name = "test.jpg";
String directory = "avatar";
FileServiceImpl.PATH_PREFIX_DATE_ENABLE = true;
FileServiceImpl.PATH_SUFFIX_TIMESTAMP_ENABLE = false;
// 调用
String path = fileService.generateUploadPath(name, directory);
// 断言
// 格式为avatar/yyyyMMdd/test.jpg
assertTrue(path.startsWith(directory + "/"));
// 包含日期格式8 位数字,如 20240517
assertTrue(path.matches(directory + "/\\d{8}/test\\.jpg"));
}
@Test
public void testGenerateUploadPath_PrefixDisabled_SuffixEnabled() {
// 准备参数
String name = "test.jpg";
String directory = "avatar";
FileServiceImpl.PATH_PREFIX_DATE_ENABLE = false;
FileServiceImpl.PATH_SUFFIX_TIMESTAMP_ENABLE = true;
// 调用
String path = fileService.generateUploadPath(name, directory);
// 断言
// 格式为avatar/test_timestamp.jpg
assertTrue(path.startsWith(directory + "/"));
assertTrue(path.matches(directory + "/test_\\d+\\.jpg"));
}
@Test
public void testGenerateUploadPath_AllDisabled() {
// 准备参数
String name = "test.jpg";
String directory = "avatar";
FileServiceImpl.PATH_PREFIX_DATE_ENABLE = false;
FileServiceImpl.PATH_SUFFIX_TIMESTAMP_ENABLE = false;
// 调用
String path = fileService.generateUploadPath(name, directory);
// 断言
// 格式为avatar/test.jpg
assertEquals(directory + "/" + name, path);
}
@Test
public void testGenerateUploadPath_NoExtension() {
// 准备参数
String name = "test";
String directory = "avatar";
FileServiceImpl.PATH_PREFIX_DATE_ENABLE = true;
FileServiceImpl.PATH_SUFFIX_TIMESTAMP_ENABLE = true;
// 调用
String path = fileService.generateUploadPath(name, directory);
// 断言
// 格式为avatar/yyyyMMdd/test_timestamp
assertTrue(path.startsWith(directory + "/"));
assertTrue(path.matches(directory + "/\\d{8}/test_\\d+"));
}
@Test
public void testGenerateUploadPath_DirectoryNull() {
// 准备参数
String name = "test.jpg";
String directory = null;
FileServiceImpl.PATH_PREFIX_DATE_ENABLE = true;
FileServiceImpl.PATH_SUFFIX_TIMESTAMP_ENABLE = true;
// 调用
String path = fileService.generateUploadPath(name, directory);
// 断言
// 格式为yyyyMMdd/test_timestamp.jpg
assertTrue(path.matches("\\d{8}/test_\\d+\\.jpg"));
}
@Test
public void testGenerateUploadPath_DirectoryEmpty() {
// 准备参数
String name = "test.jpg";
String directory = "";
FileServiceImpl.PATH_PREFIX_DATE_ENABLE = true;
FileServiceImpl.PATH_SUFFIX_TIMESTAMP_ENABLE = true;
// 调用
String path = fileService.generateUploadPath(name, directory);
// 断言
// 格式为yyyyMMdd/test_timestamp.jpg
assertTrue(path.matches("\\d{8}/test_\\d+\\.jpg"));
}
}