SysFileMultipartServiceImpl.java 20 KB

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