diff --git a/module-domain/src/main/java/com/gaethering/moduledomain/domain/member/Pet.java b/module-domain/src/main/java/com/gaethering/moduledomain/domain/member/Pet.java index 7e288ef..558c0e3 100644 --- a/module-domain/src/main/java/com/gaethering/moduledomain/domain/member/Pet.java +++ b/module-domain/src/main/java/com/gaethering/moduledomain/domain/member/Pet.java @@ -56,4 +56,8 @@ public class Pet { public void setMember(Member member) { this.member = member; } + + public void updateImage(String imageUrl) { + this.imageUrl = imageUrl; + } } diff --git a/module-member/build.gradle b/module-member/build.gradle index 7c9ca4f..5b5dc88 100644 --- a/module-member/build.gradle +++ b/module-member/build.gradle @@ -3,8 +3,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' runtimeOnly 'com.h2database:h2' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.findify:s3mock_2.12:0.2.4' } tasks.register("prepareKotlinBuildScriptModel"){} \ No newline at end of file diff --git a/module-member/src/main/java/com/gaethering/modulemember/ModuleMemberApplication.java b/module-member/src/main/java/com/gaethering/modulemember/ModuleMemberApplication.java index 409c79e..cc385f8 100644 --- a/module-member/src/main/java/com/gaethering/modulemember/ModuleMemberApplication.java +++ b/module-member/src/main/java/com/gaethering/modulemember/ModuleMemberApplication.java @@ -14,4 +14,8 @@ public static void main(String[] args) { SpringApplication.run(ModuleMemberApplication.class, args); } + static { + System.setProperty("com.amazonaws.sdk.disableEc2Metadata", "true"); + } + } diff --git a/module-member/src/main/java/com/gaethering/modulemember/config/AwsS3Config.java b/module-member/src/main/java/com/gaethering/modulemember/config/AwsS3Config.java new file mode 100644 index 0000000..09481ea --- /dev/null +++ b/module-member/src/main/java/com/gaethering/modulemember/config/AwsS3Config.java @@ -0,0 +1,31 @@ +package com.gaethering.modulemember.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 AwsS3Config { + + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AmazonS3Client amazonS3Client() { + BasicAWSCredentials awsCreds = new BasicAWSCredentials(accessKey, secretKey); + return (AmazonS3Client) AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCreds)) + .build(); + } +} diff --git a/module-member/src/main/java/com/gaethering/modulemember/controller/PetController.java b/module-member/src/main/java/com/gaethering/modulemember/controller/PetController.java new file mode 100644 index 0000000..d0a418c --- /dev/null +++ b/module-member/src/main/java/com/gaethering/modulemember/controller/PetController.java @@ -0,0 +1,23 @@ +package com.gaethering.modulemember.controller; + +import com.gaethering.modulemember.exception.pet.ImageNotFoundException; +import com.gaethering.modulemember.service.pet.PetServiceImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("${api-prefix}") +@RequiredArgsConstructor +public class PetController { + + private final PetServiceImpl petService; + + @PatchMapping("/mypage/pets/{petId}/image") + public ResponseEntity updatePetImage(@PathVariable("petId") Long id, + @RequestPart("file") MultipartFile multipartFile) { + + return ResponseEntity.ok(petService.updatePetImage(id, multipartFile)); + } +} diff --git a/module-member/src/main/java/com/gaethering/modulemember/exception/errorcode/PetErrorCode.java b/module-member/src/main/java/com/gaethering/modulemember/exception/errorcode/PetErrorCode.java new file mode 100644 index 0000000..91279e0 --- /dev/null +++ b/module-member/src/main/java/com/gaethering/modulemember/exception/errorcode/PetErrorCode.java @@ -0,0 +1,17 @@ +package com.gaethering.modulemember.exception.errorcode; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum PetErrorCode { + + IMAGE_NOT_FOUND("E1001", "사진이 존재하지 않습니다."), + INVALID_IMAGE_TYPE("E1002", "사진 형식이 잘못되었습니다."), + FAILED_UPLOAD_IMAGE("E1003", "사진 업로드에 실패하였습니다."), + PET_NOT_FOUND("E1004", "반려동물이 존재하지 않습니다."); + + private final String code; + private final String message; +} diff --git a/module-member/src/main/java/com/gaethering/modulemember/exception/handler/GlobalExceptionHandler.java b/module-member/src/main/java/com/gaethering/modulemember/exception/handler/GlobalExceptionHandler.java index 93de8ea..eaec135 100644 --- a/module-member/src/main/java/com/gaethering/modulemember/exception/handler/GlobalExceptionHandler.java +++ b/module-member/src/main/java/com/gaethering/modulemember/exception/handler/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import com.gaethering.modulecore.exception.ErrorResponse; import com.gaethering.modulemember.exception.errorcode.MemberErrorCode; import com.gaethering.modulemember.exception.member.MemberException; +import com.gaethering.modulemember.exception.pet.PetException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -36,4 +37,15 @@ public ResponseEntity handleMailSendException(MailSendException e return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); } + @ExceptionHandler(PetException.class) + public ResponseEntity handlePetException(PetException e) { + + ErrorResponse response = ErrorResponse.builder() + .code(e.getErrorCode().getCode()) + .message(e.getMessage()) + .build(); + + return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + } + } diff --git a/module-member/src/main/java/com/gaethering/modulemember/exception/pet/FailedUploadImageException.java b/module-member/src/main/java/com/gaethering/modulemember/exception/pet/FailedUploadImageException.java new file mode 100644 index 0000000..e79b831 --- /dev/null +++ b/module-member/src/main/java/com/gaethering/modulemember/exception/pet/FailedUploadImageException.java @@ -0,0 +1,10 @@ +package com.gaethering.modulemember.exception.pet; + +import com.gaethering.modulemember.exception.errorcode.PetErrorCode; + +public class FailedUploadImageException extends PetException { + + public FailedUploadImageException() { + super(PetErrorCode.FAILED_UPLOAD_IMAGE); + } +} diff --git a/module-member/src/main/java/com/gaethering/modulemember/exception/pet/ImageNotFoundException.java b/module-member/src/main/java/com/gaethering/modulemember/exception/pet/ImageNotFoundException.java new file mode 100644 index 0000000..fb2f6c6 --- /dev/null +++ b/module-member/src/main/java/com/gaethering/modulemember/exception/pet/ImageNotFoundException.java @@ -0,0 +1,10 @@ +package com.gaethering.modulemember.exception.pet; + +import com.gaethering.modulemember.exception.errorcode.PetErrorCode; + +public class ImageNotFoundException extends PetException { + + public ImageNotFoundException() { + super(PetErrorCode.IMAGE_NOT_FOUND); + } +} diff --git a/module-member/src/main/java/com/gaethering/modulemember/exception/pet/InvalidImageTypeException.java b/module-member/src/main/java/com/gaethering/modulemember/exception/pet/InvalidImageTypeException.java new file mode 100644 index 0000000..ef1811b --- /dev/null +++ b/module-member/src/main/java/com/gaethering/modulemember/exception/pet/InvalidImageTypeException.java @@ -0,0 +1,10 @@ +package com.gaethering.modulemember.exception.pet; + +import com.gaethering.modulemember.exception.errorcode.PetErrorCode; + +public class InvalidImageTypeException extends PetException { + + public InvalidImageTypeException() { + super(PetErrorCode.INVALID_IMAGE_TYPE); + } +} diff --git a/module-member/src/main/java/com/gaethering/modulemember/exception/pet/PetException.java b/module-member/src/main/java/com/gaethering/modulemember/exception/pet/PetException.java new file mode 100644 index 0000000..b1277db --- /dev/null +++ b/module-member/src/main/java/com/gaethering/modulemember/exception/pet/PetException.java @@ -0,0 +1,15 @@ +package com.gaethering.modulemember.exception.pet; + +import com.gaethering.modulemember.exception.errorcode.PetErrorCode; +import lombok.Getter; + +@Getter +public class PetException extends RuntimeException { + + private final PetErrorCode errorCode; + + protected PetException(PetErrorCode petErrorCode) { + super(petErrorCode.getMessage()); + this.errorCode = petErrorCode; + } +} \ No newline at end of file diff --git a/module-member/src/main/java/com/gaethering/modulemember/exception/pet/PetNotFoundException.java b/module-member/src/main/java/com/gaethering/modulemember/exception/pet/PetNotFoundException.java new file mode 100644 index 0000000..e20adae --- /dev/null +++ b/module-member/src/main/java/com/gaethering/modulemember/exception/pet/PetNotFoundException.java @@ -0,0 +1,10 @@ +package com.gaethering.modulemember.exception.pet; + +import com.gaethering.modulemember.exception.errorcode.PetErrorCode; + +public class PetNotFoundException extends PetException { + + public PetNotFoundException() { + super(PetErrorCode.PET_NOT_FOUND); + } +} diff --git a/module-member/src/main/java/com/gaethering/modulemember/service/ImageUploadService.java b/module-member/src/main/java/com/gaethering/modulemember/service/ImageUploadService.java new file mode 100644 index 0000000..5b028b1 --- /dev/null +++ b/module-member/src/main/java/com/gaethering/modulemember/service/ImageUploadService.java @@ -0,0 +1,16 @@ +package com.gaethering.modulemember.service; + +import org.springframework.web.multipart.MultipartFile; + +public interface ImageUploadService { + + String uploadImage(MultipartFile multipartFile); + + void removeImage(String filename); + + String createFileName(String filename); + + String getFileExtension(String filename); + + void validateFileExtension(String filename); +} diff --git a/module-member/src/main/java/com/gaethering/modulemember/service/ImageUploadServiceImpl.java b/module-member/src/main/java/com/gaethering/modulemember/service/ImageUploadServiceImpl.java new file mode 100644 index 0000000..00a8b4c --- /dev/null +++ b/module-member/src/main/java/com/gaethering/modulemember/service/ImageUploadServiceImpl.java @@ -0,0 +1,91 @@ +package com.gaethering.modulemember.service; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.CannedAccessControlList; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.gaethering.modulemember.exception.pet.FailedUploadImageException; +import com.gaethering.modulemember.exception.pet.ImageNotFoundException; +import com.gaethering.modulemember.exception.pet.InvalidImageTypeException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Transactional +public class ImageUploadServiceImpl implements ImageUploadService { + + private final AmazonS3 amazonS3; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + @Value("${dir}") + private String dir; + + @Override + public String uploadImage(MultipartFile multipartFile) { + + String fileName = createFileName(multipartFile.getOriginalFilename()); + + ObjectMetadata objectMetadata = new ObjectMetadata(); + objectMetadata.setContentLength(multipartFile.getSize()); + objectMetadata.setContentType(multipartFile.getContentType()); + + try { + amazonS3.putObject(new PutObjectRequest(bucket + "/" + dir, fileName, multipartFile.getInputStream(), objectMetadata) + .withCannedAcl(CannedAccessControlList.PublicRead)); + + } catch(IOException e) { + throw new FailedUploadImageException(); + } + + return amazonS3.getUrl(bucket, dir + "/" + fileName).toString(); + } + + @Override + public void removeImage(String filename) { + amazonS3.deleteObject(bucket, + dir + "/" + filename.substring( filename.lastIndexOf("/") + 1)); + } + + @Override + public String createFileName(String filename) { + return UUID.randomUUID().toString().concat(getFileExtension(filename)); + } + + @Override + public String getFileExtension(String filename) { + if (filename.length() == 0) { + throw new ImageNotFoundException(); + } + validateFileExtension(filename); + + return filename.substring(filename.lastIndexOf(".")); + } + + @Override + public void validateFileExtension(String filename) { + ArrayList fileValidate = new ArrayList<>(); + fileValidate.add(".jpg"); + fileValidate.add(".jpeg"); + fileValidate.add(".png"); + fileValidate.add(".JPG"); + fileValidate.add(".JPEG"); + fileValidate.add(".PNG"); + + String idxFileName = filename.substring(filename.lastIndexOf(".")); + + if (!fileValidate.contains(idxFileName)) { + throw new InvalidImageTypeException(); + } + } +} diff --git a/module-member/src/main/java/com/gaethering/modulemember/service/pet/PetService.java b/module-member/src/main/java/com/gaethering/modulemember/service/pet/PetService.java new file mode 100644 index 0000000..201efa4 --- /dev/null +++ b/module-member/src/main/java/com/gaethering/modulemember/service/pet/PetService.java @@ -0,0 +1,8 @@ +package com.gaethering.modulemember.service.pet; + +import org.springframework.web.multipart.MultipartFile; + +public interface PetService { + + String updatePetImage(Long id, MultipartFile multipartFile); +} diff --git a/module-member/src/main/java/com/gaethering/modulemember/service/pet/PetServiceImpl.java b/module-member/src/main/java/com/gaethering/modulemember/service/pet/PetServiceImpl.java new file mode 100644 index 0000000..a2546c4 --- /dev/null +++ b/module-member/src/main/java/com/gaethering/modulemember/service/pet/PetServiceImpl.java @@ -0,0 +1,44 @@ +package com.gaethering.modulemember.service.pet; + +import com.gaethering.moduledomain.domain.member.Pet; +import com.gaethering.moduledomain.repository.pet.PetRepository; +import com.gaethering.modulemember.exception.pet.ImageNotFoundException; +import com.gaethering.modulemember.exception.pet.PetNotFoundException; +import com.gaethering.modulemember.service.ImageUploadService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +@Transactional +@RequiredArgsConstructor +public class PetServiceImpl implements PetService { + + private final ImageUploadService imageUploadService; + private final PetRepository petRepository; + + @Value("${default.image-url}") + private String defaultImageUrl; + + @Override + public String updatePetImage(Long id, MultipartFile multipartFile) { + if (multipartFile.isEmpty()) { + throw new ImageNotFoundException(); + } + + Pet pet = petRepository.findById(id) + .orElseThrow(PetNotFoundException::new); + + if (!defaultImageUrl.equals(pet.getImageUrl())) { + imageUploadService.removeImage(pet.getImageUrl()); + } + + String newImageUrl = imageUploadService.uploadImage(multipartFile); + pet.updateImage(newImageUrl); + + return newImageUrl; + } + +} diff --git a/module-member/src/test/java/com/gaethering/modulemember/config/AwsS3MockConfig.java b/module-member/src/test/java/com/gaethering/modulemember/config/AwsS3MockConfig.java new file mode 100644 index 0000000..f8be0b9 --- /dev/null +++ b/module-member/src/test/java/com/gaethering/modulemember/config/AwsS3MockConfig.java @@ -0,0 +1,46 @@ +package com.gaethering.modulemember.config; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.AnonymousAWSCredentials; +import com.amazonaws.client.builder.AwsClientBuilder; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import io.findify.s3mock.S3Mock; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +@TestConfiguration +public class AwsS3MockConfig { + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public S3Mock s3Mock() { + return new S3Mock.Builder().withPort(8001).withInMemoryBackend().build(); + } + + @Primary + @Bean + public AmazonS3 amazonS3(S3Mock s3Mock){ + s3Mock.start(); + + AwsClientBuilder.EndpointConfiguration endpoint = + new AwsClientBuilder.EndpointConfiguration("http://127.0.0.1:8001", region); + + AmazonS3 client = AmazonS3ClientBuilder + .standard() + .withPathStyleAccessEnabled(true) + .withEndpointConfiguration(endpoint) + .withCredentials(new AWSStaticCredentialsProvider(new AnonymousAWSCredentials())) + .build(); + client.createBucket(bucket); + + return client; + } +} diff --git a/module-member/src/test/java/com/gaethering/modulemember/service/ImageUploadServiceTest.java b/module-member/src/test/java/com/gaethering/modulemember/service/ImageUploadServiceTest.java new file mode 100644 index 0000000..45b8921 --- /dev/null +++ b/module-member/src/test/java/com/gaethering/modulemember/service/ImageUploadServiceTest.java @@ -0,0 +1,65 @@ +package com.gaethering.modulemember.service; + +import com.gaethering.modulemember.config.AwsS3MockConfig; +import com.gaethering.modulemember.exception.errorcode.PetErrorCode; +import com.gaethering.modulemember.exception.pet.InvalidImageTypeException; +import io.findify.s3mock.S3Mock; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +@Import(AwsS3MockConfig.class) +@SpringBootTest +class ImageUploadServiceTest { + + @Autowired + S3Mock s3Mock; + + @Autowired + ImageUploadServiceImpl imageUploadService; + + @AfterEach + public void shutdownMockS3(){ + s3Mock.stop(); + } + + @Test + void uploadPetImageFailure_fileExtension() throws IOException { + // given + String filename = "test.txt"; + String contentType = "image/png"; + + MockMultipartFile file = new MockMultipartFile("test", filename, contentType, "test".getBytes()); + + // when + InvalidImageTypeException exception = Assertions.assertThrows(InvalidImageTypeException.class, + () -> imageUploadService.uploadImage(file)); + + // then + assertThat(exception.getErrorCode()).isEqualTo(PetErrorCode.INVALID_IMAGE_TYPE); + } + + @Test + void uploadPetImageSuccess() throws IOException { + // given + String filename = "test.png"; + String contentType = "image/png"; + String path = "http://127.0.0.1:8001/test-bucket/test-dir/"; + + MockMultipartFile file = new MockMultipartFile("test", filename, contentType, "test".getBytes()); + + // when + String urlPath = imageUploadService.uploadImage(file); + + // then + assertThat(urlPath.substring(0, urlPath.lastIndexOf("/") + 1)).isEqualTo(path); + } +} \ No newline at end of file diff --git a/module-member/src/test/resources/application.yml b/module-member/src/test/resources/application.yml index 8ac14d5..c6e2ea8 100644 --- a/module-member/src/test/resources/application.yml +++ b/module-member/src/test/resources/application.yml @@ -11,4 +11,23 @@ spring : ddl-auto: update properties: hibernate: - format_sql: true \ No newline at end of file + format_sql: true + +cloud: + aws: + credentials: + access-key: test + secret-key: test + s3: + bucket: test-bucket + region: + static: ap-northeast-2 + auto: false + stack: + auto: false + +dir: test-dir +default: + image-url: http://127.0.0.1:8001/test-bucket/test-dir/4a95753d-1827-43c5-b9a0-2359186aedec.png + +api-prefix: /api \ No newline at end of file