package com.backendsys.modules.upload.service.impl; import cn.hutool.core.codec.Base64; import cn.hutool.core.convert.Convert; import cn.hutool.core.date.DateUtil; import cn.hutool.core.io.file.FileNameUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.digest.DigestUtil; import com.backendsys.exception.CustException; import com.backendsys.modules.common.config.security.utils.HttpRequestUtil; import com.backendsys.modules.common.config.security.utils.SecurityUtil; import com.backendsys.modules.sdk.douyincloud.tos.service.DouyinTosService; import com.backendsys.modules.sdk.tencentcloud.cos.service.TencentCosService; import com.backendsys.modules.system.dao.SysUserDao; import com.backendsys.modules.system.entity.SysCommon; import com.backendsys.modules.system.entity.SysUser; import com.backendsys.modules.system.service.SysCommonService; import com.backendsys.modules.upload.dao.SysFileDao; import com.backendsys.modules.upload.entity.SysFile; import com.backendsys.modules.upload.enums.StyleEnums; import com.backendsys.modules.upload.service.SysFileMultipartService; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.qcloud.cos.model.*; import com.volcengine.tos.model.object.CompleteMultipartUploadV2Output; import com.volcengine.tos.model.object.CreateMultipartUploadOutput; import com.volcengine.tos.model.object.UploadPartV2Output; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; @Service public class SysFileMultipartServiceImpl implements SysFileMultipartService { @Value("${tencent.cos.accessible-domain}") private String TENCENT_ACCESSIBLE_DOMAIN; @Value("${douyin.tos.domain}") private String DOUYIN_ACCESSIBLE_DOMAIN; @Lazy @Autowired RedissonClient redissonClient; @Autowired private HttpRequestUtil httpRequestUtil; @Autowired private TencentCosService tencentCosService; @Autowired private DouyinTosService douyinTosService; @Autowired private SysCommonService sysCommonService; @Autowired private SysUserDao sysUserDao; @Autowired private SysFileDao sysFileDao; // [方法] 设置缩略图 (参数) // 不同的云环境 (target),缩略图配置也不一样 // -1:本地: // 1:腾讯云: https://cloud.tencent.com/document/product/436/113295 // 2:阿里云: // 3:抖音云: https://www.volcengine.com/docs/6349/153626 private SysFile setThumbUrl(SysFile sysFile, Integer width, Integer height, String backgroundColor) { if (sysFile.getContent_type() != null) { if (sysFile.getContent_type().toLowerCase().contains("image")) { // 本地上传 if (sysFile.getTarget() == -1) { sysFile.setUrl_thumb(sysFile.getUrl() + "?w=" + width + "&h=" + height); } // 腾讯云 (color值通过base64加密, #f8f8f8) if (sysFile.getTarget() == 1) { backgroundColor = "#" + backgroundColor; System.out.println("base64 encode: " + Base64.encode(backgroundColor)); sysFile.setUrl_thumb(sysFile.getUrl() + "?imageMogr2/thumbnail/" + width + "x" + height + "/pad/1/color/" + Base64.encode(backgroundColor)); } // 抖音云 if (sysFile.getTarget() == 3) { sysFile.setUrl_thumb(sysFile.getUrl() + "?x-tos-process=image/resize,w_" + width + ",h_" + height + ",m_pad,color_" + backgroundColor); } } } return sysFile; } // [方法] 上传事件 private SysFile uploadInitEvent(MultipartFile multipartFile, Long category_id, Integer target, Integer upload_chunk_count, String md5) { System.out.println("[系统配置] 上传目标(-1:本地, 1:腾讯云, 2:阿里云, 3.抖音云): " + target); String filename = multipartFile.getOriginalFilename(); if (filename.length() > 50) throw new CustException("文件名长度不能超过 50 字符"); String target_label = StyleEnums.targetToLabel(target); String upload_id = null; String object_key = null; // target: 上传目标 (-1:本地, 1:腾讯云, 2:阿里云, 3.抖音云) if (target == 1) { InitiateMultipartUploadResult uploadResult = tencentCosService.initiateMultipartUpload(multipartFile); upload_id = uploadResult.getUploadId(); object_key = uploadResult.getKey(); } // 3: 抖音云 if (target == 3) { CreateMultipartUploadOutput uploadResult = douyinTosService.initiateMultipartUpload(multipartFile); upload_id = uploadResult.getUploadID(); object_key = uploadResult.getKey(); } // 如果没有获得以上参数,则抛出报错 if (StrUtil.isEmpty(upload_id) || StrUtil.isEmpty(object_key)) { throw new CustException("upload_id 或 object_key 不能为空"); } // [新增] 上传文件记录 SysFile sysFileEntity = new SysFile(); sysFileEntity.setCategory_id(category_id); sysFileEntity.setUser_id(httpRequestUtil.getUserId()); sysFileEntity.setUpload_id(upload_id); sysFileEntity.setUpload_chunk_count(upload_chunk_count); sysFileEntity.setUpload_chunk_index(0); // sysFileEntity.setName(FileNameUtil.getName(object_key)); sysFileEntity.setName(filename); sysFileEntity.setObject_key(object_key); sysFileEntity.setTarget(target); sysFileEntity.setContent_type(multipartFile.getContentType()); sysFileEntity.setSize(multipartFile.getSize()); sysFileEntity.setMd5(md5); sysFileDao.insert(sysFileEntity); return sysFileEntity; } // 1.初始化分块上传 (获得 upload_id, object_key) @Override public Map multipartUploadInit(MultipartFile multipartFile, Long category_id, Integer upload_chunk_count, String md5) { if (multipartFile.isEmpty()) throw new CustException("file 上传文件不能为空"); // if (upload_chunk_count == null) throw new CustException("upload_chunk_count 分块数量不能为空"); // if (md5.isEmpty()) throw new CustException("md5 不能为空"); // // String filename = multipartFile.getOriginalFilename(); // if (filename.length() > 50) throw new CustException("文件名长度不能超过 50 字符"); // // 按用户加锁 // RLock lock = redissonClient.getLock("lock::multipart-upload::user-id::" + SecurityUtil.getUserId()); // try { lock.tryLock(3, TimeUnit.SECONDS); // 获得公共配置 List sysCommonList = sysCommonService.getCommonByCategory("UPLOAD"); AtomicReference UPLOAD_TARGET = new AtomicReference<>(); AtomicReference UPLOAD_THUMB_SIZE = new AtomicReference<>(); AtomicReference UPLOAD_MD5_DUPLICATE = new AtomicReference<>(); sysCommonList.stream().forEach(sysCommon -> { if (sysCommon.getTag().equals("UPLOAD_TARGET")) UPLOAD_TARGET.set(Convert.toInt(sysCommon.getValue())); if (sysCommon.getTag().equals("UPLOAD_THUMB_SIZE")) UPLOAD_THUMB_SIZE.set(Convert.toInt(sysCommon.getValue())); if (sysCommon.getTag().equals("UPLOAD_MD5_DUPLICATE")) UPLOAD_MD5_DUPLICATE.set(Convert.toBool(sysCommon.getValue())); }); SysFile sysFileEntity = null; return Map.of("test", 1); // // [弃用] 获取大文件流需要比较久的时间 // // String md5 = DigestUtil.md5Hex(multipartFile.getInputStream()); // // // 如果开启了 MD5秒传,则判断文件是否上传过,是的话就直接返回 // // [系统配置] 是否启用文件MD5查重 (仅判断用户自己上传过的文件) // // - 存在,则根据文件MD5 判断是否上传过文件 (仅更新时间,不上传已存在的文件,允许更新文件分类) // // - 存在,如果存在两个相同 md5 以上的文件,则需要排重后再上传 (如果是不同用户 // // - 不存在,则走上传流程 // Boolean is_exist = false; // if (UPLOAD_MD5_DUPLICATE.get()) { // // // 排除文件异常的情况(出现两个以上相同MD5的文件) // LambdaQueryWrapper wrapperFile = new LambdaQueryWrapper<>(); // wrapperFile.eq(SysFile::getMd5, md5); // wrapperFile.eq(SysFile::getUser_id, SecurityUtil.getUserId()); // // // [DB] 查询已存在的文件分块记录 (只有完全上传成功,才会有 MD5) // // - 异常情况:如果有两个相同的文件,一个上传50%,一个上传成功,就会出现以下这种情况,需要手动解决 // List sysFileEntityList = sysFileDao.selectList(wrapperFile); // if (sysFileEntityList != null && sysFileEntityList.size() > 1) { // throw new CustException("存在 " + sysFileEntityList.size() + " 个相同MD5 (" + md5 + ") 的文件,请删除后再重新上传"); // } // // if (sysFileEntityList != null && sysFileEntityList.size() > 0) { // is_exist = true; // // 将已存在的文件,赋值 // sysFileEntity = sysFileEntityList.get(0); // // [DB] 更新文件 (文件分类、上传时间) // sysFileEntity.setCategory_id(category_id); // sysFileEntity.setUpload_time(DateUtil.now()); // sysFileDao.updateById(sysFileEntity); // } else { // // [DB] 创建新的文件 // sysFileEntity = uploadInitEvent(multipartFile, category_id, UPLOAD_TARGET.get(), upload_chunk_count, md5); // // [格式化] 封面 (图片类型) // sysFileEntity = setThumbUrl(sysFileEntity, UPLOAD_THUMB_SIZE.get(), UPLOAD_THUMB_SIZE.get(), StyleEnums.THUMB_BACKGROUND.getValue()); // } // // } else { // // 不开启 MD5秒 传,则直接初始化分块 // // [DB] 创建新的文件 // sysFileEntity = uploadInitEvent(multipartFile, category_id, UPLOAD_TARGET.get(), upload_chunk_count, md5); // // [格式化] 封面 (图片类型) // sysFileEntity = setThumbUrl(sysFileEntity, UPLOAD_THUMB_SIZE.get(), UPLOAD_THUMB_SIZE.get(), StyleEnums.THUMB_BACKGROUND.getValue()); // } // // Map resp = new LinkedHashMap<>(); // resp.put("target", UPLOAD_TARGET.get()); // resp.put("target_label", StyleEnums.targetToLabel(UPLOAD_TARGET.get())); // resp.put("upload_chunk_count", upload_chunk_count); // resp.put("upload_id", sysFileEntity.getUpload_id()); // resp.put("object_key", sysFileEntity.getObject_key()); // resp.put("md5", md5); // resp.put("is_exist", is_exist); // resp.put("file_info", is_exist ? sysFileEntity : null); // return resp; // //// } catch (InterruptedException e) { throw new RuntimeException(e); //// } finally { lock.unlock(); } } // 2.上传分块 // - 单个分块大小不能小于4MB @Override @Transactional(rollbackFor = Exception.class) public Map multipartUpload(MultipartFile multipartFile, String upload_id, Integer upload_chunk_index) { if (multipartFile.isEmpty()) throw new CustException("file 上传文件不能为空"); if (StrUtil.isEmpty(upload_id)) throw new CustException("upload_id 不能为空"); if (upload_chunk_index == null) throw new CustException("upload_chunk_index 不能为空"); // // 按用户加锁 // RLock lock = redissonClient.getLock("lock::multipart-upload::user-id::" + SecurityUtil.getUserId()); // try { lock.tryLock(3, TimeUnit.SECONDS); // [Get] 获得初始化分块信息 SysFile sysFileEntity = sysFileDao.selectOne(new LambdaQueryWrapper().eq(SysFile::getUpload_id, upload_id)); if (sysFileEntity == null) throw new CustException("分块记录不存在"); String object_key = sysFileEntity.getObject_key(); Integer upload_chunk_size = sysFileEntity.getUpload_chunk_count(); if (upload_chunk_index > upload_chunk_size) throw new CustException("分块索引(index)不能大于分块数量(count)"); // 进入分块上传流程 if (upload_chunk_index < upload_chunk_size) { // 分片编号从 1 开始,最大为 10000。除最后一个分片以外,其他分片大小最小为 4MiB if (multipartFile.getSize() < 4 * 1024 * 1024) throw new CustException("分块文件大小不能小于 4MB"); } // 获得公共配置 List sysCommonList = sysCommonService.getCommonByCategory("UPLOAD"); AtomicReference UPLOAD_TARGET = new AtomicReference<>(); sysCommonList.stream().forEach(sysCommon -> { if (sysCommon.getTag().equals("UPLOAD_TARGET")) UPLOAD_TARGET.set(Convert.toInt(sysCommon.getValue())); }); System.out.println("[系统配置] 上传目标(-1:本地, 1:腾讯云, 2:阿里云, 3.抖音云): " + UPLOAD_TARGET); String part_etag = null; // target: 上传目标 (-1:本地, 1:腾讯云, 2:阿里云, 3.抖音云) if (UPLOAD_TARGET.get() == 1) { UploadPartResult partResult = tencentCosService.uploadPart(multipartFile, upload_id, object_key, upload_chunk_index); part_etag = partResult.getPartETag().getETag(); } // 3: 抖音云 if (UPLOAD_TARGET.get() == 3) { UploadPartV2Output partResult = douyinTosService.uploadPart(multipartFile, upload_id, object_key, upload_chunk_index); part_etag = partResult.getEtag().replace("\"", ""); } // [Update] 更新分块文件信息 sysFileEntity.setUpload_chunk_index(upload_chunk_index); sysFileDao.updateById(sysFileEntity); Map resp = new LinkedHashMap<>(); resp.put("upload_chunk_index", upload_chunk_index); resp.put("upload_id", upload_id); resp.put("object_key", object_key); resp.put("part_etag", part_etag); return resp; // } catch (InterruptedException e) { throw new RuntimeException(e); // } finally { lock.unlock(); } } // 完成分块上传 @Override public SysFile multipartUploadComplete(String upload_id) { if (StrUtil.isEmpty(upload_id)) throw new CustException("upload_id 不能为空"); // // 按用户加锁 // RLock lock = redissonClient.getLock("lock::multipart-upload::user-id::" + SecurityUtil.getUserId()); // try { lock.tryLock(3, TimeUnit.SECONDS); // [Get] 查询文件分块记录 SysFile sysFileEntity = sysFileDao.selectOne(new LambdaQueryWrapper().eq(SysFile::getUpload_id, upload_id)); if (sysFileEntity == null) throw new CustException("upload_id 不存在"); // 判断分块索引和分块数量是否一致 if (sysFileEntity.getUpload_chunk_index() != sysFileEntity.getUpload_chunk_count()) { throw new CustException("分块索引和分块数量不一致"); } // 获得公共配置 List sysCommonList = sysCommonService.getCommonByCategory("UPLOAD"); AtomicReference UPLOAD_TARGET = new AtomicReference<>(); AtomicReference UPLOAD_THUMB_SIZE = new AtomicReference<>(); sysCommonList.stream().forEach(sysCommon -> { if (sysCommon.getTag().equals("UPLOAD_TARGET")) UPLOAD_TARGET.set(Convert.toInt(sysCommon.getValue())); if (sysCommon.getTag().equals("UPLOAD_THUMB_SIZE")) UPLOAD_THUMB_SIZE.set(Convert.toInt(sysCommon.getValue())); }); System.out.println("[系统配置] 上传目标(-1:本地, 1:腾讯云, 2:阿里云, 3.抖音云): " + UPLOAD_TARGET); System.out.println("[系统配置] 图片的缩略图宽高尺寸 (px): " + UPLOAD_THUMB_SIZE); // target: 上传目标 (-1:本地, 1:腾讯云, 2:阿里云, 3.抖音云) if (UPLOAD_TARGET.get() == 1) { // 查询分块集合 (etag) Map partList = listParts(upload_id, sysFileEntity.getObject_key()); PartListing partListing = (PartListing) partList.get("listParts"); List partSummaryList = partListing.getParts(); List etags = partSummaryList.stream() .map(partSummary -> new PartETag(partSummary.getPartNumber(), partSummary.getETag())) .collect(Collectors.toList()); // 如果分块数量对不上,最好还是重新上传 if (sysFileEntity.getUpload_chunk_count() != etags.size()) throw new CustException("分块索引异常,请重新上传"); // [腾讯云] 合并分块 CompleteMultipartUploadResult completeResult = tencentCosService.completeMultipartUpload(upload_id, sysFileEntity.getObject_key(), etags); if (completeResult == null) throw new CustException("分块合并失败"); // 拼接图片路径 sysFileEntity.setUrl(TENCENT_ACCESSIBLE_DOMAIN + "/" + completeResult.getKey()); sysFileEntity.setRequest_id(completeResult.getRequestId()); } // 3: 抖音云 if (UPLOAD_TARGET.get() == 3) { CompleteMultipartUploadV2Output completeResult = douyinTosService.completeMultipartUpload(upload_id, sysFileEntity.getObject_key()); if (completeResult == null) throw new CustException("分块合并失败"); // 拼接图片路径 sysFileEntity.setUrl(DOUYIN_ACCESSIBLE_DOMAIN + "/" + completeResult.getKey()); sysFileEntity.setRequest_id(completeResult.getRequestInfo().getRequestId()); } /* 如果是图片类型,就设置封面: 图片Icon (png / jpg / jpeg / gif / bmp / webp) 视频 Icon (mp4 / avi / mov / flv / 3gp / mpeg / wmv) Word Icon (doc / docx) Excel Icon (xls / xlsx) PDF Icon (pdf) 其他 Icon */ setThumbUrl(sysFileEntity, UPLOAD_THUMB_SIZE.get(), UPLOAD_THUMB_SIZE.get(), StyleEnums.THUMB_BACKGROUND.getValue()); // [DB] 更新分块记录 sysFileDao.updateCompleteFile(sysFileEntity); // -- 合并分块成功时,返回值 ------------------------------------- // 移除 upload_id sysFileEntity.setUpload_id(null); // 翻译 储存介质 sysFileEntity.setTarget_label(StyleEnums.targetToLabel(sysFileEntity.getTarget())); // 翻译 用户名 SysUser userEntity = sysUserDao.selectById(sysFileEntity.getUser_id()); sysFileEntity.setUsername(userEntity.getUsername()); // ---------------------------------------------------- return sysFileEntity; // } catch (InterruptedException e) { throw new RuntimeException(e); // } finally { lock.unlock(); } } // 查询分块上传情况 @Override public Map listParts(String upload_id, String object_key) { Integer UPLOAD_TARGET = Convert.toInt(sysCommonService.getCommonByTag("UPLOAD_TARGET")); // target: 上传目标 (-1:本地, 1:腾讯云, 2:阿里云, 3.抖音云) if (UPLOAD_TARGET == 1) { return Map.of("listParts", tencentCosService.listParts(upload_id, object_key)); } if (UPLOAD_TARGET == 3) { return Map.of("listParts", douyinTosService.listParts(upload_id, object_key)); } return null; } }