From d8309ce66b63d9088c08a6754a356c77487626c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9A=B0=EB=94=94?= <38103085+EunjiShin@users.noreply.github.com> Date: Tue, 16 Jul 2024 22:44:36 +0900 Subject: [PATCH] =?UTF-8?q?[BSVR-68]=20=EB=AF=B8=EB=94=94=EC=96=B4=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=EB=A5=BC=20=EC=9C=84=ED=95=9C=20pre?= =?UTF-8?q?signed=20url=20=EC=83=9D=EC=84=B1=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(module) : ncp module 생성 * build: spring cloud starter aws dependency 추가 * build: openfeign dependency 추가 * feat: ncp 모듈 공통 configuration, object storage configuration 추가 * feat: MediaUploadPort 추가 * feat: presigned url 생성 컴포넌트 추가 * refactor: 현재 시간 조회 부분 분리 * feat: 1차 와이어프레임 변경사항 반영 * feat: media 관련 exception 추가 * feat: Media 생성자에 not null 체크 로직 추가 * test(Media) : Media 테스트 추가 * test(Media) : media 도메인 테스트 추가 * test(ImageExtension) : 이미지 확장자 테스트 추가 * test : 좌석 미디어 확장자 테스트 추가 * test(PresignedUrlGenerator) : presigned url 생성자 테스트 추가 * feat: config에 profile 설정 추가 * feat: config에 profile 설정 추가 * feat: appliction 모듈에 ncp 서브모듈 profile 추가 * feat: ncp 모듈의 application.yaml 설정 + 키는 .env 로 관리 * feat: 도커에 env 설정 추가 * feat: application.yaml에 정의한 prefix 적용 * feat: controller 생성 * fix: ncp 관련 에러 수정 * feat: api endpoint prefix 추가 --------- Co-authored-by: Minseong Park --- .gitignore | 2 + application/build.gradle.kts | 1 + .../common/config/SpotApplicationConfig.java | 3 +- .../application/media/MediaController.java | 50 ++++++++ .../request/CreatePresignedUrlRequest.java | 5 + .../media/dto/response/MediaUrlResponse.java | 3 + .../member/controller/MemberController.java | 2 +- .../src/main/resources/application.yaml | 4 +- build.gradle.kts | 5 + .../exception/media/MediaErrorCode.java | 26 +++++ .../exception/media/MediaException.java | 38 ++++++ docker-compose.yml | 78 +++++++------ .../depromeet/spot/domain/media/Media.java | 28 +++++ .../spot/domain/media/MediaProperty.java | 7 ++ .../media/extension/ImageExtension.java | 40 +++++++ .../extension/StadiumSeatMediaExtension.java | 38 ++++++ .../spot/domain/media/MediaTest.java | 57 +++++++++ .../media/extension/ImageExtensionTest.java | 58 ++++++++++ .../StadiumSeatMediaExtensionTest.java | 50 ++++++++ infrastructure/jpa/build.gradle.kts | 2 +- .../src/main/resources/application-jpa.yaml | 45 ++++---- infrastructure/ncp/build.gradle.kts | 16 +++ .../org/depromeet/spot/ncp/NcpConfig.java | 12 ++ .../spot/ncp/config/ObjectStorageConfig.java | 36 ++++++ .../ncp/objectstorage/FileNameGenerator.java | 48 ++++++++ .../objectstorage/PresignedUrlGenerator.java | 108 ++++++++++++++++++ .../ncp/property/ObjectStorageProperties.java | 8 ++ .../ncp/property/ReviewStorageProperties.java | 7 ++ .../property/StadiumStorageProperties.java | 7 ++ .../spot/ncp/resources/application.yaml | 10 ++ .../spot/ncp/mock/FakeAmazonS3Config.java | 27 +++++ .../spot/ncp/mock/FakeTimeUsecase.java | 20 ++++ .../objectstorage/FileNameGeneratorTest.java | 48 ++++++++ .../PresignedUrlGeneratorTest.java | 91 +++++++++++++++ .../src/test/resources/application-test.yml | 0 settings.gradle.kts | 2 + .../port/in/{ => member}/MemberUsecase.java | 2 +- .../usecase/port/in/util/TimeUsecase.java | 8 ++ .../out/media/CreatePresignedUrlPort.java | 21 ++++ .../service/{ => member}/MemberService.java | 4 +- .../usecase/service/util/TimeService.java | 15 +++ versions.properties | 3 + 42 files changed, 966 insertions(+), 69 deletions(-) create mode 100644 application/src/main/java/org/depromeet/spot/application/media/MediaController.java create mode 100644 application/src/main/java/org/depromeet/spot/application/media/dto/request/CreatePresignedUrlRequest.java create mode 100644 application/src/main/java/org/depromeet/spot/application/media/dto/response/MediaUrlResponse.java create mode 100644 common/src/main/java/org/depromeet/spot/common/exception/media/MediaErrorCode.java create mode 100644 common/src/main/java/org/depromeet/spot/common/exception/media/MediaException.java create mode 100644 domain/src/main/java/org/depromeet/spot/domain/media/Media.java create mode 100644 domain/src/main/java/org/depromeet/spot/domain/media/MediaProperty.java create mode 100644 domain/src/main/java/org/depromeet/spot/domain/media/extension/ImageExtension.java create mode 100644 domain/src/main/java/org/depromeet/spot/domain/media/extension/StadiumSeatMediaExtension.java create mode 100644 domain/src/test/java/org/depromeet/spot/domain/media/MediaTest.java create mode 100644 domain/src/test/java/org/depromeet/spot/domain/media/extension/ImageExtensionTest.java create mode 100644 domain/src/test/java/org/depromeet/spot/domain/media/extension/StadiumSeatMediaExtensionTest.java create mode 100644 infrastructure/ncp/build.gradle.kts create mode 100644 infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/NcpConfig.java create mode 100644 infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/config/ObjectStorageConfig.java create mode 100644 infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/objectstorage/FileNameGenerator.java create mode 100644 infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/objectstorage/PresignedUrlGenerator.java create mode 100644 infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/property/ObjectStorageProperties.java create mode 100644 infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/property/ReviewStorageProperties.java create mode 100644 infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/property/StadiumStorageProperties.java create mode 100644 infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/resources/application.yaml create mode 100644 infrastructure/ncp/src/test/java/org/depromeet/spot/ncp/mock/FakeAmazonS3Config.java create mode 100644 infrastructure/ncp/src/test/java/org/depromeet/spot/ncp/mock/FakeTimeUsecase.java create mode 100644 infrastructure/ncp/src/test/java/org/depromeet/spot/ncp/objectstorage/FileNameGeneratorTest.java create mode 100644 infrastructure/ncp/src/test/java/org/depromeet/spot/ncp/objectstorage/PresignedUrlGeneratorTest.java create mode 100644 infrastructure/ncp/src/test/resources/application-test.yml rename usecase/src/main/java/org/depromeet/spot/usecase/port/in/{ => member}/MemberUsecase.java (78%) create mode 100644 usecase/src/main/java/org/depromeet/spot/usecase/port/in/util/TimeUsecase.java create mode 100644 usecase/src/main/java/org/depromeet/spot/usecase/port/out/media/CreatePresignedUrlPort.java rename usecase/src/main/java/org/depromeet/spot/usecase/service/{ => member}/MemberService.java (89%) create mode 100644 usecase/src/main/java/org/depromeet/spot/usecase/service/util/TimeService.java diff --git a/.gitignore b/.gitignore index fcc22047..754acf39 100644 --- a/.gitignore +++ b/.gitignore @@ -381,3 +381,5 @@ gradle-app.setting # End of https://www.toptal.com/developers/gitignore/api/macos,windows,intellij,intellij+iml,intellij+all,visualstudiocode,java,gradle,kotlin /db/ + +.env \ No newline at end of file diff --git a/application/build.gradle.kts b/application/build.gradle.kts index 165b43db..ff13140a 100644 --- a/application/build.gradle.kts +++ b/application/build.gradle.kts @@ -3,6 +3,7 @@ dependencies { implementation(project(":domain")) implementation(project(":usecase")) implementation(project(":infrastructure:jpa")) + implementation(project(":infrastructure:ncp")) // spring implementation("org.springframework.boot:spring-boot-starter") diff --git a/application/src/main/java/org/depromeet/spot/application/common/config/SpotApplicationConfig.java b/application/src/main/java/org/depromeet/spot/application/common/config/SpotApplicationConfig.java index fbeb93db..65567e17 100644 --- a/application/src/main/java/org/depromeet/spot/application/common/config/SpotApplicationConfig.java +++ b/application/src/main/java/org/depromeet/spot/application/common/config/SpotApplicationConfig.java @@ -1,6 +1,7 @@ package org.depromeet.spot.application.common.config; import org.depromeet.spot.jpa.config.JpaConfig; +import org.depromeet.spot.ncp.NcpConfig; import org.depromeet.spot.usecase.config.UsecaseConfig; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; @@ -8,5 +9,5 @@ @ComponentScan(basePackages = {"org.depromeet.spot.application"}) @Configuration -@Import(value = {UsecaseConfig.class, JpaConfig.class}) +@Import(value = {UsecaseConfig.class, JpaConfig.class, NcpConfig.class}) public class SpotApplicationConfig {} diff --git a/application/src/main/java/org/depromeet/spot/application/media/MediaController.java b/application/src/main/java/org/depromeet/spot/application/media/MediaController.java new file mode 100644 index 00000000..53825b1e --- /dev/null +++ b/application/src/main/java/org/depromeet/spot/application/media/MediaController.java @@ -0,0 +1,50 @@ +package org.depromeet.spot.application.media; + +import jakarta.validation.Valid; + +import org.depromeet.spot.application.media.dto.request.CreatePresignedUrlRequest; +import org.depromeet.spot.application.media.dto.response.MediaUrlResponse; +import org.depromeet.spot.usecase.port.out.media.CreatePresignedUrlPort; +import org.depromeet.spot.usecase.port.out.media.CreatePresignedUrlPort.PresignedUrlRequest; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +@Tag(name = "미디어 (이미지, 영상)") +public class MediaController { + + private final CreatePresignedUrlPort createPresignedUrlPort; + + @ResponseStatus(HttpStatus.CREATED) + @PostMapping(value = "/members/{memberId}/reviews/images") + @Operation(summary = "리뷰 이미지 업로드 url을 생성합니다.") + public MediaUrlResponse createReviewImageUploadUrl( + @PathVariable Long memberId, @RequestBody @Valid CreatePresignedUrlRequest request) { + PresignedUrlRequest command = + new PresignedUrlRequest(request.fileExtension(), request.property()); + String presignedUrl = createPresignedUrlPort.forReview(memberId, command); + return new MediaUrlResponse(presignedUrl); + } + + @ResponseStatus(HttpStatus.CREATED) + @PostMapping(value = "/stadiums/images") + @Operation(summary = "공연장 이미지 업로드 url을 생성합니다.") + public MediaUrlResponse createStadiumSeatUploadUrl( + @RequestBody @Valid CreatePresignedUrlRequest request) { + PresignedUrlRequest command = + new PresignedUrlRequest(request.fileExtension(), request.property()); + String presignedUrl = createPresignedUrlPort.forStadiumSeat(command); + return new MediaUrlResponse(presignedUrl); + } +} diff --git a/application/src/main/java/org/depromeet/spot/application/media/dto/request/CreatePresignedUrlRequest.java b/application/src/main/java/org/depromeet/spot/application/media/dto/request/CreatePresignedUrlRequest.java new file mode 100644 index 00000000..3645442b --- /dev/null +++ b/application/src/main/java/org/depromeet/spot/application/media/dto/request/CreatePresignedUrlRequest.java @@ -0,0 +1,5 @@ +package org.depromeet.spot.application.media.dto.request; + +import org.depromeet.spot.domain.media.MediaProperty; + +public record CreatePresignedUrlRequest(String fileExtension, MediaProperty property) {} diff --git a/application/src/main/java/org/depromeet/spot/application/media/dto/response/MediaUrlResponse.java b/application/src/main/java/org/depromeet/spot/application/media/dto/response/MediaUrlResponse.java new file mode 100644 index 00000000..1f336af0 --- /dev/null +++ b/application/src/main/java/org/depromeet/spot/application/media/dto/response/MediaUrlResponse.java @@ -0,0 +1,3 @@ +package org.depromeet.spot.application.media.dto.response; + +public record MediaUrlResponse(String presignedUrl) {} diff --git a/application/src/main/java/org/depromeet/spot/application/member/controller/MemberController.java b/application/src/main/java/org/depromeet/spot/application/member/controller/MemberController.java index 673abfb3..bcf9ebed 100644 --- a/application/src/main/java/org/depromeet/spot/application/member/controller/MemberController.java +++ b/application/src/main/java/org/depromeet/spot/application/member/controller/MemberController.java @@ -4,7 +4,7 @@ import org.depromeet.spot.application.member.dto.request.MemberRequest; import org.depromeet.spot.application.member.dto.response.MemberResponse; -import org.depromeet.spot.usecase.port.in.MemberUsecase; +import org.depromeet.spot.usecase.port.in.member.MemberUsecase; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; diff --git a/application/src/main/resources/application.yaml b/application/src/main/resources/application.yaml index 1077c07f..8e9bc0b4 100644 --- a/application/src/main/resources/application.yaml +++ b/application/src/main/resources/application.yaml @@ -5,7 +5,9 @@ server: spring: # 서브모듈 profile profiles: - include: jpa + include: + - jpa + - ncp # swagger를 이용해 API 명세서 생성 doc: swagger-ui: diff --git a/build.gradle.kts b/build.gradle.kts index 85044864..a76da88c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,6 +31,11 @@ subprojects { // lombok compileOnly("org.projectlombok:lombok:_") annotationProcessor("org.projectlombok:lombok:_") + testCompileOnly("org.projectlombok:lombok:_") + testAnnotationProcessor("org.projectlombok:lombok:_") + + // configuration processor + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor:_") // test testImplementation("org.springframework.boot:spring-boot-starter-test:_") diff --git a/common/src/main/java/org/depromeet/spot/common/exception/media/MediaErrorCode.java b/common/src/main/java/org/depromeet/spot/common/exception/media/MediaErrorCode.java new file mode 100644 index 00000000..7fedd5dc --- /dev/null +++ b/common/src/main/java/org/depromeet/spot/common/exception/media/MediaErrorCode.java @@ -0,0 +1,26 @@ +package org.depromeet.spot.common.exception.media; + +import org.depromeet.spot.common.exception.ErrorCode; +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum MediaErrorCode implements ErrorCode { + INVALID_EXTENSION(HttpStatus.BAD_REQUEST, "ME001", "허용하지 않는 확장자입니다."), + INVALID_STADIUM_MEDIA(HttpStatus.BAD_REQUEST, "ME002", "경기장과 관련된 미디어 파일이 아닙니다."), + INVALID_REVIEW_MEDIA(HttpStatus.BAD_REQUEST, "ME003", "리뷰와 관련된 미디어 파일이 아닙니다."), + INVALID_MEDIA(HttpStatus.INTERNAL_SERVER_ERROR, "ME004", "잘못된 미디어 형식입니다."), + ; + + private final HttpStatus status; + private final String code; + private String message; + + public MediaErrorCode appended(final String s) { + message = message + " {" + s + "}"; + return this; + } +} diff --git a/common/src/main/java/org/depromeet/spot/common/exception/media/MediaException.java b/common/src/main/java/org/depromeet/spot/common/exception/media/MediaException.java new file mode 100644 index 00000000..c416da41 --- /dev/null +++ b/common/src/main/java/org/depromeet/spot/common/exception/media/MediaException.java @@ -0,0 +1,38 @@ +package org.depromeet.spot.common.exception.media; + +import org.depromeet.spot.common.exception.BusinessException; + +public abstract class MediaException extends BusinessException { + + protected MediaException(MediaErrorCode errorCode) { + super(errorCode); + } + + public static class InvalidExtensionException extends MediaException { + public InvalidExtensionException() { + super(MediaErrorCode.INVALID_EXTENSION); + } + + public InvalidExtensionException(final String s) { + super(MediaErrorCode.INVALID_EXTENSION.appended(s)); + } + } + + public static class InvalidStadiumMediaException extends MediaException { + public InvalidStadiumMediaException() { + super(MediaErrorCode.INVALID_STADIUM_MEDIA); + } + } + + public static class InvalidReviewMediaException extends MediaException { + public InvalidReviewMediaException() { + super(MediaErrorCode.INVALID_REVIEW_MEDIA); + } + } + + public static class InvalidMediaException extends MediaException { + public InvalidMediaException() { + super(MediaErrorCode.INVALID_MEDIA); + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 262876be..ed72f3b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,42 +1,44 @@ # docker-compose.yml services: - mysql: - container_name: spot-mysql - image: mysql:8 # 선호하는 버전 있을 경우 선정 예정! - ports: - - 3306:3306 # 혹시나 기존에 MySQL 사용 중일 경우 앞자리를 다른 포트로 바꿔야함. - volumes: - - ./db/mysql/data:/var/lib/mysql # 기존 데이터 파일과 격리를 위해 db/mysql/data 로 설정함! - command: - - '--character-set-server=utf8mb4' - - '--collation-server=utf8mb4_unicode_ci' - environment: - TZ : "Asia/Seoul" - MYSQL_ROOT_PASSWORD: test1234 # 임시 비밀번호 - MYSQL_DATABASE: spot # DB 이름 선정 시 변경 예정. - MYSQL_USER: test1234 # 임시 유저 - MYSQL_PASSWORD: test1234 # 임시 비밀번호 - healthcheck: # MySQL 서비스 상태 확인 - test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] - timeout: 20s - retries: 10 + mysql: + container_name: spot-mysql + image: mysql:8 # 선호하는 버전 있을 경우 선정 예정! + ports: + - 3306:3306 # 혹시나 기존에 MySQL 사용 중일 경우 앞자리를 다른 포트로 바꿔야함. + volumes: + - ./db/mysql/data:/var/lib/mysql # 기존 데이터 파일과 격리를 위해 db/mysql/data 로 설정함! + command: + - '--character-set-server=utf8mb4' + - '--collation-server=utf8mb4_unicode_ci' + environment: + TZ : "Asia/Seoul" + MYSQL_ROOT_PASSWORD: test1234 # 임시 비밀번호 + MYSQL_DATABASE: spot # DB 이름 선정 시 변경 예정. + MYSQL_USER: test1234 # 임시 유저 + MYSQL_PASSWORD: test1234 # 임시 비밀번호 + healthcheck: # MySQL 서비스 상태 확인 + test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] + timeout: 20s + retries: 10 - server: - build: # 디렉토리에 있는 도커파일 이용해서 이미지 빌드 - context: . - dockerfile: Dockerfile.dev - container_name: spot-spring-server - ports: - - 8080:8080 - depends_on: # 항상 mysql 실행하고 서버 실행되게 함. - mysql: - condition: service_healthy # mysql 컨테이너의 healthcheck가 정상일 때까지 대기! - environment: - SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/spot - SPRING_DATASOURCE_USERNAME: test1234 - SPRING_DATASOURCE_PASSWORD: test1234 - volumes: - - ./:/app - - ~/.gradle:/root/.gradle - command: ./gradlew :application:bootRun \ No newline at end of file + server: + build: # 디렉토리에 있는 도커파일 이용해서 이미지 빌드 + context: . + dockerfile: Dockerfile.dev + container_name: spot-spring-server + ports: + - 8080:8080 + depends_on: # 항상 mysql 실행하고 서버 실행되게 함. + mysql: + condition: service_healthy # mysql 컨테이너의 healthcheck가 정상일 때까지 대기! + environment: + SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/spot + SPRING_DATASOURCE_USERNAME: test1234 + SPRING_DATASOURCE_PASSWORD: test1234 + volumes: + - ./:/app + - ~/.gradle:/root/.gradle + command: ./gradlew :application:bootRun + env_file: + - .env \ No newline at end of file diff --git a/domain/src/main/java/org/depromeet/spot/domain/media/Media.java b/domain/src/main/java/org/depromeet/spot/domain/media/Media.java new file mode 100644 index 00000000..d6920696 --- /dev/null +++ b/domain/src/main/java/org/depromeet/spot/domain/media/Media.java @@ -0,0 +1,28 @@ +package org.depromeet.spot.domain.media; + +import org.depromeet.spot.common.exception.media.MediaException.InvalidMediaException; + +import lombok.Getter; + +@Getter +public class Media { + + private final String url; + private final String fileName; + + public Media(final String url, final String fileName) { + checkIsValidMedia(url, fileName); + this.url = url; + this.fileName = fileName; + } + + private void checkIsValidMedia(final String url, final String fileName) { + if (isBlankOrNull(url) || isBlankOrNull(fileName)) { + throw new InvalidMediaException(); + } + } + + private boolean isBlankOrNull(final String str) { + return str == null || str.isBlank(); + } +} diff --git a/domain/src/main/java/org/depromeet/spot/domain/media/MediaProperty.java b/domain/src/main/java/org/depromeet/spot/domain/media/MediaProperty.java new file mode 100644 index 00000000..3005af33 --- /dev/null +++ b/domain/src/main/java/org/depromeet/spot/domain/media/MediaProperty.java @@ -0,0 +1,7 @@ +package org.depromeet.spot.domain.media; + +public enum MediaProperty { + REVIEW, + STADIUM, + ; +} diff --git a/domain/src/main/java/org/depromeet/spot/domain/media/extension/ImageExtension.java b/domain/src/main/java/org/depromeet/spot/domain/media/extension/ImageExtension.java new file mode 100644 index 00000000..07fda214 --- /dev/null +++ b/domain/src/main/java/org/depromeet/spot/domain/media/extension/ImageExtension.java @@ -0,0 +1,40 @@ +package org.depromeet.spot.domain.media.extension; + +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +import org.depromeet.spot.common.exception.media.MediaException.InvalidExtensionException; + +import lombok.Getter; + +@Getter +public enum ImageExtension { + JPG("jpg"), + JPEG("jpeg"), + PNG("png"), + ; + + private final String value; + + private static final Map cachedImageExtension = + Arrays.stream(ImageExtension.values()) + .collect( + Collectors.toMap(extension -> extension.value, extension -> extension)); + + ImageExtension(final String value) { + this.value = value; + } + + public static boolean isValid(final String reqExtension) { + return cachedImageExtension.containsKey(reqExtension); + } + + public static ImageExtension from(final String reqExtension) { + ImageExtension extension = cachedImageExtension.get(reqExtension); + if (extension == null) { + throw new InvalidExtensionException(reqExtension); + } + return extension; + } +} diff --git a/domain/src/main/java/org/depromeet/spot/domain/media/extension/StadiumSeatMediaExtension.java b/domain/src/main/java/org/depromeet/spot/domain/media/extension/StadiumSeatMediaExtension.java new file mode 100644 index 00000000..9c2ce6fd --- /dev/null +++ b/domain/src/main/java/org/depromeet/spot/domain/media/extension/StadiumSeatMediaExtension.java @@ -0,0 +1,38 @@ +package org.depromeet.spot.domain.media.extension; + +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +import org.depromeet.spot.common.exception.media.MediaException.InvalidExtensionException; + +import lombok.Getter; + +@Getter +public enum StadiumSeatMediaExtension { + SVG("svg"), + ; + + private final String value; + + private static final Map cachedStadiumMedia = + Arrays.stream(StadiumSeatMediaExtension.values()) + .collect( + Collectors.toMap(extension -> extension.value, extension -> extension)); + + StadiumSeatMediaExtension(final String value) { + this.value = value; + } + + public static boolean isValid(final String reqExtension) { + return cachedStadiumMedia.containsKey(reqExtension); + } + + public static StadiumSeatMediaExtension from(final String reqExtension) { + StadiumSeatMediaExtension extension = cachedStadiumMedia.get(reqExtension); + if (extension == null) { + throw new InvalidExtensionException(reqExtension); + } + return extension; + } +} diff --git a/domain/src/test/java/org/depromeet/spot/domain/media/MediaTest.java b/domain/src/test/java/org/depromeet/spot/domain/media/MediaTest.java new file mode 100644 index 00000000..ea4deb9e --- /dev/null +++ b/domain/src/test/java/org/depromeet/spot/domain/media/MediaTest.java @@ -0,0 +1,57 @@ +package org.depromeet.spot.domain.media; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import org.depromeet.spot.common.exception.media.MediaException.InvalidMediaException; +import org.junit.jupiter.api.Test; + +class MediaTest { + + @Test + public void url이_공백이면_미디어를_생성할_수_없다() { + // given + final String url = ""; + final String fileName = "file"; + + // when + // then + assertThatThrownBy(() -> new Media(url, fileName)) + .isInstanceOf(InvalidMediaException.class); + } + + @Test + public void url이_null이면_미디어를_생성할_수_없다() { + // given + final String url = null; + final String fileName = "file"; + + // when + // then + assertThatThrownBy(() -> new Media(url, fileName)) + .isInstanceOf(InvalidMediaException.class); + } + + @Test + public void fileName이_공백이면_미디어를_생성할_수_없다() { + // given + final String url = "url"; + final String fileName = ""; + + // when + // then + assertThatThrownBy(() -> new Media(url, fileName)) + .isInstanceOf(InvalidMediaException.class); + } + + @Test + public void fileName이_null이면_미디어를_생성할_수_없다() { + // given + final String url = "url"; + final String fileName = ""; + + // when + // then + assertThatThrownBy(() -> new Media(url, fileName)) + .isInstanceOf(InvalidMediaException.class); + } +} diff --git a/domain/src/test/java/org/depromeet/spot/domain/media/extension/ImageExtensionTest.java b/domain/src/test/java/org/depromeet/spot/domain/media/extension/ImageExtensionTest.java new file mode 100644 index 00000000..172ead98 --- /dev/null +++ b/domain/src/test/java/org/depromeet/spot/domain/media/extension/ImageExtensionTest.java @@ -0,0 +1,58 @@ +package org.depromeet.spot.domain.media.extension; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.depromeet.spot.common.exception.media.MediaException.InvalidExtensionException; +import org.junit.jupiter.api.Test; + +class ImageExtensionTest { + + @Test + public void 유효한_리뷰_미디어_확장자인지_판별할_수_있다() { + // given + final String validValue = "jpg"; + final String invalidValue = "mp4"; + + // when + final boolean checkValid = ImageExtension.isValid(validValue); + final boolean checkInvalid = ImageExtension.isValid(invalidValue); + + // then + assertTrue(checkValid); + assertFalse(checkInvalid); + } + + @Test + public void value로_리뷰_미디어_확장자를_찾을_수_있다() { + // given + final String jpgValue = "jpg"; + final String jpegValue = "jpeg"; + final String pngValue = "png"; + + // when + ImageExtension jpgResult = ImageExtension.from(jpgValue); + ImageExtension jpegResult = ImageExtension.from(jpegValue); + ImageExtension pngResult = ImageExtension.from(pngValue); + + // then + assertAll( + () -> assertEquals(jpgResult, ImageExtension.JPG), + () -> assertEquals(jpegResult, ImageExtension.JPEG), + () -> assertEquals(pngResult, ImageExtension.PNG)); + } + + @Test + public void 유효하지_않은_value로_좌석_미디어_확장자를_찾을_수_없다() { + // given + final String value = "test!"; + + // when + // then + assertThatThrownBy(() -> ImageExtension.from(value)) + .isInstanceOf(InvalidExtensionException.class); + } +} diff --git a/domain/src/test/java/org/depromeet/spot/domain/media/extension/StadiumSeatMediaExtensionTest.java b/domain/src/test/java/org/depromeet/spot/domain/media/extension/StadiumSeatMediaExtensionTest.java new file mode 100644 index 00000000..41d62778 --- /dev/null +++ b/domain/src/test/java/org/depromeet/spot/domain/media/extension/StadiumSeatMediaExtensionTest.java @@ -0,0 +1,50 @@ +package org.depromeet.spot.domain.media.extension; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.depromeet.spot.common.exception.media.MediaException.InvalidExtensionException; +import org.junit.jupiter.api.Test; + +class StadiumSeatMediaExtensionTest { + + @Test + public void 유효한_좌석_미디어_확장자인지_판별할_수_있다() { + // given + final String validValue = "svg"; + final String invalidValue = "png"; + + // when + final boolean checkValid = StadiumSeatMediaExtension.isValid(validValue); + final boolean checkInvalid = StadiumSeatMediaExtension.isValid(invalidValue); + + // then + assertTrue(checkValid); + assertFalse(checkInvalid); + } + + @Test + public void value로_좌석_미디어_확장자를_찾을_수_있다() { + // given + final String value = "svg"; + + // when + StadiumSeatMediaExtension result = StadiumSeatMediaExtension.from(value); + + // then + assertEquals(result, StadiumSeatMediaExtension.SVG); + } + + @Test + public void 유효하지_않은_value로_좌석_미디어_확장자를_찾을_수_없다() { + // given + final String value = "test!"; + + // when + // then + assertThatThrownBy(() -> StadiumSeatMediaExtension.from(value)) + .isInstanceOf(InvalidExtensionException.class); + } +} diff --git a/infrastructure/jpa/build.gradle.kts b/infrastructure/jpa/build.gradle.kts index e8dd4c4b..6643e45c 100644 --- a/infrastructure/jpa/build.gradle.kts +++ b/infrastructure/jpa/build.gradle.kts @@ -6,7 +6,7 @@ dependencies { // spring implementation("org.springframework.boot:spring-boot-starter-data-jpa:_") - + // mysql runtimeOnly("com.mysql:mysql-connector-j") // queryDSL diff --git a/infrastructure/jpa/src/main/resources/application-jpa.yaml b/infrastructure/jpa/src/main/resources/application-jpa.yaml index c7affc08..b7782cd2 100644 --- a/infrastructure/jpa/src/main/resources/application-jpa.yaml +++ b/infrastructure/jpa/src/main/resources/application-jpa.yaml @@ -1,36 +1,33 @@ spring: - datasource: - url: jdbc:mysql://mysql:3306/spot - username: test1234 - password: test1234 - driver-class-name: com.mysql.cj.jdbc.Driver + datasource: + url: jdbc:mysql://mysql:3306/spot + username: test1234 + password: test1234 + driver-class-name: com.mysql.cj.jdbc.Driver - jpa: - database: mysql - database-platform: org.hibernate.dialect.MySQL8Dialect - hibernate: - ddl-auto: update # 주의: 프로덕션 환경에서는 'validate' 사용 권장 - properties: - hibernate: - use_sql_comments: true - defer-datasource-initialization: true + jpa: + database: mysql + database-platform: org.hibernate.dialect.MySQL8Dialect + hibernate: + ddl-auto: update # 주의: 프로덕션 환경에서는 'validate' 사용 권장 + properties: + hibernate: + use_sql_comments: true + defer-datasource-initialization: true - sql: - init: - mode: always # 필요한 경우 'never'로 변경 + sql: + init: + mode: never # 필요한 경우 'never'로 변경 server: - port: 8080 + port: 8080 logging: - level: - org.hibernate.SQL: DEBUG - org.hibernate.type.descriptor.sql.BasicBinder: TRACE + level: + org.hibernate.SQL: DEBUG + org.hibernate.type.descriptor.sql.BasicBinder: TRACE decorator: datasource: p6spy: enable-logging: true - -# 필요한 경우 추가 설정 - diff --git a/infrastructure/ncp/build.gradle.kts b/infrastructure/ncp/build.gradle.kts new file mode 100644 index 00000000..d444a5dd --- /dev/null +++ b/infrastructure/ncp/build.gradle.kts @@ -0,0 +1,16 @@ +dependencies { + implementation(project(":domain")) + implementation(project(":common")) + implementation(project(":usecase")) + + // spring + implementation("org.springframework.boot:spring-boot-starter") + + // ncp + implementation("org.springframework.cloud:spring-cloud-starter-aws:_") { + because("ncp의 object storage를 사용하기 위해 필요한 의존성이에요.") + } +} + +tasks.bootJar { enabled = false } +tasks.jar { enabled = true } \ No newline at end of file diff --git a/infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/NcpConfig.java b/infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/NcpConfig.java new file mode 100644 index 00000000..8194945b --- /dev/null +++ b/infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/NcpConfig.java @@ -0,0 +1,12 @@ +package org.depromeet.spot.ncp; + +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties +@ConfigurationPropertiesScan(basePackages = {"org.depromeet.spot.ncp.property"}) +@ComponentScan(basePackages = {"org.depromeet.spot.ncp"}) +public class NcpConfig {} diff --git a/infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/config/ObjectStorageConfig.java b/infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/config/ObjectStorageConfig.java new file mode 100644 index 00000000..7a042d5b --- /dev/null +++ b/infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/config/ObjectStorageConfig.java @@ -0,0 +1,36 @@ +package org.depromeet.spot.ncp.config; + +import org.depromeet.spot.ncp.property.ObjectStorageProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.client.builder.AwsClientBuilder.EndpointConfiguration; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; + +import lombok.RequiredArgsConstructor; + +@Configuration +@Profile("!test") +@RequiredArgsConstructor +public class ObjectStorageConfig { + + private final ObjectStorageProperties objectStorageProperties; + private static final String ENDPOINT = "https://kr.object.ncloudstorage.com"; + private static final String REGION = "kr-standard"; + + @Bean + public AmazonS3 getAmazonS3() { + return AmazonS3ClientBuilder.standard() + .withEndpointConfiguration(new EndpointConfiguration(ENDPOINT, REGION)) + .withCredentials( + new AWSStaticCredentialsProvider( + new BasicAWSCredentials( + objectStorageProperties.accessKey(), + objectStorageProperties.secretKey()))) + .build(); + } +} diff --git a/infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/objectstorage/FileNameGenerator.java b/infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/objectstorage/FileNameGenerator.java new file mode 100644 index 00000000..55c4b4eb --- /dev/null +++ b/infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/objectstorage/FileNameGenerator.java @@ -0,0 +1,48 @@ +package org.depromeet.spot.ncp.objectstorage; + +import org.depromeet.spot.domain.media.MediaProperty; +import org.depromeet.spot.domain.media.extension.ImageExtension; +import org.depromeet.spot.domain.media.extension.StadiumSeatMediaExtension; +import org.depromeet.spot.usecase.port.in.util.TimeUsecase; +import org.springframework.stereotype.Service; + +import lombok.Builder; +import lombok.RequiredArgsConstructor; + +@Service +@Builder +@RequiredArgsConstructor +public class FileNameGenerator { + + private final TimeUsecase timeUsecase; + + public String createReviewFileName( + final Long userId, final ImageExtension fileExtension, final String folderName) { + StringBuilder stringBuilder = new StringBuilder(); + stringBuilder + .append(folderName) + .append("/") + .append(MediaProperty.REVIEW) + .append("_user_") + .append(userId) + .append("_") + .append(timeUsecase.getNow()) + .append(".") + .append(fileExtension.getValue()); + return stringBuilder.toString(); + } + + public String createStadiumFileName( + final StadiumSeatMediaExtension fileExtension, final String folderName) { + StringBuilder stringBuilder = new StringBuilder(); + stringBuilder + .append(folderName) + .append("/") + .append(MediaProperty.STADIUM) + .append("_") + .append(timeUsecase.getNow()) + .append(".") + .append(fileExtension.getValue()); + return stringBuilder.toString(); + } +} diff --git a/infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/objectstorage/PresignedUrlGenerator.java b/infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/objectstorage/PresignedUrlGenerator.java new file mode 100644 index 00000000..64f5385e --- /dev/null +++ b/infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/objectstorage/PresignedUrlGenerator.java @@ -0,0 +1,108 @@ +package org.depromeet.spot.ncp.objectstorage; + +import java.net.URL; +import java.util.Date; + +import org.depromeet.spot.common.exception.media.MediaException.InvalidExtensionException; +import org.depromeet.spot.common.exception.media.MediaException.InvalidReviewMediaException; +import org.depromeet.spot.common.exception.media.MediaException.InvalidStadiumMediaException; +import org.depromeet.spot.domain.media.MediaProperty; +import org.depromeet.spot.domain.media.extension.ImageExtension; +import org.depromeet.spot.domain.media.extension.StadiumSeatMediaExtension; +import org.depromeet.spot.ncp.property.ReviewStorageProperties; +import org.depromeet.spot.ncp.property.StadiumStorageProperties; +import org.depromeet.spot.usecase.port.out.media.CreatePresignedUrlPort; +import org.springframework.stereotype.Service; + +import com.amazonaws.HttpMethod; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.Headers; +import com.amazonaws.services.s3.model.CannedAccessControlList; +import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; + +import lombok.Builder; +import lombok.RequiredArgsConstructor; + +@Service +@Builder +@RequiredArgsConstructor +public class PresignedUrlGenerator implements CreatePresignedUrlPort { + + private final AmazonS3 amazonS3; + private final FileNameGenerator fileNameGenerator; + private final ReviewStorageProperties reviewStorageProperties; + private final StadiumStorageProperties stadiumStorageProperties; + + private static final long EXPIRE_MS = 1000 * 60 * 5L; + + @Override + public String forReview(final Long userId, PresignedUrlRequest request) { + isValidReviewMedia(request.getProperty(), request.getFileExtension()); + + final ImageExtension fileExtension = ImageExtension.from(request.getFileExtension()); + final String folderName = reviewStorageProperties.folderName(); + final String fileName = + fileNameGenerator.createReviewFileName(userId, fileExtension, folderName); + final URL url = createPresignedUrl(reviewStorageProperties.bucketName(), fileName); + + return url.toString(); + } + + // 1차 MVP에서 사진만 허용 + private void isValidReviewMedia(final MediaProperty property, final String fileExtension) { + if (property != MediaProperty.REVIEW) { + throw new InvalidReviewMediaException(); + } + + if (!ImageExtension.isValid(fileExtension)) { + throw new InvalidExtensionException(fileExtension); + } + } + + @Override + public String forStadiumSeat(PresignedUrlRequest request) { + isValidStadiumMedia(request.getProperty(), request.getFileExtension()); + + final StadiumSeatMediaExtension fileExtension = + StadiumSeatMediaExtension.from(request.getFileExtension()); + final String folderName = stadiumStorageProperties.folderName(); + final String fileName = fileNameGenerator.createStadiumFileName(fileExtension, folderName); + final URL url = createPresignedUrl(stadiumStorageProperties.bucketName(), fileName); + + return url.toString(); + } + + private void isValidStadiumMedia(final MediaProperty property, final String fileExtension) { + if (property != MediaProperty.STADIUM) { + throw new InvalidStadiumMediaException(); + } + + if (!StadiumSeatMediaExtension.isValid(fileExtension)) { + throw new InvalidExtensionException(fileExtension); + } + } + + private URL createPresignedUrl(final String bucketName, final String fileName) { + return amazonS3.generatePresignedUrl( + createGeneratePreSignedUrlRequest(bucketName, fileName)); + } + + private GeneratePresignedUrlRequest createGeneratePreSignedUrlRequest( + final String bucket, final String fileName) { + GeneratePresignedUrlRequest generatePresignedUrlRequest = + new GeneratePresignedUrlRequest(bucket, fileName) + .withMethod(HttpMethod.PUT) + .withExpiration(createPreSignedUrlExpiration()); + + generatePresignedUrlRequest.addRequestParameter( + Headers.S3_CANNED_ACL, CannedAccessControlList.PublicRead.toString()); + + return generatePresignedUrlRequest; + } + + private Date createPreSignedUrlExpiration() { + Date expiration = new Date(); + expiration.setTime(expiration.getTime() + EXPIRE_MS); + return expiration; + } +} diff --git a/infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/property/ObjectStorageProperties.java b/infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/property/ObjectStorageProperties.java new file mode 100644 index 00000000..9a61d136 --- /dev/null +++ b/infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/property/ObjectStorageProperties.java @@ -0,0 +1,8 @@ +package org.depromeet.spot.ncp.property; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +// FIXME: ncp 세팅 완료 후, applicaiton.yml 참고해서 prefix 추가 +@ConfigurationProperties(prefix = "ncp.object-storage") +public record ObjectStorageProperties( + String accessKey, String secretKey, String region, String endPoint) {} diff --git a/infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/property/ReviewStorageProperties.java b/infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/property/ReviewStorageProperties.java new file mode 100644 index 00000000..cec8b3fa --- /dev/null +++ b/infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/property/ReviewStorageProperties.java @@ -0,0 +1,7 @@ +package org.depromeet.spot.ncp.property; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +// FIXME: ncp 세팅 완료 후, applicaiton.yml 참고해서 prefix 추가 +@ConfigurationProperties(prefix = "ncp.review-storage") +public record ReviewStorageProperties(String bucketName, String folderName) {} diff --git a/infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/property/StadiumStorageProperties.java b/infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/property/StadiumStorageProperties.java new file mode 100644 index 00000000..b2e15271 --- /dev/null +++ b/infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/property/StadiumStorageProperties.java @@ -0,0 +1,7 @@ +package org.depromeet.spot.ncp.property; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +// FIXME: ncp 세팅 완료 후, applicaiton.yml 참고해서 prefix 추가 +@ConfigurationProperties(prefix = "ncp.stadium-storage") +public record StadiumStorageProperties(String bucketName, String folderName) {} diff --git a/infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/resources/application.yaml b/infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/resources/application.yaml new file mode 100644 index 00000000..711b4027 --- /dev/null +++ b/infrastructure/ncp/src/main/java/org/depromeet/spot/ncp/resources/application.yaml @@ -0,0 +1,10 @@ +ncp: + object-storage: + accessKey: ${NCP_OBJECT_STORAGE_ACCESS_KEY} + secretKey: ${NCP_OBJECT_STORAGE_SECRET_KEY} + review-storage: + bucketName: "spot-image-bucket" + folderName: "review-images" + stadium-storage: + bucketName: "spot-image-bucket" + folderName: "stadium-images" \ No newline at end of file diff --git a/infrastructure/ncp/src/test/java/org/depromeet/spot/ncp/mock/FakeAmazonS3Config.java b/infrastructure/ncp/src/test/java/org/depromeet/spot/ncp/mock/FakeAmazonS3Config.java new file mode 100644 index 00000000..65fc3fd6 --- /dev/null +++ b/infrastructure/ncp/src/test/java/org/depromeet/spot/ncp/mock/FakeAmazonS3Config.java @@ -0,0 +1,27 @@ +package org.depromeet.spot.ncp.mock; + +import org.depromeet.spot.ncp.config.ObjectStorageConfig; +import org.depromeet.spot.ncp.property.ObjectStorageProperties; +import org.mockito.Mockito; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; + +import com.amazonaws.services.s3.AmazonS3; + +@Profile("test") +@Configuration +public class FakeAmazonS3Config extends ObjectStorageConfig { + + public FakeAmazonS3Config(ObjectStorageProperties objectStorageProperties) { + super(objectStorageProperties); + } + + @Bean + @Primary + @Override + public AmazonS3 getAmazonS3() { + return Mockito.mock(AmazonS3.class); + } +} diff --git a/infrastructure/ncp/src/test/java/org/depromeet/spot/ncp/mock/FakeTimeUsecase.java b/infrastructure/ncp/src/test/java/org/depromeet/spot/ncp/mock/FakeTimeUsecase.java new file mode 100644 index 00000000..bc03bcf8 --- /dev/null +++ b/infrastructure/ncp/src/test/java/org/depromeet/spot/ncp/mock/FakeTimeUsecase.java @@ -0,0 +1,20 @@ +package org.depromeet.spot.ncp.mock; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +import org.depromeet.spot.usecase.port.in.util.TimeUsecase; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class FakeTimeUsecase implements TimeUsecase { + + private final String dateTimeStr; + + @Override + public LocalDateTime getNow() { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + return LocalDateTime.parse(dateTimeStr, formatter); + } +} diff --git a/infrastructure/ncp/src/test/java/org/depromeet/spot/ncp/objectstorage/FileNameGeneratorTest.java b/infrastructure/ncp/src/test/java/org/depromeet/spot/ncp/objectstorage/FileNameGeneratorTest.java new file mode 100644 index 00000000..8ceb1e46 --- /dev/null +++ b/infrastructure/ncp/src/test/java/org/depromeet/spot/ncp/objectstorage/FileNameGeneratorTest.java @@ -0,0 +1,48 @@ +package org.depromeet.spot.ncp.objectstorage; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +import org.depromeet.spot.domain.media.extension.ImageExtension; +import org.depromeet.spot.domain.media.extension.StadiumSeatMediaExtension; +import org.depromeet.spot.ncp.mock.FakeTimeUsecase; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class FileNameGeneratorTest { + + private FileNameGenerator fileNameGenerator; + + @BeforeEach + void init() { + FakeTimeUsecase fakeTimeUsecase = new FakeTimeUsecase("2024-07-09 21:00:00"); + this.fileNameGenerator = FileNameGenerator.builder().timeUsecase(fakeTimeUsecase).build(); + } + + @Test + void 리뷰_첨부파일_이름을_생성할_수_있다() { + // given + Long userId = 1L; + ImageExtension extension = ImageExtension.JPG; + + // when + final String folderName = "review-images"; + final String fileName = + fileNameGenerator.createReviewFileName(userId, extension, folderName); + + // then + assertThat(fileName).isEqualTo("review-images/REVIEW_user_1_2024-07-09T21:00.jpg"); + } + + @Test + void 경기장_첨부파일_이름을_생성할_수_있다() { + // given + StadiumSeatMediaExtension extension = StadiumSeatMediaExtension.SVG; + + // when + final String folderName = "stadium-images"; + final String fileName = fileNameGenerator.createStadiumFileName(extension, folderName); + + // then + assertThat(fileName).isEqualTo("stadium-images/STADIUM_2024-07-09T21:00.svg"); + } +} diff --git a/infrastructure/ncp/src/test/java/org/depromeet/spot/ncp/objectstorage/PresignedUrlGeneratorTest.java b/infrastructure/ncp/src/test/java/org/depromeet/spot/ncp/objectstorage/PresignedUrlGeneratorTest.java new file mode 100644 index 00000000..91b47d80 --- /dev/null +++ b/infrastructure/ncp/src/test/java/org/depromeet/spot/ncp/objectstorage/PresignedUrlGeneratorTest.java @@ -0,0 +1,91 @@ +package org.depromeet.spot.ncp.objectstorage; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import org.depromeet.spot.common.exception.media.MediaException.InvalidExtensionException; +import org.depromeet.spot.common.exception.media.MediaException.InvalidReviewMediaException; +import org.depromeet.spot.common.exception.media.MediaException.InvalidStadiumMediaException; +import org.depromeet.spot.domain.media.MediaProperty; +import org.depromeet.spot.ncp.mock.FakeAmazonS3Config; +import org.depromeet.spot.ncp.mock.FakeTimeUsecase; +import org.depromeet.spot.ncp.property.ObjectStorageProperties; +import org.depromeet.spot.ncp.property.ReviewStorageProperties; +import org.depromeet.spot.ncp.property.StadiumStorageProperties; +import org.depromeet.spot.usecase.port.out.media.CreatePresignedUrlPort.PresignedUrlRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class PresignedUrlGeneratorTest { + + private PresignedUrlGenerator presignedUrlGenerator; + + @BeforeEach + void init() { + ObjectStorageProperties objectStorageProperties = + new ObjectStorageProperties("accessKey", "secretKey", "region", "endPoint"); + FakeAmazonS3Config amazonS3 = new FakeAmazonS3Config(objectStorageProperties); + + ReviewStorageProperties reviewStorageProperties = + new ReviewStorageProperties("review", "folder"); + StadiumStorageProperties stadiumStorageProperties = + new StadiumStorageProperties("stadium", "folder"); + + FakeTimeUsecase fakeTimeUsecase = new FakeTimeUsecase("2024-07-09 21:00:00"); + FileNameGenerator fileNameGenerator = + FileNameGenerator.builder().timeUsecase(fakeTimeUsecase).build(); + + this.presignedUrlGenerator = + PresignedUrlGenerator.builder() + .amazonS3(amazonS3.getAmazonS3()) + .fileNameGenerator(fileNameGenerator) + .reviewStorageProperties(reviewStorageProperties) + .stadiumStorageProperties(stadiumStorageProperties) + .build(); + } + + @Test + void 리뷰_속성이_아니라면_리뷰_미디어를_생성할_수_없다() { + // given + Long userId = 1L; + PresignedUrlRequest request = new PresignedUrlRequest("jpg", MediaProperty.STADIUM); + + // when + // then + assertThatThrownBy(() -> presignedUrlGenerator.forReview(userId, request)) + .isInstanceOf(InvalidReviewMediaException.class); + } + + @Test + void 리뷰_미디어_확장자가_아니라면_리뷰_미디어를_생성할_수_없다() { + // given + Long userId = 1L; + PresignedUrlRequest request = new PresignedUrlRequest("mp4", MediaProperty.REVIEW); + + // when + // then + assertThatThrownBy(() -> presignedUrlGenerator.forReview(userId, request)) + .isInstanceOf(InvalidExtensionException.class); + } + + @Test + void 경기장_속성이_아니라면_경기장_미디어를_생성할_수_없다() { + // given + PresignedUrlRequest request = new PresignedUrlRequest("svg", MediaProperty.REVIEW); + + // when + // then + assertThatThrownBy(() -> presignedUrlGenerator.forStadiumSeat(request)) + .isInstanceOf(InvalidStadiumMediaException.class); + } + + @Test + void 경기장_미디어_확장자가_아니라면_경기장_미디어를_생성할_수_없다() { + // given + PresignedUrlRequest request = new PresignedUrlRequest("mp4", MediaProperty.STADIUM); + + // when + // then + assertThatThrownBy(() -> presignedUrlGenerator.forStadiumSeat(request)) + .isInstanceOf(InvalidExtensionException.class); + } +} diff --git a/infrastructure/ncp/src/test/resources/application-test.yml b/infrastructure/ncp/src/test/resources/application-test.yml new file mode 100644 index 00000000..e69de29b diff --git a/settings.gradle.kts b/settings.gradle.kts index 974ab8ca..cb7b0d9e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -12,3 +12,5 @@ include("infrastructure:jpa") findProject(":infrastructure:jpa")?.name = "jpa" include("usecase") include("common") +include("infrastructure:ncp") +findProject(":infrastructure:ncp")?.name = "ncp" diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/port/in/MemberUsecase.java b/usecase/src/main/java/org/depromeet/spot/usecase/port/in/member/MemberUsecase.java similarity index 78% rename from usecase/src/main/java/org/depromeet/spot/usecase/port/in/MemberUsecase.java rename to usecase/src/main/java/org/depromeet/spot/usecase/port/in/member/MemberUsecase.java index 42b00e6e..cfda7bf0 100644 --- a/usecase/src/main/java/org/depromeet/spot/usecase/port/in/MemberUsecase.java +++ b/usecase/src/main/java/org/depromeet/spot/usecase/port/in/member/MemberUsecase.java @@ -1,4 +1,4 @@ -package org.depromeet.spot.usecase.port.in; +package org.depromeet.spot.usecase.port.in.member; import java.util.List; diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/port/in/util/TimeUsecase.java b/usecase/src/main/java/org/depromeet/spot/usecase/port/in/util/TimeUsecase.java new file mode 100644 index 00000000..36356418 --- /dev/null +++ b/usecase/src/main/java/org/depromeet/spot/usecase/port/in/util/TimeUsecase.java @@ -0,0 +1,8 @@ +package org.depromeet.spot.usecase.port.in.util; + +import java.time.LocalDateTime; + +public interface TimeUsecase { + + LocalDateTime getNow(); +} diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/port/out/media/CreatePresignedUrlPort.java b/usecase/src/main/java/org/depromeet/spot/usecase/port/out/media/CreatePresignedUrlPort.java new file mode 100644 index 00000000..3e3aa3e6 --- /dev/null +++ b/usecase/src/main/java/org/depromeet/spot/usecase/port/out/media/CreatePresignedUrlPort.java @@ -0,0 +1,21 @@ +package org.depromeet.spot.usecase.port.out.media; + +import org.depromeet.spot.domain.media.MediaProperty; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +public interface CreatePresignedUrlPort { + + // FIXME: 유저 도메인 생성 후 userId -> Member 등으로 교체 + String forReview(Long userId, PresignedUrlRequest request); + + String forStadiumSeat(PresignedUrlRequest request); + + @Getter + @AllArgsConstructor + class PresignedUrlRequest { + private String fileExtension; + private MediaProperty property; + } +} diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/service/MemberService.java b/usecase/src/main/java/org/depromeet/spot/usecase/service/member/MemberService.java similarity index 89% rename from usecase/src/main/java/org/depromeet/spot/usecase/service/MemberService.java rename to usecase/src/main/java/org/depromeet/spot/usecase/service/member/MemberService.java index 0d5ca5e0..5d5cf60a 100644 --- a/usecase/src/main/java/org/depromeet/spot/usecase/service/MemberService.java +++ b/usecase/src/main/java/org/depromeet/spot/usecase/service/member/MemberService.java @@ -1,10 +1,10 @@ -package org.depromeet.spot.usecase.service; +package org.depromeet.spot.usecase.service.member; import java.util.List; import org.depromeet.spot.common.exception.member.MemberException.MemberNotFoundException; import org.depromeet.spot.domain.member.Member; -import org.depromeet.spot.usecase.port.in.MemberUsecase; +import org.depromeet.spot.usecase.port.in.member.MemberUsecase; import org.depromeet.spot.usecase.port.out.MemberRepository; import org.springframework.stereotype.Service; diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/service/util/TimeService.java b/usecase/src/main/java/org/depromeet/spot/usecase/service/util/TimeService.java new file mode 100644 index 00000000..aafeec36 --- /dev/null +++ b/usecase/src/main/java/org/depromeet/spot/usecase/service/util/TimeService.java @@ -0,0 +1,15 @@ +package org.depromeet.spot.usecase.service.util; + +import java.time.LocalDateTime; + +import org.depromeet.spot.usecase.port.in.util.TimeUsecase; +import org.springframework.stereotype.Service; + +@Service +public class TimeService implements TimeUsecase { + + @Override + public LocalDateTime getNow() { + return LocalDateTime.now(); + } +} diff --git a/versions.properties b/versions.properties index 5844e6f2..be599584 100644 --- a/versions.properties +++ b/versions.properties @@ -29,3 +29,6 @@ version.com.querydsl..querydsl-apt=5.0.0 version.com.querydsl..querydsl-jpa=5.0.0 +version.org.springframework.cloud..spring-cloud-starter-aws=2.2.6.RELEASE + +version.org.springframework.boot..spring-boot-configuration-processor=3.0.1 \ No newline at end of file