Browse Source

reactor:【INFRA】文件上传 api,增加 directory 参数,去除 path 参数,并支持按照日期分目录、文件名不再使用 sha256 而是时间戳

YunaiV 6 tháng trước cách đây
mục cha
commit
cce09044c1
24 tập tin đã thay đổi với 418 bổ sung220 xóa
  1. 0 23
      yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/io/FileUtils.java
  2. 11 7
      yudao-module-infra/yudao-module-infra-api/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java
  3. 3 4
      yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java
  4. 12 4
      yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileController.java
  5. 10 1
      yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/file/FilePresignedUrlRespVO.java
  6. 2 2
      yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/file/FileUploadReqVO.java
  7. 13 5
      yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/controller/app/file/AppFileController.java
  8. 2 2
      yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/controller/app/file/vo/AppFileUploadReqVO.java
  9. 9 11
      yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/ftp/FtpFileClient.java
  10. 1 5
      yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/local/LocalFileClient.java
  11. 9 5
      yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/sftp/SftpFileClient.java
  12. 23 2
      yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtils.java
  13. 16 11
      yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java
  14. 87 29
      yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java
  15. 19 4
      yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/ftp/FtpFileClientTest.java
  16. 15 5
      yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/sftp/SftpFileClientTest.java
  17. 178 12
      yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java
  18. 1 23
      yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberUserServiceImplTest.java
  19. 1 1
      yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/material/MpMaterialServiceImpl.java
  20. 0 15
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/user/UserProfileController.java
  21. 0 9
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/user/AdminUserService.java
  22. 0 15
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/user/AdminUserServiceImpl.java
  23. 1 1
      yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImplTest.java
  24. 5 24
      yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/service/user/AdminUserServiceImplTest.java

+ 0 - 23
yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/io/FileUtils.java

@@ -1,14 +1,9 @@
 package cn.iocoder.yudao.framework.common.util.io;
 
-import cn.hutool.core.io.FileTypeUtil;
 import cn.hutool.core.io.FileUtil;
-import cn.hutool.core.io.file.FileNameUtil;
 import cn.hutool.core.util.IdUtil;
-import cn.hutool.core.util.StrUtil;
-import cn.hutool.crypto.digest.DigestUtil;
 import lombok.SneakyThrows;
 
-import java.io.ByteArrayInputStream;
 import java.io.File;
 
 /**
@@ -63,22 +58,4 @@ public class FileUtils {
         return file;
     }
 
-    /**
-     * 生成文件路径
-     *
-     * @param content      文件内容
-     * @param originalName 原始文件名
-     * @return path,唯一不可重复
-     */
-    public static String generatePath(byte[] content, String originalName) {
-        String sha256Hex = DigestUtil.sha256Hex(content);
-        // 情况一:如果存在 name,则优先使用 name 的后缀
-        if (StrUtil.isNotBlank(originalName)) {
-            String extName = FileNameUtil.extName(originalName);
-            return StrUtil.isBlank(extName) ? sha256Hex : sha256Hex + "." + extName;
-        }
-        // 情况二:基于 content 计算
-        return sha256Hex + '.' + FileTypeUtil.getType(new ByteArrayInputStream(content));
-    }
-
 }

+ 11 - 7
yudao-module-infra/yudao-module-infra-api/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java

@@ -1,5 +1,7 @@
 package cn.iocoder.yudao.module.infra.api.file;
 
+import jakarta.validation.constraints.NotEmpty;
+
 /**
  * 文件 API 接口
  *
@@ -14,28 +16,30 @@ public interface FileApi {
      * @return 文件路径
      */
     default String createFile(byte[] content) {
-        return createFile(null, null, content);
+        return createFile(content, null, null, null);
     }
 
     /**
      * 保存文件,并返回文件的访问路径
      *
-     * @param path 文件路径
      * @param content 文件内容
+     * @param name 文件名称,允许空
      * @return 文件路径
      */
