diff --git a/build.gradle b/build.gradle index 7d21ce8..cac3648 100644 --- a/build.gradle +++ b/build.gradle @@ -21,6 +21,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-test' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.flywaydb:flyway-core' @@ -59,6 +60,8 @@ dependencies { // 썸네일 라이브러리 implementation 'net.coobird:thumbnailator:0.4.20' + + implementation 'software.amazon.awssdk:s3' } tasks.named('test') { diff --git a/src/main/java/kr/kro/photoliner/domain/photo/controller/PhotoController.java b/src/main/java/kr/kro/photoliner/domain/photo/controller/PhotoController.java index 14fb26f..8727e31 100644 --- a/src/main/java/kr/kro/photoliner/domain/photo/controller/PhotoController.java +++ b/src/main/java/kr/kro/photoliner/domain/photo/controller/PhotoController.java @@ -2,21 +2,22 @@ import jakarta.validation.Valid; import java.util.List; +import kr.kro.photoliner.domain.photo.dto.request.CreatePhotosRequest; import kr.kro.photoliner.domain.photo.dto.request.DeletePhotosRequest; 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.request.PhotoMarkersRequest; +import kr.kro.photoliner.domain.photo.dto.request.PresignedUrlRequest; import kr.kro.photoliner.domain.photo.dto.response.PhotoMarkersResponse; -import kr.kro.photoliner.domain.photo.dto.response.PhotoUploadResponse; import kr.kro.photoliner.domain.photo.dto.response.PhotosResponse; +import kr.kro.photoliner.domain.photo.dto.response.PresignedUrlResponse; +import kr.kro.photoliner.domain.photo.infra.S3CustomClient; import kr.kro.photoliner.domain.photo.service.PhotoService; -import kr.kro.photoliner.domain.photo.service.PhotoUploadService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -26,9 +27,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; @RestController @RequiredArgsConstructor @@ -36,12 +35,12 @@ public class PhotoController { private final PhotoService photoService; - private final PhotoUploadService photoUploadService; + private final S3CustomClient s3CustomClient; @GetMapping public ResponseEntity getPhotos( @RequestParam Long userId, - @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + @PageableDefault(sort = "capturedDt", direction = Sort.Direction.DESC) Pageable pageable ) { return ResponseEntity.ok(photoService.getPhotosByIds(userId, pageable)); } @@ -51,13 +50,20 @@ public ResponseEntity getPhotoMarkers(@Valid PhotoMarkersR return ResponseEntity.ok(photoService.getPhotoMarkers(request)); } - @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity uploadPhotos( - @RequestParam("userId") Long userId, - @RequestPart("files") List files + @PostMapping("/presigned-urls") + public ResponseEntity> getPresignedUrls( + @Valid @RequestBody List requests ) { - PhotoUploadResponse response = photoUploadService.uploadPhotos(userId, files); - return ResponseEntity.status(HttpStatus.CREATED).body(response); + List responses = s3CustomClient.generatePresignedUrls(requests); + return ResponseEntity.ok(responses); + } + + @PostMapping + public ResponseEntity createPhotos( + @Valid @RequestBody CreatePhotosRequest request + ) { + photoService.createPhotos(request); + return ResponseEntity.status(HttpStatus.CREATED).build(); } @PatchMapping("/{photoId}/captured-date") diff --git a/src/main/java/kr/kro/photoliner/domain/photo/dto/ExifData.java b/src/main/java/kr/kro/photoliner/domain/photo/dto/ExifData.java deleted file mode 100644 index e778361..0000000 --- a/src/main/java/kr/kro/photoliner/domain/photo/dto/ExifData.java +++ /dev/null @@ -1,11 +0,0 @@ -package kr.kro.photoliner.domain.photo.dto; - -import java.time.LocalDateTime; -import org.locationtech.jts.geom.Point; - -public record ExifData( - LocalDateTime capturedDt, - Point location -) { - -} diff --git a/src/main/java/kr/kro/photoliner/domain/photo/dto/request/CreatePhotosRequest.java b/src/main/java/kr/kro/photoliner/domain/photo/dto/request/CreatePhotosRequest.java new file mode 100644 index 0000000..06e3a43 --- /dev/null +++ b/src/main/java/kr/kro/photoliner/domain/photo/dto/request/CreatePhotosRequest.java @@ -0,0 +1,50 @@ +package kr.kro.photoliner.domain.photo.dto.request; + +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; +import org.locationtech.jts.geom.Coordinate; + +public record CreatePhotosRequest( + @NotNull + Long userId, + + @NotNull + @NotEmpty + List photos +) { + + public record InnerPhoto( + @NotNull + String fileName, + + @NotNull + String uploadFileName, + + @Nullable + LocalDateTime capturedDate, + + @Nullable + @Min(0) + @Max(90) + Double latitude, + + @Nullable + @Min(0) + @Max(180) + Double longitude + ) { + + public Coordinate convertToGeo() { + if (Objects.isNull(latitude) || Objects.isNull(longitude)) { + return null; + } + return new Coordinate(longitude, latitude); + } + } +} diff --git a/src/main/java/kr/kro/photoliner/domain/photo/dto/request/PresignedUrlRequest.java b/src/main/java/kr/kro/photoliner/domain/photo/dto/request/PresignedUrlRequest.java new file mode 100644 index 0000000..036e695 --- /dev/null +++ b/src/main/java/kr/kro/photoliner/domain/photo/dto/request/PresignedUrlRequest.java @@ -0,0 +1,12 @@ +package kr.kro.photoliner.domain.photo.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record PresignedUrlRequest( + @NotBlank(message = "파일명은 필수입니다") + String originalFileName, + + @NotBlank(message = "Content-Type은 필수입니다") + String contentType +) { +} \ No newline at end of file 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 59e8009..650e8ff 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 @@ -2,9 +2,7 @@ import java.time.LocalDateTime; import java.util.List; -import java.util.Optional; import kr.kro.photoliner.domain.photo.model.Photo; -import org.locationtech.jts.geom.Point; import org.springframework.data.domain.Page; public record PhotosResponse( @@ -60,7 +58,7 @@ public static InnerPhotoResponse from(Photo photo) { photo.getCapturedDt(), photo.getLongitude(), photo.getLatitude(), - photo.getUser().getId()); + photo.getId()); } } } diff --git a/src/main/java/kr/kro/photoliner/domain/photo/dto/response/PresignedUrlResponse.java b/src/main/java/kr/kro/photoliner/domain/photo/dto/response/PresignedUrlResponse.java new file mode 100644 index 0000000..b2f893f --- /dev/null +++ b/src/main/java/kr/kro/photoliner/domain/photo/dto/response/PresignedUrlResponse.java @@ -0,0 +1,11 @@ +package kr.kro.photoliner.domain.photo.dto.response; + +public record PresignedUrlResponse( + String presignedUrl, + String uploadFileName +) { + + public static PresignedUrlResponse of(String presignedUrl, String uploadFileName) { + return new PresignedUrlResponse(presignedUrl, uploadFileName); + } +} diff --git a/src/main/java/kr/kro/photoliner/domain/photo/infra/ExifExtractor.java b/src/main/java/kr/kro/photoliner/domain/photo/infra/ExifExtractor.java deleted file mode 100644 index ea3a005..0000000 --- a/src/main/java/kr/kro/photoliner/domain/photo/infra/ExifExtractor.java +++ /dev/null @@ -1,83 +0,0 @@ -package kr.kro.photoliner.domain.photo.infra; - -import com.drew.imaging.ImageMetadataReader; -import com.drew.imaging.ImageProcessingException; -import com.drew.lang.GeoLocation; -import com.drew.metadata.Metadata; -import com.drew.metadata.exif.ExifSubIFDDirectory; -import com.drew.metadata.exif.GpsDirectory; -import java.io.IOException; -import java.io.InputStream; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.Date; -import java.util.Optional; -import kr.kro.photoliner.domain.photo.dto.ExifData; -import kr.kro.photoliner.global.code.ApiResponseCode; -import kr.kro.photoliner.global.exception.CustomException; -import lombok.RequiredArgsConstructor; -import org.locationtech.jts.geom.Coordinate; -import org.locationtech.jts.geom.GeometryFactory; -import org.locationtech.jts.geom.Point; -import org.springframework.stereotype.Component; -import org.springframework.web.multipart.MultipartFile; - -@Component -@RequiredArgsConstructor -public class ExifExtractor { - - private final GeometryFactory geometryFactory; - - public ExifData extract(MultipartFile file) { - try (InputStream inputStream = file.getInputStream()) { - Metadata metadata = ImageMetadataReader.readMetadata(inputStream); - LocalDateTime capturedDt = extractCapturedDateTime(metadata); - Point location = extractGpsLocation(metadata); - return new ExifData(capturedDt, location); - } catch (ImageProcessingException | IOException e) { - throw CustomException.of(ApiResponseCode.FILE_PROCESSING_ERROR, "file title: " + file.getOriginalFilename(), - e); - } - } - - private LocalDateTime extractCapturedDateTime(Metadata metadata) { - return Optional.ofNullable(metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class)) - .map(this::extractCapturedDate) - .map(this::normalize) - .orElse(null); - } - - private Point extractGpsLocation(Metadata metadata) { - return Optional.ofNullable(metadata) - .map(m -> m.getFirstDirectoryOfType(GpsDirectory.class)) - .map(GpsDirectory::getGeoLocation) - .filter(this::filterInvalidLocation) - .map(this::toPoint) - .orElse(null); - } - - private LocalDateTime normalize(Date date) { - return date.toInstant() - .atZone(ZoneId.systemDefault()) - .toLocalDateTime(); - } - - private Date extractCapturedDate(ExifSubIFDDirectory dir) { - return Optional.ofNullable(dir.getDate(ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL)) - .orElseGet(() -> dir.getDate(ExifSubIFDDirectory.TAG_DATETIME)); - } - - private boolean filterInvalidLocation(GeoLocation loc) { - double lat = loc.getLatitude(); - double lon = loc.getLongitude(); - return Double.isFinite(lat) && Double.isFinite(lon) && isValidCoordinate(lat, lon); - } - - private Point toPoint(GeoLocation loc) { - return geometryFactory.createPoint(new Coordinate(loc.getLongitude(), loc.getLatitude())); - } - - private boolean isValidCoordinate(double latitude, double longitude) { - return latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180; - } -} 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 deleted file mode 100644 index 4a6b346..0000000 --- a/src/main/java/kr/kro/photoliner/domain/photo/infra/FileStorage.java +++ /dev/null @@ -1,124 +0,0 @@ -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; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; -import java.util.Objects; -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; - -@Component -public class FileStorage { - - 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) { - Path rootLocation = Paths.get(baseDir).toAbsolutePath().normalize(); - this.originalLocation = rootLocation.resolve(ORIGINAL_DIR); - this.thumbnailLocation = rootLocation.resolve(THUMBNAIL_DIR); - - try { - Files.createDirectories(this.originalLocation); - Files.createDirectories(this.thumbnailLocation); - } catch (IOException e) { - throw CustomException.of(DIRECTORY_CREATION_FAILED); - } - } - - public String store(MultipartFile file) { - validateFile(file); - String fileName = generateFileName(file); - 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) { - if (file.isEmpty()) { - throw CustomException.of(ApiResponseCode.INVALID_FILE); - } - - String originalFilename = file.getOriginalFilename(); - if (originalFilename == null || originalFilename.contains("..")) { - throw CustomException.of(ApiResponseCode.INVALID_FILE_NAME, "file title: " + originalFilename); - } - } - - private String generateFileName(MultipartFile file) { - String extension = getExtension(Objects.requireNonNull(file.getOriginalFilename())); - return UUID.randomUUID() + "." + extension; - } - - private void saveFile(MultipartFile file, Path directory, String fileName) { - try { - Path targetLocation = directory.resolve(fileName); - try (InputStream inputStream = file.getInputStream()) { - Files.copy(inputStream, targetLocation, StandardCopyOption.REPLACE_EXISTING); - } - } catch (IOException e) { - throw CustomException.of(ApiResponseCode.FILE_STORE_ERROR, "file title: " + file.getOriginalFilename(), e); - } - } - - private String getExtension(String filename) { - int lastDotIndex = filename.lastIndexOf('.'); - if (lastDotIndex == -1) { - return ""; - } - return filename.substring(lastDotIndex + 1).toLowerCase(); - } -} diff --git a/src/main/java/kr/kro/photoliner/domain/photo/infra/S3CustomClient.java b/src/main/java/kr/kro/photoliner/domain/photo/infra/S3CustomClient.java new file mode 100644 index 0000000..131d2f8 --- /dev/null +++ b/src/main/java/kr/kro/photoliner/domain/photo/infra/S3CustomClient.java @@ -0,0 +1,79 @@ +package kr.kro.photoliner.domain.photo.infra; + +import java.time.Duration; +import java.util.List; +import java.util.UUID; +import kr.kro.photoliner.domain.photo.dto.request.PresignedUrlRequest; +import kr.kro.photoliner.domain.photo.dto.response.PresignedUrlResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +@Component +@RequiredArgsConstructor +public class S3CustomClient { + + private final S3Presigner s3Presigner; + private final S3Client s3Client; + + @Value("${cloud.aws.s3.bucket}") + private String bucketName; + + private static final Duration PRESIGNED_URL_EXPIRATION = Duration.ofMinutes(10); + private static final String BASE_URL = "images/original/"; + + public List generatePresignedUrls(List requests) { + return requests.stream() + .map(this::generatePresignedUrl) + .toList(); + } + + private PresignedUrlResponse generatePresignedUrl(PresignedUrlRequest request) { + String objectKey = generateObjectKey(request.originalFileName()); + + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(BASE_URL + objectKey) + .contentType(request.contentType()) + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(PRESIGNED_URL_EXPIRATION) + .putObjectRequest(putObjectRequest) + .build(); + + PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); + String presignedUrl = presignedRequest.url().toString(); + + return PresignedUrlResponse.of(presignedUrl, objectKey); + } + + private String generateObjectKey(String originalFileName) { + String extension = extractExtension(originalFileName); + String uuid = UUID.randomUUID().toString(); + return uuid + extension; + } + + private String extractExtension(String fileName) { + int lastDotIndex = fileName.lastIndexOf("."); + if (lastDotIndex == -1) { + return ""; + } + return fileName.substring(lastDotIndex); + } + + public void delete(String objectKey) { + DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder() + .bucket(bucketName) + .key(objectKey) + .build(); + + s3Client.deleteObject(deleteRequest); + } +} 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 a7ba947..4eeebb3 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 @@ -2,18 +2,14 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import jakarta.validation.constraints.NotNull; import java.time.LocalDateTime; import java.util.Objects; import kr.kro.photoliner.common.model.BaseEntity; -import kr.kro.photoliner.domain.user.model.User; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; @@ -51,9 +47,8 @@ public class Photo extends BaseEntity { @Column(name = "location") private Point location; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "user_id", referencedColumnName = "id") - private User user; + @Column(name = "user_id") + private Long userId; public void updateCapturedDate(LocalDateTime capturedDt) { this.capturedDt = capturedDt; diff --git a/src/main/java/kr/kro/photoliner/domain/photo/repository/PhotoRepository.java b/src/main/java/kr/kro/photoliner/domain/photo/repository/PhotoRepository.java index 0d0c62c..7b82616 100644 --- a/src/main/java/kr/kro/photoliner/domain/photo/repository/PhotoRepository.java +++ b/src/main/java/kr/kro/photoliner/domain/photo/repository/PhotoRepository.java @@ -21,7 +21,7 @@ Page findByUserId( select p from Photo p left outer join PhotoItem pi on p.id = pi.photoId - where p.user.id = :userId + where p.userId = :userId and function('st_x', p.location) between function('st_x', :sw) and function('st_x', :ne) and function('st_y', p.location) between function('st_y', :sw) and function('st_y', :ne) order by p.capturedDt 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 380951c..9414be8 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,14 +1,14 @@ package kr.kro.photoliner.domain.photo.service; import java.util.List; -import kr.kro.photoliner.domain.album.repository.AlbumPhotoRepository; +import kr.kro.photoliner.domain.photo.dto.request.CreatePhotosRequest; import kr.kro.photoliner.domain.photo.dto.request.DeletePhotosRequest; 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.request.PhotoMarkersRequest; import kr.kro.photoliner.domain.photo.dto.response.PhotoMarkersResponse; import kr.kro.photoliner.domain.photo.dto.response.PhotosResponse; -import kr.kro.photoliner.domain.photo.infra.FileStorage; +import kr.kro.photoliner.domain.photo.infra.S3CustomClient; import kr.kro.photoliner.domain.photo.model.Photo; import kr.kro.photoliner.domain.photo.model.Photos; import kr.kro.photoliner.domain.photo.repository.PhotoRepository; @@ -18,6 +18,7 @@ import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.Point; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,9 +28,14 @@ public class PhotoService { private final PhotoRepository photoRepository; - private final AlbumPhotoRepository albumPhotoRepository; private final GeometryFactory geometryFactory; - private final FileStorage fileStorage; + private final S3CustomClient s3CustomClient; + + @Value("${cloud.aws.cdn.base-url}") + private String cdnURL; + + private static final String ORIGINAL_BASE_PATH = "/images/original/"; + private static final String THUMBNAIL_BASE_PATH = "/images/thumb/"; @Transactional(readOnly = true) public PhotosResponse getPhotosByIds(Long userId, Pageable pageable) { @@ -46,6 +52,21 @@ public PhotoMarkersResponse getPhotoMarkers(PhotoMarkersRequest request) { return PhotoMarkersResponse.from(photos); } + @Transactional + public void createPhotos(CreatePhotosRequest request) { + List photos = request.photos().stream() + .map(photo -> Photo.builder() + .userId(request.userId()) + .fileName(photo.fileName()) + .filePath(cdnURL + ORIGINAL_BASE_PATH + photo.uploadFileName()) + .thumbnailPath(cdnURL + THUMBNAIL_BASE_PATH + photo.uploadFileName()) + .capturedDt(photo.capturedDate()) + .location(getPointOrNull(photo.convertToGeo())) + .build() + ).toList(); + photoRepository.saveAll(photos); + } + @Transactional public void updatePhotoCapturedDate(Long photoId, PhotoCapturedDateUpdateRequest request) { Photo photo = photoRepository.findById(photoId) @@ -66,8 +87,15 @@ 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())); + photos.forEach(photo -> s3CustomClient.delete(photo.getFilePath())); + photos.forEach(photo -> s3CustomClient.delete(photo.getThumbnailPath())); photoRepository.deleteAllByIdInBatch(request.ids()); } + + private Point getPointOrNull(Coordinate coordinate) { + if (coordinate == null) { + return null; + } + return geometryFactory.createPoint(coordinate); + } } 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 deleted file mode 100644 index c83606d..0000000 --- a/src/main/java/kr/kro/photoliner/domain/photo/service/PhotoUploadService.java +++ /dev/null @@ -1,57 +0,0 @@ -package kr.kro.photoliner.domain.photo.service; - -import java.util.List; -import kr.kro.photoliner.domain.photo.dto.ExifData; -import kr.kro.photoliner.domain.photo.dto.response.PhotoUploadResponse; -import kr.kro.photoliner.domain.photo.dto.response.PhotoUploadResponse.InnerUploadedPhotoInfo; -import kr.kro.photoliner.domain.photo.infra.ExifExtractor; -import kr.kro.photoliner.domain.photo.infra.FileStorage; -import kr.kro.photoliner.domain.photo.model.Photo; -import kr.kro.photoliner.domain.photo.repository.PhotoRepository; -import kr.kro.photoliner.domain.user.model.User; -import kr.kro.photoliner.domain.user.repository.UserRepository; -import kr.kro.photoliner.global.code.ApiResponseCode; -import kr.kro.photoliner.global.exception.CustomException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; - -@Service -@RequiredArgsConstructor -public class PhotoUploadService { - - private final FileStorage fileStorage; - private final ExifExtractor exifExtractor; - private final PhotoRepository photoRepository; - private final UserRepository userRepository; - - @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(); - Photo photo = createPhoto(user, exifData, fileName, filePath, thumbnailPath); - Photo savedPhoto = photoRepository.save(photo); - return InnerUploadedPhotoInfo.from(savedPhoto); - }) - .toList(); - return PhotoUploadResponse.from(uploadedPhotos); - } - - 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) - .build(); - } -} diff --git a/src/main/java/kr/kro/photoliner/global/config/S3Config.java b/src/main/java/kr/kro/photoliner/global/config/S3Config.java new file mode 100644 index 0000000..22d5c46 --- /dev/null +++ b/src/main/java/kr/kro/photoliner/global/config/S3Config.java @@ -0,0 +1,32 @@ +package kr.kro.photoliner.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +public class S3Config { + + @Value("${cloud.aws.region.static:ap-northeast-2}") + private String region; + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(DefaultCredentialsProvider.create()) + .build(); + } + + @Bean + public S3Presigner s3Presigner() { + return S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(DefaultCredentialsProvider.create()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 87aa5ba..dcf4b57 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -3,6 +3,8 @@ spring: import: - "aws-parameterstore:/PHOTO_LINER_API/rds/dev/" - "aws-parameterstore:/PHOTO_LINER_API/ec2/dev/" + - "aws-parameterstore:/PHOTO_LINER_API/cdn/dev/" + - "aws-parameterstore:/PHOTO_LINER_API/s3/dev/" cloud: aws: @@ -20,3 +22,12 @@ photo: cors: allow-url: ${CORS_ALLOW_URL} + +cloud: + aws: + region: + static: ap-northeast-2 + s3: + bucket: ${BUCKET-NAME} + cdn: + base-url: ${CDN-URL} diff --git a/src/main/resources/application-local-example.yml b/src/main/resources/application-local-example.yml index cad07ec..a724c0a 100644 --- a/src/main/resources/application-local-example.yml +++ b/src/main/resources/application-local-example.yml @@ -1,5 +1,4 @@ spring: - datasource: url: url username: username @@ -11,3 +10,12 @@ photo: cors: allow-url: url + +cloud: + aws: + region: + static: ap-northeast-2 + s3: + bucket: name + cdn: + base-url: url \ No newline at end of file