diff --git a/src/main/java/com/c4cometrue/mystorage/exception/ErrorCode.java b/src/main/java/com/c4cometrue/mystorage/exception/ErrorCode.java index a1846b8..d2c6998 100644 --- a/src/main/java/com/c4cometrue/mystorage/exception/ErrorCode.java +++ b/src/main/java/com/c4cometrue/mystorage/exception/ErrorCode.java @@ -8,37 +8,41 @@ @Getter @AllArgsConstructor public enum ErrorCode { - UNAUTHORIZED_FILE_ACCESS(HttpStatus.FORBIDDEN, "비정상적인 요청입니다."), + UNAUTHORIZED_FILE_ACCESS(HttpStatus.FORBIDDEN, "비정상적인 요청입니다."), - CANNOT_FOUND_FILE(HttpStatus.NOT_FOUND, "해당 파일을 찾을 수 없습니다."), + CANNOT_FOUND_FILE(HttpStatus.NOT_FOUND, "해당 파일을 찾을 수 없습니다."), - FILE_COPY_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "파일 복사 중 오류가 발생했습니다."), + FILE_COPY_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "파일 복사 중 오류가 발생했습니다."), - FILE_DELETE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "파일 삭제 중 오류가 발생했습니다."), + FILE_DELETE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "파일 삭제 중 오류가 발생했습니다."), - DUPLICATE_FILE_NAME(HttpStatus.BAD_REQUEST, "파일 업로드에 중복이 발생 했습니다"), + DUPLICATE_FILE_NAME(HttpStatus.BAD_REQUEST, "파일 업로드에 중복이 발생 했습니다"), - FOLDER_CREATE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "폴더 생성 중 오류가 발생했습니다"), - UNAUTHORIZED_FOLDER_ACCESS(HttpStatus.FORBIDDEN, "비정상적인 요청입니다."), - DUPLICATE_FOLDER_NAME(HttpStatus.BAD_REQUEST, "폴더 업로드에 중복이 발생 했습니다"), - DUPLICATE_SERVER_FOLDER_NAME(HttpStatus.BAD_REQUEST, "폴더 UUID 중복이 발생 했습니다"), - CANNOT_FOUND_FOLDER(HttpStatus.NOT_FOUND, "해당 폴더를 찾을 수 없습니다."), + FOLDER_CREATE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "폴더 생성 중 오류가 발생했습니다"), + UNAUTHORIZED_FOLDER_ACCESS(HttpStatus.FORBIDDEN, "비정상적인 요청입니다."), + DUPLICATE_FOLDER_NAME(HttpStatus.BAD_REQUEST, "폴더 업로드에 중복이 발생 했습니다"), + DUPLICATE_SERVER_FOLDER_NAME(HttpStatus.BAD_REQUEST, "폴더 UUID 중복이 발생 했습니다"), + CANNOT_FOUND_FOLDER(HttpStatus.NOT_FOUND, "해당 폴더를 찾을 수 없습니다."), - DUPLICATE_BASE_PATH(HttpStatus.BAD_REQUEST, "기본 경로 생성에 중복이 발생했습니다"), + DUPLICATE_BASE_PATH(HttpStatus.BAD_REQUEST, "기본 경로 생성에 중복이 발생했습니다"), - MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 맴버를 찾지 못했습니다"), - EXCEEDED_CAPACITY(HttpStatus.INSUFFICIENT_STORAGE, "더 이상 업로드 할 수 없습니다"), - INVALID_OPERATION(HttpStatus.BAD_REQUEST, "사용 중인 공간보다 많은 공간은 해제할 수 없습니다"), - VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "유효하지 않은 요청입니다."); + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 맴버를 찾지 못했습니다"), + EXCEEDED_CAPACITY(HttpStatus.INSUFFICIENT_STORAGE, "더 이상 업로드 할 수 없습니다"), + INVALID_OPERATION(HttpStatus.BAD_REQUEST, "사용 중인 공간보다 많은 공간은 해제할 수 없습니다"), + VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "유효하지 않은 요청입니다."), - private final HttpStatus httpStatus; - private final String message; + DUPLICATE_SHARE_LINK(HttpStatus.INTERNAL_SERVER_ERROR, "링크 생성에 중복이 발생했습니다"), + NOT_FOUND_SHARE_LINK(HttpStatus.NOT_FOUND, "링크를 찾을 수 없습니다"), + NOT_FRESH_LINK(HttpStatus.BAD_REQUEST, "만료된 링크입니다."); - public ServiceException serviceException() { - return new ServiceException(this.name(), message); - } + private final HttpStatus httpStatus; + private final String message; - public ServiceException serviceException(String debugMessage, Object... debugMessageArgs) { - return new ServiceException(this.name(), message, String.format(debugMessage, debugMessageArgs)); - } + public ServiceException serviceException() { + return new ServiceException(this.name(), message); + } + + public ServiceException serviceException(String debugMessage, Object... debugMessageArgs) { + return new ServiceException(this.name(), message, String.format(debugMessage, debugMessageArgs)); + } } diff --git a/src/main/java/com/c4cometrue/mystorage/file/FileDataHandlerService.java b/src/main/java/com/c4cometrue/mystorage/file/FileDataHandlerService.java index eefe8ee..57dd948 100644 --- a/src/main/java/com/c4cometrue/mystorage/file/FileDataHandlerService.java +++ b/src/main/java/com/c4cometrue/mystorage/file/FileDataHandlerService.java @@ -13,50 +13,55 @@ @Service @RequiredArgsConstructor public class FileDataHandlerService { - private final FileRepository fileRepository; - - @Transactional - public void deleteBy(Long fileId) { - existBy(fileId); - fileRepository.deleteById(fileId); - } - - private void existBy(Long fileId) { - if (!fileRepository.existsById(fileId)) { - throw ErrorCode.CANNOT_FOUND_FILE.serviceException("fileId : {}", fileId); - } - } - - public FileMetadata findBy(Long fileId, Long userId) { - return fileRepository.findByIdAndUploaderId(fileId, userId) - .orElseThrow(() -> ErrorCode.CANNOT_FOUND_FILE.serviceException("fileId : {}, userId : {}", fileId, - userId)); - } - - public void persist(FileMetadata fileMetadata) { - fileRepository.save(fileMetadata); - } - - public void duplicateBy(Long parentId, String fileName) { - if (fileRepository.existsByParentIdAndOriginalFileName(parentId, fileName)) { - throw ErrorCode.DUPLICATE_FILE_NAME.serviceException("fileName : {}", fileName); - } - } - - public List getFileList(Long parentId, Long cursorId, Long userId, Pageable page) { - return cursorId == null ? fileRepository.findAllByParentIdAndUploaderIdOrderByIdDesc(parentId, userId, page) - : fileRepository.findByParentIdAndUploaderIdAndIdLessThanOrderByIdDesc(parentId, cursorId, userId, page); - } - - public Boolean hashNext(Long parentId, Long userId, Long lastIdOfList) { - return fileRepository.existsByParentIdAndUploaderIdAndIdLessThan(parentId, userId, lastIdOfList); - } - - public List findAllBy(Long parentId) { - return fileRepository.findAllByParentId(parentId); - } - - public void deleteAll(List fileMetadataList) { - fileRepository.deleteAll(fileMetadataList); - } + private final FileRepository fileRepository; + + @Transactional + public void deleteBy(Long fileId) { + existBy(fileId); + fileRepository.deleteById(fileId); + } + + private void existBy(Long fileId) { + if (!fileRepository.existsById(fileId)) { + throw ErrorCode.CANNOT_FOUND_FILE.serviceException("fileId : {}", fileId); + } + } + + public FileMetadata findBy(Long fileId, Long userId) { + return fileRepository.findByIdAndUploaderId(fileId, userId) + .orElseThrow(() -> ErrorCode.CANNOT_FOUND_FILE.serviceException("fileId : {}, userId : {}", fileId, + userId)); + } + + public void persist(FileMetadata fileMetadata) { + fileRepository.save(fileMetadata); + } + + public void duplicateBy(Long parentId, String fileName) { + if (fileRepository.existsByParentIdAndOriginalFileName(parentId, fileName)) { + throw ErrorCode.DUPLICATE_FILE_NAME.serviceException("fileName : {}", fileName); + } + } + + public List getFileList(Long parentId, Long cursorId, Long userId, Pageable page) { + return cursorId == null ? fileRepository.findAllByParentIdAndUploaderIdOrderByIdDesc(parentId, userId, page) + : fileRepository.findByParentIdAndUploaderIdAndIdLessThanOrderByIdDesc(parentId, cursorId, userId, page); + } + + public Boolean hashNext(Long parentId, Long userId, Long lastIdOfList) { + return fileRepository.existsByParentIdAndUploaderIdAndIdLessThan(parentId, userId, lastIdOfList); + } + + public List findAllBy(Long parentId) { + return fileRepository.findAllByParentId(parentId); + } + + public void deleteAll(List fileMetadataList) { + fileRepository.deleteAll(fileMetadataList); + } + + // 파일이 삭제된 경우에는 파일 공유 링크로 다운 받을 수 없다. + public FileMetadata findBy(Long fileId) { + return fileRepository.findById(fileId).orElseThrow(ErrorCode.CANNOT_FOUND_FILE::serviceException); + } } diff --git a/src/main/java/com/c4cometrue/mystorage/file/FileRepository.java b/src/main/java/com/c4cometrue/mystorage/file/FileRepository.java index 661d0b1..9200080 100644 --- a/src/main/java/com/c4cometrue/mystorage/file/FileRepository.java +++ b/src/main/java/com/c4cometrue/mystorage/file/FileRepository.java @@ -5,23 +5,22 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; public interface FileRepository extends JpaRepository { - Optional findByIdAndUploaderId(Long id, Long uploaderId); + Optional findByIdAndUploaderId(Long id, Long uploaderId); - boolean existsByParentIdAndOriginalFileName(Long parentId, String fileName); + boolean existsByParentIdAndOriginalFileName(Long parentId, String fileName); - List findByParentIdAndUploaderId(Long parentId, Long userId); + List findByParentIdAndUploaderId(Long parentId, Long userId); - Boolean existsByIdAndUploaderId(Long parentId, Long userId); + Boolean existsByIdAndUploaderId(Long parentId, Long userId); - List findAllByParentIdAndUploaderIdOrderByIdDesc(Long parentId, Long uploaderId, Pageable page); + List findAllByParentIdAndUploaderIdOrderByIdDesc(Long parentId, Long uploaderId, Pageable page); - List findByParentIdAndUploaderIdAndIdLessThanOrderByIdDesc(Long parentId, Long userId, Long cursorId, - Pageable pageable); + List findByParentIdAndUploaderIdAndIdLessThanOrderByIdDesc(Long parentId, Long userId, Long cursorId, + Pageable pageable); - Boolean existsByParentIdAndUploaderIdAndIdLessThan(Long parentId, Long uploaderId, Long id); + Boolean existsByParentIdAndUploaderIdAndIdLessThan(Long parentId, Long uploaderId, Long id); - List findAllByParentId(Long parentId); + List findAllByParentId(Long parentId); } diff --git a/src/main/java/com/c4cometrue/mystorage/file/FileService.java b/src/main/java/com/c4cometrue/mystorage/file/FileService.java index d39a2b4..d828cdc 100644 --- a/src/main/java/com/c4cometrue/mystorage/file/FileService.java +++ b/src/main/java/com/c4cometrue/mystorage/file/FileService.java @@ -61,10 +61,7 @@ protected void uploadFile(Long userId, Long rootId, BigDecimal fileSize, FileMet public void downloadFile(Long fileId, String userPath, Long userId) { FileMetadata fileMetadata = fileDataHandlerService.findBy(fileId, userId); - Path originalPath = Paths.get(fileMetadata.getFilePath()); - Path userDesignatedPath = Paths.get(userPath).resolve(fileMetadata.getOriginalFileName()).normalize(); - - FileUtil.download(originalPath, userDesignatedPath, bufferSize); + FileUtil.download(fileMetadata, userPath); } @Transactional diff --git a/src/main/java/com/c4cometrue/mystorage/fileshare/DeleteLinkScheduler.java b/src/main/java/com/c4cometrue/mystorage/fileshare/DeleteLinkScheduler.java new file mode 100644 index 0000000..6abed57 --- /dev/null +++ b/src/main/java/com/c4cometrue/mystorage/fileshare/DeleteLinkScheduler.java @@ -0,0 +1,28 @@ +package com.c4cometrue.mystorage.fileshare; + +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.List; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class DeleteLinkScheduler { + private static final int DELETE_SIZE = 100; + + private final FileShareRepository fileShareRepository; + + @Scheduled(cron = "*/5 * * * * ?") + public void deleteExpiredLinks() { + ZonedDateTime expirationTime = ZonedDateTime.now(ZoneOffset.UTC).minusHours(3); + List expirationLinkIds; + + do { + expirationLinkIds = fileShareRepository.deleteByExpirations(expirationTime, DELETE_SIZE); + } while (expirationLinkIds.size() == DELETE_SIZE); + } +} diff --git a/src/main/java/com/c4cometrue/mystorage/fileshare/FileShare.java b/src/main/java/com/c4cometrue/mystorage/fileshare/FileShare.java new file mode 100644 index 0000000..e25c31b --- /dev/null +++ b/src/main/java/com/c4cometrue/mystorage/fileshare/FileShare.java @@ -0,0 +1,46 @@ +package com.c4cometrue.mystorage.fileshare; + +import java.time.ZoneOffset; +import java.time.ZonedDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "file_share", indexes = { + @Index(name = "idx_share_link", columnList = "shareLink"), + @Index(name = "idx_share_created_at", columnList = "createAt") +}) +public class FileShare { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private Long fileId; + @Column(nullable = false, unique = true) + private String shareLink; + @Column(updatable = false) + private ZonedDateTime createdAt; + + @Builder + public FileShare(Long fileId, String shareLink) { + this.fileId = fileId; + this.shareLink = shareLink; + } + + @PrePersist + public void prePersist() { + createdAt = ZonedDateTime.now(ZoneOffset.UTC); + } +} diff --git a/src/main/java/com/c4cometrue/mystorage/fileshare/FileShareController.java b/src/main/java/com/c4cometrue/mystorage/fileshare/FileShareController.java new file mode 100644 index 0000000..c603bc0 --- /dev/null +++ b/src/main/java/com/c4cometrue/mystorage/fileshare/FileShareController.java @@ -0,0 +1,36 @@ +package com.c4cometrue.mystorage.fileshare; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.c4cometrue.mystorage.fileshare.dto.CreateShareFileReq; +import com.c4cometrue.mystorage.fileshare.dto.CreateShareFileRes; +import com.c4cometrue.mystorage.fileshare.dto.DownloadShareFileReq; +import com.c4cometrue.mystorage.fileshare.dto.DownloadShareFileRes; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/files") +@RequiredArgsConstructor +public class FileShareController { + private final FileShareService fileShareService; + + @PostMapping("/share") + public ResponseEntity createShareFile(@Valid @RequestBody CreateShareFileReq req) { + CreateShareFileRes res = fileShareService.createShareFileLink(req.fileId(), req.userId()); + return ResponseEntity.ok(res); + } + + @GetMapping("/share/download") + public ResponseEntity downloadShareFile(@Valid @ModelAttribute DownloadShareFileReq req) { + DownloadShareFileRes res = fileShareService.downloadShareFile(req.shareLink(), req.userPath()); + return ResponseEntity.ok(res); + } +} diff --git a/src/main/java/com/c4cometrue/mystorage/fileshare/FileShareRepository.java b/src/main/java/com/c4cometrue/mystorage/fileshare/FileShareRepository.java new file mode 100644 index 0000000..02f8a0b --- /dev/null +++ b/src/main/java/com/c4cometrue/mystorage/fileshare/FileShareRepository.java @@ -0,0 +1,21 @@ +package com.c4cometrue.mystorage.fileshare; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface FileShareRepository extends JpaRepository { + boolean existsByShareLink(String shareLink); + + Optional findByShareLink(String shareLink); + + @Query(value = "DELETE FROM FileShare fs WHERE fs.createdAt < :expirationTime LIMIT :limit", nativeQuery = true) + List deleteByExpirations(@Param("expirationTime") ZonedDateTime expirationTime, @Param("limit") int limit); + + @Query("DELETE FROM FileShare fs WHERE fs.id IN :ids") + void deleteByIds(@Param("ids") List ids); +} diff --git a/src/main/java/com/c4cometrue/mystorage/fileshare/FileShareService.java b/src/main/java/com/c4cometrue/mystorage/fileshare/FileShareService.java new file mode 100644 index 0000000..7a8fecd --- /dev/null +++ b/src/main/java/com/c4cometrue/mystorage/fileshare/FileShareService.java @@ -0,0 +1,71 @@ +package com.c4cometrue.mystorage.fileshare; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import java.util.UUID; + +import org.springframework.stereotype.Service; + +import com.c4cometrue.mystorage.exception.ErrorCode; +import com.c4cometrue.mystorage.file.FileDataHandlerService; +import com.c4cometrue.mystorage.file.FileMetadata; +import com.c4cometrue.mystorage.fileshare.dto.CreateShareFileRes; +import com.c4cometrue.mystorage.fileshare.dto.DownloadShareFileRes; +import com.c4cometrue.mystorage.util.FileUtil; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class FileShareService { + + private final FileShareRepository fileShareRepository; + private final FileDataHandlerService fileDataHandlerService; + + public CreateShareFileRes createShareFileLink(Long fileId, Long userId) { + // 파일 찾기 + FileMetadata file = fileDataHandlerService.findBy(fileId, userId); + + // shareLink 생성 + String shareLink = UUID.randomUUID().toString(); + checkDuplicate(shareLink); + + // 저장 + persist(fileId, shareLink); + + return CreateShareFileRes.of(file.getOriginalFileName(), shareLink); + } + + private void checkDuplicate(String sharedLink) { + if (fileShareRepository.existsByShareLink(sharedLink)) { + throw ErrorCode.DUPLICATE_SHARE_LINK.serviceException(); + } + } + + public void persist(Long fileId, String shareLink) { + FileShare fileShare = FileShare.builder().shareLink(shareLink).fileId(fileId).build(); + fileShareRepository.save(fileShare); + } + + public DownloadShareFileRes downloadShareFile(String sharedLink, String userPath) { + FileShare fileShare = fileShareRepository.findByShareLink(sharedLink) + .orElseThrow(ErrorCode.NOT_FOUND_SHARE_LINK::serviceException); + + isValid(fileShare.getCreatedAt()); + FileMetadata fileMetadata = fileDataHandlerService.findBy(fileShare.getFileId()); + + FileUtil.download(fileMetadata, userPath); + return DownloadShareFileRes.of(fileMetadata.getOriginalFileName()); + } + + private void isValid(ZonedDateTime createTimeInLink) { + ZonedDateTime now = ZonedDateTime.now(); + Duration duration = Duration.between(createTimeInLink, now); + + int maxTime = 3; + if (duration.toHours() > maxTime) { + throw ErrorCode.NOT_FRESH_LINK.serviceException(); + } + } +} diff --git a/src/main/java/com/c4cometrue/mystorage/fileshare/dto/CreateShareFileReq.java b/src/main/java/com/c4cometrue/mystorage/fileshare/dto/CreateShareFileReq.java new file mode 100644 index 0000000..3e76ead --- /dev/null +++ b/src/main/java/com/c4cometrue/mystorage/fileshare/dto/CreateShareFileReq.java @@ -0,0 +1,12 @@ +package com.c4cometrue.mystorage.fileshare.dto; + +import jakarta.validation.constraints.Positive; + +public record CreateShareFileReq( + @Positive Long fileId, + @Positive Long userId +) { + public static CreateShareFileReq of(Long fileId, Long userId) { + return new CreateShareFileReq(fileId, userId); + } +} diff --git a/src/main/java/com/c4cometrue/mystorage/fileshare/dto/CreateShareFileRes.java b/src/main/java/com/c4cometrue/mystorage/fileshare/dto/CreateShareFileRes.java new file mode 100644 index 0000000..d3c0d92 --- /dev/null +++ b/src/main/java/com/c4cometrue/mystorage/fileshare/dto/CreateShareFileRes.java @@ -0,0 +1,12 @@ +package com.c4cometrue.mystorage.fileshare.dto; + +import jakarta.validation.constraints.NotBlank; + +public record CreateShareFileRes( + @NotBlank String fileName, + @NotBlank String sharedLink +) { + public static CreateShareFileRes of(String fileName, String sharedLink) { + return new CreateShareFileRes(fileName, sharedLink); + } +} diff --git a/src/main/java/com/c4cometrue/mystorage/fileshare/dto/DownloadShareFileReq.java b/src/main/java/com/c4cometrue/mystorage/fileshare/dto/DownloadShareFileReq.java new file mode 100644 index 0000000..428b90b --- /dev/null +++ b/src/main/java/com/c4cometrue/mystorage/fileshare/dto/DownloadShareFileReq.java @@ -0,0 +1,12 @@ +package com.c4cometrue.mystorage.fileshare.dto; + +import jakarta.validation.constraints.NotBlank; + +public record DownloadShareFileReq( + @NotBlank String shareLink, + @NotBlank String userPath +) { + public static DownloadShareFileReq of(String shareLink, String userPath) { + return new DownloadShareFileReq(shareLink, userPath); + } +} diff --git a/src/main/java/com/c4cometrue/mystorage/fileshare/dto/DownloadShareFileRes.java b/src/main/java/com/c4cometrue/mystorage/fileshare/dto/DownloadShareFileRes.java new file mode 100644 index 0000000..b8dcae9 --- /dev/null +++ b/src/main/java/com/c4cometrue/mystorage/fileshare/dto/DownloadShareFileRes.java @@ -0,0 +1,11 @@ +package com.c4cometrue.mystorage.fileshare.dto; + +import jakarta.validation.constraints.NotBlank; + +public record DownloadShareFileRes( + @NotBlank String fileName +) { + public static DownloadShareFileRes of(String fileName) { + return new DownloadShareFileRes(fileName); + } +} diff --git a/src/main/java/com/c4cometrue/mystorage/util/FileUtil.java b/src/main/java/com/c4cometrue/mystorage/util/FileUtil.java index e09e8d5..db2b189 100644 --- a/src/main/java/com/c4cometrue/mystorage/util/FileUtil.java +++ b/src/main/java/com/c4cometrue/mystorage/util/FileUtil.java @@ -5,16 +5,22 @@ import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; +import org.springframework.beans.factory.annotation.Value; import org.springframework.web.multipart.MultipartFile; import com.c4cometrue.mystorage.exception.ErrorCode; +import com.c4cometrue.mystorage.file.FileMetadata; public class FileUtil { private FileUtil() { throw new AssertionError("should not be invoke"); } + @Value("${file.buffer}") + private static int bufferSize; + public static void uploadFile(MultipartFile file, Path path, int bufferSize) { try (InputStream is = file.getInputStream(); OutputStream os = Files.newOutputStream(path)) { byte[] buffer = new byte[bufferSize]; @@ -47,4 +53,10 @@ public static void delete(Path path) { throw ErrorCode.FILE_DELETE_ERROR.serviceException(); } } + + public static void download(FileMetadata fileMetadata, String userPath) { + Path originalPath = Paths.get(fileMetadata.getFilePath()); + Path userDesignatedPath = Paths.get(userPath).resolve(fileMetadata.getOriginalFileName()).normalize(); + FileUtil.download(originalPath, userDesignatedPath, bufferSize); + } } diff --git a/src/test/java/com/c4cometrue/mystorage/TestConstants.java b/src/test/java/com/c4cometrue/mystorage/TestConstants.java index edbfbbc..b667d88 100644 --- a/src/test/java/com/c4cometrue/mystorage/TestConstants.java +++ b/src/test/java/com/c4cometrue/mystorage/TestConstants.java @@ -21,6 +21,7 @@ public class TestConstants { public static final String STORED_ROOT_FOLDER_NAME = RootFolderMetadata.storedName(); public static final Long FILE_ID = 1L; public static final String USER_PATH = "C:\\Users\\g2c10\\OneDrive\\C4\\down"; + public static final String SHARE_Link = "alskdjiopqjwnioen1222lnm"; public static final String ORIGINAL_FILE_NAME = "청천"; public static final String STORED_FILE_NAME = "청천12345"; public static final String USER_FOLDER_NAME = "폴더"; diff --git a/src/test/java/com/c4cometrue/mystorage/fileshare/DeleteLinkSchedulerTest.java b/src/test/java/com/c4cometrue/mystorage/fileshare/DeleteLinkSchedulerTest.java new file mode 100644 index 0000000..50f3c1e --- /dev/null +++ b/src/test/java/com/c4cometrue/mystorage/fileshare/DeleteLinkSchedulerTest.java @@ -0,0 +1,37 @@ +package com.c4cometrue.mystorage.fileshare; + +import static org.mockito.Mockito.*; + +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@DisplayName("파일공유 링크삭제 스케줄러 테스트") +class DeleteLinkSchedulerTest { + @Mock + private FileShareRepository fileShareRepository; + + @InjectMocks + private DeleteLinkScheduler deleteLinkScheduler; + + @Test + @DisplayName("파일공유 링크 삭제 성공 테스트") + void deleteExpiredLinksSuccess() { + List expiredLinkIds = Arrays.asList(1L, 2L, 3L); + + when(fileShareRepository.deleteByExpirations(any(ZonedDateTime.class), anyInt())) + .thenReturn(expiredLinkIds); + + deleteLinkScheduler.deleteExpiredLinks(); + + verify(fileShareRepository, times(1)).deleteByExpirations(any(ZonedDateTime.class), anyInt()); + } +} diff --git a/src/test/java/com/c4cometrue/mystorage/fileshare/FileShareControllerTest.java b/src/test/java/com/c4cometrue/mystorage/fileshare/FileShareControllerTest.java new file mode 100644 index 0000000..9acec89 --- /dev/null +++ b/src/test/java/com/c4cometrue/mystorage/fileshare/FileShareControllerTest.java @@ -0,0 +1,41 @@ +package com.c4cometrue.mystorage.fileshare; + +import static com.c4cometrue.mystorage.TestConstants.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import com.c4cometrue.mystorage.fileshare.dto.CreateShareFileReq; +import com.c4cometrue.mystorage.fileshare.dto.DownloadShareFileReq; + +@DisplayName("공유파일 컨트롤러 테스트") +class FileShareControllerTest { + @InjectMocks + private FileShareController fileShareController; + @Mock + private FileShareService fileShareService; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + @DisplayName("공유링크 생성 테스트") + void createShareLinkTest() { + fileShareController.createShareFile(CreateShareFileReq.of(FILE_ID, USER_ID)); + verify(fileShareService, times(1)).createShareFileLink(FILE_ID, USER_ID); + } + + @Test + @DisplayName("공유파일 다운로드 테스트") + void downloadShareFileTest() { + fileShareController.downloadShareFile(DownloadShareFileReq.of(anyString(), anyString())); + verify(fileShareService, times(1)).downloadShareFile(anyString(), anyString()); + } +} diff --git a/src/test/java/com/c4cometrue/mystorage/fileshare/FileShareServiceTest.java b/src/test/java/com/c4cometrue/mystorage/fileshare/FileShareServiceTest.java new file mode 100644 index 0000000..03e09a2 --- /dev/null +++ b/src/test/java/com/c4cometrue/mystorage/fileshare/FileShareServiceTest.java @@ -0,0 +1,121 @@ +package com.c4cometrue.mystorage.fileshare; + +import static com.c4cometrue.mystorage.TestConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.time.ZonedDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.c4cometrue.mystorage.exception.ErrorCode; +import com.c4cometrue.mystorage.exception.ServiceException; +import com.c4cometrue.mystorage.file.FileDataHandlerService; +import com.c4cometrue.mystorage.file.FileMetadata; +import com.c4cometrue.mystorage.fileshare.dto.CreateShareFileRes; +import com.c4cometrue.mystorage.fileshare.dto.DownloadShareFileRes; +import com.c4cometrue.mystorage.util.FileUtil; + +@DisplayName("공유파일 서비스 테스트") +@ExtendWith(MockitoExtension.class) +class FileShareServiceTest { + @InjectMocks + private FileShareService fileShareService; + @Mock + private FileShareRepository fileShareRepository; + @Mock + private FileDataHandlerService fileDataHandlerService; + + @Test + @DisplayName("공유링크 생성 성공 테스트") + void createShareLink() { + when(fileDataHandlerService.findBy(FILE_ID, USER_ID)).thenReturn(FILE_METADATA); + when(fileShareRepository.existsByShareLink(anyString())).thenReturn(false); + + CreateShareFileRes res = fileShareService.createShareFileLink(FILE_ID, USER_ID); + + verify(fileDataHandlerService, times(1)).findBy(FILE_ID, USER_ID); + verify(fileShareRepository, times(1)).existsByShareLink(any()); + assertNotNull(res); + assertEquals(FILE_METADATA.getOriginalFileName(), res.fileName()); + assertNotNull(res.sharedLink()); + } + + @Test + @DisplayName("공유링크 생성 실패 테스트 : 중복된 링크 생성") + void createShareLinkFailTestNotAuthorize() { + when(fileDataHandlerService.findBy(FILE_ID, USER_ID)).thenReturn(FILE_METADATA); + when(fileShareRepository.existsByShareLink(anyString())).thenReturn(true); + + ServiceException thrown = assertThrows(ServiceException.class, + () -> fileShareService.createShareFileLink(FILE_ID, USER_ID)); + + assertEquals(ErrorCode.DUPLICATE_SHARE_LINK.name(), thrown.getErrCode()); + } + + @Test + @DisplayName("공유파일 다운로드 성공 테스트") + void downloadShareFileTest() { + FileShare fileShare = mock(FileShare.class); + FileMetadata fileMetadata = mock(FileMetadata.class); + + when(fileShare.getCreatedAt()).thenReturn(ZonedDateTime.now().minusHours(1)); + when(fileShare.getFileId()).thenReturn(FILE_ID); + when(fileMetadata.getOriginalFileName()).thenReturn(ORIGINAL_FILE_NAME); + + when(fileShareRepository.findByShareLink(anyString())).thenReturn(Optional.of(fileShare)); + when(fileDataHandlerService.findBy(anyLong())).thenReturn(fileMetadata); + + try (MockedStatic mockedStatic = Mockito.mockStatic(FileUtil.class)) { + DownloadShareFileRes result = fileShareService.downloadShareFile(SHARE_Link, USER_PATH); + + assertEquals(ORIGINAL_FILE_NAME, result.fileName()); + verify(fileShareRepository).findByShareLink(SHARE_Link); + verify(fileDataHandlerService).findBy(FILE_ID); + + mockedStatic.verify(() -> FileUtil.download(fileMetadata, USER_PATH), times(1)); + } + } + + @Test + @DisplayName("공유파일 다운로드 실패 테스트 - 링크 만료") + void downloadShareFileExpiredLinkTest() { + FileShare fileShare = mock(FileShare.class); + + ZonedDateTime expiredCreationTime = ZonedDateTime.now().minusHours(4); + + when(fileShare.getCreatedAt()).thenReturn(expiredCreationTime); + when(fileShareRepository.findByShareLink(anyString())).thenReturn(Optional.of(fileShare)); + + ServiceException thrown = assertThrows(ServiceException.class, + () -> fileShareService.downloadShareFile(SHARE_Link, USER_PATH)); + + assertEquals(ErrorCode.NOT_FRESH_LINK.name(), thrown.getErrCode()); + verify(fileShareRepository).findByShareLink(SHARE_Link); + verify(fileDataHandlerService, never()).findBy(any()); + } + + @Test + @DisplayName("공유파일 다운로드 실패 테스트 - 파일 삭제") + void downloadShareFileFailDeleteFileTest() { + FileShare fileShare = mock(FileShare.class); + when(fileShare.getCreatedAt()).thenReturn(ZonedDateTime.now().minusHours(1)); + when(fileShare.getFileId()).thenReturn(FILE_ID); + when(fileShareRepository.findByShareLink(anyString())).thenReturn(Optional.of(fileShare)); + when(fileDataHandlerService.findBy(FILE_ID)).thenThrow(ErrorCode.CANNOT_FOUND_FILE.serviceException()); + + ServiceException thrown = assertThrows(ServiceException.class, + () -> fileShareService.downloadShareFile(SHARE_Link, USER_PATH)); + + assertEquals(ErrorCode.CANNOT_FOUND_FILE.name(), thrown.getErrCode()); + } + +} diff --git a/todo/Todo.txt b/todo/Todo.txt index 36cd322..d3f5a12 100644 --- a/todo/Todo.txt +++ b/todo/Todo.txt @@ -1,50 +1 @@ -# 구현 해야할 것 -- [x] 일반 폴더 요약 -- [x] 루트 폴더 생성 로직 -- [x] 파일 업로드, 폴더 삭제 시 루트 폴더 사용 용량 변화 -- [x] 루트 폴더 요약 -# 요구 사항 - -## 폴더 요약 -폴더 요약에는 다음 정보가 담겨야 한다 -1. 폴더 명 -2. 생성 날짜 -2. 수정 날짜 (하위 폴더 과업 발생 시에도 바뀌어야 한다) - -루트 폴더의 경우에는 -1. 하위 폴더 개수 -2. 하위 파일 개수 -3. 전체 파일 용량 -4. 가용 용량 (즉, 전체 파일의 크기가 2GB를 넘어간다면, 더 이상 파일 업로드가 불가능 합니다.) - -# 내 설계 -1. 파일 경로에 루트 폴더 이름(서버 저장명)이 들어간다 -2. 루트 폴더는 하나만 생성 가능하다 -3. 사용자 로그인 로직 만들면서 루트 폴더 생성하기 - -## 작업 순위 및 설계 -1. 사용자 로그인 (루트 로직 생성 로직) -2. 기존 폴더 생성 로직에서 parentId Long -> long - -3. 일반 폴더 요약 로직 만들기 (하위 폴더에서 변경 사항 생기면 상위 폴더 타서 수정 날짜 변경) - -4. 루트 폴더 요약 로직 만들기 -4-0. 테이블 분리 `루트 테이블 생성`, 폴더 테이블이 루트 테이블을 의존한다. 즉, 루트 테이블을 위한 별도의 서비스 레이어는 생성하지 않는 다 -4-1. 필드 : 유저 id(많이 조회되는 것이니 파일과 폴더에도 놔둔다), 경로, 최대 용량 (본 프로젝트에선 2GB로 제한, 별도의 enum , 상품 테이블 ㄴㄴ), 사용 용량 -루트 폴더 저장 경로(루트 폴더 저장 경로는 UUID + zoneTime -> 이 이유는 단순 UUID 만 할 경우 4-2 4-3 로직에서 파일과 폴더 개수를 체크하는 데 예상치 못한 문제가 발생 할 수 있기 때문 (다른 사용자의 폴더, 파일 개수를 체킹) 사용 용량도 이런 문제가 발생할 수 있기에) -4-2. 하위 파일 개수 -> 서버 저장 경로 인덱스 설정 그리고 검색 (루트 폴더는 스토리지위치/루트폴더 로 되어 있고 나머지는 스토리지위치/루트폴더/파일서버저장명) -4-3. 하위 폴더 개수 -> 서버 저장 경로 인덱스 설정 그리고 검색 (루트 폴더는 스토리지위치/루트폴더 로 되어 있고 나머지는 스토리지위치/루트폴더/파일서버저장명) - - -## 번외 : 루트 테이블을 만든 이유 -모든 폴더에 폴더 용량 필드 만들면 이에 따른 수정(parent Id를 타고타고 다 수정해야하니) (업로드, 삭제 시 수정) 이러면 아찔해지죠 - -> `루트 폴더`만 폴더 가용 용량 필드 가지고 있게 만들기 (업로드, 삭제 시 수정) - -> 그리고 루트 폴더는 자주 조회되니 && 루트 폴더만 가진 필드를 위해 다른 필드는 null 이 들어갈 필요가 없으니 - - -## 일반 파일과 폴더에 루트 폴더id가 있을 필요가 있을까? -사용자는 루트 폴더 하나만 가질 수 있다. 즉, 사용자id를 통해서 루트 폴더 정보를 뽑아 낼 수 있다. -기존 파일, 폴더에는 사용자id가 있기 때문에 이를 활용해서 루트 폴더 정보를 뽑는 다. - -폴더 공유 로직에는 파일 -> 소유자id, 업로드id 로 구성해야 할 듯 \ No newline at end of file