diff --git a/build.gradle b/build.gradle index 88bcc8c..cd1e991 100644 --- a/build.gradle +++ b/build.gradle @@ -48,6 +48,10 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + // 우선 S3에 업로드, + // 즉 외부 API를 사용하기 위해 아래의 의존성을 + // build.gradle에 추가 해줍시다. + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' } tasks.named('test') { @@ -56,6 +60,7 @@ tasks.named('test') { +// 11주차 jar{ enabled = false } \ No newline at end of file diff --git a/src/main/java/umc/spring/aws/s3/AmazonS3Manager.java b/src/main/java/umc/spring/aws/s3/AmazonS3Manager.java new file mode 100644 index 0000000..47b9bb7 --- /dev/null +++ b/src/main/java/umc/spring/aws/s3/AmazonS3Manager.java @@ -0,0 +1,93 @@ +package umc.spring.aws.s3; + +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 umc.spring.config.AmazonConfig; +import umc.spring.domain.Uuid; +import umc.spring.repository.UuidRepository; + +import java.io.IOException; + +// 12주차 사진도 같이 업로드하기 + +// 이제 다음으로 AWS S3에 사진을 업로드 하는 매서드를 가진 +// AmazonS3Manager라는 클래스를 만들어서 사용합시다. +// S3 말고도 다른 AWS의 서비스를 사용 할 수 있으니 +// aws라는 폴더에 s3라는 폴더를 만들고 +// 하위에 AmazonS3Manager를 만들어 두겠습니다. +@Slf4j +@Component +@RequiredArgsConstructor +public class AmazonS3Manager { + + private final AmazonS3 amazonS3; + // amazonS3는 저희가 만들 필요는 없고 build.gradle에서 + // amazon 관련 외부 모듈을 받으면 사용이 가능합니다. + // 해당 amazonS3가 제공하는 메서드를 사용하면 됩니다. + private final AmazonConfig amazonConfig; + // amazonConfig는 S3를 사용하기 위해서 필요한 + // 인증에 대한 과정 등이 포함이 됩니다. + // 인증이 되었다는 것을 가지고 AmazonS3를 사용해서 + // 파일을 업로드하면 됩니다 + private final UuidRepository uuidRepository; + + // AWS S3를 사용해서 파일을 업로드하고 그 결과로 + // 우리가 필요한 것은 파일의 URL입니다. + // uploadFile은 내부적으로 Amazon이 제공하는 + // putObject 메서드를 사용 할 것입니다. + public String uploadFile(String KeyName, MultipartFile file){ + + System.out.println(KeyName); + + ObjectMetadata metadata = new ObjectMetadata(); + // metaData는 필수는 아니고 추가적인 정보를 담아주는 것입니다. + metadata.setContentLength(file.getSize()); + + try { + + amazonS3.putObject(new PutObjectRequest(amazonConfig.getBucket(), KeyName, file.getInputStream(), metadata)); + // putObject 메서드는 PutObjectRequest를 + // 파라미터로 받아 S3 버킷에 저장합니다. + // amazonS3의 putObject 매서드에 파라미터로 + // PutObjectReqeust를 담는 것을 확인 가능하며, + // PutObjectRequest를 만들 때, + // 어떤 S3의 버킷에 올릴지, 식별 할 이름은 무엇인지, + // 그리고 파일의 데이터는 무엇인지를 담죠. + // keyName과 file의 경우는 당연히 + // 해당 매서드를 호출할 서비스 계층에서 담아줍니다! + + // 이제 저희가 S3에 사진을 업로드 할 때 + // 이렇게 요청을 해야합니다. + // 어떤 버킷의 특정 디렉토리에 이런 식별자로 + // 업로드 해줘! + // amazonConfig.getBucket() + // 어떤 버킷인지 가져온다 + // KeyName + // 어떤 디렉토리의 어떤 식별자인지 KeyName으로 지정한다 + + }catch (IOException e){ + + log.error("error at AmazonS3Manager uploadFile : {}", (Object) e.getStackTrace()); + + } + + return amazonS3.getUrl(amazonConfig.getBucket(), KeyName).toString(); + // 우리는 getUrl 메서드를 이용해서 버킷에 저장된 + // 파일의 URL을 받아서 최종적으로 return합니다. + + } + + // 이제 KeyName을 만들어서 리턴 해주는 매서드를 + // Manager에 추가해봅시다! + public String generateReviewKeyName(Uuid uuid){ + + return amazonConfig.getReviewPath() + '/' + uuid.getUuid(); + + } + +} diff --git a/src/main/java/umc/spring/config/AmazonConfig.java b/src/main/java/umc/spring/config/AmazonConfig.java new file mode 100644 index 0000000..ab14f0e --- /dev/null +++ b/src/main/java/umc/spring/config/AmazonConfig.java @@ -0,0 +1,86 @@ +package umc.spring.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.AmazonS3ClientBuilder; +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +// 12주차 사진도 같이 업로드하기 + +// 이제 AWS S3에 사진을 업로드 하기 위한 +// 설정 정보가 담긴 클래스를 만들어 줍시다. +// 해당 클래스는 순수하게 AWS의 서비스를 사용하기 위한 +// 설정 정보가 담기게 됩니다. +// 11주차 CI/CD에서도 AWS는 보안에 굉장히 민감하고 +// 외부에서 AWS의 서비스를 사용하기 위해 +// Access Key와 Secret Key를 발급했죠? +// 그런 정보를 담아 둘 클래스라고 생각하면 됩니다. +@Configuration +@Getter +public class AmazonConfig { + + private AWSCredentials awsCredentials; + // 이제 저 키를 어디에 보관 해야할지가 문제이죠 + // CI/CD에서는 깃허브 액션에서 사용이 되니 + // 리포지토리의 키에 저장을 했죠? + // 지금은 어떻게 할까요? + // 여기서 환경변수라는 개념을 활용 할 수 있습니다. + // 일단 무식하게 아래처럼 가능합니다. +// private String accessKey = "Springboot 액세스 키"; +// private String secretKey = "Springboot 비밀 액세스 키"; +// private String region = "ap-northeast-2"; + // 네. 저렇게 했다가 깃허브에 올리면 바로 털리겠죠? + // 따라서 저런 상수 값들은 저렇게 직접 가져다 넣기 보다는 + // application.yml에 두고 가져오는 것이 좋습니다. + // application.yml에서 처음 시작이 cloud + // (잘 보면 spring이랑 별개임) + // 1depth 아래가 aws 그리고 s3, region 등등이 같은 depth이죠 + // 저렇게 depth에 맞춰서 어떤 값을 가져올지 기입하면 됩니다. + @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; + // 버킷의 정보 지정 + @Value("${cloud.aws.s3.path.review}") + private String reviewPath; + + + + @PostConstruct + public void init(){ + + this.awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + + } + + @Bean + public AmazonS3 amazonS3(){ + + AWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + + return AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) + .build(); + + } + + @Bean + public AWSCredentialsProvider awsCredentialsProvider() { + + return new AWSStaticCredentialsProvider(awsCredentials); + + } + +} diff --git a/src/main/java/umc/spring/converter/ReviewConverter.java b/src/main/java/umc/spring/converter/ReviewConverter.java new file mode 100644 index 0000000..65c87b5 --- /dev/null +++ b/src/main/java/umc/spring/converter/ReviewConverter.java @@ -0,0 +1,21 @@ +package umc.spring.converter; + +import umc.spring.domain.Review; +import umc.spring.domain.ReviewImage; +import umc.spring.web.dto.StoreRequestDTO; + +import java.util.List; + +// 12주차 사진도 같이 업로드하기 +public class ReviewConverter { + + public static ReviewImage toReviewImage(String imageUrl, Review review){ + + return ReviewImage.builder() + .imageUrl(imageUrl) + .review(review) + .build(); + + } + +} diff --git a/src/main/java/umc/spring/domain/Uuid.java b/src/main/java/umc/spring/domain/Uuid.java new file mode 100644 index 0000000..450dcf9 --- /dev/null +++ b/src/main/java/umc/spring/domain/Uuid.java @@ -0,0 +1,41 @@ +package umc.spring.domain; + +import jakarta.persistence.*; +import lombok.*; +import umc.spring.domain.common.BaseEntity; + +// 12주차 사진도 같이 업로드하기 + +// 우선 너무 코드가 복잡해지지 않도록 하기 위해 +// 리뷰에 사진이 여러 장 가능하지만 한 장만 업로드를 해보겠습니다. +// 우선 사진 각각을 아마존 자체에서도 구분이 가능해야 합니다. +// 그 이유는 물론 사진마다 이름이 존재하겠지만… +// 사람이 붙이는 이름이 같을 수 있기 때문에 +// 따로 겹치지 않는 정보가 필요합니다. +// 가장 쉬운 방법은 업로드 하는 파일에 +// 일련번호를 붙이는 방법이 있겠지만.. 이 방법도 유효하지 않습니다. +// 왜냐하면 자바 자체적으로 변수를 두면 +// 서버가 꺼질 때 초기화가 되기 때문에 영속되는 데이터로 둬야 합니다. +// 저희는 UUID라는 것을 사용해서 일련번호를 두겠습니다. +// 4UUID는 겹칠 확률이 극히 적은(사실상 안 겹칩니다) +// 일련의 식별자 입니다! +// Java에서는 UUID를 생성해주는 API가 존재하며 +// 이를 이용해서 UUID를 담은 엔티티를 하나 만들어서 사용합시다. +// 이후 AWS S3에 업로드 시 uuid를 파일 이름에 붙여서 +// 업로드 해서 업로더가 지은 파일의 이름이 +// 동일하더라도 각각의 파일이 식별이 되도록 합시다! +@Entity +@Builder +@Getter +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Uuid extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true) + private String uuid; + +} diff --git a/src/main/java/umc/spring/repository/ReviewImageRepository.java b/src/main/java/umc/spring/repository/ReviewImageRepository.java new file mode 100644 index 0000000..a8628f7 --- /dev/null +++ b/src/main/java/umc/spring/repository/ReviewImageRepository.java @@ -0,0 +1,11 @@ +package umc.spring.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import umc.spring.domain.ReviewImage; + +// 12주차 사진도 같이 업로드하기 +public interface ReviewImageRepository extends JpaRepository { + + + +} diff --git a/src/main/java/umc/spring/repository/UuidRepository.java b/src/main/java/umc/spring/repository/UuidRepository.java new file mode 100644 index 0000000..4da9900 --- /dev/null +++ b/src/main/java/umc/spring/repository/UuidRepository.java @@ -0,0 +1,11 @@ +package umc.spring.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import umc.spring.domain.Uuid; + +// 12주차 사진도 같이 업로드하기 +public interface UuidRepository extends JpaRepository { + + + +} diff --git a/src/main/java/umc/spring/service/StoreService/StoreCommandService.java b/src/main/java/umc/spring/service/StoreService/StoreCommandService.java index b2ad235..82977f7 100644 --- a/src/main/java/umc/spring/service/StoreService/StoreCommandService.java +++ b/src/main/java/umc/spring/service/StoreService/StoreCommandService.java @@ -7,6 +7,7 @@ public interface StoreCommandService { // 9주차 2. 가게에 리뷰 추가하기 API + // 12주차 사진도 같이 업로드하기 Review createReview(Long memberId, Long storeId, StoreRequestDTO.ReviewDTO request); diff --git a/src/main/java/umc/spring/service/StoreService/StoreCommandServiceImpl.java b/src/main/java/umc/spring/service/StoreService/StoreCommandServiceImpl.java index 767d5cf..8861fca 100644 --- a/src/main/java/umc/spring/service/StoreService/StoreCommandServiceImpl.java +++ b/src/main/java/umc/spring/service/StoreService/StoreCommandServiceImpl.java @@ -3,15 +3,20 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import umc.spring.aws.s3.AmazonS3Manager; +import umc.spring.converter.ReviewConverter; import umc.spring.converter.StoreConverter; import umc.spring.domain.Mission; import umc.spring.domain.Review; -import umc.spring.repository.MemberRepository; -import umc.spring.repository.MissionRepository; -import umc.spring.repository.ReviewRepository; -import umc.spring.repository.StoreRepository; +import umc.spring.domain.ReviewImage; +import umc.spring.domain.Uuid; +import umc.spring.repository.*; import umc.spring.web.dto.StoreRequestDTO; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + @Service @Transactional @RequiredArgsConstructor @@ -24,17 +29,84 @@ public class StoreCommandServiceImpl implements StoreCommandService { // 9주차 3. 가게에 미션 추가하기 API private final MissionRepository missionRepository; + // 12주차 사진도 같이 업로드하기 + private final AmazonS3Manager s3Manager; + // 서비스에서 AmazonS3Manager를 추가합시다. + private final UuidRepository uuidRepository; + private final ReviewImageRepository reviewImageRepository; + // 기획 자체가 사진 여러장을 업로드 가능했기 때문에 + // review와 업로드가 되는 테이블을 연결 지었습니다. + // 따라서 reveiw Image를 만들어야 하며 이 과정에서 + // 이미지를 업로드 해야합니다. + // 9주차 2. 가게에 리뷰 추가하기 API + // 12주차 사진도 같이 업로드하기 @Override public Review createReview(Long memberId, Long storeId, StoreRequestDTO.ReviewDTO request){ + + +// Review review = StoreConverter.toReview(request); +// +// String uuid = UUID.randomUUID().toString(); +// Uuid savedUuid = uuidRepository.save(Uuid.builder() +// .uuid(uuid).build()); +// +// String pictureUrl = s3Manager.uploadFile(s3Manager.generateReviewKeyName(savedUuid), request.getReviewPicture()); +// +// review.setMember(memberRepository.findById(memberId).get()); +// review.setStore(storeRepository.findById(storeId).get()); +// +// +// reviewImageRepository.save(ReviewConverter.toReviewImage(pictureUrl,review)); +// return reviewRepository.save(review); + + + +// Review review = StoreConverter.toReview(request); +// review.setMember(memberRepository.findById(memberId).orElseThrow(() -> new IllegalArgumentException("Invalid member ID"))); +// review.setStore(storeRepository.findById(storeId).orElseThrow(() -> new IllegalArgumentException("Invalid store ID"))); +// +// if (request.getReviewPicture() != null && !request.getReviewPicture().isEmpty()) { +// String uuid = UUID.randomUUID().toString(); +// Uuid savedUuid = uuidRepository.save(Uuid.builder().uuid(uuid).build()); +// String pictureUrl = s3Manager.uploadFile(s3Manager.generateReviewKeyName(savedUuid), request.getReviewPicture()); +// reviewImageRepository.save(ReviewConverter.toReviewImage(pictureUrl, review)); +// } +// +// return reviewRepository.save(review); + + + Review review = StoreConverter.toReview(request); review.setMember(memberRepository.findById(memberId).get()); review.setStore(storeRepository.findById(storeId).get()); + List reviewImages = request.getReviewPicture().stream() + .map(picture -> { + String uuid = UUID.randomUUID().toString(); + Uuid savedUuid = uuidRepository.save( + Uuid.builder() + .uuid(uuid) + .build() + ); + // Uuid 엔티티를 만들어 + + String pictureUrl = s3Manager.uploadFile(s3Manager.generateReviewKeyName(savedUuid), picture); + // s3Manager.generateReviewKeyName(savedUuid) + // 이를 가지고 파일에 대한 KeyName을 만들고 + //파일을 업로드 후 + + return ReviewConverter.toReviewImage(pictureUrl, review); + // 파일의 URL을 ReviewImage 엔티티에 담아서 저장하고 있습니다. + }) + .collect(Collectors.toList()); + + reviewImageRepository.saveAll(reviewImages); + return reviewRepository.save(review); } diff --git a/src/main/java/umc/spring/web/controller/StoreRestController.java b/src/main/java/umc/spring/web/controller/StoreRestController.java index dd87006..dd2068b 100644 --- a/src/main/java/umc/spring/web/controller/StoreRestController.java +++ b/src/main/java/umc/spring/web/controller/StoreRestController.java @@ -35,9 +35,14 @@ public class StoreRestController { private final StoreQueryService storeQueryService; // 9주차 2. 가게에 리뷰 추가하기 API - @PostMapping("/{storeId}/addreviews") + // 12주차 사진도 같이 업로드하기 + @PostMapping(value = "/{storeId}/addreviews", consumes = "multipart/form-data") + // consumes = "multipart/form-data" + // 위처럼 Controller를 수정하고 다시 swagger에 접속하면 + // swagger 자체도 변화되며 사진 업로드가 가능합니다! public ApiResponse createReview( - @RequestBody @Valid StoreRequestDTO.ReviewDTO request, + //@RequestBody @Valid StoreRequestDTO.ReviewDTO request, + @ModelAttribute @Valid StoreRequestDTO.ReviewDTO request, @ExistStore @PathVariable(name = "storeId") Long storeId, // public @interface ExistStore { // 이렇게 Request Body가 아닌 PathVariable 등 diff --git a/src/main/java/umc/spring/web/dto/StoreRequestDTO.java b/src/main/java/umc/spring/web/dto/StoreRequestDTO.java index 5ac6f6a..d4d0b86 100644 --- a/src/main/java/umc/spring/web/dto/StoreRequestDTO.java +++ b/src/main/java/umc/spring/web/dto/StoreRequestDTO.java @@ -2,18 +2,19 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; +import org.springframework.web.multipart.MultipartFile; import java.time.LocalDate; import java.util.List; +import java.util.Optional; public class StoreRequestDTO { @Getter // 9주차 2. 가게에 리뷰 추가하기 API + // 12주차 사진도 같이 업로드하기 + @Setter public static class ReviewDTO{ @NotBlank String title; @@ -21,6 +22,21 @@ public static class ReviewDTO{ Float score; @NotBlank String body; + + // StoreRestController > createReview에서 + // 사진도 저장하도록 수정 + // 본래 기획은 사진의 List이지만 단건으로 해볼게요. + //MultipartFile reviewPicture; + List reviewPicture; +// MultipartFile reviewPicture; + // request Body에 이제 사진이 추가됩니다. + // swagger에 가보면 reviewPicture가 + // String으로 표시가 됩니다 + // 그러나 목표는 사진 업로드이기 때문에 + // 의도와 다른 것을 확인 가능하죠 + // 이를 해결하기 위해 StoreRestController에 + // 한 가지 추가적인 설정이 필요합니다. + // consumes = "multipart/form-data" } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a9a0e78..512dec5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,10 +2,19 @@ spring: datasource: - url: jdbc:mysql://tempdb.c7kwsyswc6i7.ap-northeast-2.rds.amazonaws.com:3306/study - username: root - password: EarthUsPlogUs20240405! + #url: jdbc:mysql://tempdb.c7kwsyswc6i7.ap-northeast-2.rds.amazonaws.com:3306/study + url: ${AWS_DB_URL} + #username: root + username: ${AWS_DB_USER} + #password: EarthUsPlogUs20240405! + password: ${AWS_DB_PASS} driver-class-name: com.mysql.cj.jdbc.Driver + servlet: + multipart: + enabled: true + max-file-size: 10MB + max-request-size: 10MB + resolve-lazily: true sql: init: mode: never @@ -29,4 +38,21 @@ spring: # DataGrip will alert error # cause there's noting to delete existing tables # so it's OK - default_batch_fetch_size: 1000 \ No newline at end of file + default_batch_fetch_size: 1000 + +cloud: + aws: + s3: + bucket: umc-5th-practice + path: + review: review + region: + static: ap-northeast-2 + stack: + auto: false + credentials: + # Environment variables + #accessKey: Springboot accessKey + accessKey: ${AWS_ACCESS_KEY_ID} + #secretKey: Springboot secretKey + secretKey: ${AWS_SECRET_ACCESS_KEY} \ No newline at end of file