diff --git a/src/main/java/poomasi/domain/image/controller/ImageController.java b/src/main/java/poomasi/domain/image/controller/ImageController.java new file mode 100644 index 0000000..38c2f52 --- /dev/null +++ b/src/main/java/poomasi/domain/image/controller/ImageController.java @@ -0,0 +1,67 @@ +package poomasi.domain.image.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import poomasi.domain.image.dto.ImageRequest; +import poomasi.domain.image.entity.Image; +import poomasi.domain.image.entity.ImageType; +import poomasi.domain.image.service.ImageService; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/image") +public class ImageController { + private final ImageService imageService; + + // 이미지 정보 저장 + @PostMapping + public ResponseEntity saveImageInfo(@RequestBody ImageRequest imageRequest) { + Image savedImage = imageService.saveImage(imageRequest); + return ResponseEntity.ok(savedImage); + } + + // 여러 이미지 정보 저장 + @PostMapping("/multiple") + public ResponseEntity> saveMultipleImages(@RequestBody List imageRequests) { + List savedImages = imageService.saveMultipleImages(imageRequests); + return ResponseEntity.ok(savedImages); + } + + // 특정 이미지 삭제 + @DeleteMapping("/delete/{id}") + public ResponseEntity deleteImage(@PathVariable Long id) { + imageService.deleteImage(id); + return ResponseEntity.noContent().build(); + } + + // 특정 이미지 조회 + @GetMapping("/{id}") + public ResponseEntity getImage(@PathVariable Long id) { + return ResponseEntity.ok(imageService.getImageById(id)); + } + + // 모든 이미지 조회 (특정 referenceId에 따라) + @GetMapping("/reference/{type}/{referenceId}") + public ResponseEntity> getImagesByTypeAndReference(@PathVariable ImageType type, @PathVariable Long referenceId) { + List images = imageService.getImagesByTypeAndReferenceId(type, referenceId); + return ResponseEntity.ok(images); + } + + // 이미지 정보 수정 + @PutMapping("/{id}") + public ResponseEntity updateImageInfo(@PathVariable Long id, @RequestBody ImageRequest imageRequest) { + Image updatedImage = imageService.updateImage(id, imageRequest); + return ResponseEntity.ok(updatedImage); + } + + @PutMapping("/recover/{id}") + public ResponseEntity recoverImage(@PathVariable Long id) { + imageService.recoverImage(id); + return ResponseEntity.noContent().build(); + } + + +} \ No newline at end of file diff --git a/src/main/java/poomasi/domain/image/dto/ImageRequest.java b/src/main/java/poomasi/domain/image/dto/ImageRequest.java new file mode 100644 index 0000000..d6e0521 --- /dev/null +++ b/src/main/java/poomasi/domain/image/dto/ImageRequest.java @@ -0,0 +1,16 @@ +package poomasi.domain.image.dto; + +import poomasi.domain.image.entity.Image; +import poomasi.domain.image.entity.ImageType; + +public record ImageRequest(String objectKey, String imageUrl, ImageType type, Long referenceId) { + public Image toEntity(ImageRequest imageRequest){ + return new Image( + imageRequest.objectKey, + imageRequest.imageUrl, + imageRequest.type, + imageRequest.referenceId + ); + } +} + diff --git a/src/main/java/poomasi/domain/image/entity/Image.java b/src/main/java/poomasi/domain/image/entity/Image.java new file mode 100644 index 0000000..dba3541 --- /dev/null +++ b/src/main/java/poomasi/domain/image/entity/Image.java @@ -0,0 +1,67 @@ +package poomasi.domain.image.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.SQLDelete; +import poomasi.domain.image.dto.ImageRequest; + +import java.time.LocalDateTime; +import java.util.Date; + +@Entity +@Table(name = "image", uniqueConstraints = { + @UniqueConstraint(columnNames = {"type", "reference_id", "object_key"}) +}) +@Getter +@Setter +@NoArgsConstructor +@SQLDelete(sql = "UPDATE image SET deleted_at = current_timestamp WHERE id = ?") +public class Image { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String objectKey; + + @Column(nullable = false) + private String imageUrl; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ImageType type; + + @Column(name = "reference_id", nullable = false) + private Long referenceId; + + @Column(name = "created_at", nullable = false) + @Temporal(TemporalType.TIMESTAMP) + private Date createdAt = new Date(); + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + public Image(String objectKey, String imageUrl, ImageType type, Long referenceId) { + this.objectKey = objectKey; + this.imageUrl = imageUrl; + this.type = type; + this.referenceId = referenceId; + } + + public void update(ImageRequest request) { + this.objectKey = request.objectKey(); + this.imageUrl = request.imageUrl(); + this.type = request.type(); + this.referenceId = request.referenceId(); + } + + public ImageRequest toRequest(Image image){ + return new ImageRequest( + image.objectKey, + image.imageUrl, + image.type, + image.referenceId + ); + } +} \ No newline at end of file diff --git a/src/main/java/poomasi/domain/image/entity/ImageType.java b/src/main/java/poomasi/domain/image/entity/ImageType.java new file mode 100644 index 0000000..8c6f592 --- /dev/null +++ b/src/main/java/poomasi/domain/image/entity/ImageType.java @@ -0,0 +1,5 @@ +package poomasi.domain.image.entity; + +public enum ImageType { + FARM, FARM_REVIEW, PRODUCT, PRODUCT_REVIEW +} \ No newline at end of file diff --git a/src/main/java/poomasi/domain/image/repository/ImageRepository.java b/src/main/java/poomasi/domain/image/repository/ImageRepository.java new file mode 100644 index 0000000..2b82bad --- /dev/null +++ b/src/main/java/poomasi/domain/image/repository/ImageRepository.java @@ -0,0 +1,15 @@ +package poomasi.domain.image.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import poomasi.domain.image.entity.Image; +import poomasi.domain.image.entity.ImageType; + +import java.util.List; +import java.util.Optional; + +public interface ImageRepository extends JpaRepository { + long countByTypeAndReferenceIdAndDeletedAtIsNull(ImageType type, Long referenceId); + List findByTypeAndReferenceIdAndDeletedAtIsNull(ImageType type, Long referenceId); + Optional findByIdAndDeletedAtIsNull(Long id); + Optional findByObjectKeyAndTypeAndReferenceId(String s, ImageType type, Long aLong); +} \ No newline at end of file diff --git a/src/main/java/poomasi/domain/image/service/ImageService.java b/src/main/java/poomasi/domain/image/service/ImageService.java new file mode 100644 index 0000000..4815703 --- /dev/null +++ b/src/main/java/poomasi/domain/image/service/ImageService.java @@ -0,0 +1,109 @@ +package poomasi.domain.image.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import poomasi.domain.image.dto.ImageRequest; +import poomasi.domain.image.entity.Image; +import poomasi.domain.image.entity.ImageType; +import poomasi.domain.image.repository.ImageRepository; +import poomasi.global.error.BusinessException; + +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import static poomasi.global.error.BusinessError.*; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ImageService { + private final ImageRepository imageRepository; + + @Transactional + public Image saveImage(ImageRequest imageRequest) { + // 기존 이미지가 있는 경우 복구 또는 예외 처리 (실제 복구 로직과는 차이가 있음) + validateImageLimit(imageRequest); + + Image image = findExistingOrRecoverableImage(imageRequest) + .map(existingImage -> recoverImageOrThrow(existingImage, imageRequest)) + .orElseGet(() -> imageRequest.toEntity(imageRequest)); + + return imageRepository.save(image); + } + + private Optional findExistingOrRecoverableImage(ImageRequest imageRequest) { + return imageRepository.findByObjectKeyAndTypeAndReferenceId( + imageRequest.objectKey(), imageRequest.type(), imageRequest.referenceId()); + } + + private Image recoverImageOrThrow(Image existingImage, ImageRequest imageRequest) { + if (existingImage.getDeletedAt() == null) { + throw new BusinessException(IMAGE_ALREADY_EXISTS); + } + existingImage.setDeletedAt(null); + existingImage.setCreatedAt(new Date()); + existingImage.update(imageRequest); + return existingImage; + } + + private void validateImageLimit(ImageRequest imageRequest) { + if (imageRepository.countByTypeAndReferenceIdAndDeletedAtIsNull(imageRequest.type(), imageRequest.referenceId()) >= 5) { + throw new BusinessException(IMAGE_LIMIT_EXCEED); + } + } + + // 여러 이미지 저장 + @Transactional + public List saveMultipleImages(List imageRequests) { + return imageRequests.stream() + .map(this::saveImage) + .collect(Collectors.toList()); + } + + @Transactional + public void deleteImage(Long id) { + imageRepository.deleteById(id); + } + + public Image getImageById(Long id) { + return imageRepository.findByIdAndDeletedAtIsNull(id) + .orElseThrow(() -> new BusinessException(IMAGE_NOT_FOUND)); + } + + public List getImagesByTypeAndReferenceId(ImageType type, Long referenceId) { + return imageRepository.findByTypeAndReferenceIdAndDeletedAtIsNull(type, referenceId); + } + + // 이미지 수정 + @Transactional + public Image updateImage(Long id, ImageRequest imageRequest) { + Image image = getImageById(id); + + if (!image.getType().equals(imageRequest.type()) || + !image.getReferenceId().equals(imageRequest.referenceId())) { + validateImageLimit(imageRequest); + } + + image.update(imageRequest); + + return imageRepository.save(image); + } + + @Transactional + public void recoverImage(Long id) { + Image image = imageRepository.findById(id) + .orElseThrow(() -> new BusinessException(IMAGE_NOT_FOUND)); + + if (image.getDeletedAt() == null) { + throw new BusinessException(IMAGE_ALREADY_EXISTS); + } + + validateImageLimit(image.toRequest(image)); + + image.setDeletedAt(null); + imageRepository.save(image); + } +} \ No newline at end of file diff --git a/src/main/java/poomasi/domain/member/dto/MemberResponse.java b/src/main/java/poomasi/domain/member/dto/MemberResponse.java new file mode 100644 index 0000000..73e8ff0 --- /dev/null +++ b/src/main/java/poomasi/domain/member/dto/MemberResponse.java @@ -0,0 +1,4 @@ +package poomasi.domain.member.dto; + +public record MemberResponse (Long id, String name, String email){ +} diff --git a/src/main/java/poomasi/domain/member/entity/Member.java b/src/main/java/poomasi/domain/member/entity/Member.java index 3ca8299..cf464be 100644 --- a/src/main/java/poomasi/domain/member/entity/Member.java +++ b/src/main/java/poomasi/domain/member/entity/Member.java @@ -47,6 +47,7 @@ public class Member { @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, fetch = FetchType.LAZY) private List wishLists; + @Column(name="deleted_at") private LocalDateTime deletedAt; public Member(String email, String password, LoginType loginType, Role role) { diff --git a/src/main/java/poomasi/global/config/s3/S3Config.java b/src/main/java/poomasi/global/config/s3/S3Config.java index 7489330..93bf46b 100644 --- a/src/main/java/poomasi/global/config/s3/S3Config.java +++ b/src/main/java/poomasi/global/config/s3/S3Config.java @@ -5,8 +5,9 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import poomasi.global.config.aws.AwsProperties; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; -import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3AsyncClient; import software.amazon.awssdk.services.s3.S3Client; @@ -20,7 +21,11 @@ public class S3Config { @Bean("awsCredentials") public AwsCredentialsProvider awsCredentials() { - return DefaultCredentialsProvider.create(); + AwsBasicCredentials awsBasicCredentials = AwsBasicCredentials.create( + awsProperties.getAccess(), + awsProperties.getSecret() + ); + return StaticCredentialsProvider.create(awsBasicCredentials); } @Bean diff --git a/src/main/java/poomasi/global/config/s3/TestController.java b/src/main/java/poomasi/global/config/s3/S3PresignedUrlController.java similarity index 51% rename from src/main/java/poomasi/global/config/s3/TestController.java rename to src/main/java/poomasi/global/config/s3/S3PresignedUrlController.java index 97e2eb9..33b6c15 100644 --- a/src/main/java/poomasi/global/config/s3/TestController.java +++ b/src/main/java/poomasi/global/config/s3/S3PresignedUrlController.java @@ -2,24 +2,26 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; +import poomasi.global.config.aws.AwsProperties; +import poomasi.global.config.s3.dto.request.PresignedUrlPutRequest; @RestController @RequiredArgsConstructor -public class TestController { +@RequestMapping("/api/s3") +public class S3PresignedUrlController { private final S3PresignedUrlService s3PresignedUrlService; - - @GetMapping("/presigned-url-put") - public ResponseEntity presignedUrlPut() { - String presignedPutUrl = s3PresignedUrlService.createPresignedPutUrl("poomasi", "test", null); - return ResponseEntity.ok(presignedPutUrl); - } + private final AwsProperties awsProperties; @GetMapping("/presigned-url-get") public ResponseEntity presignedUrlGet(@RequestParam String keyname) { - String presignedGetUrl = s3PresignedUrlService.createPresignedGetUrl("poomasi", keyname); + String presignedGetUrl = s3PresignedUrlService.createPresignedGetUrl(awsProperties.getS3().getBucket(), keyname); return ResponseEntity.ok(presignedGetUrl); } + + @PostMapping("/presigned-url-put") + public ResponseEntity presignedUrlPut(@RequestBody PresignedUrlPutRequest request) { + String presignedPutUrl = s3PresignedUrlService.createPresignedPutUrl(awsProperties.getS3().getBucket(), request.keyPrefix(), request.metadata()); + return ResponseEntity.ok(presignedPutUrl); + } } diff --git a/src/main/java/poomasi/global/config/s3/S3PresignedUrlService.java b/src/main/java/poomasi/global/config/s3/S3PresignedUrlService.java index f7c470c..12330c8 100644 --- a/src/main/java/poomasi/global/config/s3/S3PresignedUrlService.java +++ b/src/main/java/poomasi/global/config/s3/S3PresignedUrlService.java @@ -16,6 +16,7 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Map; +import java.util.UUID; @Service @RequiredArgsConstructor @@ -26,7 +27,6 @@ public class S3PresignedUrlService { private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); private static final Long SIGNATURE_DURATION = 10L; - public String createPresignedGetUrl(String bucketName, String keyName) { GetObjectRequest objectRequest = GetObjectRequest.builder() .bucket(bucketName) @@ -51,8 +51,12 @@ public String createPresignedPutUrl(String bucketName, String keyPrefix, Map 그럴일 거의 없긴할텐데 생기면 s3 원래 파일 지워짐 + String uniqueIdentifier = UUID.randomUUID().toString(); + String keyName = String.format("%s/%s/%s_%s.jpg", keyPrefix, date, uniqueIdentifier, encodedTime); PutObjectRequest objectRequest = PutObjectRequest.builder() .bucket(bucketName) @@ -65,7 +69,6 @@ public String createPresignedPutUrl(String bucketName, String keyPrefix, Map metadata) { +} diff --git a/src/main/java/poomasi/global/error/BusinessError.java b/src/main/java/poomasi/global/error/BusinessError.java index d9ce696..11cde2c 100644 --- a/src/main/java/poomasi/global/error/BusinessError.java +++ b/src/main/java/poomasi/global/error/BusinessError.java @@ -49,7 +49,12 @@ public enum BusinessError { RESERVATION_CANCELLATION_PERIOD_EXPIRED(HttpStatus.BAD_REQUEST, "예약 취소 기간이 지났습니다."), // ETC - START_DATE_SHOULD_BE_BEFORE_END_DATE(HttpStatus.BAD_REQUEST, "시작 날짜는 종료 날짜보다 이전이어야 합니다."); + START_DATE_SHOULD_BE_BEFORE_END_DATE(HttpStatus.BAD_REQUEST, "시작 날짜는 종료 날짜보다 이전이어야 합니다."), + + // Image + IMAGE_LIMIT_EXCEED(HttpStatus.BAD_REQUEST, "사진은 최대 5장까지 등록 가능합니다."), + IMAGE_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 이미지가 존재합니다"), + IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "이미지를 찾을 수 없습니다."); private final HttpStatus httpStatus;