diff --git a/bottlenote-admin-api/VERSION b/bottlenote-admin-api/VERSION index 66c4c2263..7ee7020b3 100644 --- a/bottlenote-admin-api/VERSION +++ b/bottlenote-admin-api/VERSION @@ -1 +1 @@ -1.0.9 +1.0.10 diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/banner/AdminBannerControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/banner/AdminBannerControllerDocsTest.kt index 3e1d08ac5..a3d5c2062 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/banner/AdminBannerControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/banner/AdminBannerControllerDocsTest.kt @@ -87,6 +87,7 @@ class AdminBannerControllerDocsTest { fieldWithPath("data[]").type(JsonFieldType.ARRAY).description("배너 목록"), fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("배너 ID"), fieldWithPath("data[].name").type(JsonFieldType.STRING).description("배너명"), + fieldWithPath("data[].mediaType").type(JsonFieldType.STRING).description("미디어 유형 (IMAGE, VIDEO). 프론트엔드에서 img/video 태그 분기용"), fieldWithPath("data[].bannerType").type(JsonFieldType.STRING).description("배너 유형"), fieldWithPath("data[].sortOrder").type(JsonFieldType.NUMBER).description("정렬 순서"), fieldWithPath("data[].isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태"), @@ -147,6 +148,7 @@ class AdminBannerControllerDocsTest { fieldWithPath("data.textPosition").type(JsonFieldType.STRING).description("텍스트 위치 (RT/CENTER/LB 등)"), fieldWithPath("data.isExternalUrl").type(JsonFieldType.BOOLEAN).description("외부 URL 여부"), fieldWithPath("data.targetUrl").type(JsonFieldType.VARIES).description("이동 URL. [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다").optional(), + fieldWithPath("data.mediaType").type(JsonFieldType.STRING).description("미디어 유형 (IMAGE, VIDEO). 프론트엔드에서 img/video 태그 분기용"), fieldWithPath("data.bannerType").type(JsonFieldType.STRING).description("배너 유형"), fieldWithPath("data.sortOrder").type(JsonFieldType.NUMBER).description("정렬 순서"), fieldWithPath("data.startDate").type(JsonFieldType.VARIES).description("시작일시").optional(), @@ -202,6 +204,7 @@ class AdminBannerControllerDocsTest { fieldWithPath("textPosition").type(JsonFieldType.STRING).description("텍스트 위치 (RT/CENTER/LB 등, 기본값: RT)").optional(), fieldWithPath("isExternalUrl").type(JsonFieldType.BOOLEAN).description("외부 URL 여부 (기본값: false)").optional(), fieldWithPath("targetUrl").type(JsonFieldType.VARIES).description("이동 URL (isExternalUrl=true 시 필수). [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다").optional(), + fieldWithPath("mediaType").type(JsonFieldType.STRING).description("미디어 유형 (IMAGE/VIDEO, 기본값: IMAGE). 프론트엔드에서 img/video 태그 분기용").optional(), fieldWithPath("bannerType").type(JsonFieldType.STRING).description("배너 유형 (필수: CURATION/AD/SURVEY/PARTNERSHIP/ETC)"), fieldWithPath("sortOrder").type(JsonFieldType.NUMBER).description("정렬 순서 (0 이상, 기본값: 0)").optional(), fieldWithPath("startDate").type(JsonFieldType.VARIES).description("시작일시").optional(), @@ -266,6 +269,7 @@ class AdminBannerControllerDocsTest { fieldWithPath("textPosition").type(JsonFieldType.STRING).description("텍스트 위치 (RT/CENTER/LB 등)"), fieldWithPath("isExternalUrl").type(JsonFieldType.BOOLEAN).description("외부 URL 여부"), fieldWithPath("targetUrl").type(JsonFieldType.VARIES).description("이동 URL (isExternalUrl=true 시 필수). [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다").optional(), + fieldWithPath("mediaType").type(JsonFieldType.STRING).description("미디어 유형 (IMAGE/VIDEO, 미입력 시 기존 값 유지). 프론트엔드에서 img/video 태그 분기용").optional(), fieldWithPath("bannerType").type(JsonFieldType.STRING).description("배너 유형 (필수: CURATION/AD/SURVEY/PARTNERSHIP/ETC)"), fieldWithPath("sortOrder").type(JsonFieldType.NUMBER).description("정렬 순서 (0 이상, 필수)"), fieldWithPath("startDate").type(JsonFieldType.VARIES).description("시작일시").optional(), diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/file/AdminImageUploadControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/file/AdminImageUploadControllerDocsTest.kt index 775415163..9489151db 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/file/AdminImageUploadControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/file/AdminImageUploadControllerDocsTest.kt @@ -80,6 +80,7 @@ class AdminImageUploadControllerDocsTest { .header("Authorization", "Bearer test_access_token") .param("rootPath", "admin/banner") .param("uploadSize", "2") + .param("contentType", "image/jpeg") ) .hasStatusOk() .apply( @@ -92,7 +93,8 @@ class AdminImageUploadControllerDocsTest { ), queryParameters( parameterWithName("rootPath").description("업로드 경로 (예: admin/banner, admin/alcohol)"), - parameterWithName("uploadSize").description("발급할 URL 개수") + parameterWithName("uploadSize").description("발급할 URL 개수"), + parameterWithName("contentType").description("업로드 파일의 Content-Type. 허용 목록: image/jpeg, image/png, image/webp, image/gif, image/svg+xml, video/mp4, application/pdf. 업로드(PUT) 요청 시 동일한 Content-Type 헤더 필요").optional() ), responseFields( fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), diff --git a/bottlenote-admin-api/src/test/kotlin/app/helper/banner/BannerHelper.kt b/bottlenote-admin-api/src/test/kotlin/app/helper/banner/BannerHelper.kt index 3057e8e42..2ce810118 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/helper/banner/BannerHelper.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/helper/banner/BannerHelper.kt @@ -1,6 +1,7 @@ package app.helper.banner import app.bottlenote.banner.constant.BannerType +import app.bottlenote.banner.constant.MediaType import app.bottlenote.banner.constant.TextPosition import app.bottlenote.banner.dto.response.AdminBannerDetailResponse import app.bottlenote.banner.dto.response.AdminBannerListResponse @@ -12,6 +13,7 @@ object BannerHelper { fun createAdminBannerListResponse( id: Long = 1L, name: String = "테스트 배너", + mediaType: MediaType = MediaType.IMAGE, bannerType: BannerType = BannerType.CURATION, sortOrder: Int = 0, isActive: Boolean = true, @@ -19,7 +21,7 @@ object BannerHelper { endDate: LocalDateTime? = null, createdAt: LocalDateTime = LocalDateTime.of(2024, 1, 1, 0, 0) ): AdminBannerListResponse = AdminBannerListResponse( - id, name, bannerType, sortOrder, isActive, startDate, endDate, createdAt + id, name, mediaType, bannerType, sortOrder, isActive, startDate, endDate, createdAt ) fun createAdminBannerListResponses(count: Int = 3): List = @@ -43,6 +45,7 @@ object BannerHelper { textPosition: TextPosition = TextPosition.RT, isExternalUrl: Boolean = false, targetUrl: String? = null, + mediaType: MediaType = MediaType.IMAGE, bannerType: BannerType = BannerType.CURATION, sortOrder: Int = 0, startDate: LocalDateTime? = null, @@ -52,7 +55,7 @@ object BannerHelper { modifiedAt: LocalDateTime = LocalDateTime.of(2024, 6, 1, 0, 0) ): AdminBannerDetailResponse = AdminBannerDetailResponse( id, name, nameFontColor, descriptionA, descriptionB, descriptionFontColor, - imageUrl, textPosition, isExternalUrl, targetUrl, bannerType, sortOrder, + imageUrl, textPosition, isExternalUrl, targetUrl, mediaType, bannerType, sortOrder, startDate, endDate, isActive, createdAt, modifiedAt ) @@ -66,6 +69,7 @@ object BannerHelper { textPosition: String = "RT", isExternalUrl: Boolean = false, targetUrl: String? = null, + mediaType: String = "IMAGE", bannerType: String = "CURATION", sortOrder: Int = 0, startDate: String? = null, @@ -80,6 +84,7 @@ object BannerHelper { "textPosition" to textPosition, "isExternalUrl" to isExternalUrl, "targetUrl" to targetUrl, + "mediaType" to mediaType, "bannerType" to bannerType, "sortOrder" to sortOrder, "startDate" to startDate, @@ -96,6 +101,7 @@ object BannerHelper { textPosition: String = "CENTER", isExternalUrl: Boolean = false, targetUrl: String? = null, + mediaType: String = "IMAGE", bannerType: String = "CURATION", sortOrder: Int = 1, startDate: String? = null, @@ -111,6 +117,7 @@ object BannerHelper { "textPosition" to textPosition, "isExternalUrl" to isExternalUrl, "targetUrl" to targetUrl, + "mediaType" to mediaType, "bannerType" to bannerType, "sortOrder" to sortOrder, "startDate" to startDate, diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt index 79305ce73..edf19292c 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt @@ -221,7 +221,7 @@ class AdminImageUploadIntegrationTest : IntegrationTestSupport() { return try { connection.doOutput = true connection.requestMethod = "PUT" - connection.setRequestProperty("Content-Type", "application/octet-stream") + connection.setRequestProperty("Content-Type", "image/jpeg") OutputStreamWriter(connection.outputStream).use { writer -> writer.write(content) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/constant/MediaType.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/constant/MediaType.java new file mode 100644 index 000000000..a61f53ca9 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/constant/MediaType.java @@ -0,0 +1,22 @@ +package app.bottlenote.banner.constant; + +import com.fasterxml.jackson.annotation.JsonCreator; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum MediaType { + IMAGE("이미지"), + VIDEO("동영상"); + + private final String description; + + @JsonCreator + public static MediaType parsing(String source) { + if (source == null || source.isEmpty()) { + return null; + } + return MediaType.valueOf(source.toUpperCase()); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/domain/Banner.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/domain/Banner.java index 504022d77..fa0c5a925 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/banner/domain/Banner.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/domain/Banner.java @@ -1,6 +1,7 @@ package app.bottlenote.banner.domain; import app.bottlenote.banner.constant.BannerType; +import app.bottlenote.banner.constant.MediaType; import app.bottlenote.banner.constant.TextPosition; import app.bottlenote.common.domain.BaseEntity; import jakarta.persistence.Column; @@ -73,6 +74,12 @@ public class Banner extends BaseEntity { @Column(name = "target_url") private String targetUrl; + @Comment("미디어 유형") + @Column(name = "media_type", nullable = false) + @Enumerated(EnumType.STRING) + @Builder.Default + private MediaType mediaType = MediaType.IMAGE; + @Comment("배너 유형") @Column(name = "banner_type", nullable = false) @Enumerated(EnumType.STRING) @@ -106,6 +113,7 @@ public void update( TextPosition textPosition, Boolean isExternalUrl, String targetUrl, + MediaType mediaType, BannerType bannerType, Integer sortOrder, LocalDateTime startDate, @@ -120,6 +128,7 @@ public void update( this.textPosition = textPosition; this.isExternalUrl = isExternalUrl; this.targetUrl = targetUrl; + this.mediaType = mediaType != null ? mediaType : this.mediaType; this.bannerType = bannerType; this.sortOrder = sortOrder; this.startDate = startDate; diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerCreateRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerCreateRequest.java index 150d85681..9c26ef4b9 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerCreateRequest.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerCreateRequest.java @@ -1,6 +1,7 @@ package app.bottlenote.banner.dto.request; import app.bottlenote.banner.constant.BannerType; +import app.bottlenote.banner.constant.MediaType; import app.bottlenote.banner.constant.TextPosition; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; @@ -22,6 +23,7 @@ public record AdminBannerCreateRequest( TextPosition textPosition, Boolean isExternalUrl, String targetUrl, + MediaType mediaType, @NotNull(message = "BANNER_TYPE_REQUIRED") BannerType bannerType, @Min(value = 0, message = "BANNER_SORT_ORDER_MINIMUM") Integer sortOrder, LocalDateTime startDate, @@ -33,6 +35,7 @@ public record AdminBannerCreateRequest( descriptionFontColor = descriptionFontColor != null ? descriptionFontColor : "#ffffff"; textPosition = textPosition != null ? textPosition : TextPosition.RT; isExternalUrl = isExternalUrl != null ? isExternalUrl : false; + mediaType = mediaType != null ? mediaType : MediaType.IMAGE; sortOrder = sortOrder != null ? sortOrder : 0; } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerUpdateRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerUpdateRequest.java index 39c4a2251..98d42471e 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerUpdateRequest.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/request/AdminBannerUpdateRequest.java @@ -1,6 +1,7 @@ package app.bottlenote.banner.dto.request; import app.bottlenote.banner.constant.BannerType; +import app.bottlenote.banner.constant.MediaType; import app.bottlenote.banner.constant.TextPosition; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; @@ -21,6 +22,7 @@ public record AdminBannerUpdateRequest( TextPosition textPosition, Boolean isExternalUrl, String targetUrl, + MediaType mediaType, @NotNull(message = "BANNER_TYPE_REQUIRED") BannerType bannerType, @NotNull(message = "BANNER_SORT_ORDER_REQUIRED") @Min(value = 0, message = "BANNER_SORT_ORDER_MINIMUM") diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerDetailResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerDetailResponse.java index 4d00249ac..68d3a7482 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerDetailResponse.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerDetailResponse.java @@ -1,6 +1,7 @@ package app.bottlenote.banner.dto.response; import app.bottlenote.banner.constant.BannerType; +import app.bottlenote.banner.constant.MediaType; import app.bottlenote.banner.constant.TextPosition; import java.time.LocalDateTime; @@ -15,6 +16,7 @@ public record AdminBannerDetailResponse( TextPosition textPosition, Boolean isExternalUrl, String targetUrl, + MediaType mediaType, BannerType bannerType, Integer sortOrder, LocalDateTime startDate, diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerListResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerListResponse.java index cdf7daa47..2b2a2f54c 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerListResponse.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/AdminBannerListResponse.java @@ -1,11 +1,13 @@ package app.bottlenote.banner.dto.response; import app.bottlenote.banner.constant.BannerType; +import app.bottlenote.banner.constant.MediaType; import java.time.LocalDateTime; public record AdminBannerListResponse( Long id, String name, + MediaType mediaType, BannerType bannerType, Integer sortOrder, Boolean isActive, diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/BannerResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/BannerResponse.java index a52c739af..93c076af8 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/BannerResponse.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/dto/response/BannerResponse.java @@ -1,6 +1,7 @@ package app.bottlenote.banner.dto.response; import app.bottlenote.banner.constant.BannerType; +import app.bottlenote.banner.constant.MediaType; import app.bottlenote.banner.constant.TextPosition; import java.time.LocalDateTime; import lombok.AllArgsConstructor; @@ -24,6 +25,7 @@ public class BannerResponse { private TextPosition textPosition; private String targetUrl; private Boolean isExternalUrl; + private MediaType mediaType; private BannerType bannerType; private Integer sortOrder; private LocalDateTime startDate; diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/repository/CustomBannerRepositoryImpl.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/repository/CustomBannerRepositoryImpl.java index b46542b33..b43cb3bc8 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/banner/repository/CustomBannerRepositoryImpl.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/repository/CustomBannerRepositoryImpl.java @@ -32,6 +32,7 @@ public Page searchForAdmin( AdminBannerListResponse.class, banner.id, banner.name, + banner.mediaType, banner.bannerType, banner.sortOrder, banner.isActive, diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/service/AdminBannerService.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/service/AdminBannerService.java index cdb06871d..a75448b28 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/banner/service/AdminBannerService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/service/AdminBannerService.java @@ -60,6 +60,7 @@ public AdminBannerDetailResponse getDetail(Long bannerId) { banner.getTextPosition(), banner.getIsExternalUrl(), banner.getTargetUrl(), + banner.getMediaType(), banner.getBannerType(), banner.getSortOrder(), banner.getStartDate(), @@ -93,6 +94,7 @@ public AdminResultResponse create(AdminBannerCreateRequest request) { .textPosition(request.textPosition()) .isExternalUrl(request.isExternalUrl()) .targetUrl(request.targetUrl()) + .mediaType(request.mediaType()) .bannerType(request.bannerType()) .sortOrder(request.sortOrder()) .startDate(request.startDate()) @@ -130,6 +132,7 @@ public AdminResultResponse update(Long bannerId, AdminBannerUpdateRequest reques request.textPosition(), request.isExternalUrl(), request.targetUrl(), + request.mediaType(), request.bannerType(), request.sortOrder(), request.startDate(), diff --git a/bottlenote-mono/src/main/java/app/bottlenote/banner/service/BannerQueryService.java b/bottlenote-mono/src/main/java/app/bottlenote/banner/service/BannerQueryService.java index 3484f31e8..df59e8219 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/banner/service/BannerQueryService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/banner/service/BannerQueryService.java @@ -34,6 +34,7 @@ public List getActiveBanners(Integer limit) { .textPosition(banner.getTextPosition()) .targetUrl(banner.getTargetUrl()) .isExternalUrl(banner.getIsExternalUrl()) + .mediaType(banner.getMediaType()) .bannerType(banner.getBannerType()) .sortOrder(banner.getSortOrder()) .startDate(banner.getStartDate()) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/PreSignUrlProvider.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/PreSignUrlProvider.java index 2ea12209b..80d0dab79 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/PreSignUrlProvider.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/PreSignUrlProvider.java @@ -1,17 +1,28 @@ package app.bottlenote.common.file; import static app.bottlenote.common.file.exception.FileExceptionCode.EXPIRY_TIME_RANGE_INVALID; +import static app.bottlenote.common.file.exception.FileExceptionCode.UNSUPPORTED_CONTENT_TYPE; import static java.time.format.DateTimeFormatter.ofPattern; import app.bottlenote.common.file.exception.FileException; import java.time.LocalDate; import java.util.Calendar; +import java.util.Map; import java.util.Objects; import java.util.UUID; public interface PreSignUrlProvider { - String EXTENSION = "jpg"; + Map ALLOWED_CONTENT_TYPES = + Map.of( + "image/jpeg", "jpg", + "image/png", "png", + "image/webp", "webp", + "image/gif", "gif", + "image/svg+xml", "svg", + "video/mp4", "mp4", + "application/pdf", "pdf"); + String PATH_DELIMITER = "/"; String KEY_DELIMITER = "-"; @@ -19,9 +30,10 @@ public interface PreSignUrlProvider { * PreSignUrl을 생성한다. * * @param imageKey the image key + * @param contentType 업로드할 파일의 Content-Type * @return the string */ - String generatePreSignUrl(String imageKey); + String generatePreSignUrl(String imageKey, String contentType); /** * ViewUrl을 생성한다. cloud front url 에 s3 key를 조합해 반환한다. 실제 오브젝트를 조회하기 위해 사용된다. @@ -33,20 +45,33 @@ public interface PreSignUrlProvider { String generateViewUrl(String cloudFrontUrl, String imageKey); /** - * 루트 경로를 포함한 이미지 키를 생성한다. 확장자의 경우 .jpg로 고정한다. + * 루트 경로를 포함한 오브젝트 키를 생성한다. contentType에 따라 확장자를 결정한다. * * @param rootPath 저장할 루트 경로 - * @return 생성된 이미지 키 + * @param index 업로드 순번 + * @param contentType 업로드할 파일의 Content-Type + * @return 생성된 오브젝트 키 */ - default String getImageKey(String rootPath, Long index) { + default String getImageKey(String rootPath, Long index, String contentType) { if (rootPath.startsWith(PATH_DELIMITER)) { rootPath = rootPath.substring(1); } if (rootPath.endsWith(PATH_DELIMITER)) { rootPath = rootPath.substring(0, rootPath.length() - 1); } + + String normalized = contentType.strip().toLowerCase(); + int semicolon = normalized.indexOf(';'); + if (semicolon > 0) { + normalized = normalized.substring(0, semicolon).strip(); + } + String extension = ALLOWED_CONTENT_TYPES.get(normalized); + if (extension == null) { + throw new FileException(UNSUPPORTED_CONTENT_TYPE); + } + String uploadAt = LocalDate.now().format(ofPattern("yyyyMMdd")); - String imageId = index + KEY_DELIMITER + UUID.randomUUID() + "." + EXTENSION; + String imageId = index + KEY_DELIMITER + UUID.randomUUID() + "." + extension; return rootPath + PATH_DELIMITER + uploadAt + PATH_DELIMITER + imageId; } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/request/ImageUploadRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/request/ImageUploadRequest.java index 9c84f0b97..ef5505dcc 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/request/ImageUploadRequest.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/request/ImageUploadRequest.java @@ -1,7 +1,8 @@ package app.bottlenote.common.file.dto.request; -public record ImageUploadRequest(String rootPath, Long uploadSize) { +public record ImageUploadRequest(String rootPath, Long uploadSize, String contentType) { public ImageUploadRequest { uploadSize = uploadSize == null ? 1 : uploadSize; + contentType = contentType == null ? "image/jpeg" : contentType; } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/exception/FileExceptionCode.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/exception/FileExceptionCode.java index 87ae014d3..51e69a2e4 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/exception/FileExceptionCode.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/exception/FileExceptionCode.java @@ -4,7 +4,8 @@ import org.springframework.http.HttpStatus; public enum FileExceptionCode implements ExceptionCode { - EXPIRY_TIME_RANGE_INVALID(HttpStatus.BAD_REQUEST, "만료 기간의 범위가 적절하지 않습니다.( 최소 1분 ,최대 10분) "); + EXPIRY_TIME_RANGE_INVALID(HttpStatus.BAD_REQUEST, "만료 기간의 범위가 적절하지 않습니다.( 최소 1분 ,최대 10분) "), + UNSUPPORTED_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 Content-Type입니다."); private final HttpStatus httpStatus; private final String message; diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java index 58aa8a412..b31989d1a 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java @@ -9,6 +9,7 @@ import app.bottlenote.global.security.SecurityContextUtil; import com.amazonaws.HttpMethod; import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; import java.util.ArrayList; import java.util.Calendar; import java.util.List; @@ -64,11 +65,12 @@ public ImageUploadResponse getPreSignUrlForAdmin(Long adminId, ImageUploadReques private List generatePreSignUrls(ImageUploadRequest request) { String rootPath = request.rootPath(); Long uploadSize = request.uploadSize(); + String contentType = request.contentType(); List keys = new ArrayList<>(); for (long index = 1; index <= uploadSize; index++) { - String imageKey = getImageKey(rootPath, index); - String preSignUrl = generatePreSignUrl(imageKey); + String imageKey = getImageKey(rootPath, index, contentType); + String preSignUrl = generatePreSignUrl(imageKey, contentType); String viewUrl = generateViewUrl(cloudFrontUrl, imageKey); keys.add( ImageUploadItem.builder().order(index).viewUrl(viewUrl).uploadUrl(preSignUrl).build()); @@ -97,11 +99,14 @@ public String generateViewUrl(String cloudFrontUrl, String imageKey) { } @Override - public String generatePreSignUrl(String imageKey) { + public String generatePreSignUrl(String imageKey, String contentType) { Calendar uploadExpiryTime = getUploadExpiryTime(EXPIRY_TIME); - return amazonS3 - .generatePresignedUrl(imageBucketName, imageKey, uploadExpiryTime.getTime(), HttpMethod.PUT) - .toString(); + GeneratePresignedUrlRequest request = + new GeneratePresignedUrlRequest(imageBucketName, imageKey) + .withMethod(HttpMethod.PUT) + .withExpiration(uploadExpiryTime.getTime()) + .withContentType(contentType); + return amazonS3.generatePresignedUrl(request).toString(); } private void saveImageUploadLogs(String rootPath, List items) { diff --git a/bottlenote-mono/src/test/java/app/bottlenote/banner/fixture/BannerTestFactory.java b/bottlenote-mono/src/test/java/app/bottlenote/banner/fixture/BannerTestFactory.java index 3b37cdcaf..3683ede5e 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/banner/fixture/BannerTestFactory.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/banner/fixture/BannerTestFactory.java @@ -1,6 +1,7 @@ package app.bottlenote.banner.fixture; import app.bottlenote.banner.constant.BannerType; +import app.bottlenote.banner.constant.MediaType; import app.bottlenote.banner.constant.TextPosition; import app.bottlenote.banner.domain.Banner; import jakarta.persistence.EntityManager; @@ -33,6 +34,7 @@ public Banner persistBanner(@NotNull String name, @NotNull String imageUrl) { .descriptionB(name + " 설명B") .imageUrl(imageUrl) .textPosition(TextPosition.CENTER) + .mediaType(MediaType.IMAGE) .bannerType(BannerType.CURATION) .sortOrder(0) .isActive(true) @@ -60,6 +62,7 @@ public Banner persistBanner( .descriptionB(name + " 설명B") .imageUrl(imageUrl) .textPosition(textPosition) + .mediaType(MediaType.IMAGE) .bannerType(bannerType) .sortOrder(sortOrder) .isActive(isActive) @@ -96,6 +99,7 @@ public List persistMultipleBanners(int count) { .descriptionB("배너 " + (i + 1) + " 설명B") .imageUrl("https://example.com/banner" + (i + 1) + ".jpg") .textPosition(TextPosition.CENTER) + .mediaType(MediaType.IMAGE) .bannerType(BannerType.CURATION) .sortOrder(i) .isActive(true) @@ -122,6 +126,7 @@ public List persistMixedActiveBanners(int activeCount, int inactiveCount .descriptionB("활성 배너 " + (i + 1) + " 설명B") .imageUrl("https://example.com/active" + (i + 1) + ".jpg") .textPosition(TextPosition.CENTER) + .mediaType(MediaType.IMAGE) .bannerType(BannerType.CURATION) .sortOrder(i) .isActive(true) @@ -139,6 +144,7 @@ public List persistMixedActiveBanners(int activeCount, int inactiveCount .descriptionB("비활성 배너 " + (i + 1) + " 설명B") .imageUrl("https://example.com/inactive" + (i + 1) + ".jpg") .textPosition(TextPosition.CENTER) + .mediaType(MediaType.IMAGE) .bannerType(BannerType.AD) .sortOrder(activeCount + i) .isActive(false) @@ -164,6 +170,7 @@ public Banner persistBannerWithPeriod( .descriptionB(name + " 설명B") .imageUrl("https://example.com/" + name + ".jpg") .textPosition(TextPosition.CENTER) + .mediaType(MediaType.IMAGE) .bannerType(BannerType.SURVEY) .sortOrder(0) .startDate(startDate) @@ -193,6 +200,9 @@ private Banner.BannerBuilder fillMissingBannerFields( if (tempBanner.getTextPosition() == null) { builder.textPosition(TextPosition.CENTER); } + if (tempBanner.getMediaType() == null) { + builder.mediaType(MediaType.IMAGE); + } if (tempBanner.getBannerType() == null) { builder.bannerType(BannerType.CURATION); } diff --git a/bottlenote-mono/src/test/java/app/bottlenote/banner/fixture/InMemoryBannerRepository.java b/bottlenote-mono/src/test/java/app/bottlenote/banner/fixture/InMemoryBannerRepository.java index bd554b5c8..90aa05beb 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/banner/fixture/InMemoryBannerRepository.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/banner/fixture/InMemoryBannerRepository.java @@ -86,6 +86,7 @@ public Page searchForAdmin( new AdminBannerListResponse( b.getId(), b.getName(), + b.getMediaType(), b.getBannerType(), b.getSortOrder(), b.getIsActive(), diff --git a/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java b/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java index c573a6265..b264b05a8 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java @@ -101,7 +101,7 @@ class PreSignedUrlTest { @DisplayName("PreSigned URL 생성 시 MinIO에서 유효한 URL을 반환한다") void test_1() { // given - ImageUploadRequest request = new ImageUploadRequest("review", 1L); + ImageUploadRequest request = new ImageUploadRequest("review", 1L, null); // when ImageUploadResponse response = imageUploadService.getPreSignUrl(request); @@ -120,7 +120,7 @@ void test_1() { @DisplayName("PreSigned URL로 실제 파일 업로드가 가능하다") void test_2() throws Exception { // given - ImageUploadRequest request = new ImageUploadRequest("review", 1L); + ImageUploadRequest request = new ImageUploadRequest("review", 1L, null); ImageUploadResponse response = imageUploadService.getPreSignUrl(request); String uploadUrl = response.imageUploadInfo().get(0).uploadUrl(); byte[] testData = "test image content".getBytes(); @@ -148,7 +148,7 @@ void test_2() throws Exception { @DisplayName("업로드된 파일이 MinIO에 존재한다") void test_3() throws Exception { // given - ImageUploadRequest request = new ImageUploadRequest("review", 1L); + ImageUploadRequest request = new ImageUploadRequest("review", 1L, null); ImageUploadResponse response = imageUploadService.getPreSignUrl(request); String uploadUrl = response.imageUploadInfo().get(0).uploadUrl(); String viewUrl = response.imageUploadInfo().get(0).viewUrl(); @@ -186,7 +186,7 @@ void test_1() { Long userId = 1L; SecurityContextHolder.getContext() .setAuthentication(new TestingAuthenticationToken(userId.toString(), null)); - ImageUploadRequest request = new ImageUploadRequest("review", 2L); + ImageUploadRequest request = new ImageUploadRequest("review", 2L, null); // when imageUploadService.getPreSignUrl(request); @@ -204,7 +204,7 @@ void test_1() { @DisplayName("비로그인 사용자가 PreSigned URL 생성 시 로그가 저장되지 않는다") void test_2() { // given - ImageUploadRequest request = new ImageUploadRequest("review", 2L); + ImageUploadRequest request = new ImageUploadRequest("review", 2L, null); // when imageUploadService.getPreSignUrl(request); diff --git a/bottlenote-product-api/VERSION b/bottlenote-product-api/VERSION index b0f3d96f8..66c4c2263 100644 --- a/bottlenote-product-api/VERSION +++ b/bottlenote-product-api/VERSION @@ -1 +1 @@ -1.0.8 +1.0.9 diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadServiceTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadServiceTest.java index df12b9aa7..002b49968 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadServiceTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadServiceTest.java @@ -10,12 +10,14 @@ import app.bottlenote.common.file.dto.response.ImageUploadItem; import app.bottlenote.common.file.dto.response.ImageUploadResponse; import app.bottlenote.common.file.exception.FileException; +import app.bottlenote.common.file.exception.FileExceptionCode; import app.bottlenote.common.file.service.ImageUploadService; import app.bottlenote.common.file.service.ResourceCommandService; import app.bottlenote.common.file.upload.fixture.FakeAmazonS3; import app.bottlenote.common.file.upload.fixture.InMemoryResourceLogRepository; import java.time.LocalDate; import java.util.Calendar; +import java.util.Map; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -48,14 +50,18 @@ void setUp() { new ImageUploadService( resourceCommandService, new FakeAmazonS3(), BUCKET_NAME, CLOUD_FRONT_URL) { @Override - public String getImageKey(String rootPath, Long index) { + public String getImageKey(String rootPath, Long index, String contentType) { if (rootPath.startsWith(PATH_DELIMITER)) { rootPath = rootPath.substring(1); } if (rootPath.endsWith(PATH_DELIMITER)) { rootPath = rootPath.substring(0, rootPath.length() - 1); } - String imageId = index + KEY_DELIMITER + FAKE_UUID + "." + EXTENSION; + String extension = ALLOWED_CONTENT_TYPES.get(contentType); + if (extension == null) { + throw new FileException(FileExceptionCode.UNSUPPORTED_CONTENT_TYPE); + } + String imageId = index + KEY_DELIMITER + FAKE_UUID + "." + extension; return rootPath + PATH_DELIMITER + UPLOAD_DATE + PATH_DELIMITER + imageId; } }; @@ -69,10 +75,10 @@ class PreSignedUrlTest { @DisplayName("PreSignUrl을 생성할 수 있다") void test_1() { // given - String imageKey = imageUploadService.getImageKey("review", 1L); + String imageKey = imageUploadService.getImageKey("review", 1L, "image/jpeg"); // when - String preSignUrl = imageUploadService.generatePreSignUrl(imageKey); + String preSignUrl = imageUploadService.generatePreSignUrl(imageKey, "image/jpeg"); // then log.info("PreSignUrl: {}", preSignUrl); @@ -84,7 +90,7 @@ void test_1() { @DisplayName("업로드용 인증 URL을 생성할 수 있다") void test_2() { // given - ImageUploadRequest request = new ImageUploadRequest("review", 2L); + ImageUploadRequest request = new ImageUploadRequest("review", 2L, null); // when ImageUploadResponse response = imageUploadService.getPreSignUrl(request); @@ -96,8 +102,8 @@ void test_2() { assertEquals(BUCKET_NAME, response.bucketName()); for (Long index = 1L; index <= response.imageUploadInfo().size(); index++) { - String imageKey = imageUploadService.getImageKey(request.rootPath(), index); - String uploadUrlExpected = imageUploadService.generatePreSignUrl(imageKey); + String imageKey = imageUploadService.getImageKey(request.rootPath(), index, "image/jpeg"); + String uploadUrlExpected = imageUploadService.generatePreSignUrl(imageKey, "image/jpeg"); String viewUrlExpected = imageUploadService.generateViewUrl(CLOUD_FRONT_URL, imageKey); ImageUploadItem info = response.imageUploadInfo().get((int) (index - 1)); @@ -113,7 +119,7 @@ void test_2() { @DisplayName("단건 이미지 업로드 URL을 생성할 수 있다") void test_3() { // given - ImageUploadRequest request = new ImageUploadRequest("review", 1L); + ImageUploadRequest request = new ImageUploadRequest("review", 1L, null); // when ImageUploadResponse response = imageUploadService.getPreSignUrl(request); @@ -137,7 +143,7 @@ class ViewUrlTest { @DisplayName("조회용 URL을 생성할 수 있다") void test_1() { // given - String imageKey = imageUploadService.getImageKey("review", 1L); + String imageKey = imageUploadService.getImageKey("review", 1L, "image/jpeg"); // when String viewUrl = imageUploadService.generateViewUrl(CLOUD_FRONT_URL, imageKey); @@ -157,7 +163,7 @@ class ImageKeyTest { @DisplayName("이미지 루트 경로와 인덱스를 제공해 이미지 키를 생성할 수 있다") void test_1() { // given & when - String imageKey = imageUploadService.getImageKey("review", 1L); + String imageKey = imageUploadService.getImageKey("review", 1L, "image/jpeg"); String expected = "review/" + UPLOAD_DATE + "/1-" + FAKE_UUID + ".jpg"; // then @@ -165,6 +171,54 @@ void test_1() { assertNotNull(imageKey); assertEquals(expected, imageKey); } + + @Test + @DisplayName("video/mp4 contentType으로 키 생성 시 확장자가 .mp4이다") + void test_2() { + // given & when + String imageKey = imageUploadService.getImageKey("review", 1L, "video/mp4"); + + // then + log.info("ImageKey: {}", imageKey); + assertTrue(imageKey.endsWith(".mp4")); + } + + @Test + @DisplayName("허용된 모든 contentType으로 키를 생성할 수 있다") + void test_3() { + // given + Map expectedExtensions = + Map.of( + "image/jpeg", + ".jpg", + "image/png", + ".png", + "image/webp", + ".webp", + "video/mp4", + ".mp4"); + + expectedExtensions.forEach( + (contentType, extension) -> { + // when + String imageKey = imageUploadService.getImageKey("review", 1L, contentType); + + // then + log.info("contentType: {} -> ImageKey: {}", contentType, imageKey); + assertTrue( + imageKey.endsWith(extension), + "contentType " + contentType + "의 확장자는 " + extension + "이어야 한다"); + }); + } + + @Test + @DisplayName("허용되지 않은 contentType으로 키 생성 시 예외가 발생한다") + void test_4() { + // given & when & then + assertThrows( + FileException.class, + () -> imageUploadService.getImageKey("review", 1L, "application/zip")); + } } @Nested diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/FakeAmazonS3.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/FakeAmazonS3.java index 9e90c998a..02563b961 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/FakeAmazonS3.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/FakeAmazonS3.java @@ -38,7 +38,9 @@ public URL generatePresignedUrl(GeneratePresignedUrlRequest generatePresignedUrl throws SdkClientException { URL url; try { - url = new URL("http://localhost:8080"); + String bucketName = generatePresignedUrlRequest.getBucketName(); + String key = generatePresignedUrlRequest.getKey(); + url = new URL("https", bucketName + ".s3.amazonaws.com", "/" + key); } catch (MalformedURLException e) { throw new RuntimeException(e); } diff --git a/bottlenote-product-api/src/test/java/app/docs/banner/RestBannerQueryControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/banner/RestBannerQueryControllerTest.java index c5ca93842..b823f5194 100644 --- a/bottlenote-product-api/src/test/java/app/docs/banner/RestBannerQueryControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/banner/RestBannerQueryControllerTest.java @@ -11,6 +11,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import app.bottlenote.banner.constant.BannerType; +import app.bottlenote.banner.constant.MediaType; import app.bottlenote.banner.constant.TextPosition; import app.bottlenote.banner.controller.BannerQueryController; import app.bottlenote.banner.dto.response.BannerResponse; @@ -49,6 +50,7 @@ void getActiveBanners() throws Exception { .textPosition(TextPosition.LB) .targetUrl("/events/new-whiskey") .isExternalUrl(false) + .mediaType(MediaType.IMAGE) .bannerType(BannerType.CURATION) .sortOrder(0) .startDate(LocalDateTime.of(2025, 1, 1, 0, 0)) @@ -65,6 +67,7 @@ void getActiveBanners() throws Exception { .textPosition(TextPosition.CENTER) .targetUrl("https://forms.google.com/survey123") .isExternalUrl(true) + .mediaType(MediaType.IMAGE) .bannerType(BannerType.SURVEY) .sortOrder(1) .startDate(LocalDateTime.of(2025, 6, 1, 0, 0)) @@ -81,6 +84,7 @@ void getActiveBanners() throws Exception { .textPosition(TextPosition.RT) .targetUrl("/partners/brand-abc") .isExternalUrl(false) + .mediaType(MediaType.VIDEO) .bannerType(BannerType.PARTNERSHIP) .sortOrder(2) .startDate(null) @@ -115,6 +119,8 @@ void getActiveBanners() throws Exception { fieldWithPath("data[].targetUrl").description("클릭 시 이동할 URL").optional(), fieldWithPath("data[].isExternalUrl") .description("외부 URL 여부 (true: 외부, false: 내부)"), + fieldWithPath("data[].mediaType") + .description("미디어 유형 (IMAGE, VIDEO). 프론트엔드에서 img/video 태그 분기용"), fieldWithPath("data[].bannerType").description("배너 유형 (하단 BannerType 참조)"), fieldWithPath("data[].sortOrder").description("정렬 순서 (오름차순)"), fieldWithPath("data[].startDate").description("노출 시작일시 (nullable)").optional(), diff --git a/bottlenote-product-api/src/test/java/app/docs/upload/RestImageUploadControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/upload/RestImageUploadControllerTest.java index 13602b44e..f570e15b3 100644 --- a/bottlenote-product-api/src/test/java/app/docs/upload/RestImageUploadControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/upload/RestImageUploadControllerTest.java @@ -36,7 +36,7 @@ protected Object initController() { @Test void test_1() throws Exception { // given - ImageUploadRequest request = new ImageUploadRequest("images", 1L); + ImageUploadRequest request = new ImageUploadRequest("images", 1L, "image/jpeg"); ImageUploadResponse response = ImageUploadResponse.builder() .imageUploadInfo( @@ -59,14 +59,21 @@ void test_1() throws Exception { .perform( get("/api/v1/s3/presign-url") .param("rootPath", request.rootPath()) - .param("uploadSize", String.valueOf(request.uploadSize()))) + .param("uploadSize", String.valueOf(request.uploadSize())) + .param("contentType", "image/jpeg")) .andExpect(status().isOk()) .andDo( document( "file/image/upload/presign-url", queryParameters( parameterWithName("rootPath").description("업로드 파일 경로 (하단 설명 참조)"), - parameterWithName("uploadSize").description("업로드할 이미지의 사이즈 ( 이미지당 1개 )")), + parameterWithName("uploadSize").description("업로드할 이미지의 사이즈 ( 이미지당 1개 )"), + parameterWithName("contentType") + .description( + "업로드 파일의 Content-Type. " + + "허용 목록: image/jpeg, image/png, image/webp, image/gif, image/svg+xml, video/mp4, application/pdf. " + + "업로드(PUT) 요청 시 동일한 Content-Type 헤더를 포함해야 합니다.") + .optional()), responseFields( fieldWithPath("success").description("응답 성공 여부"), fieldWithPath("code").description("응답 코드(http status code)"), diff --git a/git.environment-variables b/git.environment-variables index cc43c2e4f..f4d9daf9b 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit cc43c2e4f88c987deb6208330e5911ed8d458e7a +Subproject commit f4d9daf9b509735fa9fe759191141530f1750468 diff --git a/plan/banner-media-type.md b/plan/banner-media-type.md new file mode 100644 index 000000000..7c894a300 --- /dev/null +++ b/plan/banner-media-type.md @@ -0,0 +1,58 @@ +# Banner mediaType 필드 추가 + +> Issue: https://github.com/bottle-note/workspace/issues/205 +> 관련: [presigned-url-content-type.md](presigned-url-content-type.md) + +## 배경 + +animated WebP는 모바일에서 하드웨어 디코딩 미지원으로 CPU 소프트웨어 디코딩 → 저사양 기기 버벅임. +mp4는 모바일 VPU 활용으로 부하 없이 재생 가능. Banner에 미디어 유형 구분이 필요하다. + +## 작업 범위 + +1. `MediaType` enum 생성 (`IMAGE`, `VIDEO`) +2. `Banner` 엔티티에 `mediaType` 필드 추가 +3. 배너 등록/수정 API: `mediaType` 파라미터 수신 +4. 배너 조회 API: 응답에 `mediaType` 포함 (FE에서 `` vs `