package com.backendsys.modules.upload.service.impl; import cn.hutool.core.codec.Base64; import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.convert.Convert; import cn.hutool.core.date.DateUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.digest.DigestUtil; import cn.hutool.json.JSONArray; import com.backendsys.exception.CustException; import com.backendsys.modules.common.config.security.enums.SecurityEnum; 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.entity.SysCommon; import com.backendsys.modules.system.service.SysCommonService; import com.backendsys.modules.upload.dao.SysFileCategoryDao; import com.backendsys.modules.upload.dao.SysFileDao; import com.backendsys.modules.upload.entity.SysFile; import com.backendsys.modules.upload.entity.SysFileCategory; import com.backendsys.modules.upload.entity.SysFileMergeByMd5; import com.backendsys.modules.upload.entity.SysFileResult; import com.backendsys.modules.upload.enums.StyleEnums; import com.backendsys.modules.upload.enums.TargetEnums; import com.backendsys.modules.upload.service.SysFileService; import com.backendsys.modules.upload.utils.UploadUtil; import com.backendsys.utils.response.PageEntity; import com.backendsys.utils.response.PageInfoResult; import com.backendsys.utils.response.ResultEnum; import com.backendsys.utils.v2.PageUtils; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.net.URL; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; @Service public class SysFileServiceImpl extends ServiceImpl implements SysFileService { @Autowired private HttpRequestUtil httpRequestUtil; @Autowired private SecurityUtil securityUtil; @Autowired private TencentCosService tencentCosService; @Autowired private DouyinTosService douyinTosService; @Autowired private UploadUtil uploadUtil; @Autowired private SysFileDao sysFileDao; @Autowired private SysFileCategoryDao sysFileCategoryDao; @Autowired private SysCommonService sysCommonService; // [方法] 设置缩略图 (参数) // 不同的云环境 (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 (StrUtil.isEmpty(sysFile.getUpload_id())) { // 本地上传 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; } /** * 获取文件列表 */ @Override public PageEntity selectUploadFileList(SysFile sysFile) { PageUtils.startPage(); // 分页 List sysFileList = sysFileDao.selectUploadFileList(sysFile); // 完成分页渲染 PageEntity pageEntity = new PageInfoResult(sysFileList).toEntity(); // -- 完成分页渲染之后,再做列表格式化 ----------------------------------- // [Common] 根据 Tag 获得 Options // JSONArray COMMON_OPTIONS = sysCommonService.getCommonOptionByTag("UPLOAD_TARGET"); // 遍历列表,赋值公共值翻译 sysFileList = sysFileList.stream().map(item -> { // 查询出 上传存储介质(target) 的翻译 // String target_label = Convert.toStr(sysCommonService.getLabelByValue(COMMON_OPTIONS, item.getTarget())); String target_label = TargetEnums.targetToLabel(item.getTarget()); item.setTarget_label(target_label); return item; }).collect(Collectors.toList()); List objectList = sysFileList.stream().map(file -> (Object) file).collect(Collectors.toList()); pageEntity.setList(objectList); // ----------------------------------------------------------------- return pageEntity; } @Override public Map selectUploadTarget() { Integer UPLOAD_TARGET = Convert.toInt(sysCommonService.getCommonByTag("UPLOAD_TARGET")); String target_label = TargetEnums.targetToLabel(UPLOAD_TARGET); Map resp = new LinkedHashMap<>(); resp.put("target", UPLOAD_TARGET); resp.put("target_label", target_label); return resp; } // [方法] 上传事件 private SysFile uploadEvent(MultipartFile multipartFile, Long category_id, Integer target) { try { String filename = multipartFile.getOriginalFilename(); if (filename.length() > 50) throw new CustException("文件名长度不能超过 50 字符"); SysFileResult uploadResult = new SysFileResult(); // target: 上传目标 (-1:本地, 1:腾讯云, 2:阿里云, 3.抖音云) if (target == -1) { uploadResult = uploadUtil.put(multipartFile); } if (target == 1) { // [腾讯云-上传对象] uploadResult = tencentCosService.putObject(multipartFile, null); } if (target == 3) { // [抖音云-上传对象] uploadResult = douyinTosService.putObject(multipartFile, null); } // [新增] 上传文件记录 SysFile sysFileEntity = new SysFile(); sysFileEntity.setCategory_id(category_id); sysFileEntity.setRequest_id(uploadResult.getRequest_id()); sysFileEntity.setUser_id(httpRequestUtil.getUserId()); sysFileEntity.setObject_key(uploadResult.getKey()); // 文件名 // sysFileEntity.setName(FileNameUtil.getName(uploadResult.getKey())); String filename_prefix = StrUtil.subBefore(filename, '.', true); String filename_suffix = StringUtils.getFilenameExtension(filename); /* filename = 200x134.png filename_prefix = 200x134 filename_suffix = png */ // 查询如果有同名文件,则在文件中加入数量命名 LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); // 构造正则表达式 (匹配同名、同名(数量)) String regex = "^" + filename_prefix + "(\\(\\d+\\))?\\." + filename_suffix + "$"; wrapper.apply("name REGEXP {0}", regex); Long count = sysFileDao.selectCount(wrapper); if (count > 0) { sysFileEntity.setName(filename_prefix + "(" + count + ")." + filename_suffix); } else { sysFileEntity.setName(filename_prefix + "." + filename_suffix); } sysFileEntity.setContent_type(multipartFile.getContentType()); if (target != -1) { // 非本地的 (Cos, Tos, ..) sysFileEntity.setUrl(uploadResult.getDomain() + "/" + uploadResult.getKey()); } else { // 本地的 sysFileEntity.setUrl(uploadResult.getDomain()); } sysFileEntity.setSize(multipartFile.getSize()); if (StrUtil.isNotEmpty(uploadResult.getE_tag())) { sysFileEntity.setMd5(uploadResult.getE_tag().replace("\"", "")); } sysFileEntity.setTarget(target); sysFileEntity.setTarget_label(TargetEnums.targetToLabel(target)); // 获得公共配置 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); // 设置缩略图 sysFileEntity = setThumbUrl(sysFileEntity, UPLOAD_THUMB_SIZE.get(), UPLOAD_THUMB_SIZE.get(), StyleEnums.THUMB_BACKGROUND.getValue()); sysFileEntity.setCreate_time(DateUtil.now()); sysFileEntity.setUpdate_time(DateUtil.now()); sysFileDao.insert(sysFileEntity); return sysFileEntity; } catch (IOException e) { throw new RuntimeException(e); } } /** * 上传文件 (单文件大小不超过 n) * - target: 上传目标 (-1:本地, 1:腾讯云, 2:阿里云, 3.抖音云) */ @Override public SysFile uploadSmall(MultipartFile multipartFile, Long category_id) { List sysCommonList = sysCommonService.getCommonByCategory("UPLOAD"); AtomicReference UPLOAD_TARGET = new AtomicReference<>(); AtomicReference UPLOAD_MAX_SIZE_MB = 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_MAX_SIZE_MB")) UPLOAD_MAX_SIZE_MB.set(Convert.toLong(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())); }); System.out.println("[系统配置] 上传目标(-1:本地, 1:腾讯云, 2:阿里云, 3.抖音云): " + UPLOAD_TARGET); System.out.println("[系统配置] 单文件上传大小限制(MB): " + UPLOAD_MAX_SIZE_MB); System.out.println("[系统配置] 图片的缩略图宽高尺寸 (px): " + UPLOAD_THUMB_SIZE); System.out.println("[系统配置] 是否启用文件MD5查重: " + UPLOAD_MD5_DUPLICATE); // 判断文件是否超过大小 Long MAX_SIZE = UPLOAD_MAX_SIZE_MB.get() * 1024 * 1024; if (multipartFile.getSize() > MAX_SIZE) { throw new CustException("上传文件不能大于 " + MAX_SIZE / 1024 / 1024 + " MB,请使用大文件上传功能"); } try { // [系统配置] 是否启用文件MD5查重 (仅判断用户自己上传过的文件) // - 存在,则根据文件MD5 判断是否上传过文件 (仅更新时间,不上传已存在的文件,允许更新文件分类) // - 存在,如果存在两个相同 md5 以上的文件,则需要排重后再上传 (如果是不同用户 // - 不存在,则走上传流程 SysFile sysFileEntity = null; if (UPLOAD_MD5_DUPLICATE.get()) { String md5 = DigestUtil.md5Hex(multipartFile.getInputStream()); // 排除文件异常的情况(出现两个以上相同MD5的文件) LambdaQueryWrapper wrapperFile = new LambdaQueryWrapper<>(); wrapperFile.eq(SysFile::getMd5, md5); wrapperFile.eq(SysFile::getUser_id, SecurityUtil.getUserId()); wrapperFile.isNull(SysFile::getUpload_id); List sysFileEntityList = sysFileDao.selectList(wrapperFile); if (sysFileEntityList != null && sysFileEntityList.size() > 1) { throw new CustException("存在 " + sysFileEntityList.size() + " 个相同MD5 (" + md5 + ") 的文件", ResultEnum.STATUS_CONFLICT.getCode()); } if (sysFileEntityList != null && sysFileEntityList.size() > 0) { // 将已存在的文件,赋值 sysFileEntity = sysFileEntityList.get(0); // [DB] 更新文件 (支持更换文件分类) sysFileEntity.setIs_exist(true); sysFileEntity.setCategory_id(category_id); sysFileEntity.setUpload_time(DateUtil.now()); sysFileDao.updateById(sysFileEntity); } else { // [DB] 创建新的文件 sysFileEntity = uploadEvent(multipartFile, category_id, UPLOAD_TARGET.get()); // [格式化] 封面 (图片类型) sysFileEntity = setThumbUrl(sysFileEntity, UPLOAD_THUMB_SIZE.get(), UPLOAD_THUMB_SIZE.get(), StyleEnums.THUMB_BACKGROUND.getValue()); } return sysFileEntity; } else { // 不开启 MD5秒 传,则直接初始化分块 // [方法] 上传事件 sysFileEntity = uploadEvent(multipartFile, category_id, UPLOAD_TARGET.get()); // [格式化] 封面 (图片类型) sysFileEntity = setThumbUrl(sysFileEntity, UPLOAD_THUMB_SIZE.get(), UPLOAD_THUMB_SIZE.get(), StyleEnums.THUMB_BACKGROUND.getValue()); return sysFileEntity; } } catch (IOException e) { throw new RuntimeException(e); } } // target: 上传目标 (-1:本地, 1:腾讯云, 2:阿里云, 3.抖音云) private void deleteObject(String object_key, Integer target) { if (target == -1) { // [本地] 删除文件 uploadUtil.delete(object_key); } if (target == 1) { // [腾讯云] 删除对象 tencentCosService.deleteObject(object_key); System.out.println("Delete tencent cos object: " + object_key); } if (target == 3) { // [抖音云] 删除对象 try { douyinTosService.deleteObject(object_key); } catch (IOException e) { throw new RuntimeException(e); } System.out.println("Delete douyin tos object: " + object_key); } } /** * 删除文件 (包括缩略图,如果有的话) */ @Override public Map removeUploadFile(SysFile sysFile, SysFile querySysFile) { String object_key = querySysFile.getObject_key(); // [Delete] 删除文件记录 sysFileDao.delete(new LambdaQueryWrapper().eq(SysFile::getObject_key, object_key)); // [异步任务] 创建一个 CompletableFuture 来执行异步任务 CompletableFuture.runAsync(() -> { deleteObject(querySysFile.getObject_key(), querySysFile.getTarget()); }); return Map.of("object_key", object_key); } /** * 删除文件 (批量) */ @Override public Map removeUploadFileBatch(SysFile sysFile, List querySysFileList) { // 判断是否存在 List object_keys = sysFile.getObject_keys(); // [Delete] 批量删除 sysFileDao.delete(new LambdaQueryWrapper().in(SysFile::getObject_key, object_keys)); // [异步任务] 创建一个 CompletableFuture 来执行异步任务 CompletableFuture.runAsync(() -> { querySysFileList.stream().forEach(entity -> { deleteObject(entity.getObject_key(), entity.getTarget()); }); }); return Map.of("object_keys", object_keys); } /** * 编辑文件 (名称、文件分类) * - 判断文件、文件分类是否存在 * - 判断文件、文件分类是否当前用户所有 */ @Override public Map updateUploadFile(SysFile sysFile) { // 查询文件是否存在 LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.eq(SysFile::getUser_id, sysFile.getUser_id()); wrapper.eq(SysFile::getId, sysFile.getId()); Boolean isFileExist = sysFileDao.exists(wrapper); if (!isFileExist) throw new CustException("文件不存在"); // 查询文件分类是否存在 if (sysFile.getCategory_id() != null) { LambdaQueryWrapper wrapperCategory = new LambdaQueryWrapper<>(); wrapperCategory.eq(SysFileCategory::getUser_id, sysFile.getUser_id()); wrapperCategory.eq(SysFileCategory::getId, sysFile.getCategory_id()); Boolean isFileCategoryExist = sysFileCategoryDao.exists(wrapperCategory); if (!isFileCategoryExist) throw new CustException("文件分类不存在"); } // [DB] 更新文件 sysFileDao.updateFile(sysFile); return Map.of("id", sysFile.getId()); } /** * 编辑文件 (批量) * - 文件、文件分类必须存在,且是该用户所有 */ @Override public Map updateUploadFileBatch(SysFile sysFile) { List ids = sysFile.getIds(); Long category_id = sysFile.getCategory_id(); // 判断 文件分类ID 是否存在 if (category_id != null) { Boolean isExist = sysFileCategoryDao.exists(new LambdaQueryWrapper().eq(SysFileCategory::getId, category_id)); if (!isExist) throw new CustException("文件分类不存在"); } // [DB] 批量更新 分类ID LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(SysFile::getUser_id, sysFile.getUser_id()).in(SysFile::getId, ids); wrapper.set(SysFile::getCategory_id, category_id); sysFileDao.update(null, wrapper); return Map.of("ids", ids); } // URL转存 @Override public SysFileResult urlToUploadFile(String origin_url) { Integer UPLOAD_TARGET = Convert.toInt(sysCommonService.getCommonByTag("UPLOAD_TARGET")); SysFileResult sysFileResult = null; // target: 上传目标 (-1:本地, 1:腾讯云, 2:阿里云, 3.抖音云) if (UPLOAD_TARGET == 1) { sysFileResult = tencentCosService.urlToCOS(origin_url); } // 3: 抖音云 if (UPLOAD_TARGET == 3) { sysFileResult = douyinTosService.urlToTOS(origin_url); } if (sysFileResult == null) throw new CustException("上传失败"); // [DB] 创建文件 SysFile sysFileEntity = new SysFile(); sysFileEntity.setRequest_id(sysFileEntity.getRequest_id()); sysFileEntity.setUser_id(SecurityUtil.getUserId()); try { URL parsed_url = new URL(origin_url); sysFileEntity.setName(StrUtil.subAfter(parsed_url.getPath(), '/', true)); } catch (Exception e) { throw new CustException(e.getMessage()); } sysFileEntity.setUrl(sysFileResult.getDomain() + "/" + sysFileResult.getKey()); sysFileEntity.setObject_key(sysFileResult.getKey()); sysFileEntity.setSize(sysFileResult.getSize()); sysFileEntity.setTarget(UPLOAD_TARGET); sysFileEntity.setTarget_label(TargetEnums.targetToLabel(UPLOAD_TARGET)); sysFileEntity.setUpload_time(DateUtil.now()); sysFileDao.insert(sysFileEntity); return sysFileResult; } /** * 根据 MD5 获取文件列表 (我的) */ @Override public List> getUploadFileListByMd5(SysFile sysFile) { SysFile entity = new SysFile(); entity.setUser_id(sysFile.getUser_id()); entity.setUpload_id(""); entity.setMd5(sysFile.getMd5()); return sysFileDao.selectUploadFileSimple(entity); } /** * 合并重复 MD5 文件 { md5, ids } */ @Override @Transactional(rollbackFor = Exception.class) public Map mergeFileByMd5(SysFileMergeByMd5 sysFileMergeByMd5) { Long user_id = sysFileMergeByMd5.getUser_id(); List object_keys = sysFileMergeByMd5.getObject_keys(); String target_object_key = sysFileMergeByMd5.getTarget_object_key(); String target_file_name = sysFileMergeByMd5.getTarget_file_name(); // 判断 target_object 是否存在于 object_keys 中 boolean exists = CollectionUtil.contains(object_keys, target_object_key); if (!exists) throw new CustException("合并目标文件ID不存在于列表中"); // 判断权限 (需要子权限或超级管理员) if (!SecurityUtil.isSuper() && !securityUtil.hasPermission("1.1.5")) { throw new CustException(SecurityEnum.NOAUTH); } // [DB] 查询文件列表 (不包含 target_object_key、当前用户ID、upload_id 为空) LambdaQueryWrapper selectWrapper = new LambdaQueryWrapper<>(); selectWrapper.in(SysFile::getUser_id, user_id); // 过滤一个没有 target_object_key 的 object_keys 集合 List file_object_keys_without_target = object_keys.stream().filter(object_key -> !object_key.equals(target_object_key)).collect(Collectors.toList()); selectWrapper.in(SysFile::getObject_key, file_object_keys_without_target); selectWrapper.isNull(SysFile::getUpload_id); List querySysFileList = sysFileDao.selectList(selectWrapper); if (querySysFileList.size() == 0) { throw new CustException("选中的文件不存在", ResultEnum.PARAMETER_EXCEPTION.getCode(), "Field: object_keys"); } // [DB] 更新文件命名 LambdaQueryWrapper updateWrapper = new LambdaQueryWrapper<>(); updateWrapper.eq(SysFile::getObject_key, target_object_key); SysFile updateEntity = new SysFile(); updateEntity.setName(target_file_name); sysFileDao.update(updateEntity, updateWrapper); // [DB] 批量删除 (除了 target_object_key 外的文件) List delete_object_keys = querySysFileList.stream().map(entity -> entity.getObject_key()).collect(Collectors.toList()); System.out.println(delete_object_keys); sysFileDao.delete(new LambdaQueryWrapper().in(SysFile::getObject_key, delete_object_keys)); // [异步任务] 创建一个 CompletableFuture 来执行异步任务 CompletableFuture.runAsync(() -> { querySysFileList.stream().forEach(entity -> { deleteObject(entity.getObject_key(), entity.getTarget()); }); }); Map resp = new LinkedHashMap<>(); resp.put("delete_object_keys", delete_object_keys); resp.put("target_object_key", target_object_key); return resp; } }