Skip to content

Commit

Permalink
[BSVR-68] 미디어 업로드를 위한 presigned url 생성 컴포넌트 (#16)
Browse files Browse the repository at this point in the history
* 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 <pminsung12@gmail.com>
  • Loading branch information
EunjiShin and pminsung12 authored Jul 16, 2024
1 parent 0e873da commit d8309ce
Show file tree
Hide file tree
Showing 42 changed files with 966 additions and 69 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions application/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
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;
import org.springframework.context.annotation.Import;

@ComponentScan(basePackages = {"org.depromeet.spot.application"})
@Configuration
@Import(value = {UsecaseConfig.class, JpaConfig.class})
@Import(value = {UsecaseConfig.class, JpaConfig.class, NcpConfig.class})
public class SpotApplicationConfig {}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package org.depromeet.spot.application.media.dto.response;

public record MediaUrlResponse(String presignedUrl) {}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion application/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ server:
spring:
# 서브모듈 profile
profiles:
include: jpa
include:
- jpa
- ncp
# swagger를 이용해 API 명세서 생성
doc:
swagger-ui:
Expand Down
5 changes: 5 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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:_")
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
78 changes: 40 additions & 38 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
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
28 changes: 28 additions & 0 deletions domain/src/main/java/org/depromeet/spot/domain/media/Media.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.depromeet.spot.domain.media;

public enum MediaProperty {
REVIEW,
STADIUM,
;
}
Original file line number Diff line number Diff line change
@@ -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<String, ImageExtension> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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<String, StadiumSeatMediaExtension> 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;
}
}
Loading

0 comments on commit d8309ce

Please sign in to comment.