diff --git a/build.gradle b/build.gradle index 455da4f..7c6792c 100644 --- a/build.gradle +++ b/build.gradle @@ -56,6 +56,10 @@ dependencies { // CI/CD health check implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // Multipart file + implementation("software.amazon.awssdk:bom:2.21.0") + implementation("software.amazon.awssdk:s3:2.21.0") } tasks.named('test') { diff --git a/src/main/java/com/telepigeon/server/config/AwsConfig.java b/src/main/java/com/telepigeon/server/config/AwsConfig.java new file mode 100644 index 0000000..073fc21 --- /dev/null +++ b/src/main/java/com/telepigeon/server/config/AwsConfig.java @@ -0,0 +1,48 @@ +package com.telepigeon.server.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.SystemPropertyCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +@Configuration +public class AwsConfig { + + private static final String AWS_ACCESS_KEY_ID = "aws.accessKeyId"; + private static final String AWS_SECRET_ACCESS_KEY = "aws.secretAccessKey"; + + private final String accessKey; + private final String secretKey; + private final String regionString; + + public AwsConfig(@Value("${aws-property.access-key}") final String accessKey, + @Value("${aws-property.secret-key}") final String secretKey, + @Value("${aws-property.aws-region}") final String regionString) { + this.accessKey = accessKey; + this.secretKey = secretKey; + this.regionString = regionString; + } + + @Bean + public SystemPropertyCredentialsProvider systemPropertyCredentialsProvider() { + System.setProperty(AWS_ACCESS_KEY_ID, accessKey); + System.setProperty(AWS_SECRET_ACCESS_KEY, secretKey); + return SystemPropertyCredentialsProvider.create(); + } + + @Bean + public Region getRegion() { + return Region.of(regionString); + } + + @Bean + public S3Client getS3Client() { + return S3Client.builder() + .region(getRegion()) + .credentialsProvider(systemPropertyCredentialsProvider()) + .build(); + } + +} diff --git a/src/main/java/com/telepigeon/server/controller/AnswerController.java b/src/main/java/com/telepigeon/server/controller/AnswerController.java index 3d78925..60cf1bf 100644 --- a/src/main/java/com/telepigeon/server/controller/AnswerController.java +++ b/src/main/java/com/telepigeon/server/controller/AnswerController.java @@ -14,6 +14,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.io.IOException; import java.net.URI; import java.time.LocalDate; import java.time.YearMonth; @@ -29,8 +30,8 @@ public ResponseEntity postAnswer( @UserId final Long userId, @PathVariable final Long roomId, @PathVariable final Long questionId, - @RequestBody @Valid final AnswerCreateDto answerCreateDto - ) { + @ModelAttribute @Valid final AnswerCreateDto answerCreateDto + ) throws IOException { return ResponseEntity.created( URI.create( "/answers/" + answerService.create( diff --git a/src/main/java/com/telepigeon/server/domain/Answer.java b/src/main/java/com/telepigeon/server/domain/Answer.java index 8640276..b4a6103 100644 --- a/src/main/java/com/telepigeon/server/domain/Answer.java +++ b/src/main/java/com/telepigeon/server/domain/Answer.java @@ -39,14 +39,15 @@ public class Answer { private Profile profile; public static Answer create( - AnswerCreateDto answerCreateDto, + String content, + String image, Double emotion, Question question, Profile profile ){ return new Answer( - answerCreateDto.content(), - answerCreateDto.image(), + content, + image, emotion, question, profile); diff --git a/src/main/java/com/telepigeon/server/dto/answer/request/AnswerCreateDto.java b/src/main/java/com/telepigeon/server/dto/answer/request/AnswerCreateDto.java index b8fe346..b098265 100644 --- a/src/main/java/com/telepigeon/server/dto/answer/request/AnswerCreateDto.java +++ b/src/main/java/com/telepigeon/server/dto/answer/request/AnswerCreateDto.java @@ -2,11 +2,12 @@ import jakarta.annotation.Nullable; import jakarta.validation.constraints.NotBlank; +import org.springframework.web.multipart.MultipartFile; public record AnswerCreateDto( @NotBlank String content, @Nullable - String image + MultipartFile image ) { } diff --git a/src/main/java/com/telepigeon/server/service/answer/AnswerService.java b/src/main/java/com/telepigeon/server/service/answer/AnswerService.java index 91407dc..62df5ef 100644 --- a/src/main/java/com/telepigeon/server/service/answer/AnswerService.java +++ b/src/main/java/com/telepigeon/server/service/answer/AnswerService.java @@ -11,8 +11,9 @@ import com.telepigeon.server.dto.naverCloud.ConfidenceDto; import com.telepigeon.server.dto.room.response.RoomStateDto; import com.telepigeon.server.dto.type.FcmContent; +import com.telepigeon.server.service.external.S3Service; import com.telepigeon.server.service.fcm.FcmService; -import com.telepigeon.server.service.naverCloud.NaverCloudService; +import com.telepigeon.server.service.external.NaverCloudService; import com.telepigeon.server.service.user.UserRetriever; import com.telepigeon.server.service.hurry.HurryRetriever; import com.telepigeon.server.service.profile.ProfileRetriever; @@ -23,6 +24,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.io.IOException; import java.time.LocalDate; import java.time.YearMonth; import java.util.Collections; @@ -43,6 +45,9 @@ public class AnswerService { private final HurryRetriever hurryRetriever; private final NaverCloudService naverCloudService; private final FcmService fcmService; + private final S3Service s3Service; + + private static String ANSWER_S3_UPLOAD_FOLDER = "/answer"; @Transactional public Answer create( @@ -50,7 +55,7 @@ public Answer create( final Long roomId, final Long questionId, final AnswerCreateDto answerCreateDto - ){ + ) throws IOException { User user = userRetriever.findById(userId); Room room = roomRetriever.findById(roomId); Profile profile = profileRetriever.findByUserAndRoom(user, room); @@ -60,8 +65,11 @@ public Answer create( ); Double emotion = (confidence.positive() - confidence.negative()) * 0.01; Answer answer = answerSaver.create( - Answer.create(answerCreateDto, emotion, question, profile) + Answer.create(answerCreateDto.content(), + s3Service.uploadImage(ANSWER_S3_UPLOAD_FOLDER, answerCreateDto.image()), + emotion, question, profile) ); + profile.updateEmotion( CalculateEmotion( profile.getEmotion(), diff --git a/src/main/java/com/telepigeon/server/service/naverCloud/NaverCloudService.java b/src/main/java/com/telepigeon/server/service/external/NaverCloudService.java similarity index 97% rename from src/main/java/com/telepigeon/server/service/naverCloud/NaverCloudService.java rename to src/main/java/com/telepigeon/server/service/external/NaverCloudService.java index 3378a9d..0e1c753 100644 --- a/src/main/java/com/telepigeon/server/service/naverCloud/NaverCloudService.java +++ b/src/main/java/com/telepigeon/server/service/external/NaverCloudService.java @@ -1,4 +1,4 @@ -package com.telepigeon.server.service.naverCloud; +package com.telepigeon.server.service.external; import com.telepigeon.server.dto.naverCloud.request.ConfidenceCreateDto; import com.telepigeon.server.dto.naverCloud.ConfidenceDto; diff --git a/src/main/java/com/telepigeon/server/service/external/S3Service.java b/src/main/java/com/telepigeon/server/service/external/S3Service.java new file mode 100644 index 0000000..a390498 --- /dev/null +++ b/src/main/java/com/telepigeon/server/service/external/S3Service.java @@ -0,0 +1,75 @@ +package com.telepigeon.server.service.external; + +import com.telepigeon.server.config.AwsConfig; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +@Component +public class S3Service { + + private final String bucketName; + private final AwsConfig awsConfig; + private static final List IMAGE_EXTENSIONS = Arrays.asList("image/jpeg", "image/png", "image/jpg", "image/webp"); + + public S3Service(@Value("${aws-property.s3-bucket-name}") final String bucketName, AwsConfig awsConfig) { + this.bucketName = bucketName; + this.awsConfig = awsConfig; + } + + public String uploadImage(String directoryPath, MultipartFile image) throws IOException { + final String key = directoryPath + generateImageFileName(); + final S3Client s3Client = awsConfig.getS3Client(); + + validateExtension(image); + validateFileSize(image); + + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucketName) + .key(key) + .contentType(image.getContentType()) + .contentDisposition("inline") + .build(); + + RequestBody requestBody = RequestBody.fromBytes(image.getBytes()); + s3Client.putObject(request, requestBody); + return key; + } + + public void deleteImage(String key) throws IOException { + final S3Client s3Client = awsConfig.getS3Client(); + + s3Client.deleteObject((DeleteObjectRequest.Builder builder) -> + builder.bucket(bucketName) + .key(key) + .build()); + } + + private String generateImageFileName() { + return UUID.randomUUID() + ".jpg"; + } + + private void validateExtension(MultipartFile image) { + String contentType = image.getContentType(); + if(!IMAGE_EXTENSIONS.contains(contentType)) { + throw new RuntimeException("이미지 확장자는 jpg, png, webp만 가능합니다."); + } + } + + private static final Long MAX_FILE_SIZE = 5 * 1024 * 1024L; + + private void validateFileSize(MultipartFile image) { + if (image.getSize() > MAX_FILE_SIZE) { + throw new RuntimeException("이미지 사이즈는 5MB를 넘을 수 없습니다."); + } + } +} diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 6bcdf94..cd7a63c 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -34,4 +34,10 @@ openai: model: gpt-3.5-turbo api: key: ${openai_api_key} - url: ${openai_api_url} \ No newline at end of file + url: ${openai_api_url} + +aws-property: + s3-bucket-name: ${bucket_name} + access-key: ${access_key} + secret-key: ${secret_key} + aws-region: ap-northeast-2 \ No newline at end of file diff --git a/src/test/java/com/telepigeon/server/answer/AnswerDomainTest.java b/src/test/java/com/telepigeon/server/answerTest/AnswerDomainTest.java similarity index 72% rename from src/test/java/com/telepigeon/server/answer/AnswerDomainTest.java rename to src/test/java/com/telepigeon/server/answerTest/AnswerDomainTest.java index 53cbe70..4c67c1f 100644 --- a/src/test/java/com/telepigeon/server/answer/AnswerDomainTest.java +++ b/src/test/java/com/telepigeon/server/answerTest/AnswerDomainTest.java @@ -1,4 +1,4 @@ -package com.telepigeon.server.answer; +package com.telepigeon.server.answerTest; import com.telepigeon.server.domain.Answer; import com.telepigeon.server.dto.answer.request.AnswerCreateDto; @@ -10,16 +10,16 @@ public class AnswerDomainTest { @Test @DisplayName("Answer 객체 생성") void createAnswerTest(){ - AnswerCreateDto answerCreateDto = new AnswerCreateDto("content", "image"); - Answer answer = Answer.create(answerCreateDto, 0.0, null, null); + AnswerCreateDto answerCreateDto = new AnswerCreateDto("content", null); + Answer answer = Answer.create(answerCreateDto.content(), null, null, null, null); Assertions.assertNotNull(answer); } @Test @DisplayName("Answer 객체 생성 확인") void checkCreateAnswerTest(){ - AnswerCreateDto answerCreateDto = new AnswerCreateDto("content", "image"); - Answer answer = Answer.create(answerCreateDto, 0.0, null, null); + AnswerCreateDto answerCreateDto = new AnswerCreateDto("content", null); + Answer answer = Answer.create(answerCreateDto.content(), null, null, null, null); Assertions.assertEquals(answer.getContent(), "content"); } } diff --git a/src/test/java/com/telepigeon/server/auth/AuthServiceTest.java b/src/test/java/com/telepigeon/server/authTest/AuthServiceTest.java similarity index 99% rename from src/test/java/com/telepigeon/server/auth/AuthServiceTest.java rename to src/test/java/com/telepigeon/server/authTest/AuthServiceTest.java index 5a1aee6..9a51ec5 100644 --- a/src/test/java/com/telepigeon/server/auth/AuthServiceTest.java +++ b/src/test/java/com/telepigeon/server/authTest/AuthServiceTest.java @@ -1,4 +1,4 @@ -package com.telepigeon.server.auth; +package com.telepigeon.server.authTest; import com.telepigeon.server.domain.User; import com.telepigeon.server.dto.auth.response.JwtTokensDto; diff --git a/src/test/java/com/telepigeon/server/question/QuestionDomainTest.java b/src/test/java/com/telepigeon/server/questionTest/QuestionDomainTest.java similarity index 95% rename from src/test/java/com/telepigeon/server/question/QuestionDomainTest.java rename to src/test/java/com/telepigeon/server/questionTest/QuestionDomainTest.java index e184568..5183f20 100644 --- a/src/test/java/com/telepigeon/server/question/QuestionDomainTest.java +++ b/src/test/java/com/telepigeon/server/questionTest/QuestionDomainTest.java @@ -1,4 +1,4 @@ -package com.telepigeon.server.question; +package com.telepigeon.server.questionTest; import com.telepigeon.server.domain.Question; import org.junit.jupiter.api.Assertions; diff --git a/src/test/java/com/telepigeon/server/question/QuestionServiceTest.java b/src/test/java/com/telepigeon/server/questionTest/QuestionServiceTest.java similarity index 100% rename from src/test/java/com/telepigeon/server/question/QuestionServiceTest.java rename to src/test/java/com/telepigeon/server/questionTest/QuestionServiceTest.java diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index c436672..55b4a39 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -12,4 +12,10 @@ spring: ddl-auto: create-drop properties: hibernate: - dialect: org.hibernate.dialect.MySQL8Dialect \ No newline at end of file + dialect: org.hibernate.dialect.MySQL8Dialect + +aws-property: + s3-bucket-name: ${bucket_name} + access-key: ${access_key} + secret-key: ${secret_key} + aws-region: ap-northeast-2 \ No newline at end of file