package com.backendsys.modules.upload.service.impl; import cn.hutool.core.codec.Base64; import cn.hutool.core.convert.Convert; import cn.hutool.core.date.DateTime; import cn.hutool.core.date.DateUtil; import cn.hutool.core.io.FileUtil; import cn.hutool.core.io.file.FileNameUtil; import cn.hutool.core.lang.UUID; 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.MultipartUploadParams; import com.backendsys.modules.upload.entity.SysFile; import com.backendsys.modules.upload.enums.StyleEnums; import com.backendsys.modules.upload.enums.TargetEnums; import com.backendsys.modules.upload.service.SysFileMultipartService; import com.backendsys.utils.response.ResultEnum; 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.time.LocalDateTime; import java.time.ZoneId; 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(MultipartUploadParams multipartUploadParams, Integer target) { System.out.println("[系统配置] 上传目标(-1:本地, 1:腾讯云, 2:阿里云, 3.抖音云): " + target); String upload_id = null; String object_key = null; String object_name = Convert.toStr(UUID.randomUUID()) + "." + FileUtil.extName(multipartUploadParams.getFilename()); // target: 上传目标 (-1:本地, 1:腾讯云, 2:阿里云, 3.抖音云) if (target == 1) { InitiateMultipartUploadResult uploadResult = tencentCosService.initiateMultipartUpload(object_name); upload_id = uploadResult.getUploadId(); object_key = uploadResult.getKey(); } // 3: 抖音云 if (target == 3) { CreateMultipartUploadOutput uploadResult = douyinTosService.initiateMultipartUpload(object_name); 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(multipartUploadParams.getCategory_id()); sysFileEntity.setUser_id(httpRequestUtil.getUserId()); sysFileEntity.setUpload_id(upload_id); sysFileEntity.setUpload_chunk_count(multipartUploadParams.getUpload_chunk_count()); sysFileEntity.setUpload_chunk_index(0); // sysFileEntity.setName(FileNameUtil.getName(object_key)); sysFileEntity.setName(multipartUploadParams.getFilename()); sysFileEntity.setObject_key(object_key); sysFileEntity.setTarget(target); sysFileEntity.setContent_type(multipartUploadParams.getContent_type()); sysFileEntity.setSize(multipartUploadParams.getSize()); sysFileEntity.setMd5(multipartUploadParams.getMd5()); sysFileDao.insert(sysFileEntity); return sysFileEntity; } // 1.初始化分块上传 (获得 upload_id, object_key) @Override public Map multipartUploadInit(MultipartUploadParams multipartUploadParams) { // // 按用户加锁 // 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; // [弃用] 获取大文件流需要比较久的时间 // 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, multipartUploadParams.getMd5()); wrapperFile.eq(SysFile::getUser_id, SecurityUtil.getUserId()); wrapperFile.isNull(SysFile::getUpload_id); // [DB] 查询已存在的文件分块记录 (只有完全上传成功,(有 md5,没有 upload_id 才算合并成功)) // - 异常情况:如果有两个相同的文件,一个上传50%,一个上传成功,就会出现以下这种情况,需要手动解决 List sysFileEntityList = sysFileDao.selectList(wrapperFile); if (sysFileEntityList != null && sysFileEntityList.size() > 1) { throw new CustException("存在 " + sysFileEntityList.size() + " 个相同MD5 (" + multipartUploadParams.getMd5() + ") 的文件", ResultEnum.STATUS_CONFLICT.getCode()); } if (sysFileEntityList != null && sysFileEntityList.size() > 0) { is_exist = true; // 将已存在的文件,赋值 sysFileEntity = sysFileEntityList.get(0); // [DB] 更新文件 (文件分类、上传时间) sysFileEntity.setCategory_id(multipartUploadParams.getCategory_id()); LocalDateTime nowLocalDateTime = (new DateTime()).toInstant().atZone(ZoneId.of("UTC")).toLocalDateTime(); sysFileEntity.setUpload_time(nowLocalDateTime); sysFileDao.updateById(sysFileEntity); } else { // [DB] 创建新的文件 sysFileEntity = uploadInitEvent(multipartUploadParams, UPLOAD_TARGET.get()); // [格式化] 封面 (图片类型) sysFileEntity = setThumbUrl(sysFileEntity, UPLOAD_THUMB_SIZE.get(), UPLOAD_THUMB_SIZE.get(), StyleEnums.THUMB_BACKGROUND.getValue()); } } else { // 不开启 MD5秒 传,则直接初始化分块 // [DB] 创建新的文件 sysFileEntity = uploadInitEvent(multipartUploadParams, UPLOAD_TARGET.get()); // [格式化] 封面 (图片类型) 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", TargetEnums.targetToLabel(UPLOAD_TARGET.get())); resp.put("upload_chunk_count", multipartUploadParams.getUpload_chunk_count()); resp.put("upload_id", sysFileEntity.getUpload_id()); resp.put("object_key", sysFileEntity.getObject_key()); resp.put("md5", multipartUploadParams.getMd5()); 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, Integer is_watermark) { 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("分块合并失败"); if (is_watermark != null && is_watermark == 1) { // [腾讯云] 添加水印 String object_key = completeResult.getKey(); tencentCosService.addWatermask(object_key); } // 拼接图片路径 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(TargetEnums.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; } }