diff --git a/build.gradle b/build.gradle index ffd8ccf..fee0b8a 100644 --- a/build.gradle +++ b/build.gradle @@ -56,6 +56,9 @@ dependencies { // 액추에이터 추가 implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // 썸네일 라이브러리 + implementation 'net.coobird:thumbnailator:0.4.20' } tasks.named('test') { diff --git a/src/main/java/kr/kro/photoliner/domain/photo/dto/response/MapMarkersResponse.java b/src/main/java/kr/kro/photoliner/domain/photo/dto/response/MapMarkersResponse.java index 35bdabf..2b1778a 100644 --- a/src/main/java/kr/kro/photoliner/domain/photo/dto/response/MapMarkersResponse.java +++ b/src/main/java/kr/kro/photoliner/domain/photo/dto/response/MapMarkersResponse.java @@ -34,8 +34,9 @@ public record InnerPhotoMarker( Long id, LocalDateTime capturedDt, String filePath, - double lat, - double lng + String thumbnailPath, + Double lat, + Double lng ) { public static InnerPhotoMarker from(Photo photo) { @@ -43,8 +44,9 @@ public static InnerPhotoMarker from(Photo photo) { photo.getId(), photo.getCapturedDt(), photo.getFilePath(), - photo.getLocation().getY(), - photo.getLocation().getX() + photo.getThumbnailPath(), + photo.getLongitude(), + photo.getLatitude() ); } } @@ -67,16 +69,20 @@ public static InnerPoiMarkers from(List photos) { public record InnerPoiMarker( Long id, LocalDateTime capturedDt, - double lat, - double lng + String filePath, + String thumbnailPath, + Double lat, + Double lng ) { public static InnerPoiMarker from(Photo photo) { return new InnerPoiMarker( photo.getId(), photo.getCapturedDt(), - photo.getLocation().getY(), - photo.getLocation().getX() + photo.getFilePath(), + photo.getThumbnailPath(), + photo.getLongitude(), + photo.getLatitude() ); } } diff --git a/src/main/java/kr/kro/photoliner/domain/photo/dto/response/PhotosResponse.java b/src/main/java/kr/kro/photoliner/domain/photo/dto/response/PhotosResponse.java index bf8ca7f..59e8009 100644 --- a/src/main/java/kr/kro/photoliner/domain/photo/dto/response/PhotosResponse.java +++ b/src/main/java/kr/kro/photoliner/domain/photo/dto/response/PhotosResponse.java @@ -45,6 +45,7 @@ public static InnerPageInfo from(Page page) { public record InnerPhotoResponse( Long id, String filePath, + String thumbnailPath, LocalDateTime capturedDt, Double lat, Double lng, @@ -52,13 +53,13 @@ public record InnerPhotoResponse( ) { public static InnerPhotoResponse from(Photo photo) { - Optional location = Optional.ofNullable(photo.getLocation()); return new InnerPhotoResponse( photo.getId(), photo.getFilePath(), + photo.getThumbnailPath(), photo.getCapturedDt(), - location.map(Point::getY).orElse(null), - location.map(Point::getX).orElse(null), + photo.getLongitude(), + photo.getLatitude(), photo.getUser().getId()); } } diff --git a/src/main/java/kr/kro/photoliner/domain/photo/infra/FileStorage.java b/src/main/java/kr/kro/photoliner/domain/photo/infra/FileStorage.java index 9238690..a41ac18 100644 --- a/src/main/java/kr/kro/photoliner/domain/photo/infra/FileStorage.java +++ b/src/main/java/kr/kro/photoliner/domain/photo/infra/FileStorage.java @@ -1,5 +1,7 @@ package kr.kro.photoliner.domain.photo.infra; +import static kr.kro.photoliner.global.code.ApiResponseCode.DIRECTORY_CREATION_FAILED; + import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; @@ -10,6 +12,7 @@ import java.util.UUID; import kr.kro.photoliner.global.code.ApiResponseCode; import kr.kro.photoliner.global.exception.CustomException; +import net.coobird.thumbnailator.Thumbnails; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; @@ -17,24 +20,71 @@ @Component public class FileStorage { - private final Path uploadLocation; + private final Path originalLocation; + private final Path thumbnailLocation; + + private static final String ORIGINAL_DIR = "original"; + private static final String THUMBNAIL_DIR = "thumbnail"; + private static final String BASE_IMAGES_DIR = "/images"; + + private static final int THUMBNAIL_WIDTH = 300; + private static final int THUMBNAIL_HEIGHT = 300; public FileStorage(@Value("${photo.upload.base-dir}") String baseDir) { - this.uploadLocation = Paths.get(baseDir) - .toAbsolutePath() - .normalize(); + Path rootLocation = Paths.get(baseDir).toAbsolutePath().normalize(); + this.originalLocation = rootLocation.resolve(ORIGINAL_DIR); + this.thumbnailLocation = rootLocation.resolve(THUMBNAIL_DIR); + try { - Files.createDirectories(this.uploadLocation); + Files.createDirectories(this.originalLocation); + Files.createDirectories(this.thumbnailLocation); } catch (IOException e) { - throw new IllegalArgumentException("Failed to create upload directory", e); + throw CustomException.of(DIRECTORY_CREATION_FAILED); } } public String store(MultipartFile file) { validateFile(file); String fileName = generateFileName(file); - saveFile(file, fileName); - return fileName; + saveFile(file, this.originalLocation, fileName); + return BASE_IMAGES_DIR + "/" + ORIGINAL_DIR + "/" + fileName; + } + + public String storeThumbnail(String originalRelativePath) { + String fileName = Paths.get(originalRelativePath).getFileName().toString(); + Path sourceLocation = this.originalLocation.resolve(fileName); + Path targetLocation = this.thumbnailLocation.resolve(fileName); + + try { + Thumbnails.of(sourceLocation.toFile()) + .size(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT) + .toFile(targetLocation.toFile()); + return BASE_IMAGES_DIR + "/" + THUMBNAIL_DIR + "/" + fileName; + } catch (IOException e) { + throw CustomException.of(ApiResponseCode.FILE_CREATION_FAILED); + } + } + + public void deleteOriginalImage(String originalPath) { + String fileName = Paths.get(originalPath).getFileName().toString(); + Path sourceLocation = this.originalLocation.resolve(fileName); + + try { + Files.delete(sourceLocation); + } catch (IOException e) { + throw CustomException.of(ApiResponseCode.FILE_DELETE_FAILED); + } + } + + public void deleteThumbnailImage(String thumbnailPath) { + String fileName = Paths.get(thumbnailPath).getFileName().toString(); + Path sourceLocation = this.thumbnailLocation.resolve(fileName); + + try { + Files.delete(sourceLocation); + } catch (IOException e) { + throw CustomException.of(ApiResponseCode.FILE_DELETE_FAILED); + } } private void validateFile(MultipartFile file) { @@ -53,9 +103,9 @@ private String generateFileName(MultipartFile file) { return UUID.randomUUID() + "." + extension; } - private void saveFile(MultipartFile file, String fileName) { + private void saveFile(MultipartFile file, Path directory, String fileName) { try { - Path targetLocation = uploadLocation.resolve(fileName); + Path targetLocation = directory.resolve(fileName); try (InputStream inputStream = file.getInputStream()) { Files.copy(inputStream, targetLocation, StandardCopyOption.REPLACE_EXISTING); } diff --git a/src/main/java/kr/kro/photoliner/domain/photo/model/Photo.java b/src/main/java/kr/kro/photoliner/domain/photo/model/Photo.java index ba876ca..8c9cc47 100644 --- a/src/main/java/kr/kro/photoliner/domain/photo/model/Photo.java +++ b/src/main/java/kr/kro/photoliner/domain/photo/model/Photo.java @@ -12,6 +12,7 @@ import jakarta.validation.constraints.NotNull; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.Objects; import java.util.Optional; import kr.kro.photoliner.common.model.BaseEntity; import kr.kro.photoliner.domain.user.model.User; @@ -42,6 +43,10 @@ public class Photo extends BaseEntity { @Column(name = "file_path", nullable = false) private String filePath; + @NotNull + @Column(name = "thumbnail_path", nullable = false) + private String thumbnailPath; + @Column(name = "captured_dt") private LocalDateTime capturedDt; @@ -66,4 +71,18 @@ public void updateCapturedDate(LocalDateTime capturedDt) { public void updateLocation(Point location) { this.location = location; } + + public Double getLatitude() { + if (Objects.isNull(location)) { + return null; + } + return location.getX(); + } + + public Double getLongitude() { + if (Objects.isNull(location)) { + return null; + } + return location.getY(); + } } diff --git a/src/main/java/kr/kro/photoliner/domain/photo/service/PhotoService.java b/src/main/java/kr/kro/photoliner/domain/photo/service/PhotoService.java index 6c623c8..0867d17 100644 --- a/src/main/java/kr/kro/photoliner/domain/photo/service/PhotoService.java +++ b/src/main/java/kr/kro/photoliner/domain/photo/service/PhotoService.java @@ -1,11 +1,13 @@ package kr.kro.photoliner.domain.photo.service; +import java.util.List; import kr.kro.photoliner.domain.photo.dto.DeletePhotosRequest; import kr.kro.photoliner.domain.photo.dto.request.MapMarkersRequest; import kr.kro.photoliner.domain.photo.dto.request.PhotoCapturedDateUpdateRequest; import kr.kro.photoliner.domain.photo.dto.request.PhotoLocationUpdateRequest; import kr.kro.photoliner.domain.photo.dto.response.MapMarkersResponse; import kr.kro.photoliner.domain.photo.dto.response.PhotosResponse; +import kr.kro.photoliner.domain.photo.infra.FileStorage; import kr.kro.photoliner.domain.photo.model.AlbumPhotos; import kr.kro.photoliner.domain.photo.model.Photo; import kr.kro.photoliner.domain.photo.model.Photos; @@ -28,6 +30,7 @@ public class PhotoService { private final AlbumPhotoRepository albumPhotoRepository; private final PhotoRepository photoRepository; private final GeometryFactory geometryFactory; + private final FileStorage fileStorage; @Transactional(readOnly = true) public PhotosResponse getPhotos(Long userId, Pageable pageable) { @@ -72,6 +75,9 @@ public void updatePhotoLocation(Long photoId, PhotoLocationUpdateRequest request @Transactional public void deletePhotos(DeletePhotosRequest request) { + List photos = photoRepository.findAllById(request.ids()); + photos.forEach(photo -> fileStorage.deleteOriginalImage(photo.getFilePath())); + photos.forEach(photo -> fileStorage.deleteThumbnailImage(photo.getFilePath())); photoRepository.deleteAllByIdInBatch(request.ids()); } } diff --git a/src/main/java/kr/kro/photoliner/domain/photo/service/PhotoUploadService.java b/src/main/java/kr/kro/photoliner/domain/photo/service/PhotoUploadService.java index 1314251..c83606d 100644 --- a/src/main/java/kr/kro/photoliner/domain/photo/service/PhotoUploadService.java +++ b/src/main/java/kr/kro/photoliner/domain/photo/service/PhotoUploadService.java @@ -28,15 +28,15 @@ public class PhotoUploadService { @Transactional public PhotoUploadResponse uploadPhotos(Long userId, List files) { + User user = userRepository.findUserById(userId).orElseThrow( + () -> CustomException.of(ApiResponseCode.NOT_FOUND_USER, "user id: " + userId)); List uploadedPhotos = files.stream() .map(file -> { ExifData exifData = exifExtractor.extract(file); String filePath = fileStorage.store(file); + String thumbnailPath = fileStorage.storeThumbnail(filePath); String fileName = file.getOriginalFilename(); - User user = userRepository.findUserById(userId) - .orElseThrow( - () -> CustomException.of(ApiResponseCode.NOT_FOUND_USER, "user id: " + userId)); - Photo photo = createPhoto(user, exifData, filePath, fileName); + Photo photo = createPhoto(user, exifData, fileName, filePath, thumbnailPath); Photo savedPhoto = photoRepository.save(photo); return InnerUploadedPhotoInfo.from(savedPhoto); }) @@ -44,10 +44,11 @@ public PhotoUploadResponse uploadPhotos(Long userId, List files) return PhotoUploadResponse.from(uploadedPhotos); } - private Photo createPhoto(User user, ExifData exifData, String fileName, String filePath) { + private Photo createPhoto(User user, ExifData exifData, String fileName, String filePath, String thumbnailPath) { return Photo.builder() .fileName(fileName) .filePath(filePath) + .thumbnailPath(thumbnailPath) .capturedDt(exifData.capturedDt()) .location(exifData.location()) .user(user) diff --git a/src/main/java/kr/kro/photoliner/global/code/ApiResponseCode.java b/src/main/java/kr/kro/photoliner/global/code/ApiResponseCode.java index 40d1653..71903c9 100644 --- a/src/main/java/kr/kro/photoliner/global/code/ApiResponseCode.java +++ b/src/main/java/kr/kro/photoliner/global/code/ApiResponseCode.java @@ -59,7 +59,11 @@ public enum ApiResponseCode { */ INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버에서 오류가 발생했습니다."), FILE_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "파일 처리 중 오류가 발생했습니다."), - FILE_STORE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "파일 저장 중 오류가 발생했습니다."); + FILE_STORE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "파일 저장 중 오류가 발생했습니다."), + FILE_CREATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 생성 중 오류가 발생했습니다."), + DIRECTORY_CREATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "폴더 생성 중 오류가 발생했습니다."), + FILE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 삭제 중 오류가 발생했습니다.") + ; private final HttpStatus httpStatus; private final String message; diff --git a/src/main/resources/db/migration/V5__alter_photo_table.sql b/src/main/resources/db/migration/V5__alter_photo_table.sql new file mode 100644 index 0000000..552aa32 --- /dev/null +++ b/src/main/resources/db/migration/V5__alter_photo_table.sql @@ -0,0 +1,2 @@ +alter table photos + add column thumbnail_path text not null