package kr.wisestone.owl.service.impl; import com.amazonaws.services.s3.AmazonS3; import com.amazonaws.services.s3.model.*; import com.amazonaws.services.s3.transfer.*; import com.google.common.collect.Lists; import kr.wisestone.owl.constant.Constants; import kr.wisestone.owl.constant.MsgConstants; import kr.wisestone.owl.domain.AttachedFile; import kr.wisestone.owl.domain.Issue; import kr.wisestone.owl.domain.Workspace; import kr.wisestone.owl.domain.enumType.AttachedType; import kr.wisestone.owl.exception.OwlRuntimeException; import kr.wisestone.owl.mapper.AttachedFileMapper; import kr.wisestone.owl.repository.AttachedFileRepository; import kr.wisestone.owl.service.AttachedFileService; import kr.wisestone.owl.service.IssueService; import kr.wisestone.owl.service.WorkspaceService; import kr.wisestone.owl.util.CommonUtil; import kr.wisestone.owl.util.ConvertUtil; import kr.wisestone.owl.util.MapUtil; import kr.wisestone.owl.util.WebAppUtil; import kr.wisestone.owl.vo.AttachedFileVo; import kr.wisestone.owl.vo.ExportExcelAttrVo; import kr.wisestone.owl.vo.ExportExcelVo; import kr.wisestone.owl.web.condition.AttachedFileCondition; import kr.wisestone.owl.web.form.IssueForm; import kr.wisestone.owl.web.view.ExcelView; import kr.wisestone.owl.web.view.FileDownloadView; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.time.StopWatch; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.ui.Model; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.ModelAndView; import java.io.File; import java.io.InputStream; import java.text.DecimalFormat; import java.util.HashMap; import java.util.List; import java.util.Map; @Service public class AttachedFileServiceImpl extends AbstractServiceImpl> implements AttachedFileService { private static final Logger LOGGER = LoggerFactory.getLogger(AttachedFileServiceImpl.class); @Autowired private AttachedFileRepository attachedFileRepository; @Autowired private WorkspaceService workspaceService; @Autowired private IssueService issueService; @Autowired private AttachedFileMapper attachedFileMapper; @Autowired private SimpMessagingTemplate simpMessagingTemplate; @Value("${use.aws}") private boolean bUseAWS; @Value("${attached.file.path}") private String uploadFolder; @Value("${aws.bucket.name}") private String bucketName; @Value("${aws.s3.url}") private String awsS3Url; @Autowired private FileDownloadView fileDownloadView; @Autowired private AmazonS3 amazonS3; @Autowired private ExcelView excelView; @Override protected JpaRepository getRepository() { return this.attachedFileRepository; } // 첨부 파일을 등록한다. - 이슈 섬머 노트에서 사용 @Override @Transactional public List addAttachedFile(List multipartFiles, Map content) { Long workspaceId = MapUtil.getLong(content, "workspaceId"); Long issueId = MapUtil.getLong(content, "issueId"); Workspace workspace = this.workspaceService.getWorkspace(workspaceId); List> convertFileMaps = Lists.newArrayList(); for (MultipartFile multipartFile : multipartFiles) { convertFileMaps.add(CommonUtil.makeFileMap(multipartFile)); } if (issueId != null) { Issue issue = this.issueService.getIssue(issueId); return this.addAttachedFiles(workspace, convertFileMaps, issue, null, AttachedType.SUMMER); } else { return this.addAttachedFiles(workspace, convertFileMaps, null, null, AttachedType.TEMP_SUMMER); } } // 첨부 파일을 등록한다. - 이슈 생성, 수정에서 사용 @Override @Transactional public void addAttachedFile(List> convertFileMaps, Issue issue, String userAccount) { Workspace workspace = issue.getIssueStatus().getWorkspace(); if (workspace == null) { throw new OwlRuntimeException( this.messageAccessor.getMessage(MsgConstants.WORKSPACE_NOT_EXIST)); } this.addAttachedFiles(workspace, convertFileMaps, issue, userAccount, AttachedType.ISSUE_ATTACHED); } private List addAttachedFiles(Workspace workspace, List> convertFileMaps, Issue issue, String userAccount, AttachedType attachedType) { List attachedFiles = Lists.newArrayList(); AttachedFileCondition attachedFileCondition = new AttachedFileCondition(); attachedFileCondition.setWorkspaceId(workspace.getId()); Long useStorageSize = this.attachedFileMapper.findUseStorage(attachedFileCondition); if (useStorageSize == null) { useStorageSize = 0L; } // 용량 및 파일 확장자 허용 여부 체크 this.checkStorageSizeAndFileType(convertFileMaps, workspace.getStorageSize(), useStorageSize); int totalFileCount = convertFileMaps.size(); // 전체 업로드 파일 개수 int uploadFileCount = 1; // 현재 업로드 파일 순서 for (Map convertFileMap : convertFileMaps) { // 파일 업로드 후 awsKey(파일 명)을 가져온다. String awsKey = this.uploadFile(convertFileMap, this.uploadFolder + workspace.getId(), userAccount, totalFileCount, uploadFileCount); attachedFiles.add(new AttachedFile(MapUtil.getString(convertFileMap, "fileName"), MapUtil.getLong(convertFileMap, "fileSize"), MapUtil.getString(convertFileMap, "contentType"), this.setMakeFilePath(awsKey, workspace), awsKey, issue, workspace, CommonUtil.getFileType(MapUtil.getString(convertFileMap, "fileName")), attachedType)); uploadFileCount++; } if (attachedFiles.size() > 0) { this.attachedFileRepository.saveAll(attachedFiles); } return attachedFiles; } // 용량 및 파일 확장자 허용 여부 체크 private void checkStorageSizeAndFileType(List> convertFileMaps, Long totalStorageSize, Long useStorageSize) { for (Map convertFileMap : convertFileMaps) { Long fileSize = MapUtil.getLong(convertFileMap, "fileSize"); String fileName = MapUtil.getString(convertFileMap, "fileName"); if (fileSize == null) { fileSize = 0L; } // 용량 초과 체크 if (totalStorageSize < (useStorageSize + fileSize)) { throw new OwlRuntimeException( this.messageAccessor.getMessage(MsgConstants.WORKSPACE_STORAGE_SIZE_EXCESS)); } // 파일 확장자 체크 if (!CommonUtil.checkFileType(fileName)) { throw new OwlRuntimeException( this.messageAccessor.getMessage(MsgConstants.FILE_TYPE_NOT_ALLOW)); } } } // 이슈 생성, 수정에서 섬머 노트로 업로드한 이미지와 이슈를 연결시킨다. @Override @Transactional public void connectIssueIdAttachedFile(Issue issue, IssueForm issueForm) { for (Long attachedFileId : issueForm.getAttachedFileIds()) { AttachedFile attachedFile = this.getAttachedFile(attachedFileId); attachedFile.setIssue(issue); attachedFile.setAttachedType(AttachedType.SUMMER); this.attachedFileRepository.save(attachedFile); } this.attachedFileRepository.flush(); } // 이슈 목록을 조회한다. @Override @Transactional(readOnly = true) public List findAttachedFile(Map resJsonData, AttachedFileCondition condition) { List attachedFileVos = Lists.newArrayList(); for (AttachedFile attachedFile : this.findByIssueId(condition.getIssueId())) { AttachedFileVo attachedFileVo = ConvertUtil.copyProperties(attachedFile, AttachedFileVo.class, "fileType"); attachedFileVo.setFileType(attachedFile.getFileType().toString()); attachedFileVos.add(attachedFileVo); } resJsonData.put(Constants.RES_KEY_CONTENTS, attachedFileVos); return attachedFileVos; } // 이슈 아이디로 첨부 파일을 조회한다. @Override @Transactional(readOnly = true) public List findByIssueId(Long issueId) { return this.attachedFileRepository.findByIssueId(issueId); } // 첨부 파일 아이디로 첨부 파일을 조회한다. @Override @Transactional(readOnly = true) public AttachedFile getAttachedFile(Long attachedFileId) { AttachedFile attachedFile = this.findOne(attachedFileId); if (attachedFile == null) { throw new OwlRuntimeException( this.messageAccessor.getMessage(MsgConstants.ATTACHED_FILE_NOT_EXIST)); } return attachedFile; } // 첨부파일 삭제 @Override @Transactional public void removeAttachedFiles(List removeIds) { for (Long attachedId : removeIds) { AttachedFile attachedFile = this.getAttachedFile(attachedId); switch (attachedFile.getAttachedType()) { case SUMMER: // 이슈와 첨부 파일 연결을 해제한다. attachedFile.setIssue(null); break; case ISSUE_ATTACHED: // 첨부 파일을 삭제한다. this.removeAttachedFiles(attachedFile); break; } } } // 첨부 파일을 삭제한다. private void removeAttachedFiles(AttachedFile attachedFile) { // 파일을 삭제한다. this.removeFile(attachedFile.getAwsKey(), attachedFile.getWorkspace().getId()); this.attachedFileRepository.delete(attachedFile); } // 업무 공간 삭제시 이슈에 첨부된 파일을 시스템에서 삭제한다. @Override @Transactional public void deleteWorkspaceCascadeAttachedFile(Workspace workspace) { List> attachedFiles = this.attachedFileMapper.findByWorkspaceId(workspace.getId()); for (Map attachedFile : attachedFiles) { // 파일을 삭제한다. this.removeFile(MapUtil.getString(attachedFile, "awsKey"), MapUtil.getLong(attachedFile, "workspaceId")); } // 첨부 파일 삭제 this.attachedFileMapper.deleteAttachedFileByWorkspaceId(workspace.getId()); } // 프로젝트 삭제시 이슈에 첨부된 파일을 시스템에서 삭제한다. @Override @Transactional public void deleteIssueCascadeAttachedFile(List issueIds, Workspace workspace) { // 이슈가 없을 경우에는 아래 로직을 타지 않는다. -> 모든 업무공간에 첨부파일이 삭제될 위험이 있음. if (issueIds.size() < 1) { return; } AttachedFileCondition attachedFileCondition = new AttachedFileCondition(); attachedFileCondition.setIssueIds(issueIds); attachedFileCondition.setWorkspaceId(workspace.getId()); List> attachedFiles = this.attachedFileMapper.findByIssueIds(attachedFileCondition); for (Map attachedFile : attachedFiles) { // 파일을 삭제한다. this.removeFile(MapUtil.getString(attachedFile, "awsKey"), MapUtil.getLong(attachedFile, "workspaceId")); } // 첨부 파일 삭제 this.attachedFileMapper.deleteAttachedFileByIssueIds(attachedFileCondition); } // 업무 공간에서 사용중인 저장 용량을 조회한다. @Override @Transactional(readOnly = true) public Long findUseStorage(Workspace workspace) { AttachedFileCondition attachedFileCondition = new AttachedFileCondition(); attachedFileCondition.setWorkspaceId(workspace.getId()); return this.attachedFileMapper.findUseStorage(attachedFileCondition); } // 업무 공간에서 사용 트래픽을 체크하고 트래픽 초과시 다운로드를 막는다. @Override @Transactional public ModelAndView checkUseWorkspaceTraffic(Long id, Model model) { AttachedFile attachedFile = this.getAttachedFile(id); // 트래픽 사용량을 저장하고 초과할 경우에는 해당 업무 공간에서 다운로드를 일시적으로 금지한다. if (!this.workspaceService.checkUseTraffic(attachedFile.getSize())) { ExportExcelVo excelInfo = new ExportExcelVo(); excelInfo.setFileName("해당 업무 공간에서 사용할 수 있는 트래픽이 초과되었습니다. 트래픽을 추가하려면 와이즈스톤 담당자에게 문의하세요. - supportowl@wisestone.kr"); excelInfo.addAttrInfos(new ExportExcelAttrVo("name", "", 120, ExportExcelAttrVo.ALIGN_LEFT)); model.addAttribute(Constants.EXCEL, excelInfo); return new ModelAndView(this.excelView); } ModelAndView objModelView = null; if( this.bUseAWS ) { objModelView = downloadFileFromAWS(id, model); } else { objModelView = downloadFileFromLocal(id, model); } return objModelView; } private ModelAndView downloadFileFromLocal(Long id, Model model) { AttachedFile attachedFile = this.getAttachedFile(id); InputStream objectInputStream = CommonUtil.getFileInputStream(this.bucketName + this.uploadFolder + attachedFile.getWorkspace().getId(), attachedFile.getAwsKey()); try { byte[] bytes = IOUtils.toByteArray(objectInputStream); AttachedFileVo attachedFileVo = ConvertUtil.copyProperties(attachedFile, AttachedFileVo.class); attachedFileVo.setBytes(bytes); model.addAttribute("fileDownloadTarget", attachedFileVo); } catch (Exception e) { LOGGER.error("첨부 파일 다운로드에 실패하였습니다."); } return new ModelAndView(this.fileDownloadView); } private ModelAndView downloadFileFromAWS(Long id, Model model) { AttachedFile attachedFile = this.getAttachedFile(id); GetObjectRequest getObjectRequest = new GetObjectRequest(this.bucketName + this.uploadFolder + attachedFile.getWorkspace().getId(), attachedFile.getAwsKey()); S3Object s3Object = this.amazonS3.getObject(getObjectRequest); S3ObjectInputStream objectInputStream = s3Object.getObjectContent(); try { byte[] bytes = IOUtils.toByteArray(objectInputStream); AttachedFileVo attachedFileVo = ConvertUtil.copyProperties(attachedFile, AttachedFileVo.class); attachedFileVo.setBytes(bytes); model.addAttribute("fileDownloadTarget", attachedFileVo); } catch (Exception e) { LOGGER.error("아마존 클라우드에서 첨부 파일 다운로드에 실패하였습니다."); } return new ModelAndView(this.fileDownloadView); } // 이슈와 연결되지 않은 첨부파일 삭제 @Override @Transactional public void deleteAttachedFileNotId() { this.attachedFileMapper.deleteAttachedFileNotId(); } // 파일을 업로드 한다. @Override @Transactional public String uploadFile(Map convertFileMap, String awsUploadFolder, String userAccount, int totalFileCount, int uploadFileCount) { String strKeyName = ""; if( this.bUseAWS ) { strKeyName = uploadFileToAws(convertFileMap, awsUploadFolder, userAccount, totalFileCount, uploadFileCount); } else { strKeyName = uploadFileToLocal(convertFileMap, awsUploadFolder, userAccount, totalFileCount, uploadFileCount); } return strKeyName; } // local storage로 이동한다. private String uploadFileToLocal(Map convertFileMap, String awsUploadFolder, String userAccount, int totalFileCount, int uploadFileCount) { String awsKeyName = CommonUtil.getFileNameByUUID(MapUtil.getString(convertFileMap, "fileName")); StopWatch serviceStart = new StopWatch(); serviceStart.start(); File file = (File) convertFileMap.get("file"); try { // 이슈 생성, 수정에서 파일을 업로드할때는 비동기로 올리며 업로드 진행률을 표시해준다. if (!StringUtils.isEmpty(userAccount)) { } CommonUtil.moveToSaveStorage(this.bucketName + awsUploadFolder, awsKeyName, file); if (file.exists()) { file.delete(); } } catch (Exception e) { LOGGER.error("파일 업로드 에러 :" + e.getMessage()); } serviceStart.stop(); return awsKeyName; } // 아마존 클라우드에 파일을 업로드한다. private String uploadFileToAws(Map convertFileMap, String awsUploadFolder, String userAccount, int totalFileCount, int uploadFileCount) { String awsKeyName = CommonUtil.getFileNameByUUID(MapUtil.getString(convertFileMap, "fileName")); StopWatch serviceStart = new StopWatch(); serviceStart.start(); File file = (File) convertFileMap.get("file"); TransferManager transferManager = TransferManagerBuilder .standard() .withS3Client(this.amazonS3) /*.withMultipartUploadThreshold((long) 5*1024*1024)*/ /*.withExecutorFactory(() -> Executors.newFixedThreadPool(20))*/ .build(); /*TransferManager transferManager = TransferManagerBuilder .standard() .withS3Client(this.amazonS3) .withDisableParallelDownloads(false) .withMinimumUploadPartSize((long)(5 * MB)) .withMultipartUploadThreshold((long)(16 * MB)) .withMultipartCopyPartSize((long)(5 * MB)) .withMultipartCopyThreshold((long)(100 * MB)) .withExecutorFactory(() -> Executors.newFixedThreadPool(20)) .build();*/ try { PutObjectRequest putObjectRequest = new PutObjectRequest(this.bucketName + awsUploadFolder, awsKeyName, file); putObjectRequest.setCannedAcl(CannedAccessControlList.PublicRead); // file permission //this.amazonS3.putObject(putObjectRequest); // upload file long fileSize = MapUtil.getLong(convertFileMap, "fileSize"); String fileName = MapUtil.getString(convertFileMap, "fileName"); // 이슈 생성, 수정에서 파일을 업로드할때는 비동기로 올리며 업로드 진행률을 표시해준다. if (!StringUtils.isEmpty(userAccount)) { com.amazonaws.event.ProgressListener progressListener = new com.amazonaws.event.ProgressListener() { long bytesUploaded = 0; SimpMessagingTemplate webSocket = simpMessagingTemplate; @Override public void progressChanged(com.amazonaws.event.ProgressEvent progressEvent) { this.bytesUploaded += progressEvent.getBytesTransferred();// add counter double uploadProcess = this.bytesUploaded * 100.0 / fileSize; String percent = new DecimalFormat("###").format(uploadProcess); Map fileMap = new HashMap<>(); fileMap.put("display", (uploadProcess < 100)); fileMap.put("serverFileName", fileName); fileMap.put("serverProgress", percent + "%"); fileMap.put("totalFileCount", totalFileCount); fileMap.put("uploadFileCount", uploadFileCount); this.webSocket.convertAndSendToUser(userAccount, "/notification/file-upload-process", fileMap); } }; putObjectRequest.setGeneralProgressListener(progressListener); } Upload upload = transferManager.upload(putObjectRequest); upload.waitForCompletion(); transferManager.shutdownNow(false); if (file.exists()) { file.delete(); } } catch (Exception e) { LOGGER.error("파일 업로드 에러 :" + e.getMessage()); } serviceStart.stop(); return awsKeyName; } // 파일을 삭제한다. @Override @Transactional public void removeFile(String key, Long workspaceId) { try { if( this.bUseAWS ) { removeFileToAws(key, workspaceId); } else { removeFileToLocal(key, workspaceId); } } catch (Exception e) { LOGGER.error("파일 삭제 에러 :" + e.getMessage()); } } // 파일을 삭제한다. private void removeFileToLocal(String key, Long workspaceId) { try { if(workspaceId > 0 ) { CommonUtil.deleteToSaveStorage(this.bucketName + this.uploadFolder, key); } else { CommonUtil.deleteToSaveStorage(this.bucketName + this.uploadFolder + workspaceId, key); } } catch (Exception e) { LOGGER.error("파일 삭제 에러 :" + e.getMessage()); } } // 아마존클라우드에서 파일을 삭제한다. private void removeFileToAws(String key, Long workspaceId) { try { if(workspaceId > 0 ) { this.amazonS3.deleteObject(this.bucketName + this.uploadFolder, key); } else { this.amazonS3.deleteObject(this.bucketName + this.uploadFolder + workspaceId, key); } } catch (Exception e) { LOGGER.error("파일 삭제 에러 :" + e.getMessage()); } } // 업로드되는 전체 경로를 가져온다. private String setMakeFilePath(String path, Workspace workspace) { String strFilePath = ""; if( this.bUseAWS ) { strFilePath = setMakeAwsFilePath(path, workspace); } else { strFilePath = setMakeLocalFilePath(path, workspace); } return strFilePath; } // 업로드되는 전체 경로를 가져온다. private String setMakeLocalFilePath(String path, Workspace workspace) { return this.awsS3Url + this.bucketName + this.uploadFolder + workspace.getId() + "/" + path; } // 아마존 클라우드에 업로드되는 전체 경로를 가져온다. private String setMakeAwsFilePath(String path, Workspace workspace) { return this.awsS3Url + this.bucketName + this.uploadFolder + workspace.getId() + "/" + path; } }