diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/core/OssClient.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/core/OssClient.java index 42edf2ae2..9eae4376d 100644 --- a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/core/OssClient.java +++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/core/OssClient.java @@ -333,7 +333,7 @@ public class OssClient { .key(key) .build() ).join().uploadId(); - return UploadResult.builder().url(getUrl() + StringUtils.SLASH + key).filename(key).uploadId(uploadId).build(); + return UploadResult.builder().filename(key).uploadId(uploadId).build(); } catch (Exception e) { // 捕获异常并抛出自定义异常 throw new OssException("创建分片上传任务失败,请检查配置信息:[" + e.getMessage() + "]"); @@ -479,6 +479,16 @@ public class OssClient { return putPrivateUrl(getPath(properties.getPrefix(), suffix), md5Digest, second); } + /** + * 创建分片上传任务 + * + * @param suffix 对象键的后缀 + * @return 包含上传后的文件信息 + */ + public UploadResult initiateMultipart(String suffix){ + return initiateMultipartUpload(getPath(properties.getPrefix(), suffix)); + } + /** * 获取文件输入流 * diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysOssController.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysOssController.java index 73ada3b84..269d67d1b 100644 --- a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysOssController.java +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysOssController.java @@ -3,20 +3,26 @@ package org.dromara.system.controller.system; import cn.dev33.satoken.annotation.SaCheckPermission; import cn.hutool.core.util.ObjectUtil; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import lombok.RequiredArgsConstructor; import org.dromara.common.core.domain.R; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; import org.dromara.common.core.validate.QueryGroup; -import org.dromara.common.web.core.BaseController; import org.dromara.common.log.annotation.Log; import org.dromara.common.log.enums.BusinessType; import org.dromara.common.mybatis.core.page.PageQuery; import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.common.web.core.BaseController; +import org.dromara.system.domain.bo.MultipartBo; import org.dromara.system.domain.bo.SysOssBo; +import org.dromara.system.domain.vo.MultipartVo; import org.dromara.system.domain.vo.SysOssUploadVo; import org.dromara.system.domain.vo.SysOssVo; import org.dromara.system.service.ISysOssService; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.validation.constraints.NotEmpty; -import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -105,4 +111,44 @@ public class SysOssController extends BaseController { return toAjax(ossService.deleteWithValidByIds(List.of(ossIds), true)); } + /** + * 初始化分片上传任务 + * + * @param originalName 文件原名 + */ + @SaCheckPermission("system:oss:multipart") + @PostMapping(value = "/multipart/initiate") + public R initiateMultipart(@Size(min = 2, max = 255, message = "文件原名长度必须在2到255之间") + @NotBlank(message = "文件原名不能为空") String originalName) { + return R.ok(ossService.initiateMultipart(originalName)); + } + + /** + * 上传分段 + */ + @SaCheckPermission("system:oss:multipart") + @PostMapping(value = "/multipart/upload") + public R uploadPart(@Validated(AddGroup.class) @RequestBody MultipartBo multipartBo) { + return R.ok(ossService.uploadPart(multipartBo)); + } + + /** + * 查询上传分段进度 + */ + @SaCheckPermission("system:oss:multipart") + @PostMapping(value = "/multipart/list") + public R uploadPartList(@Validated(QueryGroup.class) @RequestBody MultipartBo multipartBo) { + return R.ok(ossService.uploadPartList(multipartBo)); + } + + /** + * 合并分段文件 + */ + @SaCheckPermission("system:oss:multipart") + @Log(title = "OSS对象存储-分片", businessType = BusinessType.INSERT) + @PostMapping(value = "/multipart/complete") + public R completeMultipartUpload(@Validated(EditGroup.class) @RequestBody MultipartBo multipartBo) { + return R.ok(ossService.completeMultipartUpload(multipartBo)); + } + } diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/MultipartBo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/MultipartBo.java new file mode 100644 index 000000000..760549959 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/MultipartBo.java @@ -0,0 +1,97 @@ +package org.dromara.system.domain.bo; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; +import org.dromara.common.core.validate.AddGroup; +import org.dromara.common.core.validate.EditGroup; +import org.dromara.common.core.validate.QueryGroup; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * @author AprilWind + */ +@Data +public class MultipartBo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 文件名 + */ + @NotBlank(message = "文件名不能为空", groups = {QueryGroup.class, AddGroup.class, EditGroup.class}) + private String filename; + + /** + * 用于分片上传任务的 Upload ID + * 在初始化分片上传时获取,并在后续的分片上传和完成上传过程中使用 + */ + @NotBlank(message = "上传任务ID不能为空", groups = {QueryGroup.class, AddGroup.class, EditGroup.class}) + private String uploadId; + + /** + * 分片编号(从1开始递增) + */ + @NotNull(message = "分片编号不能为空", groups = AddGroup.class) + @Size(min = 1, max = 10000, message = "分片编号必须介于1和10,000之间", groups = AddGroup.class) + private Integer partNumber; + + /** + * 内容的 MD5 摘要 + */ + @Size(max = 255, message = "内容的 MD5 摘要,如果有的话不能超过255", groups = AddGroup.class) + private String md5Digest; + + /** + * 最大返回的分片数(默认为1000,最大值1000) + * 最多分片一万,一次性返回会造成前端性能问题,需要前端多次校验 + */ + @NotNull(message = "最大返回的分片数不能为空", groups = QueryGroup.class) + private Integer maxParts; + + /** + * 分片编号的标记,用于分页查询(默认为0,表示从第一个分片开始查询) + */ + @NotNull(message = "分片编号的标记不能为空", groups = QueryGroup.class) + private Integer partNumberMarker; + + /** + * 原名 + */ + @NotBlank(message = "原名不能为空", groups = EditGroup.class) + @Size(min = 2, max = 255, message = "文件原名长度必须在2到255之间", groups = EditGroup.class) + private String originalName; + + /** + * 已上传列表(最大长度一万) + * 必须是唯一且按照递增顺序排列,严格检查是否漏传 + */ + @NotNull(message = "已上传列表不能为空") + @Size(min = 2, max = 10000, message = "已上传列表长度必须在2到10000之间", groups = EditGroup.class) + private List partUploadList; + + @Data + public static class PartUploadResult implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 分片编号(从1开始递增) + */ + @NotNull(message = "分片编号不能为空", groups = EditGroup.class) + @Size(min = 1, max = 10000, message = "分片编号必须介于1和10,000之间", groups = EditGroup.class) + private Integer partNumber; + + /** + * 从上传部分的内容生成的实体标签 + */ + @NotBlank(message = "实体标签不能为空", groups = EditGroup.class) + private String eTag; + } +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/MultipartVo.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/MultipartVo.java new file mode 100644 index 000000000..1dc9f5266 --- /dev/null +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/MultipartVo.java @@ -0,0 +1,68 @@ +package org.dromara.system.domain.vo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.AllArgsConstructor; +import lombok.Data; +import org.dromara.system.domain.SysOss; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * @author AprilWind + */ +@Data +@AutoMapper(target = SysOss.class) +public class MultipartVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 文件名 + */ + private String filename; + + /** + * 用于分片上传任务的 Upload ID + * 在初始化分片上传时获取,并在后续的分片上传和完成上传过程中使用 + */ + private String uploadId; + + /** + * 分片编号(从1开始递增) + */ + private Integer partNumber; + + /** + * 用于私有预签名 URL + * 用于上传文件或分片的预签名 URL,确保文件或分片在上传时具有私有访问权限 + */ + private String privateUrl; + + /** + * 已上传列表(最大长度一千) + * 必须是唯一且按照递增顺序排列,严格检查是否漏传 + */ + private List partUploadList; + + @Data + @AllArgsConstructor + public static class PartUploadResult implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 分片编号(从1开始递增) + */ + private Integer partNumber; + + /** + * 从上传部分的内容生成的实体标签 + */ + private String eTag; + } + +} diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/ISysOssService.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/ISysOssService.java index 057c068ce..f7c8adcd7 100644 --- a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/ISysOssService.java +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/ISysOssService.java @@ -1,10 +1,12 @@ package org.dromara.system.service; +import jakarta.servlet.http.HttpServletResponse; import org.dromara.common.mybatis.core.page.PageQuery; import org.dromara.common.mybatis.core.page.TableDataInfo; +import org.dromara.system.domain.bo.MultipartBo; import org.dromara.system.domain.bo.SysOssBo; +import org.dromara.system.domain.vo.MultipartVo; import org.dromara.system.domain.vo.SysOssVo; -import jakarta.servlet.http.HttpServletResponse; import org.springframework.web.multipart.MultipartFile; import java.io.File; @@ -77,4 +79,36 @@ public interface ISysOssService { */ Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + /** + * 初始化分片上传任务 + * + * @param originalName 文件原名 + * @return 分片上传对象信息 + */ + MultipartVo initiateMultipart(String originalName); + + /** + * 上传文件的分段(分片上传) + * + * @param multipartBo SysOssMultipartBo 分段上传的参数对象 + * @return SysOssMultipartVo 分片上传成功后的对象信息 + */ + MultipartVo uploadPart(MultipartBo multipartBo); + + /** + * 获取上传分段进度 + * + * @param multipartBo 分片上传对象信息 + * @return 分片上传对象信息 + */ + MultipartVo uploadPartList(MultipartBo multipartBo); + + /** + * 合并分段 + * + * @param multipartBo 分片上传对象信息 + * @return OSS对象存储视图对象 + */ + SysOssVo completeMultipartUpload(MultipartBo multipartBo); + } diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysOssServiceImpl.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysOssServiceImpl.java index 1df221c32..d7dc49e1e 100644 --- a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysOssServiceImpl.java +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysOssServiceImpl.java @@ -19,11 +19,14 @@ import org.dromara.common.core.utils.file.FileUtils; import org.dromara.common.mybatis.core.page.PageQuery; import org.dromara.common.mybatis.core.page.TableDataInfo; import org.dromara.common.oss.core.OssClient; +import org.dromara.common.oss.entity.PartUploadResult; import org.dromara.common.oss.entity.UploadResult; import org.dromara.common.oss.enumd.AccessPolicyType; import org.dromara.common.oss.factory.OssFactory; import org.dromara.system.domain.SysOss; +import org.dromara.system.domain.bo.MultipartBo; import org.dromara.system.domain.bo.SysOssBo; +import org.dromara.system.domain.vo.MultipartVo; import org.dromara.system.domain.vo.SysOssVo; import org.dromara.system.mapper.SysOssMapper; import org.dromara.system.service.ISysOssService; @@ -40,6 +43,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; /** * 文件上传 服务层实现 @@ -237,6 +241,74 @@ public class SysOssServiceImpl implements ISysOssService, OssService { return baseMapper.deleteBatchIds(ids) > 0; } + /** + * 初始化分片上传任务 + * + * @param originalName 文件原名 + * @return 分片上传对象信息 + */ + @Override + public MultipartVo initiateMultipart(String originalName) { + String suffix = StringUtils.substring(originalName, originalName.lastIndexOf("."), originalName.length()); + OssClient storage = OssFactory.instance(); + UploadResult uploadResult = storage.initiateMultipart(suffix); + return MapstructUtils.convert(uploadResult, MultipartVo.class); + } + + /** + * 上传文件的分段(分片上传) + * + * @param multipartBo MultipartBo 分段上传的参数对象 + * @return MultipartVo 分片上传成功后的对象信息 + */ + @Override + public MultipartVo uploadPart(MultipartBo multipartBo) { + OssClient storage = OssFactory.instance(); + String privateUrl = storage.uploadPartFutures(multipartBo.getFilename(), multipartBo.getUploadId(), multipartBo.getPartNumber(), multipartBo.getMd5Digest(), 10800); + MultipartVo multipartVo = MapstructUtils.convert(multipartBo, MultipartVo.class); + multipartVo.setPrivateUrl(privateUrl); + return multipartVo; + } + + /** + * 获取上传分段进度 + * + * @param multipartBo 分片上传对象信息 + * @return 分片上传对象信息 + */ + @Override + public MultipartVo uploadPartList(MultipartBo multipartBo) { + OssClient storage = OssFactory.instance(); + List listParts = storage.listParts(multipartBo.getFilename(), multipartBo.getUploadId(), multipartBo.getMaxParts(), multipartBo.getPartNumberMarker()); + MultipartVo multipartVo = MapstructUtils.convert(multipartBo, MultipartVo.class); + multipartVo.setPartUploadList(listParts.stream() + .map(x -> new MultipartVo.PartUploadResult(x.getPartNumber(), x.getETag())) + .collect(Collectors.toList())); + return multipartVo; + } + + /** + * 合并分段 + * + * @param multipartBo 分片上传对象信息 + * @return OSS对象存储视图对象 + */ + @Override + public SysOssVo completeMultipartUpload(MultipartBo multipartBo) { + String originalName = multipartBo.getOriginalName(); + String suffix = StringUtils.substring(originalName, originalName.lastIndexOf("."), originalName.length()); + List listParts = multipartBo.getPartUploadList().stream() + .map(x -> PartUploadResult.builder() + .partNumber(x.getPartNumber()) + .eTag(x.getETag()) + .build()) + .collect(Collectors.toList()); + OssClient storage = OssFactory.instance(); + UploadResult uploadResult = storage.completeMultipartUpload(multipartBo.getFilename(), multipartBo.getUploadId(), listParts); + // 保存文件信息 + return buildResultEntity(originalName, suffix, storage.getConfigKey(), uploadResult); + } + /** * 桶类型为 private 的URL 修改为临时URL时长为120s * @@ -251,4 +323,5 @@ public class SysOssServiceImpl implements ISysOssService, OssService { } return oss; } + }