diff --git a/SOPT_CloneCoding/.gitignore b/SOPT_CloneCoding/.gitignore index c2065bc..d2b3c00 100644 --- a/SOPT_CloneCoding/.gitignore +++ b/SOPT_CloneCoding/.gitignore @@ -35,3 +35,5 @@ out/ ### VS Code ### .vscode/ + +src/main/resources/application.yml \ No newline at end of file diff --git a/SOPT_CloneCoding/build.gradle b/SOPT_CloneCoding/build.gradle index 9cc4aed..1bcccf5 100644 --- a/SOPT_CloneCoding/build.gradle +++ b/SOPT_CloneCoding/build.gradle @@ -29,6 +29,8 @@ dependencies { runtimeOnly 'org.postgresql:postgresql' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' + implementation("software.amazon.awssdk:bom:2.21.0") + implementation("software.amazon.awssdk:s3:2.21.0") } tasks.named('test') { diff --git a/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/config/AwsConfig.java b/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/config/AwsConfig.java new file mode 100644 index 0000000..2a81fc9 --- /dev/null +++ b/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/config/AwsConfig.java @@ -0,0 +1,48 @@ +package com.example.sopt_clonecoding.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.SystemPropertyCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +@Configuration +public class AwsConfig { + + private static final String AWS_ACCESS_KEY_ID = "aws.accessKeyId"; + private static final String AWS_SECRET_ACCESS_KEY = "aws.secretAccessKey"; + + private final String accessKey; + private final String secretKey; + private final String regionString; + + public AwsConfig(@Value("${aws-property.access-key}") final String accessKey, + @Value("${aws-property.secret-key}") final String secretKey, + @Value("${aws-property.aws-region}") final String regionString) { + this.accessKey = accessKey; + this.secretKey = secretKey; + this.regionString = regionString; + } + + + @Bean + public SystemPropertyCredentialsProvider systemPropertyCredentialsProvider() { + System.setProperty(AWS_ACCESS_KEY_ID, accessKey); + System.setProperty(AWS_SECRET_ACCESS_KEY, secretKey); + return SystemPropertyCredentialsProvider.create(); + } + + @Bean + public Region getRegion() { + return Region.of(regionString); + } + + @Bean + public S3Client getS3Client() { + return S3Client.builder() + .region(getRegion()) + .credentialsProvider(systemPropertyCredentialsProvider()) + .build(); + } +} \ No newline at end of file diff --git a/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/controller/ItemController.java b/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/controller/ItemController.java index 15f1cce..94f2e7c 100644 --- a/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/controller/ItemController.java +++ b/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/controller/ItemController.java @@ -27,5 +27,12 @@ public ApiResponse findAllByPlace( ){ return ApiResponse.ok(itemService.findAllByPlace(place)); } + @DeleteMapping("/items/{itemId}") + public ApiResponse deleteItem( + @PathVariable Long itemId + ){ + itemService.deleteItem(itemId); + return ApiResponse.ok(null); + } } \ No newline at end of file diff --git a/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/domain/Image.java b/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/domain/Image.java new file mode 100644 index 0000000..9072c0d --- /dev/null +++ b/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/domain/Image.java @@ -0,0 +1,35 @@ +package com.example.sopt_clonecoding.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name="image") +@NoArgsConstructor(access= AccessLevel.PROTECTED) +public class Image { + @Id + @GeneratedValue(strategy=GenerationType.IDENTITY) + private Long id; + + private String imageUrl; + + @ManyToOne(targetEntity=Item.class, fetch= FetchType.LAZY) + private Item item; + + private LocalDateTime createdAt; + + public static Image create(String imageUrl, Item item){ + return new Image(imageUrl, item); + } + + public Image(String imageUrl, Item item) { + this.imageUrl = imageUrl; + this.item = item; + this.createdAt = LocalDateTime.now(); + } +} diff --git a/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/dto/item/request/ItemCreateDto.java b/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/dto/item/request/ItemCreateDto.java index eb44c32..d2ce99a 100644 --- a/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/dto/item/request/ItemCreateDto.java +++ b/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/dto/item/request/ItemCreateDto.java @@ -2,6 +2,9 @@ import com.example.sopt_clonecoding.dto.type.Tag; import jakarta.annotation.Nullable; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; public record ItemCreateDto( String title, @@ -14,6 +17,8 @@ public record ItemCreateDto( boolean isSell, boolean canOffer, @Nullable - String place + String place, + + List images ) { } \ No newline at end of file diff --git a/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/dto/type/ErrorCode.java b/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/dto/type/ErrorCode.java index c11072e..48bfe9c 100644 --- a/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/dto/type/ErrorCode.java +++ b/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/dto/type/ErrorCode.java @@ -14,8 +14,11 @@ public enum ErrorCode { // Custom Error NOT_FOUND_MEMBER(40401, HttpStatus.NOT_FOUND, "존재하지 않는 회원입니다."), NOT_FOUND_ITEM(40411, HttpStatus.NOT_FOUND, "존재하지 않는 물건입니다."), - NOT_FOUND_LIKE(40421, HttpStatus.NOT_FOUND, "좋아요가 존재하지 않습니다."); + NOT_FOUND_LIKE(40421, HttpStatus.NOT_FOUND, "좋아요가 존재하지 않습니다."), + FAILED_UPLOAD_IMAGE(40031, HttpStatus.BAD_REQUEST, "이미지 업로드에 실패했습니다."), + FAILED_DELETE_IMAGE(40032, HttpStatus.BAD_REQUEST, "이미지 삭제에 실패했습니다."), + ; private final Integer code; private final HttpStatus httpStatus; private final String message; diff --git a/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/repository/ImageRepository.java b/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/repository/ImageRepository.java new file mode 100644 index 0000000..21df4a7 --- /dev/null +++ b/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/repository/ImageRepository.java @@ -0,0 +1,11 @@ +package com.example.sopt_clonecoding.repository; + +import com.example.sopt_clonecoding.domain.Image; +import com.example.sopt_clonecoding.domain.Item; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ImageRepository extends JpaRepository { + List findAllByItem(Item item); +} diff --git a/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/service/ImageService.java b/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/service/ImageService.java new file mode 100644 index 0000000..a29ce11 --- /dev/null +++ b/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/service/ImageService.java @@ -0,0 +1,33 @@ +package com.example.sopt_clonecoding.service; + +import com.example.sopt_clonecoding.domain.Image; +import com.example.sopt_clonecoding.domain.Item; +import com.example.sopt_clonecoding.repository.ImageRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ImageService { + private final ImageRepository imageRepository; + + @Transactional + public void createImage( + final String imageUrl, + final Item item){ + Image image = Image.create(imageUrl, item); + imageRepository.save(image); + } + + public List findAllByItem(Item item){ + return imageRepository.findAllByItem(item); + } + + @Transactional + public void removeImage(Image image){ + imageRepository.delete(image); + } +} diff --git a/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/service/ItemService.java b/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/service/ItemService.java index a2d5533..876a646 100644 --- a/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/service/ItemService.java +++ b/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/service/ItemService.java @@ -12,6 +12,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.io.IOException; import java.util.List; @Service @@ -19,11 +20,38 @@ public class ItemService { private final ItemRepository itemRepository; private final MemberService memberService; + private final ImageService imageService; + private final S3Service s3Service; @Transactional public void createItem(Long memberId, ItemCreateDto itemCreateDto){ Member member = memberService.findMemberById(memberId); - Item item = Item.create(member, itemCreateDto); - itemRepository.save(item); + Item item = itemRepository.save(Item.create(member, itemCreateDto)); + itemCreateDto.images().forEach( + image -> { + try { + String imageUrl = s3Service.uploadImage("items/" + item.getId(), image); + imageService.createImage(imageUrl, item); + } catch (IOException e) { + throw new CustomException(ErrorCode.FAILED_UPLOAD_IMAGE); + } + } + ); + } + + @Transactional + public void deleteItem(Long itemId){ + Item item = findItemById(itemId); + imageService.findAllByItem(item).forEach( + image -> { + try{ + s3Service.deleteImage(image.getImageUrl()); + imageService.removeImage(image); + } catch (IOException e) { + throw new CustomException(ErrorCode.FAILED_DELETE_IMAGE); + } + } + ); + itemRepository.delete(item); } public ItemListDto findAllByPlace(String place){ diff --git a/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/service/S3Service.java b/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/service/S3Service.java new file mode 100644 index 0000000..ef4893a --- /dev/null +++ b/SOPT_CloneCoding/src/main/java/com/example/sopt_clonecoding/service/S3Service.java @@ -0,0 +1,81 @@ +package com.example.sopt_clonecoding.service; + +import com.example.sopt_clonecoding.config.AwsConfig; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +@Component +public class S3Service { + + private final String bucketName; + private final AwsConfig awsConfig; + private static final List IMAGE_EXTENSIONS = Arrays.asList("image/jpeg", "image/png", "image/jpg", "image/webp"); + + + public S3Service(@Value("${aws-property.s3-bucket-name}") final String bucketName, AwsConfig awsConfig) { + this.bucketName = bucketName; + this.awsConfig = awsConfig; + } + + + public String uploadImage(String directoryPath, MultipartFile image) throws IOException { + final String key = directoryPath + generateImageFileName(); + final S3Client s3Client = awsConfig.getS3Client(); + + validateExtension(image); + validateFileSize(image); + + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucketName) + .key(key) + .contentType(image.getContentType()) + .contentDisposition("inline") + .build(); + + RequestBody requestBody = RequestBody.fromBytes(image.getBytes()); + s3Client.putObject(request, requestBody); + return key; + } + + public void deleteImage(String key) throws IOException { + final S3Client s3Client = awsConfig.getS3Client(); + + s3Client.deleteObject((DeleteObjectRequest.Builder builder) -> + builder.bucket(bucketName) + .key(key) + .build() + ); + } + + + private String generateImageFileName() { + return UUID.randomUUID() + ".jpg"; + } + + + private void validateExtension(MultipartFile image) { + String contentType = image.getContentType(); + if (!IMAGE_EXTENSIONS.contains(contentType)) { + throw new RuntimeException("이미지 확장자는 jpg, png, webp만 가능합니다."); + } + } + + private static final Long MAX_FILE_SIZE = 5 * 1024 * 1024L; + + private void validateFileSize(MultipartFile image) { + if (image.getSize() > MAX_FILE_SIZE) { + throw new RuntimeException("이미지 사이즈는 5MB를 넘을 수 없습니다."); + } + } + +} \ No newline at end of file