SysFileMultipartServiceImpl.java 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  1. package com.backendsys.modules.upload.service.impl;
  2. import cn.hutool.core.codec.Base64;
  3. import cn.hutool.core.convert.Convert;
  4. import cn.hutool.core.date.DateTime;
  5. import cn.hutool.core.date.DateUtil;
  6. import cn.hutool.core.io.FileUtil;
  7. import cn.hutool.core.io.file.FileNameUtil;
  8. import cn.hutool.core.lang.UUID;
  9. import cn.hutool.core.util.StrUtil;
  10. import cn.hutool.crypto.digest.DigestUtil;
  11. import com.backendsys.exception.CustException;
  12. import com.backendsys.modules.common.config.security.utils.HttpRequestUtil;
  13. import com.backendsys.modules.common.config.security.utils.SecurityUtil;
  14. import com.backendsys.modules.sdk.douyincloud.tos.service.DouyinTosService;
  15. import com.backendsys.modules.sdk.tencentcloud.cos.service.TencentCosService;
  16. import com.backendsys.modules.system.dao.SysUserDao;
  17. import com.backendsys.modules.system.entity.SysCommon;
  18. import com.backendsys.modules.system.entity.SysUser;
  19. import com.backendsys.modules.system.service.SysCommonService;
  20. import com.backendsys.modules.upload.dao.SysFileDao;
  21. import com.backendsys.modules.upload.entity.MultipartUploadParams;
  22. import com.backendsys.modules.upload.entity.SysFile;
  23. import com.backendsys.modules.upload.enums.StyleEnums;
  24. import com.backendsys.modules.upload.enums.TargetEnums;
  25. import com.backendsys.modules.upload.service.SysFileMultipartService;
  26. import com.backendsys.utils.response.ResultEnum;
  27. import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  28. import com.qcloud.cos.model.*;
  29. import com.volcengine.tos.model.object.CompleteMultipartUploadV2Output;
  30. import com.volcengine.tos.model.object.CreateMultipartUploadOutput;
  31. import com.volcengine.tos.model.object.UploadPartV2Output;
  32. import org.redisson.api.RLock;
  33. import org.redisson.api.RedissonClient;
  34. import org.springframework.beans.factory.annotation.Autowired;
  35. import org.springframework.beans.factory.annotation.Value;
  36. import org.springframework.context.annotation.Lazy;
  37. import org.springframework.stereotype.Service;
  38. import org.springframework.transaction.annotation.Transactional;
  39. import org.springframework.web.multipart.MultipartFile;
  40. import java.io.IOException;
  41. import java.time.LocalDateTime;
  42. import java.time.ZoneId;
  43. import java.util.ArrayList;
  44. import java.util.LinkedHashMap;
  45. import java.util.List;
  46. import java.util.Map;
  47. import java.util.concurrent.TimeUnit;
  48. import java.util.concurrent.atomic.AtomicReference;
  49. import java.util.stream.Collectors;
  50. @Service
  51. public class SysFileMultipartServiceImpl implements SysFileMultipartService {
  52. @Value("${tencent.cos.accessible-domain}")
  53. private String TENCENT_ACCESSIBLE_DOMAIN;
  54. @Value("${douyin.tos.domain}")
  55. private String DOUYIN_ACCESSIBLE_DOMAIN;
  56. @Lazy
  57. @Autowired
  58. RedissonClient redissonClient;
  59. @Autowired
  60. private HttpRequestUtil httpRequestUtil;
  61. @Autowired
  62. private TencentCosService tencentCosService;
  63. @Autowired
  64. private DouyinTosService douyinTosService;
  65. @Autowired
  66. private SysCommonService sysCommonService;
  67. @Autowired
  68. private SysUserDao sysUserDao;
  69. @Autowired
  70. private SysFileDao sysFileDao;
  71. // [方法] 设置缩略图 (参数)
  72. // 不同的云环境 (target),缩略图配置也不一样
  73. // -1:本地:
  74. // 1:腾讯云: https://cloud.tencent.com/document/product/436/113295
  75. // 2:阿里云:
  76. // 3:抖音云: https://www.volcengine.com/docs/6349/153626
  77. private SysFile setThumbUrl(SysFile sysFile, Integer width, Integer height, String backgroundColor) {
  78. if (sysFile.getContent_type() != null) {
  79. if (sysFile.getContent_type().toLowerCase().contains("image")) {
  80. // 本地上传
  81. if (sysFile.getTarget() == -1) {
  82. sysFile.setUrl_thumb(sysFile.getUrl() + "?w=" + width + "&h=" + height);
  83. }
  84. // 腾讯云 (color值通过base64加密, #f8f8f8)
  85. if (sysFile.getTarget() == 1) {
  86. backgroundColor = "#" + backgroundColor;
  87. System.out.println("base64 encode: " + Base64.encode(backgroundColor));
  88. sysFile.setUrl_thumb(sysFile.getUrl() + "?imageMogr2/thumbnail/" + width + "x" + height + "/pad/1/color/" + Base64.encode(backgroundColor));
  89. }
  90. // 抖音云
  91. if (sysFile.getTarget() == 3) {
  92. sysFile.setUrl_thumb(sysFile.getUrl() + "?x-tos-process=image/resize,w_" + width + ",h_" + height + ",m_pad,color_" + backgroundColor);
  93. }
  94. }
  95. }
  96. return sysFile;
  97. }
  98. // [方法] 上传事件
  99. private SysFile uploadInitEvent(MultipartUploadParams multipartUploadParams, Integer target) {
  100. System.out.println("[系统配置] 上传目标(-1:本地, 1:腾讯云, 2:阿里云, 3.抖音云): " + target);
  101. String upload_id = null;
  102. String object_key = null;
  103. String object_name = Convert.toStr(UUID.randomUUID()) + "." + FileUtil.extName(multipartUploadParams.getFilename());
  104. // target: 上传目标 (-1:本地, 1:腾讯云, 2:阿里云, 3.抖音云)
  105. if (target == 1) {
  106. InitiateMultipartUploadResult uploadResult = tencentCosService.initiateMultipartUpload(object_name);
  107. upload_id = uploadResult.getUploadId();
  108. object_key = uploadResult.getKey();
  109. }
  110. // 3: 抖音云
  111. if (target == 3) {
  112. CreateMultipartUploadOutput uploadResult = douyinTosService.initiateMultipartUpload(object_name);
  113. upload_id = uploadResult.getUploadID();
  114. object_key = uploadResult.getKey();
  115. }
  116. // 如果没有获得以上参数,则抛出报错
  117. if (StrUtil.isEmpty(upload_id) || StrUtil.isEmpty(object_key)) {
  118. throw new CustException("upload_id 或 object_key 不能为空");
  119. }
  120. // [新增] 上传文件记录
  121. SysFile sysFileEntity = new SysFile();
  122. sysFileEntity.setCategory_id(multipartUploadParams.getCategory_id());
  123. sysFileEntity.setUser_id(httpRequestUtil.getUserId());
  124. sysFileEntity.setUpload_id(upload_id);
  125. sysFileEntity.setUpload_chunk_count(multipartUploadParams.getUpload_chunk_count());
  126. sysFileEntity.setUpload_chunk_index(0);
  127. // sysFileEntity.setName(FileNameUtil.getName(object_key));
  128. sysFileEntity.setName(multipartUploadParams.getFilename());
  129. sysFileEntity.setObject_key(object_key);
  130. sysFileEntity.setTarget(target);
  131. sysFileEntity.setContent_type(multipartUploadParams.getContent_type());
  132. sysFileEntity.setSize(multipartUploadParams.getSize());
  133. sysFileEntity.setMd5(multipartUploadParams.getMd5());
  134. sysFileDao.insert(sysFileEntity);
  135. return sysFileEntity;
  136. }
  137. // 1.初始化分块上传 (获得 upload_id, object_key)
  138. @Override
  139. public Map<String, Object> multipartUploadInit(MultipartUploadParams multipartUploadParams) {
  140. // // 按用户加锁
  141. // RLock lock = redissonClient.getLock("lock::multipart-upload::user-id::" + SecurityUtil.getUserId());
  142. // try { lock.tryLock(3, TimeUnit.SECONDS);
  143. // 获得公共配置
  144. List<SysCommon> sysCommonList = sysCommonService.getCommonByCategory("UPLOAD");
  145. AtomicReference<Integer> UPLOAD_TARGET = new AtomicReference<>();
  146. AtomicReference<Integer> UPLOAD_THUMB_SIZE = new AtomicReference<>();
  147. AtomicReference<Boolean> UPLOAD_MD5_DUPLICATE = new AtomicReference<>();
  148. sysCommonList.stream().forEach(sysCommon -> {
  149. if (sysCommon.getTag().equals("UPLOAD_TARGET")) UPLOAD_TARGET.set(Convert.toInt(sysCommon.getValue()));
  150. if (sysCommon.getTag().equals("UPLOAD_THUMB_SIZE")) UPLOAD_THUMB_SIZE.set(Convert.toInt(sysCommon.getValue()));
  151. if (sysCommon.getTag().equals("UPLOAD_MD5_DUPLICATE")) UPLOAD_MD5_DUPLICATE.set(Convert.toBool(sysCommon.getValue()));
  152. });
  153. SysFile sysFileEntity = null;
  154. // [弃用] 获取大文件流需要比较久的时间
  155. // String md5 = DigestUtil.md5Hex(multipartFile.getInputStream());
  156. // 如果开启了 MD5秒传,则判断文件是否上传过,是的话就直接返回
  157. // [系统配置] 是否启用文件MD5查重 (仅判断用户自己上传过的文件)
  158. // - 存在,则根据文件MD5 判断是否上传过文件 (仅更新时间,不上传已存在的文件,允许更新文件分类)
  159. // - 存在,如果存在两个相同 md5 以上的文件,则需要排重后再上传 (如果是不同用户
  160. // - 不存在,则走上传流程
  161. Boolean is_exist = false;
  162. if (UPLOAD_MD5_DUPLICATE.get()) {
  163. // 排除文件异常的情况(出现两个以上相同MD5的文件)
  164. LambdaQueryWrapper<SysFile> wrapperFile = new LambdaQueryWrapper<>();
  165. wrapperFile.eq(SysFile::getMd5, multipartUploadParams.getMd5());
  166. wrapperFile.eq(SysFile::getUser_id, SecurityUtil.getUserId());
  167. wrapperFile.isNull(SysFile::getUpload_id);
  168. // [DB] 查询已存在的文件分块记录 (只有完全上传成功,(有 md5,没有 upload_id 才算合并成功))
  169. // - 异常情况:如果有两个相同的文件,一个上传50%,一个上传成功,就会出现以下这种情况,需要手动解决
  170. List<SysFile> sysFileEntityList = sysFileDao.selectList(wrapperFile);
  171. if (sysFileEntityList != null && sysFileEntityList.size() > 1) {
  172. throw new CustException("存在 " + sysFileEntityList.size() + " 个相同MD5 (" + multipartUploadParams.getMd5() + ") 的文件", ResultEnum.STATUS_CONFLICT.getCode());
  173. }
  174. if (sysFileEntityList != null && sysFileEntityList.size() > 0) {
  175. is_exist = true;
  176. // 将已存在的文件,赋值
  177. sysFileEntity = sysFileEntityList.get(0);
  178. // [DB] 更新文件 (文件分类、上传时间)
  179. sysFileEntity.setCategory_id(multipartUploadParams.getCategory_id());
  180. LocalDateTime nowLocalDateTime = (new DateTime()).toInstant().atZone(ZoneId.of("UTC")).toLocalDateTime();
  181. sysFileEntity.setUpload_time(nowLocalDateTime);
  182. sysFileDao.updateById(sysFileEntity);
  183. } else {
  184. // [DB] 创建新的文件
  185. sysFileEntity = uploadInitEvent(multipartUploadParams, UPLOAD_TARGET.get());
  186. // [格式化] 封面 (图片类型)
  187. sysFileEntity = setThumbUrl(sysFileEntity, UPLOAD_THUMB_SIZE.get(), UPLOAD_THUMB_SIZE.get(), StyleEnums.THUMB_BACKGROUND.getValue());
  188. }
  189. } else {
  190. // 不开启 MD5秒 传,则直接初始化分块
  191. // [DB] 创建新的文件
  192. sysFileEntity = uploadInitEvent(multipartUploadParams, UPLOAD_TARGET.get());
  193. // [格式化] 封面 (图片类型)
  194. sysFileEntity = setThumbUrl(sysFileEntity, UPLOAD_THUMB_SIZE.get(), UPLOAD_THUMB_SIZE.get(), StyleEnums.THUMB_BACKGROUND.getValue());
  195. }
  196. Map<String, Object> resp = new LinkedHashMap<>();
  197. resp.put("target", UPLOAD_TARGET.get());
  198. resp.put("target_label", TargetEnums.targetToLabel(UPLOAD_TARGET.get()));
  199. resp.put("upload_chunk_count", multipartUploadParams.getUpload_chunk_count());
  200. resp.put("upload_id", sysFileEntity.getUpload_id());
  201. resp.put("object_key", sysFileEntity.getObject_key());
  202. resp.put("md5", multipartUploadParams.getMd5());
  203. resp.put("is_exist", is_exist);
  204. resp.put("file_info", is_exist ? sysFileEntity : null);
  205. return resp;
  206. // } catch (InterruptedException e) { throw new RuntimeException(e);
  207. // } finally { lock.unlock(); }
  208. }
  209. // 2.上传分块
  210. // - 单个分块大小不能小于4MB
  211. @Override
  212. @Transactional(rollbackFor = Exception.class)
  213. public Map<String, Object> multipartUpload(MultipartFile multipartFile, String upload_id, Integer upload_chunk_index) {
  214. if (multipartFile.isEmpty()) throw new CustException("file 上传文件不能为空");
  215. if (StrUtil.isEmpty(upload_id)) throw new CustException("upload_id 不能为空");
  216. if (upload_chunk_index == null) throw new CustException("upload_chunk_index 不能为空");
  217. // // 按用户加锁
  218. // RLock lock = redissonClient.getLock("lock::multipart-upload::user-id::" + SecurityUtil.getUserId());
  219. // try { lock.tryLock(3, TimeUnit.SECONDS);
  220. // [Get] 获得初始化分块信息
  221. SysFile sysFileEntity = sysFileDao.selectOne(new LambdaQueryWrapper<SysFile>().eq(SysFile::getUpload_id, upload_id));
  222. if (sysFileEntity == null) throw new CustException("分块记录不存在");
  223. String object_key = sysFileEntity.getObject_key();
  224. Integer upload_chunk_size = sysFileEntity.getUpload_chunk_count();
  225. if (upload_chunk_index > upload_chunk_size) throw new CustException("分块索引(index)不能大于分块数量(count)");
  226. // 进入分块上传流程
  227. if (upload_chunk_index < upload_chunk_size) {
  228. // 分片编号从 1 开始,最大为 10000。除最后一个分片以外,其他分片大小最小为 4MiB
  229. if (multipartFile.getSize() < 4 * 1024 * 1024) throw new CustException("分块文件大小不能小于 4MB");
  230. }
  231. // 获得公共配置
  232. List<SysCommon> sysCommonList = sysCommonService.getCommonByCategory("UPLOAD");
  233. AtomicReference<Integer> UPLOAD_TARGET = new AtomicReference<>();
  234. sysCommonList.stream().forEach(sysCommon -> {
  235. if (sysCommon.getTag().equals("UPLOAD_TARGET")) UPLOAD_TARGET.set(Convert.toInt(sysCommon.getValue()));
  236. });
  237. System.out.println("[系统配置] 上传目标(-1:本地, 1:腾讯云, 2:阿里云, 3.抖音云): " + UPLOAD_TARGET);
  238. String part_etag = null;
  239. // target: 上传目标 (-1:本地, 1:腾讯云, 2:阿里云, 3.抖音云)
  240. if (UPLOAD_TARGET.get() == 1) {
  241. UploadPartResult partResult = tencentCosService.uploadPart(multipartFile, upload_id, object_key, upload_chunk_index);
  242. part_etag = partResult.getPartETag().getETag();
  243. }
  244. // 3: 抖音云
  245. if (UPLOAD_TARGET.get() == 3) {
  246. UploadPartV2Output partResult = douyinTosService.uploadPart(multipartFile, upload_id, object_key, upload_chunk_index);
  247. part_etag = partResult.getEtag().replace("\"", "");
  248. }
  249. // [Update] 更新分块文件信息
  250. sysFileEntity.setUpload_chunk_index(upload_chunk_index);
  251. sysFileDao.updateById(sysFileEntity);
  252. Map<String, Object> resp = new LinkedHashMap<>();
  253. resp.put("upload_chunk_index", upload_chunk_index);
  254. resp.put("upload_id", upload_id);
  255. resp.put("object_key", object_key);
  256. resp.put("part_etag", part_etag);
  257. return resp;
  258. // } catch (InterruptedException e) { throw new RuntimeException(e);
  259. // } finally { lock.unlock(); }
  260. }
  261. // 完成分块上传
  262. @Override
  263. public SysFile multipartUploadComplete(String upload_id, Integer is_watermark) {
  264. if (StrUtil.isEmpty(upload_id)) throw new CustException("upload_id 不能为空");
  265. // // 按用户加锁
  266. // RLock lock = redissonClient.getLock("lock::multipart-upload::user-id::" + SecurityUtil.getUserId());
  267. // try { lock.tryLock(3, TimeUnit.SECONDS);
  268. // [Get] 查询文件分块记录
  269. SysFile sysFileEntity = sysFileDao.selectOne(new LambdaQueryWrapper<SysFile>().eq(SysFile::getUpload_id, upload_id));
  270. if (sysFileEntity == null) throw new CustException("upload_id 不存在");
  271. // 判断分块索引和分块数量是否一致
  272. if (sysFileEntity.getUpload_chunk_index() != sysFileEntity.getUpload_chunk_count()) {
  273. throw new CustException("分块索引和分块数量不一致");
  274. }
  275. // 获得公共配置
  276. List<SysCommon> sysCommonList = sysCommonService.getCommonByCategory("UPLOAD");
  277. AtomicReference<Integer> UPLOAD_TARGET = new AtomicReference<>();
  278. AtomicReference<Integer> UPLOAD_THUMB_SIZE = new AtomicReference<>();
  279. sysCommonList.stream().forEach(sysCommon -> {
  280. if (sysCommon.getTag().equals("UPLOAD_TARGET")) UPLOAD_TARGET.set(Convert.toInt(sysCommon.getValue()));
  281. if (sysCommon.getTag().equals("UPLOAD_THUMB_SIZE")) UPLOAD_THUMB_SIZE.set(Convert.toInt(sysCommon.getValue()));
  282. });
  283. System.out.println("[系统配置] 上传目标(-1:本地, 1:腾讯云, 2:阿里云, 3.抖音云): " + UPLOAD_TARGET);
  284. System.out.println("[系统配置] 图片的缩略图宽高尺寸 (px): " + UPLOAD_THUMB_SIZE);
  285. // target: 上传目标 (-1:本地, 1:腾讯云, 2:阿里云, 3.抖音云)
  286. if (UPLOAD_TARGET.get() == 1) {
  287. // 查询分块集合 (etag)
  288. Map<String, Object> partList = listParts(upload_id, sysFileEntity.getObject_key());
  289. PartListing partListing = (PartListing) partList.get("listParts");
  290. List<PartSummary> partSummaryList = partListing.getParts();
  291. List<PartETag> etags = partSummaryList.stream()
  292. .map(partSummary -> new PartETag(partSummary.getPartNumber(), partSummary.getETag()))
  293. .collect(Collectors.toList());
  294. // 如果分块数量对不上,最好还是重新上传
  295. if (sysFileEntity.getUpload_chunk_count() != etags.size()) throw new CustException("分块索引异常,请重新上传");
  296. // [腾讯云] 合并分块
  297. CompleteMultipartUploadResult completeResult = tencentCosService.completeMultipartUpload(upload_id, sysFileEntity.getObject_key(), etags);
  298. if (completeResult == null) throw new CustException("分块合并失败");
  299. if (is_watermark != null && is_watermark == 1) {
  300. // [腾讯云] 添加水印
  301. String object_key = completeResult.getKey();
  302. tencentCosService.addWatermask(object_key);
  303. }
  304. // 拼接图片路径
  305. sysFileEntity.setUrl(TENCENT_ACCESSIBLE_DOMAIN + "/" + completeResult.getKey());
  306. sysFileEntity.setRequest_id(completeResult.getRequestId());
  307. }
  308. // 3: 抖音云
  309. if (UPLOAD_TARGET.get() == 3) {
  310. CompleteMultipartUploadV2Output completeResult = douyinTosService.completeMultipartUpload(upload_id, sysFileEntity.getObject_key());
  311. if (completeResult == null) throw new CustException("分块合并失败");
  312. // 拼接图片路径
  313. sysFileEntity.setUrl(DOUYIN_ACCESSIBLE_DOMAIN + "/" + completeResult.getKey());
  314. sysFileEntity.setRequest_id(completeResult.getRequestInfo().getRequestId());
  315. }
  316. /*
  317. 如果是图片类型,就设置封面:
  318. 图片Icon (png / jpg / jpeg / gif / bmp / webp)
  319. 视频 Icon (mp4 / avi / mov / flv / 3gp / mpeg / wmv)
  320. Word Icon (doc / docx)
  321. Excel Icon (xls / xlsx)
  322. PDF Icon (pdf)
  323. 其他 Icon
  324. */
  325. setThumbUrl(sysFileEntity, UPLOAD_THUMB_SIZE.get(), UPLOAD_THUMB_SIZE.get(), StyleEnums.THUMB_BACKGROUND.getValue());
  326. // [DB] 更新分块记录
  327. sysFileDao.updateCompleteFile(sysFileEntity);
  328. // -- 合并分块成功时,返回值 -------------------------------------
  329. // 移除 upload_id
  330. sysFileEntity.setUpload_id(null);
  331. // 翻译 储存介质
  332. sysFileEntity.setTarget_label(TargetEnums.targetToLabel(sysFileEntity.getTarget()));
  333. // 翻译 用户名
  334. SysUser userEntity = sysUserDao.selectById(sysFileEntity.getUser_id());
  335. sysFileEntity.setUsername(userEntity.getUsername());
  336. // ----------------------------------------------------
  337. return sysFileEntity;
  338. // } catch (InterruptedException e) { throw new RuntimeException(e);
  339. // } finally { lock.unlock(); }
  340. }
  341. // 查询分块上传情况
  342. @Override
  343. public Map<String, Object> listParts(String upload_id, String object_key) {
  344. Integer UPLOAD_TARGET = Convert.toInt(sysCommonService.getCommonByTag("UPLOAD_TARGET"));
  345. // target: 上传目标 (-1:本地, 1:腾讯云, 2:阿里云, 3.抖音云)
  346. if (UPLOAD_TARGET == 1) {
  347. return Map.of("listParts", tencentCosService.listParts(upload_id, object_key));
  348. }
  349. if (UPLOAD_TARGET == 3) {
  350. return Map.of("listParts", douyinTosService.listParts(upload_id, object_key));
  351. }
  352. return null;
  353. }
  354. }