diff --git a/build.gradle b/build.gradle index 1ed3bd7..4b65aaf 100644 --- a/build.gradle +++ b/build.gradle @@ -62,6 +62,9 @@ dependencies { // Monitoring implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'io.micrometer:micrometer-registry-prometheus' + + //S3 + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' } tasks.named('test') { diff --git a/src/main/java/com/helpmeCookies/global/config/S3Config.java b/src/main/java/com/helpmeCookies/global/config/S3Config.java new file mode 100644 index 0000000..bdeb16f --- /dev/null +++ b/src/main/java/com/helpmeCookies/global/config/S3Config.java @@ -0,0 +1,31 @@ +package com.helpmeCookies.global.config; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class S3Config { + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AmazonS3 amazonS3() { + AWSCredentials credentials = new BasicAWSCredentials(accessKey,secretKey); + + return AmazonS3ClientBuilder + .standard() + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withRegion(region) + .build(); + } +} diff --git a/src/main/java/com/helpmeCookies/global/utils/AwsS3FileUtils.java b/src/main/java/com/helpmeCookies/global/utils/AwsS3FileUtils.java new file mode 100644 index 0000000..afc9043 --- /dev/null +++ b/src/main/java/com/helpmeCookies/global/utils/AwsS3FileUtils.java @@ -0,0 +1,72 @@ +package com.helpmeCookies.global.utils; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.CannedAccessControlList; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.helpmeCookies.product.dto.FileUploadResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class AwsS3FileUtils { + private final AmazonS3 amazonS3; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + //다중파일 업로드후 url 반환 + public List uploadMultiImages(List multipartFiles) { + List fileList = new ArrayList<>(); + + multipartFiles.forEach(file -> { + String fileName = createFileName(file.getOriginalFilename()); //파일 이름 난수화 + ObjectMetadata objectMetadata = new ObjectMetadata(); + objectMetadata.setContentLength(file.getSize()); + objectMetadata.setContentType(file.getContentType()); + + try (InputStream inputStream = file.getInputStream()) { + amazonS3.putObject(new PutObjectRequest(bucket, fileName, inputStream, objectMetadata) + .withCannedAcl(CannedAccessControlList.PublicRead)); + } catch (IOException e) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드 실패" + fileName); + } + + fileList.add(new FileUploadResponse(amazonS3.getUrl(bucket,fileName).toString(),fileName)); + }); + + return fileList; + } + + public String createFileName(String fileName) { + return UUID.randomUUID().toString().concat(getFileExtension(fileName)); + } + + //TODO error handler 필요 + public String getFileExtension(String fileName) { + try { + String extension = fileName.substring(fileName.lastIndexOf(".")).toLowerCase(); + //이미지 파일 확장자 목록 + List allowedExtensions = Arrays.asList(".jpg", ".jpeg", ".png"); + + if (!allowedExtensions.contains(extension)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "이미지 파일만 업로드가 가능합니다. 지원되지 않는 형식의 파일" + fileName); + } + return extension; + } catch (StringIndexOutOfBoundsException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"잘못된 형식의 파일" + fileName + "입니다."); + } + } +} diff --git a/src/main/java/com/helpmeCookies/product/controller/ProductController.java b/src/main/java/com/helpmeCookies/product/controller/ProductController.java index c87732e..c655ee7 100644 --- a/src/main/java/com/helpmeCookies/product/controller/ProductController.java +++ b/src/main/java/com/helpmeCookies/product/controller/ProductController.java @@ -1,28 +1,40 @@ package com.helpmeCookies.product.controller; +import com.helpmeCookies.product.dto.FileUploadResponse; +import com.helpmeCookies.product.dto.ProductImageResponse; import com.helpmeCookies.product.dto.ProductRequest; import com.helpmeCookies.product.dto.ProductResponse; import com.helpmeCookies.product.entity.Product; +import com.helpmeCookies.product.service.ProductImageService; import com.helpmeCookies.product.service.ProductService; +import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; @RestController @RequestMapping("/api/v1/products") +@RequiredArgsConstructor public class ProductController { private final ProductService productService; - - public ProductController(ProductService productService) { - this.productService = productService; - } + private final ProductImageService productImageService; @PostMapping public ResponseEntity saveProduct(@RequestBody ProductRequest productRequest) { - Product product = productService.save(productRequest); + productService.save(productRequest); return ResponseEntity.ok().build(); } + @PostMapping("/{productId}/images") + public ResponseEntity uploadImages(@PathVariable("productId") Long productId, List files) throws IOException { + List responses = productImageService.uploadMultiFiles(productId,files); + return ResponseEntity.ok(new ProductImageResponse(responses.stream().map(FileUploadResponse::photoUrl).toList())); + } + @GetMapping("/{productId}") public ResponseEntity getProductInfo(@PathVariable("productId") Long productId) { Product product = productService.find(productId); @@ -36,6 +48,12 @@ public ResponseEntity editProductInfo(@PathVariable("productId") Long prod return ResponseEntity.ok().build(); } + @PutMapping("/{productId}/images") + public ResponseEntity editImages(@PathVariable("productId") Long productId, List files) throws IOException { + productImageService.editImages(productId, files); + return ResponseEntity.ok().build(); + } + @DeleteMapping("/{productId}") public ResponseEntity deleteProduct(@PathVariable("productId") Long productId) { productService.delete(productId); diff --git a/src/main/java/com/helpmeCookies/product/dto/FileUploadResponse.java b/src/main/java/com/helpmeCookies/product/dto/FileUploadResponse.java new file mode 100644 index 0000000..ad03aff --- /dev/null +++ b/src/main/java/com/helpmeCookies/product/dto/FileUploadResponse.java @@ -0,0 +1,16 @@ +package com.helpmeCookies.product.dto; + +import com.helpmeCookies.product.entity.ProductImage; + +public record FileUploadResponse( + String photoUrl, + String uuid +) { + public ProductImage toEntity(Long productId) { + return ProductImage.builder() + .productId(productId) + .photoUrl(photoUrl) + .uuid(uuid) + .build(); + } +} diff --git a/src/main/java/com/helpmeCookies/product/dto/ProductImageResponse.java b/src/main/java/com/helpmeCookies/product/dto/ProductImageResponse.java new file mode 100644 index 0000000..b090167 --- /dev/null +++ b/src/main/java/com/helpmeCookies/product/dto/ProductImageResponse.java @@ -0,0 +1,8 @@ +package com.helpmeCookies.product.dto; + +import java.util.List; + +public record ProductImageResponse( + List urls +) { +} diff --git a/src/main/java/com/helpmeCookies/product/entity/Category.java b/src/main/java/com/helpmeCookies/product/entity/Category.java index a58a7c0..0ab8e3a 100644 --- a/src/main/java/com/helpmeCookies/product/entity/Category.java +++ b/src/main/java/com/helpmeCookies/product/entity/Category.java @@ -33,7 +33,6 @@ public String getName() { } public static Category fromString(String name) { - System.out.println(name); Category category = nameToCategoryMap.get(name); if (category == null) { throw new IllegalArgumentException(name + "에 해당하는 카테고리가 없습니다."); diff --git a/src/main/java/com/helpmeCookies/product/entity/ProductImage.java b/src/main/java/com/helpmeCookies/product/entity/ProductImage.java index 699f53e..0a23f4f 100644 --- a/src/main/java/com/helpmeCookies/product/entity/ProductImage.java +++ b/src/main/java/com/helpmeCookies/product/entity/ProductImage.java @@ -4,8 +4,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; +import lombok.Builder; @Entity public class ProductImage { @@ -15,7 +14,15 @@ public class ProductImage { private String photoUrl; - @ManyToOne - @JoinColumn(name = "product_id") - private Product product; + private Long productId; + private String uuid; + + public ProductImage() {} + + @Builder + public ProductImage(String photoUrl, Long productId, String uuid) { + this.photoUrl = photoUrl; + this.productId = productId; + this.uuid = uuid; + } } diff --git a/src/main/java/com/helpmeCookies/product/repository/ProductImageRepository.java b/src/main/java/com/helpmeCookies/product/repository/ProductImageRepository.java new file mode 100644 index 0000000..718b605 --- /dev/null +++ b/src/main/java/com/helpmeCookies/product/repository/ProductImageRepository.java @@ -0,0 +1,13 @@ +package com.helpmeCookies.product.repository; + +import com.helpmeCookies.product.entity.ProductImage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ProductImageRepository extends JpaRepository { + List findAllByProductId(Long productId); + void deleteAllByProductId(Long productId); +} diff --git a/src/main/java/com/helpmeCookies/product/service/ProductImageService.java b/src/main/java/com/helpmeCookies/product/service/ProductImageService.java new file mode 100644 index 0000000..25215c5 --- /dev/null +++ b/src/main/java/com/helpmeCookies/product/service/ProductImageService.java @@ -0,0 +1,35 @@ +package com.helpmeCookies.product.service; + +import com.helpmeCookies.global.utils.AwsS3FileUtils; +import com.helpmeCookies.product.dto.FileUploadResponse; +import com.helpmeCookies.product.repository.ProductImageRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ProductImageService { + private final AwsS3FileUtils awsS3FileUtils; + private final ProductImageRepository productImageRepository; + + @Transactional + public List uploadMultiFiles(Long productId, List files) throws IOException { + List uploadResponses = awsS3FileUtils.uploadMultiImages(files); + uploadResponses.forEach(response -> + productImageRepository.save(response.toEntity(productId))); + return uploadResponses; + } + + @Transactional + public void editImages(Long productId, List files) throws IOException { + //우선은 전부 삭제하고 다시 업로드 + //추후에 개선 예정 + productImageRepository.deleteAllByProductId(productId); + uploadMultiFiles(productId, files); + } +} diff --git a/src/main/java/com/helpmeCookies/product/service/ProductService.java b/src/main/java/com/helpmeCookies/product/service/ProductService.java index 1a16699..c9b22d4 100644 --- a/src/main/java/com/helpmeCookies/product/service/ProductService.java +++ b/src/main/java/com/helpmeCookies/product/service/ProductService.java @@ -3,17 +3,17 @@ import com.helpmeCookies.product.dto.ProductRequest; import com.helpmeCookies.product.entity.Category; import com.helpmeCookies.product.entity.Product; +import com.helpmeCookies.product.repository.ProductImageRepository; import com.helpmeCookies.product.repository.ProductRepository; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service +@RequiredArgsConstructor public class ProductService { private final ProductRepository productRepository; - - public ProductService(ProductRepository productRepository) { - this.productRepository = productRepository; - } + private final ProductImageRepository productImageRepository; public Product save(ProductRequest productSaveRequest) { //TODO ArtistInfo 코드 병합시 수정 예정 @@ -43,6 +43,7 @@ public void edit(Long productId, ProductRequest productRequest) { public void delete(Long productId) { Product product = productRepository.findById(productId).orElseThrow(() -> new IllegalArgumentException("유효하지 않은 id입니다")); - productRepository.deleteById(productId); + productRepository.delete(product); + productImageRepository.deleteAllByProductId(productId); } } diff --git a/src/test/java/com/helpmeCookies/product/service/ProductImageServiceTest.java b/src/test/java/com/helpmeCookies/product/service/ProductImageServiceTest.java new file mode 100644 index 0000000..b0b3653 --- /dev/null +++ b/src/test/java/com/helpmeCookies/product/service/ProductImageServiceTest.java @@ -0,0 +1,69 @@ +package com.helpmeCookies.product.service; + +import com.helpmeCookies.global.utils.AwsS3FileUtils; +import com.helpmeCookies.product.dto.FileUploadResponse; +import com.helpmeCookies.product.entity.Category; +import com.helpmeCookies.product.entity.Product; +import com.helpmeCookies.product.repository.ProductImageRepository; +import com.helpmeCookies.product.repository.ProductRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.when; + +@SpringBootTest +@ActiveProfiles("test") +class ProductImageServiceTest { + @MockBean + private AwsS3FileUtils awsS3FileUtils; + + private ProductImageService productImageService; + @MockBean + private ProductRepository productRepository; + @MockBean + private ProductImageRepository productImageRepository; + + @BeforeEach + void setUp() { + productImageService = new ProductImageService(awsS3FileUtils, productImageRepository); + } + + @AfterEach + void tearDown() { + } + + @Test + @DisplayName("상품 이미지 S3 서버에 업로드") + void uploadMultiFiles() throws IOException { + given(productRepository.findById(any())) + .willReturn(Optional.of(new Product("더미",Category.CERAMIC,"100",10000L,"테스트항목","테스트 주소", + null,null))); + MockMultipartFile file1 = new MockMultipartFile("test1","img1.jpg","image/jpeg","image content".getBytes()); + MockMultipartFile file2 = new MockMultipartFile("test2","img2.jpg","image/jpeg","image content".getBytes()); + List files = Arrays.asList(file1,file2); + + List expected = new ArrayList<>(); + expected.add(new FileUploadResponse("url1","1111")); + expected.add(new FileUploadResponse("url2","2222")); + when(awsS3FileUtils.uploadMultiImages(files)).thenReturn(expected); + + List actual = productImageService.uploadMultiFiles(1L,files); + assertEquals(2,actual.size(), "배열의 크기는 2여야함"); + } +} diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index bf26694..eea2d27 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -21,4 +21,17 @@ logging.level: jwt: secret: testtesttesttesttesttesttesttesttest access-token-expire-time: 1800000 # 30 minutes - refresh-token-expire-time: 2592000000 # 30 days \ No newline at end of file + refresh-token-expire-time: 2592000000 # 30 days + +cloud: + aws: + s3: + bucket: testtest + credentials: + access-key: test-access-key + secret-key: test-secret-key + region: + static: ap-northeast-2 + auto: false + stack: + auto: false