From c1d9600296d0ef9aedd345eb6434d58990d80545 Mon Sep 17 00:00:00 2001 From: junho <2171168@hansung.ac.kr> Date: Wed, 3 Sep 2025 01:29:03 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20aws=20=EC=84=A4=EC=A0=95,=20s3=20?= =?UTF-8?q?=EC=82=AC=EC=A7=84=20=EC=97=85=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 5 ++ .../global/aws/s3/AmazonS3Manager.java | 34 +++++++++++ .../DecodEat/global/config/AmazonConfig.java | 59 +++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 src/main/java/com/DecodEat/global/aws/s3/AmazonS3Manager.java create mode 100644 src/main/java/com/DecodEat/global/config/AmazonConfig.java diff --git a/build.gradle b/build.gradle index 540ce5b..a979070 100644 --- a/build.gradle +++ b/build.gradle @@ -47,6 +47,11 @@ dependencies { //springdoc-openapi implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' // 최신 버전 확인 + + // - AWS 서비스(Spring Cloud와 S3, SES 등)와의 통합을 쉽게 해주며, Spring Boot에서 여러 AWS 관련 자동 설정을 지원 + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + // S3 설정 + implementation 'com.amazonaws:aws-java-sdk-s3:1.12.649' } tasks.named('test') { diff --git a/src/main/java/com/DecodEat/global/aws/s3/AmazonS3Manager.java b/src/main/java/com/DecodEat/global/aws/s3/AmazonS3Manager.java new file mode 100644 index 0000000..608a45d --- /dev/null +++ b/src/main/java/com/DecodEat/global/aws/s3/AmazonS3Manager.java @@ -0,0 +1,34 @@ +package com.DecodEat.global.aws.s3; + +import com.DecodEat.global.config.AmazonConfig; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AmazonS3Manager{ + + private final AmazonS3 amazonS3; + + private final AmazonConfig amazonConfig; + + public String uploadFile(String keyName, MultipartFile file){ + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + try { + amazonS3.putObject(new PutObjectRequest(amazonConfig.getBucket(), keyName, file.getInputStream(), metadata)); + } catch (IOException e){ + log.error("error at AmazonS3Manager uploadFile : {}", (Object) e.getStackTrace()); + } + + return amazonS3.getUrl(amazonConfig.getBucket(), keyName).toString(); + } +} diff --git a/src/main/java/com/DecodEat/global/config/AmazonConfig.java b/src/main/java/com/DecodEat/global/config/AmazonConfig.java new file mode 100644 index 0000000..4c8a7da --- /dev/null +++ b/src/main/java/com/DecodEat/global/config/AmazonConfig.java @@ -0,0 +1,59 @@ +package com.DecodEat.global.config; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSCredentialsProvider; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Slf4j +@Configuration +@Getter +public class AmazonConfig { + + private AWSCredentials awsCredentials; + + @Value("${cloud.aws.credentials.accessKey}") + private String accessKey; + + @Value("${cloud.aws.credentials.secretKey}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + // 추가: 버킷 이름 프로퍼티 주입 + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + private String productImagePath; + + private String productInfoImagePath; + + @PostConstruct + public void init() { + this.awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + } + + @Bean + public AmazonS3 amazonS3() { + AWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + return (AmazonS3Client) AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) + .build(); + } + + @Bean + public AWSCredentialsProvider awsCredentialsProvider() { + return new AWSStaticCredentialsProvider(awsCredentials); + } +} From 3a404a71081c655157ce14f7879257f060daad6b Mon Sep 17 00:00:00 2001 From: junho <2171168@hansung.ac.kr> Date: Wed, 3 Sep 2025 13:41:41 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=A0=9C=ED=92=88=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ProductController.java | 32 ++++++++++- .../products/converter/ProductConverter.java | 14 ++++- .../request/ProductRegisterRequestDto.java | 28 ++++++++++ .../response/ProductRegisterResponseDto.java | 24 +++++++++ .../products/service/ProductService.java | 53 ++++++++++++++++++- 5 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/DecodEat/domain/products/dto/request/ProductRegisterRequestDto.java create mode 100644 src/main/java/com/DecodEat/domain/products/dto/response/ProductRegisterResponseDto.java diff --git a/src/main/java/com/DecodEat/domain/products/controller/ProductController.java b/src/main/java/com/DecodEat/domain/products/controller/ProductController.java index d792ac3..60479d3 100644 --- a/src/main/java/com/DecodEat/domain/products/controller/ProductController.java +++ b/src/main/java/com/DecodEat/domain/products/controller/ProductController.java @@ -1,13 +1,22 @@ package com.DecodEat.domain.products.controller; import com.DecodEat.domain.products.dto.response.ProductDetailDto; +import com.DecodEat.domain.products.dto.request.ProductRegisterRequestDto; +import com.DecodEat.domain.products.dto.response.ProductRegisterResponseDto; import com.DecodEat.domain.products.service.ProductService; +import com.DecodEat.domain.users.entity.User; import com.DecodEat.global.apiPayload.ApiResponse; +import com.DecodEat.global.common.annotation.CurrentUser; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Slice; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; @RestController @RequiredArgsConstructor @@ -23,4 +32,25 @@ public class ProductController { public ApiResponse getProduct(@PathVariable Long id) { return ApiResponse.onSuccess(productService.getDetail(id)); } + + @Operation( + summary = "제품 등록", + description = "상품 이미지, 제품명, 회사명으로 상품을 등록합니다") + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) +//- **@RequestBody**: HTTP 요청 본문을 처리하며 주로 JSON, XML 데이터를 객체로 변환할 때 사용됩니다. +// +//- **@ModelAttribute**: 요청 파라미터와 폼 데이터를 처리하며, 주로 HTML 폼 데이터와 관련된 작업에 사용됩니다. +// +//- **@ParameterObject**: Springdoc OpenAPI와 함께 사용되어 API 문서화를 돕고, 여러 요청 파라미터를 하나의 객체로 그룹화하는데 사용됩니다. + + public ApiResponse addProduct( + @CurrentUser User user, + @ParameterObject ProductRegisterRequestDto request, + @RequestPart("productImage") MultipartFile productImage, + @RequestPart("productInfoImages") List productInfoImages + ) { + return ApiResponse.onSuccess(productService.addProduct(user, request, productImage, productInfoImages)); + } + + } diff --git a/src/main/java/com/DecodEat/domain/products/converter/ProductConverter.java b/src/main/java/com/DecodEat/domain/products/converter/ProductConverter.java index c8e3c32..5c3e2ce 100644 --- a/src/main/java/com/DecodEat/domain/products/converter/ProductConverter.java +++ b/src/main/java/com/DecodEat/domain/products/converter/ProductConverter.java @@ -1,6 +1,7 @@ package com.DecodEat.domain.products.converter; import com.DecodEat.domain.products.dto.response.ProductDetailDto; +import com.DecodEat.domain.products.dto.response.ProductRegisterResponseDto; import com.DecodEat.domain.products.entity.Product; import com.DecodEat.domain.products.entity.ProductNutrition; import com.DecodEat.domain.products.entity.RawMaterial.RawMaterialCategory; @@ -13,7 +14,7 @@ public class ProductConverter { public static ProductDetailDto toProductDetailDto(Product product, - List imageUrls , + List productInfoImageUrls , ProductNutrition productNutrition) { Map> nutrientsMap = product.getIngredients().stream() @@ -43,7 +44,7 @@ public static ProductDetailDto toProductDetailDto(Product product, .sodium(productNutrition.getSodium()) .sugar(productNutrition.getSugar()) .transFat(productNutrition.getTransFat()) - .imageUrl(imageUrls) + .imageUrl(productInfoImageUrls) .animalProteins(nutrientsMap.get(ANIMAL_PROTEIN)) .plantProteins(nutrientsMap.get(PLANT_PROTEIN)) .complexCarbs(nutrientsMap.get(COMPLEX_CARBOHYDRATE)) @@ -53,4 +54,13 @@ public static ProductDetailDto toProductDetailDto(Product product, .allergens(nutrientsMap.get(ALLERGENS)) .build(); } + + public static ProductRegisterResponseDto toProductRegisterDto(Product product, List productInfoImageUrls){ + return ProductRegisterResponseDto.builder() + .name(product.getProductName()) + .manufacturer(product.getManufacturer()) + .productImage(product.getProductImage()) + .productInfoImages(productInfoImageUrls) + .build(); + } } diff --git a/src/main/java/com/DecodEat/domain/products/dto/request/ProductRegisterRequestDto.java b/src/main/java/com/DecodEat/domain/products/dto/request/ProductRegisterRequestDto.java new file mode 100644 index 0000000..eb76299 --- /dev/null +++ b/src/main/java/com/DecodEat/domain/products/dto/request/ProductRegisterRequestDto.java @@ -0,0 +1,28 @@ +package com.DecodEat.domain.products.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ProductRegisterRequestDto { + @NotBlank(message = "제품명은 필수 입력 항목입니다.") + private String name; + + @NotBlank(message = "제조사명은 필수 입력 항목입니다.") + private String manufacturer; + +// 파일은 json 과 하나의 dto로 묶어서 다룰 수 없음 +// private MultipartFile productImage; +// @NotNull +// private List productInfoImages; +} diff --git a/src/main/java/com/DecodEat/domain/products/dto/response/ProductRegisterResponseDto.java b/src/main/java/com/DecodEat/domain/products/dto/response/ProductRegisterResponseDto.java new file mode 100644 index 0000000..9248aaa --- /dev/null +++ b/src/main/java/com/DecodEat/domain/products/dto/response/ProductRegisterResponseDto.java @@ -0,0 +1,24 @@ +package com.DecodEat.domain.products.dto.response; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ProductRegisterResponseDto { + @NotNull + private String name; + @NotNull + private String manufacturer; + + private String productImage; + @NotNull + private List productInfoImages; +} diff --git a/src/main/java/com/DecodEat/domain/products/service/ProductService.java b/src/main/java/com/DecodEat/domain/products/service/ProductService.java index 346fca3..85c5353 100644 --- a/src/main/java/com/DecodEat/domain/products/service/ProductService.java +++ b/src/main/java/com/DecodEat/domain/products/service/ProductService.java @@ -1,20 +1,29 @@ package com.DecodEat.domain.products.service; import com.DecodEat.domain.products.converter.ProductConverter; +import com.DecodEat.domain.products.dto.request.ProductRegisterRequestDto; import com.DecodEat.domain.products.dto.response.ProductDetailDto; +import com.DecodEat.domain.products.dto.response.ProductRegisterResponseDto; +import com.DecodEat.domain.products.entity.DecodeStatus; import com.DecodEat.domain.products.entity.Product; import com.DecodEat.domain.products.entity.ProductInfoImage; import com.DecodEat.domain.products.entity.ProductNutrition; import com.DecodEat.domain.products.repository.ProductImageRepository; import com.DecodEat.domain.products.repository.ProductNutritionRepository; import com.DecodEat.domain.products.repository.ProductRepository; -import static com.DecodEat.global.apiPayload.code.status.ErrorStatus.*; +import com.DecodEat.domain.users.entity.User; +import com.DecodEat.global.aws.s3.AmazonS3Manager; import com.DecodEat.global.exception.GeneralException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import static com.DecodEat.global.apiPayload.code.status.ErrorStatus.PRODUCT_NOT_EXISTED; @Service @RequiredArgsConstructor @@ -23,6 +32,7 @@ public class ProductService { private final ProductRepository productRepository; private final ProductImageRepository productImageRepository; private final ProductNutritionRepository productNutritionRepository; + private final AmazonS3Manager amazonS3Manager; public ProductDetailDto getDetail(Long id) { Product product = productRepository.findById(id).orElseThrow(() -> new GeneralException(PRODUCT_NOT_EXISTED)); @@ -34,4 +44,43 @@ public ProductDetailDto getDetail(Long id) { return ProductConverter.toProductDetailDto(product, imageUrls, productNutrition); } -} + + public ProductRegisterResponseDto addProduct(User user, ProductRegisterRequestDto requestDto, MultipartFile productImage, List productInfoImages) { + String productName = requestDto.getName(); + String manufacturer = requestDto.getManufacturer(); + + String productImageUrl = null; + if (productImage != null && !productImage.isEmpty()) { + String productImageKey = "products/" + UUID.randomUUID() + "_" + productImage.getOriginalFilename(); + productImageUrl = amazonS3Manager.uploadFile(productImageKey, productImage); + } + + + Product newProduct = Product.builder() + .user(user) + .productName(productName) + .manufacturer(manufacturer) + .productImage(productImageUrl) + .decodeStatus(DecodeStatus.PROCESSING) + .build(); + + Product savedProduct = productRepository.save(newProduct); + + List productInfoImageUrls = null; + if (productInfoImages != null && !productInfoImages.isEmpty()) { + List infoImages = productInfoImages.stream().map(image -> { + String imageKey = "products/info/" + UUID.randomUUID() + "_" + image.getOriginalFilename(); + String imageUrl = amazonS3Manager.uploadFile(imageKey, image); + return ProductInfoImage.builder() + .product(savedProduct) + .imageUrl(imageUrl) + .build(); + }).collect(Collectors.toList()); + productImageRepository.saveAll(infoImages); + + productInfoImageUrls = infoImages.stream().map(ProductInfoImage::getImageUrl).toList(); + } + + return ProductConverter.toProductRegisterDto(savedProduct,productInfoImageUrls) ; + } +} \ No newline at end of file From 724bcc91e0076988f1aac60f1f0a6660473fd3bd Mon Sep 17 00:00:00 2001 From: junho <2171168@hansung.ac.kr> Date: Wed, 3 Sep 2025 23:38:22 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=EC=A0=9C=ED=92=88=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ProductController.java | 20 +++++++++---------- .../controller/TokenController.java | 2 +- .../global/config/WebOAuthSecurityConfig.java | 16 +++++++++------ 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/DecodEat/domain/products/controller/ProductController.java b/src/main/java/com/DecodEat/domain/products/controller/ProductController.java index 60479d3..3dbb29f 100644 --- a/src/main/java/com/DecodEat/domain/products/controller/ProductController.java +++ b/src/main/java/com/DecodEat/domain/products/controller/ProductController.java @@ -36,20 +36,20 @@ public ApiResponse getProduct(@PathVariable Long id) { @Operation( summary = "제품 등록", description = "상품 이미지, 제품명, 회사명으로 상품을 등록합니다") - @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) -//- **@RequestBody**: HTTP 요청 본문을 처리하며 주로 JSON, XML 데이터를 객체로 변환할 때 사용됩니다. -// -//- **@ModelAttribute**: 요청 파라미터와 폼 데이터를 처리하며, 주로 HTML 폼 데이터와 관련된 작업에 사용됩니다. -// -//- **@ParameterObject**: Springdoc OpenAPI와 함께 사용되어 API 문서화를 돕고, 여러 요청 파라미터를 하나의 객체로 그룹화하는데 사용됩니다. - - public ApiResponse addProduct( + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) //이 엔드포인트가 multipart/form-data 타입의 요청 본문을 소비(consume)한다는 것을 명확하게 선언 + public ApiResponse registerProduct( @CurrentUser User user, - @ParameterObject ProductRegisterRequestDto request, + @RequestParam("name") String name, + @RequestParam("manufacturer") String manufacturer, @RequestPart("productImage") MultipartFile productImage, @RequestPart("productInfoImages") List productInfoImages ) { - return ApiResponse.onSuccess(productService.addProduct(user, request, productImage, productInfoImages)); + ProductRegisterRequestDto requestDto = ProductRegisterRequestDto.builder() + .name(name) + .manufacturer(manufacturer) + .build(); + + return ApiResponse.onSuccess(productService.addProduct(user, requestDto, productImage, productInfoImages)); } diff --git a/src/main/java/com/DecodEat/domain/refreshToken/controller/TokenController.java b/src/main/java/com/DecodEat/domain/refreshToken/controller/TokenController.java index 48aa5fe..ccae45f 100644 --- a/src/main/java/com/DecodEat/domain/refreshToken/controller/TokenController.java +++ b/src/main/java/com/DecodEat/domain/refreshToken/controller/TokenController.java @@ -1,4 +1,4 @@ -package com.DecodEat.domain.RefreshToken.controller; +package com.DecodEat.domain.refreshToken.controller; import com.DecodEat.domain.RefreshToken.dto.request.CreateAccessTokenRequest; import com.DecodEat.domain.RefreshToken.dto.response.CreateAccessTokenResponse; diff --git a/src/main/java/com/DecodEat/global/config/WebOAuthSecurityConfig.java b/src/main/java/com/DecodEat/global/config/WebOAuthSecurityConfig.java index 1797ddb..0db5238 100644 --- a/src/main/java/com/DecodEat/global/config/WebOAuthSecurityConfig.java +++ b/src/main/java/com/DecodEat/global/config/WebOAuthSecurityConfig.java @@ -20,6 +20,7 @@ import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.web.cors.CorsConfigurationSource; @RequiredArgsConstructor @Configuration @@ -29,13 +30,14 @@ public class WebOAuthSecurityConfig { private final JwtTokenProvider tokenProvider; private final RefreshTokenRepository refreshTokenRepository; private final UserService userService; + private final CorsConfigurationSource corsConfigurationSource; // CorsCongifuragtinoSource Bean 주입 위함 - @Bean - public WebSecurityCustomizer configure() { - // H2 콘솔 및 정적 리소스에 대한 시큐리티 기능 비활성화 - return (web) -> web.ignoring() - .requestMatchers("/img/**", "/css/**", "/js/**", "/favicon.ico", "/error"); - } +// @Bean +// public WebSecurityCustomizer configure() { +// // H2 콘솔 및 정적 리소스에 대한 시큐리티 기능 비활성화 +// return (web) -> web.ignoring() +// .requestMatchers("/img/**", "/css/**", "/js/**", "/favicon.ico", "/error"); +// } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -45,11 +47,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti // 2. 불필요한 기능 비활성화 http.csrf(csrf -> csrf.disable()) // CSRF 보호 비활성화 (토큰 방식이므로) + .cors(cors -> cors.configurationSource(corsConfigurationSource)) .httpBasic(basic -> basic.disable()) // HTTP Basic 인증 비활성화 .formLogin(form -> form.disable()); // 폼 기반 로그인 비활성화 // 3. 요청별 인가 규칙 설정 http.authorizeHttpRequests(auth -> auth + .requestMatchers("/img/**", "/css/**", "/js/**", "/favicon.ico", "/error").permitAll() .requestMatchers("/swagger-ui/**","/v3/api-docs/**").permitAll() // 토큰 재발급 요청은 누구나 가능 .requestMatchers("/api/token").permitAll() .requestMatchers("/api/users/**").hasAnyRole("USER", "ADMIN") // 유저 관련 API는 USER 또는 ADMIN 권한 필요