SysFileMultipartServiceImpl.java 19 KB

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