diff --git a/build.gradle b/build.gradle index fbccb3b..d2ba28c 100644 --- a/build.gradle +++ b/build.gradle @@ -44,6 +44,8 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' } tasks.named('test') { diff --git a/src/main/java/com/retrip/trip/application/in/request/image/PresignedUrlCreateRequest.java b/src/main/java/com/retrip/trip/application/in/request/image/PresignedUrlCreateRequest.java new file mode 100644 index 0000000..06b1fdb --- /dev/null +++ b/src/main/java/com/retrip/trip/application/in/request/image/PresignedUrlCreateRequest.java @@ -0,0 +1,14 @@ +package com.retrip.trip.application.in.request.image; + +import com.retrip.trip.domain.vo.image.ImageFileExtension; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "이미지 Presigned Url 생성 Request") +public record PresignedUrlCreateRequest( + + @Schema(description = "이미지 확장자", example = "JPG") + @NotNull + ImageFileExtension extension +) { +} diff --git a/src/main/java/com/retrip/trip/application/in/response/TripCreateResponse.java b/src/main/java/com/retrip/trip/application/in/response/TripCreateResponse.java index 92a50bc..132d4de 100644 --- a/src/main/java/com/retrip/trip/application/in/response/TripCreateResponse.java +++ b/src/main/java/com/retrip/trip/application/in/response/TripCreateResponse.java @@ -41,6 +41,9 @@ public record TripCreateResponse( @Schema(description = "여행 카테고리") String category, + @Schema(description = "여행 대표 이미지 url") + String imageUrl, + @Schema(description = "여행 일정 리스트") List itineraries ) { @@ -56,6 +59,7 @@ public static TripCreateResponse of(Trip trip) { trip.getTripParticipants().getMaxParticipants(), trip.getHashTags().getValues().stream().map(TripHashTag::getName).toList(), trip.getCategory().getViewName(), + trip.getImageUrl(), trip.getItineraries() == null ? new ArrayList<>() : trip.getItineraries().getValues().stream() .map(i -> new ItineraryCreateResponse(i.getId(), i.getName(), i.getDate())) diff --git a/src/main/java/com/retrip/trip/application/in/response/image/PresignedUrlCreateResponse.java b/src/main/java/com/retrip/trip/application/in/response/image/PresignedUrlCreateResponse.java new file mode 100644 index 0000000..1d5741e --- /dev/null +++ b/src/main/java/com/retrip/trip/application/in/response/image/PresignedUrlCreateResponse.java @@ -0,0 +1,17 @@ +package com.retrip.trip.application.in.response.image; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "이미지 Presigned Url 생성 Response") +public record PresignedUrlCreateResponse( + + @Schema(description = "이미지 업로드용 presigned url") + String presignedUrl, + + @Schema(description = "읽기용 이미지 url (presignedUrl로 이미지 업로드 했으면 해당 imageUrl로 이미지 다운로드 가능 하며 여행 생성시 해당 imageUrl 추가하면 됨") + String readImageUrl +) { + public static PresignedUrlCreateResponse of(String presignedUrl, String readImageUrl) { + return new PresignedUrlCreateResponse(presignedUrl, readImageUrl); + } +} \ No newline at end of file diff --git a/src/main/java/com/retrip/trip/application/in/service/ImageService.java b/src/main/java/com/retrip/trip/application/in/service/ImageService.java new file mode 100644 index 0000000..6d20a46 --- /dev/null +++ b/src/main/java/com/retrip/trip/application/in/service/ImageService.java @@ -0,0 +1,72 @@ +package com.retrip.trip.application.in.service; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; +import com.retrip.trip.application.in.response.image.PresignedUrlCreateResponse; +import com.retrip.trip.application.in.usecase.ImageManageUseCase; +import com.retrip.trip.domain.vo.image.ImageFileExtension; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; +import java.util.UUID; + +import static com.amazonaws.HttpMethod.PUT; +import static com.amazonaws.services.s3.Headers.S3_CANNED_ACL; +import static com.amazonaws.services.s3.model.CannedAccessControlList.PublicRead; +import static java.util.Locale.ENGLISH; + +@RequiredArgsConstructor +@Transactional +@Service +public class ImageService implements ImageManageUseCase { + + private static final String IMAGE_DOMAIN_URL = "https://retrip-media.s3.ap-northeast-2.amazonaws.com"; + private static final String FORDER_NAME = "media"; + + @Value("${cloud.s3.bucket}") + private String bucket; + private final AmazonS3 amazonS3; + + @Override + public PresignedUrlCreateResponse createImagePresignedUrl(UUID memberId, ImageFileExtension extension) { + String uuid = UUID.randomUUID().toString(); + String presignedUrl = createPresignedUrl(uuid, extension); + String readImageUrl = createReadImageUrl(uuid, extension); + return PresignedUrlCreateResponse.of(presignedUrl, readImageUrl); + } + + private String createPresignedUrl(String uuid, ImageFileExtension extension) { + String fileName = createFileName(uuid, extension); + GeneratePresignedUrlRequest request = createGeneratePresignedUrlRequest(fileName); + return amazonS3.generatePresignedUrl(request).toString(); + } + + private String createReadImageUrl(String uuid, ImageFileExtension extension) { + return IMAGE_DOMAIN_URL + + "/" + createFileName(uuid, extension); + } + + private String createFileName(String uuid, ImageFileExtension extension) { + return FORDER_NAME + + "/" + uuid + + "." + extension.name().toLowerCase(ENGLISH); + } + + private GeneratePresignedUrlRequest createGeneratePresignedUrlRequest(String fileName) { + var request = new GeneratePresignedUrlRequest(bucket, fileName, PUT) + .withExpiration(createPresignedUrlExpiration()); + request.addRequestParameter(S3_CANNED_ACL, PublicRead.toString()); + return request; + } + + private Date createPresignedUrlExpiration() { + Date expiration = new Date(); + var expTimeMillis = expiration.getTime(); + expTimeMillis += 1000 * 60 * 5; + expiration.setTime(expTimeMillis); + return expiration; + } +} diff --git a/src/main/java/com/retrip/trip/application/in/usecase/ImageManageUseCase.java b/src/main/java/com/retrip/trip/application/in/usecase/ImageManageUseCase.java new file mode 100644 index 0000000..d0f246f --- /dev/null +++ b/src/main/java/com/retrip/trip/application/in/usecase/ImageManageUseCase.java @@ -0,0 +1,10 @@ +package com.retrip.trip.application.in.usecase; + +import com.retrip.trip.application.in.response.image.PresignedUrlCreateResponse; +import com.retrip.trip.domain.vo.image.ImageFileExtension; + +import java.util.UUID; + +public interface ImageManageUseCase { + PresignedUrlCreateResponse createImagePresignedUrl(UUID uuid, ImageFileExtension extension); +} \ No newline at end of file diff --git a/src/main/java/com/retrip/trip/domain/exception/common/ErrorCode.java b/src/main/java/com/retrip/trip/domain/exception/common/ErrorCode.java index 87bd705..7b2e2d6 100644 --- a/src/main/java/com/retrip/trip/domain/exception/common/ErrorCode.java +++ b/src/main/java/com/retrip/trip/domain/exception/common/ErrorCode.java @@ -56,7 +56,8 @@ public enum ErrorCode { ITINERARY_DATE_MISMATCH(BAD_REQUEST, "Trip-040", "일정과 상세 일정 일자가 다릅니다."), ITINERARY_TIME_DUPLICATED(BAD_REQUEST, "Trip-041", "해당 시간에는 이미 상세 일정이 있습니다."), TRIP_PASSWORD_MISMATCH(BAD_REQUEST, "Trip-042", "여행 비밀번호가 일치하지 않습니다."), - VOTE_MODIFY_FORBIDDEN(FORBIDDEN, "Trip-043", "투표를 만든 사람이 아니면 수정, 종료, 삭제 할 수 없습니다.") + VOTE_MODIFY_FORBIDDEN(BAD_REQUEST, "Trip-043", "투표를 만든 사람이 아니면 수정, 종료, 삭제 할 수 없습니다."), + EXTENSION_NOT_FOUND(BAD_REQUEST, "Trip-044", "지원하지 않는 이미지 확장자입니다."), ; private final HttpStatus status; diff --git a/src/main/java/com/retrip/trip/domain/vo/image/ImageFileExtension.java b/src/main/java/com/retrip/trip/domain/vo/image/ImageFileExtension.java new file mode 100644 index 0000000..5b89656 --- /dev/null +++ b/src/main/java/com/retrip/trip/domain/vo/image/ImageFileExtension.java @@ -0,0 +1,28 @@ +package com.retrip.trip.domain.vo.image; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.retrip.trip.domain.exception.common.BusinessException; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +import static com.retrip.trip.domain.exception.common.ErrorCode.EXTENSION_NOT_FOUND; + +@Getter +@AllArgsConstructor +public enum ImageFileExtension { + + JPEG, + JPG, + PNG, + ; + + @JsonCreator + public static ImageFileExtension ofName(String value) { + return Arrays.stream(ImageFileExtension.values()) + .filter(extensionType -> extensionType.name().equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new BusinessException(EXTENSION_NOT_FOUND)); + } +} diff --git a/src/main/java/com/retrip/trip/infra/adapter/in/presentation/filter/AuthenticationFilter.java b/src/main/java/com/retrip/trip/infra/adapter/in/presentation/filter/AuthenticationFilter.java index 97e0cb3..4565e0a 100644 --- a/src/main/java/com/retrip/trip/infra/adapter/in/presentation/filter/AuthenticationFilter.java +++ b/src/main/java/com/retrip/trip/infra/adapter/in/presentation/filter/AuthenticationFilter.java @@ -39,6 +39,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse pathLowercase.contains("actuator") || pathLowercase.contains("robots.txt") || pathLowercase.contains("status-check") || + pathLowercase.contains("trips") || + pathLowercase.contains("images") || pathLowercase.contains("/h2-console")) { filterChain.doFilter(request, response); return; diff --git a/src/main/java/com/retrip/trip/infra/adapter/in/presentation/resolver/UserContextArgumentResolver.java b/src/main/java/com/retrip/trip/infra/adapter/in/presentation/resolver/UserContextArgumentResolver.java index b0e2623..027b712 100644 --- a/src/main/java/com/retrip/trip/infra/adapter/in/presentation/resolver/UserContextArgumentResolver.java +++ b/src/main/java/com/retrip/trip/infra/adapter/in/presentation/resolver/UserContextArgumentResolver.java @@ -21,10 +21,12 @@ public boolean supportsParameter(MethodParameter parameter) { @Override public UserContext resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { - UserContext userContext = (UserContext) webRequest.getAttribute( - "userContext", - RequestAttributes.SCOPE_REQUEST - ); +// UserContext userContext = (UserContext) webRequest.getAttribute( +// "userContext", +// RequestAttributes.SCOPE_REQUEST +// ); + + UserContext userContext = UserContext.mockOf(); if (userContext == null) { throw new IllegalStateException("UserContext not found in request"); diff --git a/src/main/java/com/retrip/trip/infra/adapter/in/presentation/rest/ImageController.java b/src/main/java/com/retrip/trip/infra/adapter/in/presentation/rest/ImageController.java new file mode 100644 index 0000000..c426c56 --- /dev/null +++ b/src/main/java/com/retrip/trip/infra/adapter/in/presentation/rest/ImageController.java @@ -0,0 +1,47 @@ +package com.retrip.trip.infra.adapter.in.presentation.rest; + +import com.retrip.trip.application.in.request.*; +import com.retrip.trip.application.in.request.context.UserContext; +import com.retrip.trip.application.in.request.context.WithUserContext; +import com.retrip.trip.application.in.request.image.PresignedUrlCreateRequest; +import com.retrip.trip.application.in.response.*; +import com.retrip.trip.application.in.response.image.PresignedUrlCreateResponse; +import com.retrip.trip.application.in.usecase.*; +import com.retrip.trip.domain.exception.annotation.ApiErrorCodeExample; +import com.retrip.trip.domain.exception.annotation.ApiErrorCodeExamples; +import com.retrip.trip.domain.vo.TripCategory; +import com.retrip.trip.domain.vo.TripStatus; +import com.retrip.trip.infra.adapter.in.presentation.rest.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +import static com.retrip.trip.domain.exception.common.ErrorCode.*; + +@Tag(name = "Image", description = "이미지 관련 API") +@RequiredArgsConstructor +@RequestMapping("/images") +@RestController +public class ImageController { + + private final ImageManageUseCase imageManageUseCase; + + @ApiErrorCodeExamples({TRIP_DAY_MUST_BE_POSITIVE, INVALID_MAX_PARTICIPANTS_VALUE, INVALID_HASHTAG_LENGTH, PRIVATE_TRIP_PASSWORD_REQUIRED, TRIP_PASSWORD_INVALID}) + @PostMapping("/presigned-url") + public ApiResponse createImagePresignedUrl( + @WithUserContext UserContext userContext, + @RequestBody PresignedUrlCreateRequest request) { + PresignedUrlCreateResponse response = imageManageUseCase.createImagePresignedUrl(userContext.memberId(), request.extension()); + return ApiResponse.created(response); + } + +} diff --git a/src/main/java/com/retrip/trip/infra/config/S3Config.java b/src/main/java/com/retrip/trip/infra/config/S3Config.java new file mode 100644 index 0000000..dc95f68 --- /dev/null +++ b/src/main/java/com/retrip/trip/infra/config/S3Config.java @@ -0,0 +1,39 @@ +package com.retrip.trip.infra.config; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.auth.DefaultAWSCredentialsProviderChain; +import com.amazonaws.auth.InstanceProfileCredentialsProvider; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +@Configuration +@RequiredArgsConstructor +public class S3Config { + + @Value("${cloud.aws.region:ap-northeast-2}") + private String region; + + @Bean + @Primary + public AmazonS3 amazonS3() { + return AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(DefaultAWSCredentialsProviderChain.getInstance()) + .build(); + } +// +// @Bean +// public AmazonS3Client amazonS3Client() { +// InstanceProfileCredentialsProvider provider= new InstanceProfileCredentialsProvider(true); +// return (AmazonS3Client) AmazonS3ClientBuilder.standard() +// .withCredentials(provider) +// .build(); +// } +} diff --git a/src/main/java/com/retrip/trip/infra/config/SwaggerConfig.java b/src/main/java/com/retrip/trip/infra/config/SwaggerConfig.java index e72b9df..cb33dc9 100644 --- a/src/main/java/com/retrip/trip/infra/config/SwaggerConfig.java +++ b/src/main/java/com/retrip/trip/infra/config/SwaggerConfig.java @@ -20,6 +20,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import static com.retrip.trip.infra.adapter.in.presentation.rest.common.ApiResponse.*; + @Configuration public class SwaggerConfig { private final String jwtSchemeName = "jwtAuth"; @@ -89,10 +91,11 @@ private void addErrorResponse(Operation operation, ErrorCode errorCode) { MediaType mediaType = apiResponse.getContent() .computeIfAbsent("application/json", k -> new MediaType()); - ErrorResponse exampleBody = ErrorResponse.of( - errorCode, - "Current API URL", - "Current API Method" + var exampleBody = of(ErrorResponse.of( + errorCode, + "Current API URL", + "Current API Method" + ) ); Example example = new Example(); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0fd65b5..5c52589 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -6,7 +6,8 @@ spring: enabled: true datasource: driver-class-name: org.h2.Driver - url: jdbc:h2:tcp://localhost/~/trip;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false;MODE=MySQL + url: jdbc:h2:mem:trip;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false;MODE=MySQL +# url: jdbc:h2:tcp://localhost/~/trip;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false;MODE=MySQL username: sa password: @@ -45,4 +46,12 @@ jwt: KucWwwIDAQAB -----END PUBLIC KEY----- +cloud: + aws: + region: ap-northeast-2 + credentials: + access-key: ${AWS_ACCESS_KEY_ID} + secret-key: ${AWS_SECRET_ACCESS_KEY} + s3: + bucket: retrip-media