diff --git a/.gitignore b/.gitignore index 490daff..0722098 100644 --- a/.gitignore +++ b/.gitignore @@ -326,9 +326,6 @@ gradle-app.setting # End of https://www.toptal.com/developers/gitignore/api/intellij,gradle,java,windows,macos,intellij+all -# ----- 하늘님 gitignore 추가 요청 20231204.1641 -.DS_store - #yml *.yml !application.yml @@ -347,4 +344,7 @@ gradlew.bat gradlew # QueryDSL Q-classes -src/main/generated/ \ No newline at end of file +src/main/generated/ + +# s3 test folder +s3mock \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index aac06de..a3efb30 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -55,9 +55,12 @@ dependencies { implementation("io.jsonwebtoken:jjwt-api:0.11.5") implementation("io.jsonwebtoken:jjwt-impl:0.11.5") implementation("io.jsonwebtoken:jjwt-jackson:0.11.5") + //s3 + implementation("io.awspring.cloud:spring-cloud-starter-aws:2.4.4") //test testImplementation ("org.springframework.boot:spring-boot-starter-test") testRuntimeOnly ("com.h2database:h2") + implementation ("io.findify:s3mock_2.13:0.2.6") } tasks.withType { diff --git a/src/main/java/com/ward/ward_server/api/item/controller/AdminBrandController.java b/src/main/java/com/ward/ward_server/api/item/controller/AdminBrandController.java index a3487f2..06533df 100644 --- a/src/main/java/com/ward/ward_server/api/item/controller/AdminBrandController.java +++ b/src/main/java/com/ward/ward_server/api/item/controller/AdminBrandController.java @@ -5,10 +5,15 @@ import com.ward.ward_server.api.item.service.BrandService; import com.ward.ward_server.global.response.ApiResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; import static com.ward.ward_server.global.response.ApiResponseMessage.*; +@Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/admin/brands") @@ -16,13 +21,20 @@ public class AdminBrandController { private final BrandService brandService; @PostMapping - public ApiResponse createBrand(@RequestBody BrandRequest request) { - return ApiResponse.ok(BRAND_CREATE_SUCCESS, brandService.createBrand(request.koreanName(), request.englishName(), request.logoImage())); + public ApiResponse createBrand(@RequestPart BrandRequest request, + @RequestPart(required = false) MultipartFile logoImage) throws IOException { + return ApiResponse.ok(BRAND_CREATE_SUCCESS, brandService.createBrand(request.koreanName(), request.englishName(), logoImage)); } @PatchMapping("/{brandId}") - public ApiResponse updateBrand(@PathVariable("brandId") long brandId, @RequestBody BrandRequest request) { - return ApiResponse.ok(BRAND_UPDATE_SUCCESS, brandService.updateBrand(brandId, request.koreanName(), request.englishName(), request.logoImage())); + public ApiResponse updateBrand(@PathVariable("brandId") long brandId, + @RequestPart(required = false) BrandRequest request, + @RequestPart(required = false) MultipartFile logoImage) throws IOException { + if (request == null) { + return ApiResponse.ok(BRAND_UPDATE_SUCCESS, brandService.updateBrand(brandId, null, null, logoImage)); + } else { + return ApiResponse.ok(BRAND_UPDATE_SUCCESS, brandService.updateBrand(brandId, request.koreanName(), request.englishName(), logoImage)); + } } @DeleteMapping("/{brandId}") diff --git a/src/main/java/com/ward/ward_server/api/item/controller/AdminItemController.java b/src/main/java/com/ward/ward_server/api/item/controller/AdminItemController.java index 21b7649..5290e38 100644 --- a/src/main/java/com/ward/ward_server/api/item/controller/AdminItemController.java +++ b/src/main/java/com/ward/ward_server/api/item/controller/AdminItemController.java @@ -8,6 +8,10 @@ import com.ward.ward_server.global.response.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; import static com.ward.ward_server.global.response.ApiResponseMessage.*; @@ -27,16 +31,30 @@ public ApiResponse abc() { @PostMapping - public ApiResponse createItem(@RequestBody ItemRequest request) throws ApiException { + public ApiResponse createItem(@RequestPart ItemRequest request, + @RequestPart(required = false) MultipartFile mainImage, + @RequestPart(required = false) List itemImages) throws ApiException, IOException { return ApiResponse.ok(ITEM_CREATE_SUCCESS, - itemService.createItem(request.itemCode(), request.koreanName(), request.englishName(), request.mainImage(), request.itemImages(), request.brandId(), request.category(), request.price())); + itemService.createItem(request.itemCode(), request.koreanName(), request.englishName(), request.brandId(), request.category(), request.price(), + mainImage, itemImages)); } @PatchMapping("/{itemId}") public ApiResponse updateItem(@PathVariable("itemId") Long itemId, - @RequestBody ItemRequest request) { - return ApiResponse.ok(ITEM_UPDATE_SUCCESS, - itemService.updateItem(itemId, request.koreanName(), request.englishName(), request.itemCode(), request.mainImage(), request.itemImages(), request.brandId(), request.category(), request.price())); + @RequestPart(required = false) ItemRequest request, + @RequestPart(required = false) MultipartFile mainImage, + @RequestPart(required = false) List itemImages) throws IOException { + if (request == null) { + return ApiResponse.ok(ITEM_UPDATE_SUCCESS, + itemService.updateItem(itemId, + null, null, null, null, null, null, + mainImage, itemImages)); + } else { + return ApiResponse.ok(ITEM_UPDATE_SUCCESS, + itemService.updateItem(itemId, + request.koreanName(), request.englishName(), request.itemCode(), request.brandId(), request.category(), request.price(), + mainImage, itemImages)); + } } @DeleteMapping("/{itemId}") diff --git a/src/main/java/com/ward/ward_server/api/item/dto/BrandRequest.java b/src/main/java/com/ward/ward_server/api/item/dto/BrandRequest.java index 4802c83..d312ce6 100644 --- a/src/main/java/com/ward/ward_server/api/item/dto/BrandRequest.java +++ b/src/main/java/com/ward/ward_server/api/item/dto/BrandRequest.java @@ -2,7 +2,5 @@ public record BrandRequest( String koreanName, - String englishName, - String logoImage -) { + String englishName) { } diff --git a/src/main/java/com/ward/ward_server/api/item/dto/ItemRequest.java b/src/main/java/com/ward/ward_server/api/item/dto/ItemRequest.java index 2c8e800..fa42d6d 100644 --- a/src/main/java/com/ward/ward_server/api/item/dto/ItemRequest.java +++ b/src/main/java/com/ward/ward_server/api/item/dto/ItemRequest.java @@ -2,14 +2,10 @@ import com.ward.ward_server.api.item.entity.enums.Category; -import java.util.List; - public record ItemRequest( String koreanName, String englishName, String itemCode, - String mainImage, - List itemImages, Long brandId, Category category, Integer price) { diff --git a/src/main/java/com/ward/ward_server/api/item/repository/ItemImageRepository.java b/src/main/java/com/ward/ward_server/api/item/repository/ItemImageRepository.java index 99d97a0..837cbd3 100644 --- a/src/main/java/com/ward/ward_server/api/item/repository/ItemImageRepository.java +++ b/src/main/java/com/ward/ward_server/api/item/repository/ItemImageRepository.java @@ -1,17 +1,10 @@ package com.ward.ward_server.api.item.repository; -import com.ward.ward_server.api.item.entity.Item; import com.ward.ward_server.api.item.entity.ItemImage; import org.springframework.data.jpa.repository.JpaRepository; -import javax.swing.text.html.Option; import java.util.List; -import java.util.Optional; public interface ItemImageRepository extends JpaRepository { List findAllByItemId(long itemId); - - void deleteAllByItemId(long itemId); - - Optional findFirstByItemId(long itemId); } diff --git a/src/main/java/com/ward/ward_server/api/item/service/BrandService.java b/src/main/java/com/ward/ward_server/api/item/service/BrandService.java index db98391..5ae73a7 100644 --- a/src/main/java/com/ward/ward_server/api/item/service/BrandService.java +++ b/src/main/java/com/ward/ward_server/api/item/service/BrandService.java @@ -12,7 +12,7 @@ import com.ward.ward_server.global.Object.PageResponse; import com.ward.ward_server.global.Object.enums.BasicSort; import com.ward.ward_server.global.exception.ApiException; -import com.ward.ward_server.global.exception.ExceptionCode; +import com.ward.ward_server.global.util.S3ImageManager; import com.ward.ward_server.global.util.ValidationUtils; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -20,7 +20,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.util.List; import java.util.stream.Collectors; @@ -34,18 +36,21 @@ public class BrandService { private final BrandRepository brandRepository; private final ItemRepository itemRepository; private final ReleaseInfoRepository releaseInfoRepository; + private final S3ImageManager imageManager; + private final String DIR_NAME = "brand/logo"; @Transactional - public BrandResponse createBrand(String koreanName, String englishName, String brandLogoImage) { + public BrandResponse createBrand(String koreanName, String englishName, MultipartFile brandLogoImage) throws IOException { if (!StringUtils.hasText(koreanName) && !StringUtils.hasText(englishName)) { throw new ApiException(INVALID_INPUT, NAME_MUST_BE_PROVIDED.getMessage()); } ValidationUtils.validationNames(koreanName, englishName); - if (brandRepository.existsByKoreanNameOrEnglishName(koreanName, englishName)) + if (brandRepository.existsByKoreanNameOrEnglishName(koreanName, englishName)) { throw new ApiException(DUPLICATE_BRAND); - + } + String uploadedImageUrl = brandLogoImage != null ? imageManager.upload(brandLogoImage, DIR_NAME) : null; Brand savedBrand = brandRepository.save(Brand.builder() - .logoImage(brandLogoImage) + .logoImage(uploadedImageUrl) .koreanName(koreanName) .englishName(englishName) .build()); @@ -79,13 +84,13 @@ public PageResponse getBrandReleaseInfoPage(long bran } @Transactional - public BrandResponse updateBrand(long brandId, String koreanName, String englishName, String brandLogoImage) { - if (koreanName == null && englishName == null && brandLogoImage == null) - throw new ApiException(ExceptionCode.INVALID_INPUT); + public BrandResponse updateBrand(long brandId, String koreanName, String englishName, MultipartFile brandLogoImage) throws IOException { ValidationUtils.validationNames(koreanName, englishName); Brand origin = brandRepository.findById(brandId).orElseThrow(() -> new ApiException(BRAND_NOT_FOUND)); - if (StringUtils.hasText(brandLogoImage)) { - origin.updateLogoImage(brandLogoImage); + if (brandLogoImage != null) { + imageManager.delete(origin.getLogoImage()); + String uploadedLogoImageUrl = imageManager.upload(brandLogoImage, DIR_NAME); + origin.updateLogoImage(uploadedLogoImageUrl); } if (StringUtils.hasText(koreanName)) { origin.updateKoreanName(koreanName); @@ -98,10 +103,9 @@ public BrandResponse updateBrand(long brandId, String koreanName, String english @Transactional public void deleteBrand(long brandId) { - if (!brandRepository.existsById(brandId)) { - throw new ApiException(BRAND_NOT_FOUND); - } - brandRepository.deleteById(brandId); + Brand brand = brandRepository.findById(brandId).orElseThrow(() -> new ApiException(BRAND_NOT_FOUND)); + imageManager.delete(brand.getLogoImage()); + brandRepository.delete(brand); } @Transactional @@ -114,7 +118,8 @@ public long increaseBrandViewCount(long brandId) { private BrandResponse getBrandResponse(Brand brand) { return new BrandResponse( brand.getId(), - brand.getLogoImage(), + brand.getLogoImage() == null ? + imageManager.getUrl(DIR_NAME + "/brand-basic-logo.png") : brand.getLogoImage(), brand.getKoreanName(), brand.getEnglishName(), brand.getViewCount() diff --git a/src/main/java/com/ward/ward_server/api/item/service/ItemService.java b/src/main/java/com/ward/ward_server/api/item/service/ItemService.java index bb4c661..4d9667f 100644 --- a/src/main/java/com/ward/ward_server/api/item/service/ItemService.java +++ b/src/main/java/com/ward/ward_server/api/item/service/ItemService.java @@ -9,6 +9,7 @@ import com.ward.ward_server.global.Object.PageResponse; import com.ward.ward_server.global.Object.enums.Section; import com.ward.ward_server.global.exception.ApiException; +import com.ward.ward_server.global.util.S3ImageManager; import com.ward.ward_server.global.util.ValidationUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -17,7 +18,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.time.LocalDateTime; import java.util.List; import java.util.stream.Collectors; @@ -36,9 +39,13 @@ public class ItemService { private final ItemImageRepository itemImageRepository; private final ItemViewCountRepository itemViewCountRepository; private final ItemTopRankRepository itemTopRankRepository; + private final S3ImageManager imageManager; + private final String MAIN_IMAGE_DIR_NAME = "item/main"; + private final String SUB_IMAGES_DIR_NAME = "item/sub"; @Transactional - public ItemDetailResponse createItem(String itemCode, String koreanName, String englishName, String mainImage, List itemImages, Long brandId, Category category, Integer price) throws ApiException { + public ItemDetailResponse createItem(String itemCode, String koreanName, String englishName, Long brandId, Category category, Integer price, + MultipartFile mainImage, List itemImages) throws ApiException, IOException { if (!StringUtils.hasText(koreanName) && !StringUtils.hasText(englishName)) { throw new ApiException(INVALID_INPUT, NAME_MUST_BE_PROVIDED.getMessage()); } @@ -46,21 +53,35 @@ public ItemDetailResponse createItem(String itemCode, String koreanName, String if (!StringUtils.hasText(itemCode) || brandId == null || category == null) { throw new ApiException(INVALID_INPUT, REQUIRED_FIELDS_MUST_BE_PROVIDED.getMessage()); } + Brand brand = brandRepository.findById(brandId).orElseThrow(() -> new ApiException(BRAND_NOT_FOUND)); - if (itemRepository.existsByCodeAndBrandId(itemCode, brand.getId())) throw new ApiException(DUPLICATE_ITEM); + if (itemRepository.existsByCodeAndBrandId(itemCode, brand.getId())) { + throw new ApiException(DUPLICATE_ITEM); + } + String uploadedMainImageUrl = mainImage != null ? imageManager.upload(mainImage, MAIN_IMAGE_DIR_NAME) : null; Item savedItem = itemRepository.save(Item.builder() .code(itemCode) .koreanName(koreanName) .englishName(englishName) - .mainImage(mainImage) + .mainImage(uploadedMainImageUrl) .brand(brand) .category(category) .price(price) .build()); - itemImages.stream() - .map(e -> ItemImage.builder().itemId(savedItem.getId()).url(e).build()) - .forEach(itemImageRepository::save); - + if (itemImages != null) { + itemImages.stream() + .map(e -> { + try { + return ItemImage.builder() + .itemId(savedItem.getId()) + .url(imageManager.upload(e, SUB_IMAGES_DIR_NAME)) + .build(); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + }) + .forEach(itemImageRepository::save); + } // add 손지민: 실시간 Top10 을 위해 ItemViewCount 테이블 생성 ItemViewCount itemViewCount = ItemViewCount.builder() .category(savedItem.getCategory()) @@ -89,7 +110,6 @@ public void increaseViewCount(Item item) { .build()); } - @Transactional(readOnly = true) public List getItem10List(Long userId, Section section, Category category) { return switch (section) { case DUE_TODAY, RELEASE_NOW, RELEASE_SCHEDULE -> @@ -98,9 +118,7 @@ public List getItem10List(Long userId, Section section, Cate }; } - @Transactional(readOnly = true) public PageResponse getItemPage(Long userId, Section section, Category category, int page, String date) { - log.info("하늘:{}", date); return switch (section) { case RELEASE_SCHEDULE, CLOSED -> { Page itemPageInfo = itemRepository.getItemPage(userId, LocalDateTime.now().minusHours(9), category, section, date, PageRequest.of(page, API_PAGE_SIZE)); //HACK DB 시간 설정 전까지는 -9시간으로 비교해야 한다. @@ -110,7 +128,6 @@ public PageResponse getItemPage(Long userId, Section section }; } - @Transactional(readOnly = true) public List getTopItemsResponseByCategory(Category category, int limit) { List topItems; if (category == Category.ALL) { @@ -135,10 +152,8 @@ private List convertToTopResponse(List topItem @Transactional public ItemDetailResponse updateItem(Long itemId, - String koreanName, String englishName, String itemCode, String mainImage, List itemImages, Long brandId, Category category, Integer price) { - if (koreanName == null && englishName == null && itemCode == null && itemImages == null && brandId == null && category == null && price == null) { - throw new ApiException(INVALID_INPUT); - } + String koreanName, String englishName, String itemCode, Long brandId, Category category, Integer price, + MultipartFile mainImage, List itemImages) throws IOException { Item origin = itemRepository.findById(itemId).orElseThrow(() -> new ApiException(ITEM_NOT_FOUND)); Brand brand = null; if (itemCode == null && brandId != null) { @@ -160,13 +175,24 @@ public ItemDetailResponse updateItem(Long itemId, origin.updateBrand(brand); origin.updateCode(itemCode); } - if (StringUtils.hasText(mainImage)) { - origin.updateMainImage(mainImage); + if (mainImage != null) { + imageManager.delete(origin.getMainImage()); + String uploadedImageUrl = imageManager.upload(mainImage, MAIN_IMAGE_DIR_NAME); + origin.updateMainImage(uploadedImageUrl); } if (itemImages != null && !itemImages.isEmpty()) { - itemImageRepository.deleteAllByItemId(origin.getId()); + //TODO 기존 이미지를 삭제하는 로직 혹은 api 추가(지민님과 의논) itemImages.stream() - .map(e -> ItemImage.builder().itemId(origin.getId()).url(e).build()) + .map(e -> { + try { + return ItemImage.builder() + .itemId(itemId) + .url(imageManager.upload(e, SUB_IMAGES_DIR_NAME)) + .build(); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + }) .forEach(itemImageRepository::save); } if (category != null) { @@ -186,10 +212,11 @@ public ItemDetailResponse updateItem(Long itemId, @Transactional public void deleteItem(long itemId) { - if (!itemRepository.existsById(itemId)) { - throw new ApiException(ITEM_NOT_FOUND); - } - itemRepository.deleteById(itemId); + Item item = itemRepository.findById(itemId).orElseThrow(() -> new ApiException(ITEM_NOT_FOUND)); + imageManager.delete(item.getMainImage()); + itemImageRepository.findAllByItemId(itemId) + .forEach(i -> imageManager.delete(i.getUrl())); + itemRepository.delete(item); } private ItemDetailResponse getItemDetailResponse(Item item, Brand brand) { @@ -198,7 +225,8 @@ private ItemDetailResponse getItemDetailResponse(Item item, Brand brand) { item.getKoreanName(), item.getEnglishName(), item.getCode(), - item.getMainImage(), + item.getMainImage() == null ? + imageManager.getUrl(MAIN_IMAGE_DIR_NAME + "/item-basic-main-image.png") : item.getMainImage(), itemImageRepository.findAllByItemId(item.getId()).stream().map(ItemImage::getUrl).toList(), item.getViewCount(), item.getCategory().getDesc(), diff --git a/src/main/java/com/ward/ward_server/global/config/S3Config.java b/src/main/java/com/ward/ward_server/global/config/S3Config.java new file mode 100644 index 0000000..58d6056 --- /dev/null +++ b/src/main/java/com/ward/ward_server/global/config/S3Config.java @@ -0,0 +1,30 @@ +package com.ward.ward_server.global.config; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3Client; +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.accessKey}") + private String accessKey; + + @Value("${cloud.aws.credentials.secretKey}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AmazonS3Client amazonS3() { + BasicAWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + return (AmazonS3Client) AmazonS3ClientBuilder.standard() + .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) + .withRegion(region) + .build(); + } +} diff --git a/src/main/java/com/ward/ward_server/global/config/S3MockConfig.java b/src/main/java/com/ward/ward_server/global/config/S3MockConfig.java new file mode 100644 index 0000000..ddba700 --- /dev/null +++ b/src/main/java/com/ward/ward_server/global/config/S3MockConfig.java @@ -0,0 +1,49 @@ +package com.ward.ward_server.global.config; + +import akka.http.scaladsl.Http; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.AnonymousAWSCredentials; +import com.amazonaws.client.builder.AwsClientBuilder; +import com.amazonaws.regions.Regions; +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import io.findify.s3mock.S3Mock; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.DependsOn; +import org.springframework.context.annotation.Profile; + +@Slf4j +@Profile("test") +@Configuration +public class S3MockConfig { + private int port; + + @Bean(name = "s3Mock") + public S3Mock s3Mock() { + log.debug("s3 mock 빈 생성"); + S3Mock s3Mock = S3Mock.create(0, "s3mock"); + Http.ServerBinding binding = s3Mock.start(); + port = binding.localAddress().getPort(); + log.debug("port:{}", port); + return s3Mock; + } + + @Bean + @DependsOn("s3Mock") + public AmazonS3Client amazonS3() { + log.debug("amazon client 빈 생성"); + AwsClientBuilder.EndpointConfiguration endpoint = + new AwsClientBuilder.EndpointConfiguration( + "http://127.0.0.1:" + port, Regions.AP_NORTHEAST_2.name()); + AmazonS3Client client = (AmazonS3Client) AmazonS3ClientBuilder + .standard() + .withPathStyleAccessEnabled(true) + .withEndpointConfiguration(endpoint) + .withCredentials(new AWSStaticCredentialsProvider(new AnonymousAWSCredentials())) + .build(); + client.createBucket("mock-bucket"); + return client; + } +} diff --git a/src/main/java/com/ward/ward_server/global/util/S3ImageManager.java b/src/main/java/com/ward/ward_server/global/util/S3ImageManager.java new file mode 100644 index 0000000..39f9f4a --- /dev/null +++ b/src/main/java/com/ward/ward_server/global/util/S3ImageManager.java @@ -0,0 +1,76 @@ +package com.ward.ward_server.global.util; + +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.model.CannedAccessControlList; +import com.amazonaws.services.s3.model.DeleteObjectRequest; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.ward.ward_server.global.exception.ApiException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Optional; + +import static com.ward.ward_server.global.exception.ExceptionCode.INVALID_INPUT; +import static com.ward.ward_server.global.response.error.ErrorMessage.FILE_CONVERT_FAIL; + +@RequiredArgsConstructor +@Component +@Slf4j +public class S3ImageManager { + private final AmazonS3Client amazonS3Client; + @Value("${cloud.aws.s3.bucket}") + private String bucket; + @Value("${cloud.aws.cloudFront.domain}") + private String cloudFrontDomain; + + public String upload(MultipartFile multipartFile, String dirName) throws IOException { + File uploadFile = convert(multipartFile) + .orElseThrow(() -> new ApiException(INVALID_INPUT, FILE_CONVERT_FAIL.getMessage())); + log.debug("uploadFile name: {}", uploadFile.getName()); + String filePath = dirName + "/" + uploadFile.getName(); + String uploadImageUrl = putS3(uploadFile, filePath); + uploadFile.delete(); + return uploadImageUrl; + } + + private String putS3(File uploadFile, String fileName) { + amazonS3Client.putObject( + new PutObjectRequest(bucket, fileName, uploadFile) + .withCannedAcl(CannedAccessControlList.PublicRead) + ); + return cloudFrontDomain + "/" + fileName; + } + + public String getUrl(String fileName) { + return cloudFrontDomain + "/" + fileName; + } + + private Optional convert(MultipartFile file) throws IOException { + File convertFile = new File(file.getOriginalFilename()); + log.debug("convertFile name: {}", convertFile.getName()); + if (convertFile.createNewFile()) { + log.debug("create new file"); + try (FileOutputStream fos = new FileOutputStream(convertFile)) { + fos.write(file.getBytes()); + } + } + return Optional.of(convertFile); + } + + public void delete(String url) { + if (url == null) return; + String fileOriginName = extractFileOriginName(url); + log.debug("delete file origin name: {}", fileOriginName); + amazonS3Client.deleteObject(new DeleteObjectRequest(bucket, fileOriginName)); + } + + private String extractFileOriginName(String url) { + return url.substring(cloudFrontDomain.length() + 1); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 85b2ab7..96a2f47 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,7 +2,7 @@ spring: jwt: secretKey: ${JWT_SECRET_KEY} password: ${JWT_PASSWORD} - accessTokenValidity: 15 # 분 단위 + accessTokenValidity: 1500 # 분 단위 refreshTokenValidity: 30 # 일 단위 datasource: @@ -56,4 +56,16 @@ logging: SQL: DEBUG type: descriptor: - sql: TRACE \ No newline at end of file + sql: TRACE + +cloud: + aws: + s3: + bucket: ${S3_BUCKET_NAME} + stack.auto: false + region.static: ${S3_BUCKET_REGION} + credentials: + accessKey: ${S3_ACCESS_KEY} + secretKey: ${S3_SECRET_KEY} + cloudFront: + domain: https://d95395pkgpbo8.cloudfront.net \ No newline at end of file diff --git a/src/test/java/com/ward/ward_server/WardServerApplicationTests.java b/src/test/java/com/ward/ward_server/WardServerApplicationTests.java index 9dc2e25..840e2da 100644 --- a/src/test/java/com/ward/ward_server/WardServerApplicationTests.java +++ b/src/test/java/com/ward/ward_server/WardServerApplicationTests.java @@ -3,7 +3,6 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -@SpringBootTest class WardServerApplicationTests { @Test diff --git a/src/test/java/com/ward/ward_server/api/item/service/BrandServiceTest.java b/src/test/java/com/ward/ward_server/api/item/service/BrandServiceTest.java index b6011cf..a53447a 100644 --- a/src/test/java/com/ward/ward_server/api/item/service/BrandServiceTest.java +++ b/src/test/java/com/ward/ward_server/api/item/service/BrandServiceTest.java @@ -1,44 +1,124 @@ -//package com.ward.ward_server.api.item.service; -// -//import com.ward.ward_server.api.item.dto.BrandRecommendedResponse; -//import com.ward.ward_server.api.item.entity.Brand; -//import com.ward.ward_server.api.item.repository.BrandRepository; -//import org.junit.jupiter.api.Test; -//import org.mockito.InjectMocks; -//import org.mockito.Mock; -//import org.mockito.junit.jupiter.MockitoExtension; -//import org.junit.jupiter.api.extension.ExtendWith; -// -//import java.util.List; -// -//import static org.junit.jupiter.api.Assertions.assertEquals; -//import static org.mockito.Mockito.when; -// -//@ExtendWith(MockitoExtension.class) -//public class BrandServiceTest { -// -// @Mock -// private BrandRepository brandRepository; -// @InjectMocks -// private BrandService brandService; -// -// @Test -// void testGetRecommendedBrands() { -// List mockBrands = List.of( -// Brand.builder().logoImage("https://example.com/logo1.png").koreanName("브랜드1").englishName("Brand1").build(), -// Brand.builder().logoImage("https://example.com/logo2.png").koreanName("브랜드2").englishName("Brand2").build() -// ); -// -// when(brandRepository.findTop10ByOrderByViewCountDesc()).thenReturn(mockBrands); -// -// List result = brandService.getRecommendedBrands(); -// -// assertEquals(2, result.size()); -// assertEquals("브랜드1", result.get(0).koreanName()); -// assertEquals("https://example.com/logo1.png", result.get(0).logoImage()); -// assertEquals("Brand1", result.get(0).englishName()); -// assertEquals("브랜드2", result.get(1).koreanName()); -// assertEquals("https://example.com/logo2.png", result.get(1).logoImage()); -// assertEquals("Brand2", result.get(1).englishName()); -// } -//} +package com.ward.ward_server.api.item.service; + +import com.ward.ward_server.api.item.dto.BrandRecommendedResponse; +import com.ward.ward_server.api.item.dto.BrandResponse; +import com.ward.ward_server.api.item.entity.Brand; +import com.ward.ward_server.api.item.repository.BrandRepository; +import com.ward.ward_server.api.item.repository.ItemRepository; +import com.ward.ward_server.api.releaseInfo.repository.ReleaseInfoRepository; +import com.ward.ward_server.global.util.S3ImageManager; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class BrandServiceTest { + @Mock + private BrandRepository brandRepository; + @Mock + private ItemRepository itemRepository; + @Mock + private ReleaseInfoRepository releaseInfoRepository; + @Mock + private S3ImageManager s3ImageManager; + @InjectMocks + private BrandService brandService; + + @Test + void testGetRecommendedBrands() { + List mockBrands = List.of( + Brand.builder().logoImage("https://example.com/logo1.png").koreanName("브랜드1").englishName("Brand1").build(), + Brand.builder().logoImage("https://example.com/logo2.png").koreanName("브랜드2").englishName("Brand2").build() + ); + + when(brandRepository.findTop10ByOrderByViewCountDesc()).thenReturn(mockBrands); + + List result = brandService.getRecommendedBrands(); + + assertEquals(2, result.size()); + assertEquals("브랜드1", result.get(0).koreanName()); + assertEquals("https://example.com/logo1.png", result.get(0).logoImage()); + assertEquals("Brand1", result.get(0).englishName()); + assertEquals("브랜드2", result.get(1).koreanName()); + assertEquals("https://example.com/logo2.png", result.get(1).logoImage()); + assertEquals("Brand2", result.get(1).englishName()); + } + + @Test + void 브랜드_생성시_로고_이미지를_입력하지_않으면_기본_이미지로_출력한다() throws IOException { + //given + long id = 1L; + String koreanName = "전 브랜드한글이름"; + String englishName = "before brand englishName"; + Brand mockBrand = Brand.builder() + .koreanName(koreanName) + .englishName(englishName) + .logoImage(null) + .build(); + ReflectionTestUtils.setField(mockBrand, "id", id); + when(brandRepository.save(any())).thenReturn(mockBrand); + + when(brandRepository.existsByKoreanNameOrEnglishName(koreanName, englishName)).thenReturn(false); + String mockBasicLogoImageUrl = "https://mock.cloudfront.net"; + when(s3ImageManager.getUrl(Mockito.anyString())).thenReturn(mockBasicLogoImageUrl); + //when + BrandResponse result = brandService.createBrand(koreanName, englishName, null); + //then + assertThat(result.id()).isEqualTo(id); + assertThat(result.koreanName()).isEqualTo(koreanName); + assertThat(result.englishName()).isEqualTo(englishName); + assertThat(result.logoImage()).isEqualTo(mockBasicLogoImageUrl); + } + + @Test + void 브랜드_수정_로직을_확인한다() throws IOException { + //given + long originBrandId = 1L; + String originBrandKoreanName = "전 브랜드한글이름"; + String originBrandEnglishName = "before brand englishName"; + String originBrandLogoImage = "https://mock-before-brand-logo-image.net"; + Brand mockBrand = Brand.builder() + .koreanName(originBrandKoreanName) + .englishName(originBrandEnglishName) + .logoImage(originBrandLogoImage) + .build(); + ReflectionTestUtils.setField(mockBrand, "id", originBrandId); + when(brandRepository.findById(originBrandId)).thenReturn(Optional.of(mockBrand)); + + String targetBrandKoreanName = "수정후 브랜드한글이름"; + String targetBrandEnglishName = "after brand englishName"; + MultipartFile targetMultipartFile = new MockMultipartFile( + "test", + "mock-logo.png", + "image/png", + "test-logo".getBytes() + ); + + String mockBrandLogoImageUrl = "https://mock.cloudfront.net"; + when(s3ImageManager.upload(eq(targetMultipartFile), anyString())).thenReturn(mockBrandLogoImageUrl); + + //when + BrandResponse result = brandService.updateBrand(originBrandId, targetBrandKoreanName, targetBrandEnglishName, targetMultipartFile); + + //then + assertThat(result.id()).isEqualTo(originBrandId); + assertThat(result.koreanName()).isEqualTo(targetBrandKoreanName); + assertThat(result.englishName()).isEqualTo(targetBrandEnglishName); + assertThat(result.logoImage()).isEqualTo(mockBrandLogoImageUrl); + } +} diff --git a/src/test/java/com/ward/ward_server/api/item/service/ItemServiceTest.java b/src/test/java/com/ward/ward_server/api/item/service/ItemServiceTest.java index 1b79a85..78b5611 100644 --- a/src/test/java/com/ward/ward_server/api/item/service/ItemServiceTest.java +++ b/src/test/java/com/ward/ward_server/api/item/service/ItemServiceTest.java @@ -1,37 +1,184 @@ -//package com.ward.ward_server.api.item.service; -// -//import com.ward.ward_server.api.item.entity.enums.Category; -//import com.ward.ward_server.api.item.repository.ItemRepository; -//import com.ward.ward_server.global.Object.enums.Section; -//import com.ward.ward_server.global.exception.ApiException; -//import org.junit.jupiter.api.Test; -//import org.junit.jupiter.api.extension.ExtendWith; -//import org.mockito.InjectMocks; -//import org.mockito.Mock; -//import org.mockito.Mockito; -//import org.mockito.junit.jupiter.MockitoExtension; -// -//import static com.ward.ward_server.global.exception.ExceptionCode.INVALID_INPUT; -//import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -// -//@ExtendWith(MockitoExtension.class) -//class ItemServiceTest { -// @Mock -// ItemRepository itemRepository; -// @InjectMocks -// ItemService itemService; -// -// @Test -// void 제공하지_않는_섹션으로_접근시_예외를_발생한다_10List() { -// assertThatExceptionOfType(ApiException.class) -// .isThrownBy(() -> itemService.getItem10List(1L, Section.CLOSED, Category.FOOTWEAR)) -// .withMessage(INVALID_INPUT.getMessage()); -// } -// -// @Test -// void 제공하지_않는_섹션으로_접근시_예외를_발생한다_page() { -// assertThatExceptionOfType(ApiException.class) -// .isThrownBy(() -> itemService.getItemPage(1L, Section.REGISTER_TODAY, Category.FOOTWEAR, 1, "2024-07")) -// .withMessage(INVALID_INPUT.getMessage()); -// } -//} \ No newline at end of file +package com.ward.ward_server.api.item.service; + +import com.ward.ward_server.api.item.dto.ItemDetailResponse; +import com.ward.ward_server.api.item.entity.Brand; +import com.ward.ward_server.api.item.entity.Item; +import com.ward.ward_server.api.item.entity.enums.Category; +import com.ward.ward_server.api.item.repository.*; +import com.ward.ward_server.global.Object.enums.Section; +import com.ward.ward_server.global.exception.ApiException; +import com.ward.ward_server.global.util.S3ImageManager; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Optional; + +import static com.ward.ward_server.global.exception.ExceptionCode.INVALID_INPUT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ItemServiceTest { + @Mock + ItemRepository itemRepository; + @Mock + BrandRepository brandRepository; + @Mock + ItemImageRepository itemImageRepository; + @Mock + ItemViewCountRepository itemViewCountRepository; + @Mock + ItemTopRankRepository itemTopRankRepository; + @Mock + S3ImageManager s3ImageManager; + @InjectMocks + ItemService itemService; + + @Test + void 제공하지_않는_섹션으로_접근시_예외를_발생한다_10List() { + assertThatExceptionOfType(ApiException.class) + .isThrownBy(() -> itemService.getItem10List(1L, Section.CLOSED, Category.FOOTWEAR)) + .withMessage(INVALID_INPUT.getMessage()); + } + + @Test + void 제공하지_않는_섹션으로_접근시_예외를_발생한다_page() { + assertThatExceptionOfType(ApiException.class) + .isThrownBy(() -> itemService.getItemPage(1L, Section.REGISTER_TODAY, Category.FOOTWEAR, 1, "2024-07")) + .withMessage(INVALID_INPUT.getMessage()); + } + + @Test + void 상품_생성시_메인_이미지를_입력하지_않으면_기본_이미지로_출력한다() throws IOException { + //given + long itemId = 1L; + String itemCode = "상품코드"; + String itemKoreanName = "상품한글이름"; + String itemEnglishName = "itemEnglishName"; + Category category = Category.FOOTWEAR; + int price = 10000; + Item mockItem = Item.builder() + .code(itemCode) + .koreanName(itemKoreanName) + .englishName(itemEnglishName) + .category(category) + .price(price) + .build(); + ReflectionTestUtils.setField(mockItem, "id", itemId); + when(itemRepository.save(Mockito.any())).thenReturn(mockItem); + + long brandId = 1L; + String brandKoreanName = "브랜드한글이름"; + String brandEnglishName = "brandEnglishName"; + String brandLogoImage = "https://mock-brand-logo-image.net"; + Brand mockBrand = Brand.builder() + .koreanName(brandKoreanName) + .englishName(brandEnglishName) + .logoImage(brandLogoImage) + .build(); + ReflectionTestUtils.setField(mockBrand, "id", brandId); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(mockBrand)); + + String mockBasicMainImageUrl = "https://mock.cloudfront.net"; + when(s3ImageManager.getUrl(Mockito.anyString())).thenReturn(mockBasicMainImageUrl); + + when(itemRepository.existsByCodeAndBrandId(itemCode, brandId)).thenReturn(false); + + //when + ItemDetailResponse result = itemService.createItem(itemCode, itemKoreanName, itemEnglishName, brandId, category, price, null, null); + + //then + assertThat(result.itemId()).isEqualTo(itemId); + assertThat(result.itemKoreanName()).isEqualTo(itemKoreanName); + assertThat(result.itemEnglishName()).isEqualTo(itemEnglishName); + assertThat(result.itemCode()).isEqualTo(itemCode); + assertThat(result.mainImage()).isEqualTo(mockBasicMainImageUrl); + assertThat(result.itemImages()).isEqualTo(new ArrayList<>()); + assertThat(result.viewCount()).isEqualTo(0); + assertThat(result.category()).isEqualTo(category.getDesc()); + assertThat(result.price()).isEqualTo(price); + + assertThat(result.brandId()).isEqualTo(brandId); + assertThat(result.brandKoreanName()).isEqualTo(brandKoreanName); + assertThat(result.brandEnglishName()).isEqualTo(brandEnglishName); + assertThat(result.brandLogoImage()).isEqualTo(brandLogoImage); + } + + @Test + void 이미지를_제외한_상품_수정_로직을_확인한다() throws IOException { + //given + long itemId = 1L; + String originItemCode = "전 상품코드"; + String originItemKoreanName = "전 상품한글이름"; + String originItemEnglishName = "before item englishName"; + Category originCategory = Category.FOOTWEAR; + int originPrice = 10000; + Item mockItem = Item.builder() + .code(originItemCode) + .koreanName(originItemKoreanName) + .englishName(originItemEnglishName) + .category(originCategory) + .price(originPrice) + .build(); + ReflectionTestUtils.setField(mockItem, "id", itemId); + when(itemRepository.findById(itemId)).thenReturn(Optional.of(mockItem)); + + long targetBrandId = 2L; + String targetBrandKoreanName = "수정후 브랜드한글이름"; + String targetBrandEnglishName = "after brand englishName"; + String targetBrandLogoImage = "https://mock-after-brand-logo-image.net"; + Brand mockTargetBrand = Brand.builder() + .koreanName(targetBrandKoreanName) + .englishName(targetBrandEnglishName) + .logoImage(targetBrandLogoImage) + .build(); + ReflectionTestUtils.setField(mockTargetBrand, "id", targetBrandId); + when(brandRepository.findById(targetBrandId)).thenReturn(Optional.of(mockTargetBrand)); + + when(itemRepository.existsByCodeAndBrandId(Mockito.anyString(), Mockito.anyLong())).thenReturn(false); + + String mockBasicMainImageUrl = "https://mock.cloudfront.net"; + when(s3ImageManager.getUrl(Mockito.anyString())).thenReturn(mockBasicMainImageUrl); + + String targetItemKoreanName = "수정후 상품한글이름"; + String targetItemEnglishName = "after item englishName"; + String targetItemCode = "수정후 상품코드"; + Category targetCategory = Category.ACCESSORY; + int targetPrice = 20000; + + //when + ItemDetailResponse result = itemService.updateItem(itemId, + targetItemKoreanName, targetItemEnglishName, targetItemCode, targetBrandId, targetCategory, targetPrice, + null, null); + + //then + assertThat(result.itemId()).isEqualTo(itemId); + assertThat(result.itemKoreanName()).isEqualTo(targetItemKoreanName); + assertThat(result.itemEnglishName()).isEqualTo(targetItemEnglishName); + assertThat(result.itemCode()).isEqualTo(targetItemCode); + assertThat(result.mainImage()).isEqualTo(mockBasicMainImageUrl); + assertThat(result.itemImages()).isEqualTo(new ArrayList<>()); + assertThat(result.viewCount()).isEqualTo(0); + assertThat(result.category()).isEqualTo(targetCategory.getDesc()); + assertThat(result.price()).isEqualTo(targetPrice); + + assertThat(result.brandId()).isEqualTo(targetBrandId); + assertThat(result.brandKoreanName()).isEqualTo(targetBrandKoreanName); + assertThat(result.brandEnglishName()).isEqualTo(targetBrandEnglishName); + assertThat(result.brandLogoImage()).isEqualTo(targetBrandLogoImage); + } + + @Test + void 이미지_수정_로직을_확인한다(){ + //TODO + } +} \ No newline at end of file diff --git a/src/test/java/com/ward/ward_server/global/util/S3ImageManagerTest.java b/src/test/java/com/ward/ward_server/global/util/S3ImageManagerTest.java new file mode 100644 index 0000000..3d03fae --- /dev/null +++ b/src/test/java/com/ward/ward_server/global/util/S3ImageManagerTest.java @@ -0,0 +1,68 @@ +package com.ward.ward_server.global.util; + +import com.ward.ward_server.global.config.S3MockConfig; +import io.findify.s3mock.S3Mock; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.util.ResourceUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +@Slf4j +@ActiveProfiles("test") +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = { + S3ImageManager.class, + S3MockConfig.class +}) +class S3ImageManagerTest { + @Autowired + private S3Mock s3Mock; + @Autowired + private S3ImageManager s3ImageManager; + + @AfterEach + public void tearDown() { + log.debug("s3mock server stop"); + s3Mock.stop(); + } + + @Test + void 이미지_업로드() throws IOException { + // given + String dirName = "main"; + String mockBucketName = "mock-bucket"; + String mockCloudFrontDomain = "https://mock.cloudfront.net"; + File file = ResourceUtils.getFile("classpath:test-image.png"); + MultipartFile multipartFile = new MockMultipartFile( + "test", + file.getName(), + "image/png", + new FileInputStream(file) + ); + ReflectionTestUtils.setField(s3ImageManager, "bucket", mockBucketName); + ReflectionTestUtils.setField(s3ImageManager, "cloudFrontDomain", mockCloudFrontDomain); + + // when + String urlPath = s3ImageManager.upload(multipartFile, dirName); + + // then + assertThat(urlPath).contains(mockCloudFrontDomain); + assertThat(urlPath).contains(dirName); + assertThat(urlPath).contains(file.getName()); + } +} \ No newline at end of file diff --git a/src/test/resources/test-image.png b/src/test/resources/test-image.png new file mode 100644 index 0000000..1176d78 Binary files /dev/null and b/src/test/resources/test-image.png differ