-    default String createFile(String path, byte[] content) {
-        return createFile(null, path, content);
+    default String createFile(byte[] content, String name) {
+        return createFile(content, name, null, null);
     }
 
     /**
      * 保存文件,并返回文件的访问路径
      *
-     * @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);
 
 }

+ 3 - 4
yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java

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

+ 12 - 4
yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileController.java

@@ -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")

+ 10 - 1
yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/file/FilePresignedUrlRespVO.java

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

+ 2 - 2
yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/file/FileUploadReqVO.java

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

+ 13 - 5
yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/controller/app/file/AppFileController.java

@@ -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")

+ 2 - 2
yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/controller/app/file/vo/AppFileUploadReqVO.java

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

+ 9 - 11
yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/ftp/FtpFileClient.java

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

+ 1 - 5
yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/local/LocalFileClient.java

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

+ 9 - 5
yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/sftp/SftpFileClient.java

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

+ 23 - 2
yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtils.java

@@ -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,对于doc,jar等文件会有误差
+     * 获得文件的 mineType,对于 doc,jar 等文件会有误差
      *
      * @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;
+        }
+    }
+
     /**
      * 返回附件
      *

+ 16 - 11
yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java

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

+ 87 - 29
yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java

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

+ 19 - 4
yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/ftp/FtpFileClientTest.java

@@ -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();

+ 15 - 5
yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/sftp/SftpFileClientTest.java

@@ -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();
         // 上传文件

+ 178 - 12
yudao-module-infra/yudao-module-infra-biz/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java

@@ -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"));
+    }
+
 }

+ 1 - 23
yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberUserServiceImplTest.java

@@ -5,12 +5,12 @@ import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
 import cn.iocoder.yudao.framework.common.util.collection.ArrayUtils;
 import cn.iocoder.yudao.framework.redis.config.YudaoRedisAutoConfiguration;
 import cn.iocoder.yudao.framework.test.core.ut.BaseDbAndRedisUnitTest;
-import cn.iocoder.yudao.module.infra.api.file.FileApi;
 import cn.iocoder.yudao.module.member.controller.app.user.vo.AppMemberUserUpdateMobileReqVO;
 import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO;
 import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper;
 import cn.iocoder.yudao.module.member.service.auth.MemberAuthServiceImpl;
 import cn.iocoder.yudao.module.system.api.sms.SmsCodeApi;
+import jakarta.annotation.Resource;
 import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 import org.springframework.boot.test.mock.mockito.MockBean;
@@ -18,7 +18,6 @@ import org.springframework.context.annotation.Import;
 import org.springframework.data.redis.core.StringRedisTemplate;
 import org.springframework.security.crypto.password.PasswordEncoder;
 
-import jakarta.annotation.Resource;
 import java.util.function.Consumer;
 
 import static cn.hutool.core.util.RandomUtil.randomEle;
@@ -53,8 +52,6 @@ public class MemberUserServiceImplTest extends BaseDbAndRedisUnitTest {
 
     @MockBean
     private SmsCodeApi smsCodeApi;
-    @MockBean
-    private FileApi fileApi;
 
     // TODO 芋艿:后续重构这个单测
 //    @Test
@@ -72,25 +69,6 @@ public class MemberUserServiceImplTest extends BaseDbAndRedisUnitTest {
 //        String nickname = memberUserService.getUser(userDO.getId()).getNickname();
 //        // 断言
 //        assertEquals(newNickName,nickname);
-//    }
-//
-//    @Test
-//    public void testUpdateAvatar_success() throws Exception {
-//        // mock 数据
-//        MemberUserDO dbUser = randomUserDO();
-//        userMapper.insert(dbUser);
-//
-//        // 准备参数
-//        Long userId = dbUser.getId();
-//        byte[] avatarFileBytes = randomBytes(10);
-//        ByteArrayInputStream avatarFile = new ByteArrayInputStream(avatarFileBytes);
-//        // mock 方法
-//        String avatar = randomString();
-//        when(fileApi.createFile(eq(avatarFileBytes))).thenReturn(avatar);
-//        // 调用
-//        String str = memberUserService.updateUserAvatar(userId, avatarFile);
-//        // 断言
-//        assertEquals(avatar, str);
 //    }
 
     @Test

+ 1 - 1
yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/material/MpMaterialServiceImpl.java

@@ -218,7 +218,7 @@ public class MpMaterialServiceImpl implements MpMaterialService {
 
     private String uploadFile(String mediaId, File file) {
         String path = mediaId + "." + FileTypeUtil.getType(file);
-        return fileApi.createFile(path, FileUtil.readBytes(file));
+        return fileApi.createFile(FileUtil.readBytes(file), path);
     }
 
 }

+ 0 - 15
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/user/UserProfileController.java

@@ -23,14 +23,11 @@ import jakarta.validation.Valid;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.validation.annotation.Validated;
 import org.springframework.web.bind.annotation.*;
-import org.springframework.web.multipart.MultipartFile;
 
 import java.util.List;
 
-import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
 import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
 import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
-import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_IS_EMPTY;
 
 @Tag(name = "管理后台 - 用户个人中心")
 @RestController
@@ -79,16 +76,4 @@ public class UserProfileController {
         return success(true);
     }
 
-    @Deprecated // TODO @芋艿:逐步替换到 updateUserProfile 接口
-    @RequestMapping(value = "/update-avatar",
-            method = {RequestMethod.POST, RequestMethod.PUT}) // 解决 uni-app 不支持 Put 上传文件的问题
-    @Operation(summary = "上传用户个人头像")
-    public CommonResult<String> updateUserAvatar(@RequestParam("avatarFile") MultipartFile file) throws Exception {
-        if (file.isEmpty()) {
-            throw exception(FILE_IS_EMPTY);
-        }
-        String avatar = userService.updateUserAvatar(getLoginUserId(), file.getInputStream());
-        return success(avatar);
-    }
-
 }

+ 0 - 9
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/user/AdminUserService.java

@@ -13,7 +13,6 @@ import cn.iocoder.yudao.module.system.controller.admin.user.vo.user.UserSaveReqV
 import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
 import jakarta.validation.Valid;
 
-import java.io.InputStream;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
@@ -73,14 +72,6 @@ public interface AdminUserService {
      */
     void updateUserPassword(Long id, @Valid UserProfileUpdatePasswordReqVO reqVO);
 
-    /**
-     * 更新用户头像
-     *
-     * @param id         用户 id
-     * @param avatarFile 头像文件
-     */
-    String updateUserAvatar(Long id, InputStream avatarFile) throws Exception;
-
     /**
      * 修改密码
      *

+ 0 - 15
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/user/AdminUserServiceImpl.java

@@ -82,8 +82,6 @@ public class AdminUserServiceImpl implements AdminUserService {
     @Resource
     private UserPostMapper userPostMapper;
 
-    @Resource
-    private FileApi fileApi;
     @Resource
     private ConfigApi configApi;
 
@@ -205,19 +203,6 @@ public class AdminUserServiceImpl implements AdminUserService {
         userMapper.updateById(updateObj);
     }
 
-    @Override
-    public String updateUserAvatar(Long id, InputStream avatarFile) {
-        validateUserExists(id);
-        // 存储文件
-        String avatar = fileApi.createFile(IoUtil.readBytes(avatarFile));
-        // 更新路径
-        AdminUserDO sysUserDO = new AdminUserDO();
-        sysUserDO.setId(id);
-        sysUserDO.setAvatar(avatar);
-        userMapper.updateById(sysUserDO);
-        return avatar;
-    }
-
     @Override
     @LogRecord(type = SYSTEM_USER_TYPE, subType = SYSTEM_USER_UPDATE_PASSWORD_SUB_TYPE, bizNo = "{{#id}}",
             success = SYSTEM_USER_UPDATE_PASSWORD_SUCCESS)

+ 1 - 1
yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImplTest.java

@@ -185,7 +185,7 @@ public class AdminAuthServiceImplTest extends BaseDbUnitTest {
     public void testSendSmsCode() {
         // 准备参数
         String mobile = randomString();
-        Integer scene = randomEle(SmsSceneEnum.values()).getScene();
+        Integer scene = SmsSceneEnum.ADMIN_MEMBER_LOGIN.getScene();
         AuthSmsSendReqVO reqVO = new AuthSmsSendReqVO(mobile, scene);
         // mock 方法(用户信息)
         AdminUserDO user = randomPojo(AdminUserDO.class);

+ 5 - 24
yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/service/user/AdminUserServiceImplTest.java

@@ -11,7 +11,10 @@ import cn.iocoder.yudao.module.infra.api.config.ConfigApi;
 import cn.iocoder.yudao.module.infra.api.file.FileApi;
 import cn.iocoder.yudao.module.system.controller.admin.user.vo.profile.UserProfileUpdatePasswordReqVO;
 import cn.iocoder.yudao.module.system.controller.admin.user.vo.profile.UserProfileUpdateReqVO;
-import cn.iocoder.yudao.module.system.controller.admin.user.vo.user.*;
+import cn.iocoder.yudao.module.system.controller.admin.user.vo.user.UserImportExcelVO;
+import cn.iocoder.yudao.module.system.controller.admin.user.vo.user.UserImportRespVO;
+import cn.iocoder.yudao.module.system.controller.admin.user.vo.user.UserPageReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.user.vo.user.UserSaveReqVO;
 import cn.iocoder.yudao.module.system.dal.dataobject.dept.DeptDO;
 import cn.iocoder.yudao.module.system.dal.dataobject.dept.PostDO;
 import cn.iocoder.yudao.module.system.dal.dataobject.dept.UserPostDO;
@@ -24,6 +27,7 @@ import cn.iocoder.yudao.module.system.service.dept.DeptService;
 import cn.iocoder.yudao.module.system.service.dept.PostService;
 import cn.iocoder.yudao.module.system.service.permission.PermissionService;
 import cn.iocoder.yudao.module.system.service.tenant.TenantService;
+import jakarta.annotation.Resource;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.stubbing.Answer;
@@ -31,14 +35,11 @@ import org.springframework.boot.test.mock.mockito.MockBean;
 import org.springframework.context.annotation.Import;
 import org.springframework.security.crypto.password.PasswordEncoder;
 
-import jakarta.annotation.Resource;
-import java.io.ByteArrayInputStream;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 import java.util.function.Consumer;
 
-import static cn.hutool.core.util.RandomUtil.randomBytes;
 import static cn.hutool.core.util.RandomUtil.randomEle;
 import static cn.iocoder.yudao.framework.common.util.collection.SetUtils.asSet;
 import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildBetweenTime;
@@ -245,26 +246,6 @@ public class AdminUserServiceImplTest extends BaseDbUnitTest {
         assertEquals("encode:yuanma", user.getPassword());
     }
 
-    @Test
-    public void testUpdateUserAvatar_success() throws Exception {
-        // mock 数据
-        AdminUserDO dbUser = randomAdminUserDO();
-        userMapper.insert(dbUser);
-        // 准备参数
-        Long userId = dbUser.getId();
-        byte[] avatarFileBytes = randomBytes(10);
-        ByteArrayInputStream avatarFile = new ByteArrayInputStream(avatarFileBytes);
-        // mock 方法
-        String avatar = randomString();
-        when(fileApi.createFile(eq( avatarFileBytes))).thenReturn(avatar);
-
-        // 调用
-        userService.updateUserAvatar(userId, avatarFile);
-        // 断言
-        AdminUserDO user = userMapper.selectById(userId);
-        assertEquals(avatar, user.getAvatar());
-    }
-
     @Test
     public void testUpdateUserPassword02_success() {
         // mock 数据