From ee4d8d7ab1e16cc641601a7ce078ef0c4b258b1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=9D=B8=ED=99=94?= Date: Thu, 20 Nov 2025 11:03:47 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +++ 1 file changed, 3 insertions(+) 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') { From afc27c70ae4f550393c7666543162befdaf1ae23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=9D=B8=ED=99=94?= Date: Thu, 20 Nov 2025 11:04:21 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=86=8D=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/db/migration/V5__alter_photo_table.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 src/main/resources/db/migration/V5__alter_photo_table.sql 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 From c0ea7b3dc5d96e454607baab753cb56b7c2b7657 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=9D=B8=ED=99=94?= Date: Thu, 20 Nov 2025 11:04:44 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/photo/infra/FileStorage.java | 48 +++++++++++++++---- .../photoliner/domain/photo/model/Photo.java | 4 ++ .../photo/service/PhotoUploadService.java | 11 +++-- .../global/code/ApiResponseCode.java | 3 +- 4 files changed, 50 insertions(+), 16 deletions(-) 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..082cf20 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,49 @@ @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_STORE_ERROR, "썸네일 이미지 생성 실패", e); + } } private void validateFile(MultipartFile file) { @@ -53,9 +81,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..0ea20c2 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 @@ -42,6 +42,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; 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..f32d600 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,8 @@ 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, "파일 저장 중 오류가 발생했습니다."), + DIRECTORY_CREATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "폴더 생성 중 오류가 발생했습니다."); private final HttpStatus httpStatus; private final String message; From c1045db3ea468e65ca43229540e82e65d88a12e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=9D=B8=ED=99=94?= Date: Thu, 20 Nov 2025 11:35:26 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=9D=91=EB=8B=B5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/photo/dto/response/MapMarkersResponse.java | 6 ++++++ .../domain/photo/dto/response/PhotosResponse.java | 2 ++ 2 files changed, 8 insertions(+) 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..4fbc237 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,6 +34,7 @@ public record InnerPhotoMarker( Long id, LocalDateTime capturedDt, String filePath, + String thumbnailPath, double lat, double lng ) { @@ -43,6 +44,7 @@ public static InnerPhotoMarker from(Photo photo) { photo.getId(), photo.getCapturedDt(), photo.getFilePath(), + photo.getThumbnailPath(), photo.getLocation().getY(), photo.getLocation().getX() ); @@ -67,6 +69,8 @@ public static InnerPoiMarkers from(List photos) { public record InnerPoiMarker( Long id, LocalDateTime capturedDt, + String filePath, + String thumbnailPath, double lat, double lng ) { @@ -75,6 +79,8 @@ public static InnerPoiMarker from(Photo photo) { return new InnerPoiMarker( photo.getId(), photo.getCapturedDt(), + photo.getFilePath(), + photo.getThumbnailPath(), photo.getLocation().getY(), photo.getLocation().getX() ); 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..4f258cd 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, @@ -56,6 +57,7 @@ public static InnerPhotoResponse from(Photo photo) { return new InnerPhotoResponse( photo.getId(), photo.getFilePath(), + photo.getThumbnailPath(), photo.getCapturedDt(), location.map(Point::getY).orElse(null), location.map(Point::getX).orElse(null), From 08f6e9fd9373596f954ba344d1028f608c4526d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=9D=B8=ED=99=94?= Date: Thu, 20 Nov 2025 11:38:33 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20NPE=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../photo/dto/response/MapMarkersResponse.java | 16 ++++++++-------- .../photo/dto/response/PhotosResponse.java | 5 ++--- .../kro/photoliner/domain/photo/model/Photo.java | 15 +++++++++++++++ 3 files changed, 25 insertions(+), 11 deletions(-) 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 4fbc237..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 @@ -35,8 +35,8 @@ public record InnerPhotoMarker( LocalDateTime capturedDt, String filePath, String thumbnailPath, - double lat, - double lng + Double lat, + Double lng ) { public static InnerPhotoMarker from(Photo photo) { @@ -45,8 +45,8 @@ public static InnerPhotoMarker from(Photo photo) { photo.getCapturedDt(), photo.getFilePath(), photo.getThumbnailPath(), - photo.getLocation().getY(), - photo.getLocation().getX() + photo.getLongitude(), + photo.getLatitude() ); } } @@ -71,8 +71,8 @@ public record InnerPoiMarker( LocalDateTime capturedDt, String filePath, String thumbnailPath, - double lat, - double lng + Double lat, + Double lng ) { public static InnerPoiMarker from(Photo photo) { @@ -81,8 +81,8 @@ public static InnerPoiMarker from(Photo photo) { photo.getCapturedDt(), photo.getFilePath(), photo.getThumbnailPath(), - photo.getLocation().getY(), - photo.getLocation().getX() + 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 4f258cd..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 @@ -53,14 +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/model/Photo.java b/src/main/java/kr/kro/photoliner/domain/photo/model/Photo.java index 0ea20c2..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; @@ -70,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(); + } } From b93542c1ba5e7d2448f39341e89b77597678e537 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=9D=B8=ED=99=94?= Date: Thu, 20 Nov 2025 11:51:45 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20=EC=82=AC=EC=A7=84=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EC=8B=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=ED=95=A8=EA=BB=98=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/photo/infra/FileStorage.java | 24 ++++++++++++++++++- .../domain/photo/service/PhotoService.java | 6 +++++ .../global/code/ApiResponseCode.java | 5 +++- 3 files changed, 33 insertions(+), 2 deletions(-) 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 082cf20..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 @@ -61,7 +61,29 @@ public String storeThumbnail(String originalRelativePath) { .toFile(targetLocation.toFile()); return BASE_IMAGES_DIR + "/" + THUMBNAIL_DIR + "/" + fileName; } catch (IOException e) { - throw CustomException.of(ApiResponseCode.FILE_STORE_ERROR, "썸네일 이미지 생성 실패", 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); } } 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/global/code/ApiResponseCode.java b/src/main/java/kr/kro/photoliner/global/code/ApiResponseCode.java index f32d600..71903c9 100644 --- a/src/main/java/kr/kro/photoliner/global/code/ApiResponseCode.java +++ b/src/main/java/kr/kro/photoliner/global/code/ApiResponseCode.java @@ -60,7 +60,10 @@ public enum ApiResponseCode { INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버에서 오류가 발생했습니다."), FILE_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "파일 처리 중 오류가 발생했습니다."), FILE_STORE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "파일 저장 중 오류가 발생했습니다."), - DIRECTORY_CREATION_FAILED(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;