diff --git a/src/main/kotlin/com/yapp2app/auth/infra/security/token/AuthTokenProvider.kt b/src/main/kotlin/com/yapp2app/auth/infra/security/token/AuthTokenProvider.kt index 734dee7..bfa8fd7 100644 --- a/src/main/kotlin/com/yapp2app/auth/infra/security/token/AuthTokenProvider.kt +++ b/src/main/kotlin/com/yapp2app/auth/infra/security/token/AuthTokenProvider.kt @@ -45,7 +45,7 @@ class AuthTokenProvider(private val appProperties: AppProperties) { .claim(AUTHORITIES_KEY, roles) .apply { name?.let { claim(NAME_KEY, it) } - providerType?.let { claim(PROVIDER_TYPE_KEY, it) } + providerType?.let { claim(PROVIDER_TYPE_KEY, it.name) } } .issuedAt(Date.from(now)) .expiration(Date.from(now.plusMillis(expiryMillis))) diff --git a/src/main/kotlin/com/yapp2app/common/api/dto/ResultCode.kt b/src/main/kotlin/com/yapp2app/common/api/dto/ResultCode.kt index 590bdd2..62db3b5 100644 --- a/src/main/kotlin/com/yapp2app/common/api/dto/ResultCode.kt +++ b/src/main/kotlin/com/yapp2app/common/api/dto/ResultCode.kt @@ -16,6 +16,7 @@ enum class ResultCode(val code: String, val message: String) { NOT_FOUND_USER("D-03", "가입된 계정이 없습니다."), NOT_FOUND("D-04", "데이터를 찾을 수 없습니다."), ALREADY_REQUEST("D-05", "이미 처리된 요청입니다."), + UPLOAD_FAILED("D-06", "파일 업로드에 실패했습니다."), CONFLICT_FOLDER("D-06", message = "해당하는 폴더가 존재합니다."), diff --git a/src/main/kotlin/com/yapp2app/media/api/controller/MediaController.kt b/src/main/kotlin/com/yapp2app/media/api/controller/MediaController.kt new file mode 100644 index 0000000..d11c43f --- /dev/null +++ b/src/main/kotlin/com/yapp2app/media/api/controller/MediaController.kt @@ -0,0 +1,61 @@ +package com.yapp2app.media.api.controller + +import com.yapp2app.common.api.document.RequiresSecurity +import com.yapp2app.common.api.dto.BaseResponse +import com.yapp2app.media.api.converter.MediaCommandConverter +import com.yapp2app.media.api.converter.MediaResultConverter +import com.yapp2app.media.api.dto.GenerateUploadTicketRequest +import com.yapp2app.media.api.dto.GenerateUploadTicketResponse +import com.yapp2app.media.application.usecase.GenerateUploadTicketUseCase +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.security.core.annotation.AuthenticationPrincipal +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.RestController + +/** + * fileName : MediaController + * author : koo + * date : 2026. 1. 2. 오후 7:34 + * description : Media api endpoint + */ +@Tag(name = "MediaController", description = "미디어 업로드 API") +@RequiresSecurity +@RestController +@RequestMapping("/api/media") +class MediaController( + private val generateUploadTicketUseCase: GenerateUploadTicketUseCase, + private val commandConverter: MediaCommandConverter, + private val resultConverter: MediaResultConverter, +) { + + @Operation( + summary = "미디어 업로드 ticket 발급", + description = """ + mediaType: + * USER_PROFILE("user-profiles") : 사용자 프로필 + * PHOTO_BOOTH("photo-booth") : 인생네컷 + * ATTACHMENT("attachments") : 확장성을 고려한 첨부 이미지 + * TEMP("temp") : 업로드 검증, 테스트 등 + + contentType: + * image/jpeg + * image/png + """, + ) + @PostMapping("/upload") + fun generateUploadTicket( + @AuthenticationPrincipal(expression = "id") ownerId: Long, + @RequestBody request: GenerateUploadTicketRequest, + ): BaseResponse { + val command = commandConverter.toGenerateUploadTicketCommand(ownerId, request) + + val result = generateUploadTicketUseCase.execute(command) + + val response = resultConverter.toGenerateUploadTicketResponse(result) + + return BaseResponse(data = response) + } +} diff --git a/src/main/kotlin/com/yapp2app/media/api/MediaTestController.kt b/src/main/kotlin/com/yapp2app/media/api/controller/MediaTestController.kt similarity index 91% rename from src/main/kotlin/com/yapp2app/media/api/MediaTestController.kt rename to src/main/kotlin/com/yapp2app/media/api/controller/MediaTestController.kt index 69ed7e5..d9ac696 100644 --- a/src/main/kotlin/com/yapp2app/media/api/MediaTestController.kt +++ b/src/main/kotlin/com/yapp2app/media/api/controller/MediaTestController.kt @@ -1,4 +1,4 @@ -package com.yapp2app.media.api +package com.yapp2app.media.api.controller import com.yapp2app.media.application.port.MediaStoragePort import com.yapp2app.media.domain.MediaKey @@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import java.time.Instant /** * fileName : MediaTestController @@ -44,24 +45,23 @@ class MediaTestController(private val mediaStorage: MediaStoragePort) { fun generatePresignedUrl( @RequestParam(required = false) filename: String?, @RequestParam(defaultValue = "image/jpeg") contentType: String, - @RequestParam(defaultValue = "15") expirationMinutes: Long, ): PresignedUrlResponse { // filename이 없는 경우 contentType에서 확장자 추출하여 기본 파일명 생성 val effectiveFilename = filename ?: "upload.${contentType.substringAfter("/", "jpg")}" - val key = MediaKey.generate(MediaType.TEMP, effectiveFilename) + val key = MediaKey.generate(MediaType.TEMP, effectiveFilename, contentType) // Presigned URL 생성 - val presignedUrl = mediaStorage.generatePresignedUrl( + val uploadTicket = mediaStorage.generateUploadTicket( key = key, contentType = contentType, - expirationMinutes = expirationMinutes, ) return PresignedUrlResponse( key = key, - presignedUrl = presignedUrl, - expiresIn = expirationMinutes, + presignedUrl = uploadTicket.url, + method = uploadTicket.method, + expiresAt = uploadTicket.expiresAt, contentType = contentType, ) } @@ -107,6 +107,7 @@ data class MediaItem(val key: String, val url: String, val type: String) data class PresignedUrlResponse( val key: String, val presignedUrl: String, - val expiresIn: Long, + val method: String, + val expiresAt: Instant, val contentType: String, ) diff --git a/src/main/kotlin/com/yapp2app/media/api/converter/MediaCommandConverter.kt b/src/main/kotlin/com/yapp2app/media/api/converter/MediaCommandConverter.kt new file mode 100644 index 0000000..31dec59 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/media/api/converter/MediaCommandConverter.kt @@ -0,0 +1,25 @@ +package com.yapp2app.media.api.converter + +import com.yapp2app.media.api.dto.GenerateUploadTicketRequest +import com.yapp2app.media.application.command.GenerateUploadTicketCommand +import org.springframework.stereotype.Component + +/** + * fileName : MediaCommandConverter + * author : koo + * date : 2026. 1. 2. 오후 7:48 + * description : Media application layer command 변경을 위한 converter + */ +@Component +class MediaCommandConverter { + + fun toGenerateUploadTicketCommand( + ownerId: Long, + request: GenerateUploadTicketRequest, + ): GenerateUploadTicketCommand = GenerateUploadTicketCommand( + ownerId = ownerId, + filename = request.filename, + contentType = request.contentType, + mediaType = request.mediaType, + ) +} diff --git a/src/main/kotlin/com/yapp2app/media/api/converter/MediaResultConverter.kt b/src/main/kotlin/com/yapp2app/media/api/converter/MediaResultConverter.kt new file mode 100644 index 0000000..75b32ff --- /dev/null +++ b/src/main/kotlin/com/yapp2app/media/api/converter/MediaResultConverter.kt @@ -0,0 +1,24 @@ +package com.yapp2app.media.api.converter + +import com.yapp2app.media.api.dto.GenerateUploadTicketResponse +import com.yapp2app.media.application.result.GenerateUploadTicketResult +import org.springframework.stereotype.Component + +/** + * fileName : MediaResultConverter + * author : koo + * date : 2026. 1. 2. 오후 7:48 + * description : Media api layer response 변경을 위한 converter + */ +@Component +class MediaResultConverter { + + fun toGenerateUploadTicketResponse(result: GenerateUploadTicketResult): GenerateUploadTicketResponse = + GenerateUploadTicketResponse( + mediaId = result.mediaId, + uploadUrl = result.uploadUrl, + method = result.method, + expiresIn = result.expiresAt, + contentType = result.contentType, + ) +} diff --git a/src/main/kotlin/com/yapp2app/media/api/dto/GenerateUploadTicketRequest.kt b/src/main/kotlin/com/yapp2app/media/api/dto/GenerateUploadTicketRequest.kt new file mode 100644 index 0000000..cb959dc --- /dev/null +++ b/src/main/kotlin/com/yapp2app/media/api/dto/GenerateUploadTicketRequest.kt @@ -0,0 +1,11 @@ +package com.yapp2app.media.api.dto + +import com.yapp2app.media.domain.MediaType + +/** + * fileName : GenerateUploadTicketRequest + * author : koo + * date : 2026. 1. 2. 오후 7:47 + * description : object storage 저장 요청 + */ +data class GenerateUploadTicketRequest(val filename: String, val contentType: String, val mediaType: MediaType) diff --git a/src/main/kotlin/com/yapp2app/media/api/dto/GenerateUploadTicketResponse.kt b/src/main/kotlin/com/yapp2app/media/api/dto/GenerateUploadTicketResponse.kt new file mode 100644 index 0000000..75e34f7 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/media/api/dto/GenerateUploadTicketResponse.kt @@ -0,0 +1,17 @@ +package com.yapp2app.media.api.dto + +import java.time.Instant + +/** + * fileName : GenerateUploadTicketResponse + * author : koo + * date : 2026. 1. 2. 오후 7:47 + * description : object storage 저장 응답 + */ +data class GenerateUploadTicketResponse( + val mediaId: Long, + val uploadUrl: String, + val method: String, + val expiresIn: Instant, + val contentType: String, +) diff --git a/src/main/kotlin/com/yapp2app/media/application/command/MediaCommand.kt b/src/main/kotlin/com/yapp2app/media/application/command/MediaCommand.kt new file mode 100644 index 0000000..77bdd56 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/media/application/command/MediaCommand.kt @@ -0,0 +1,24 @@ +package com.yapp2app.media.application.command + +import com.yapp2app.media.domain.MediaType + +/** + * fileName : MediaCommand + * author : koo + * date : 2026. 1. 3. 오전 12:04 + * description : Media domain command + */ +data class ConfirmMediaUploadedCommand(val ownerId: Long, val mediaId: Long) + +data class GenerateUploadTicketCommand( + val ownerId: Long, + val filename: String, + val contentType: String, + val mediaType: MediaType, +) + +data class DeleteMediaCommand(val ownerId: Long, val mediaId: Long) + +data class DeleteMediasCommand(val ownerId: Long, val mediaIds: List) + +data class GetMediasCommand(val ownerId: Long, val mediaIds: List) diff --git a/src/main/kotlin/com/yapp2app/media/application/port/MediaBinaryCachePort.kt b/src/main/kotlin/com/yapp2app/media/application/port/MediaBinaryCachePort.kt new file mode 100644 index 0000000..650ba89 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/media/application/port/MediaBinaryCachePort.kt @@ -0,0 +1,16 @@ +package com.yapp2app.media.application.port + +/** + * fileName : MediaBinaryCachePort + * author : koo + * date : 2026. 1. 8. 오후 4:19 + * description : MediaBinary를 가져오기 위한 cache port + */ +interface MediaBinaryCachePort { + + fun get(key: String): ByteArray? + + fun put(key: String, value: ByteArray) + + fun evict(key: String) +} diff --git a/src/main/kotlin/com/yapp2app/media/application/port/MediaRepositoryPort.kt b/src/main/kotlin/com/yapp2app/media/application/port/MediaRepositoryPort.kt new file mode 100644 index 0000000..f03b849 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/media/application/port/MediaRepositoryPort.kt @@ -0,0 +1,21 @@ +package com.yapp2app.media.application.port + +import com.yapp2app.media.domain.entity.Media + +/** + * fileName : MediaRepositoryPort + * author : koo + * date : 2026. 1. 2. 오후 8:06 + * description : Media repository port + */ +interface MediaRepositoryPort { + + fun getActiveMedia(ownerId: Long, id: Long): Media? + fun getActiveMedias(ownerId: Long, ids: List): List + fun getMediaForUploadConfirmation(ownerId: Long, id: Long): Media? + + fun save(media: Media): Media + + fun delete(id: Long) + fun deleteAll(ids: List) +} diff --git a/src/main/kotlin/com/yapp2app/media/application/port/MediaStoragePort.kt b/src/main/kotlin/com/yapp2app/media/application/port/MediaStoragePort.kt index ddeb722..680b6d2 100644 --- a/src/main/kotlin/com/yapp2app/media/application/port/MediaStoragePort.kt +++ b/src/main/kotlin/com/yapp2app/media/application/port/MediaStoragePort.kt @@ -1,6 +1,7 @@ package com.yapp2app.media.application.port import com.yapp2app.media.application.dto.MediaRef +import java.time.Instant /** * fileName : MediaStorage @@ -14,7 +15,14 @@ interface MediaStoragePort { fun findByKey(key: String): String + fun fetchBinaryByKey(key: String): ByteArray + fun findAll(prefix: String): List - fun generatePresignedUrl(key: String, contentType: String, expirationMinutes: Long = 10): String + fun exists(key: String): Boolean + + fun generateUploadTicket(key: String, contentType: String): UploadTicket + + // contract + data class UploadTicket(val url: String, val method: String, val expiresAt: Instant, val contentType: String) } diff --git a/src/main/kotlin/com/yapp2app/media/application/result/MediaResult.kt b/src/main/kotlin/com/yapp2app/media/application/result/MediaResult.kt new file mode 100644 index 0000000..62ea707 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/media/application/result/MediaResult.kt @@ -0,0 +1,31 @@ +package com.yapp2app.media.application.result + +import java.time.Instant + +/** + * fileName : MediaResult + * author : koo + * date : 2026. 1. 3. 오전 12:04 + * description : Media domain application result + */ +data class ConfirmMediaUploadedResult(val success: Boolean) + +data class GenerateUploadTicketResult( + val mediaId: Long, + val uploadUrl: String, + val method: String, + val expiresAt: Instant, + val contentType: String, +) + +data class GetMediasResult(val medias: List) { + data class MediaInfo(val mediaId: Long, val binaryData: ByteArray, val contentType: String) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is MediaInfo) return false + return mediaId == other.mediaId + } + + override fun hashCode(): Int = mediaId.hashCode() + } +} diff --git a/src/main/kotlin/com/yapp2app/media/application/usecase/ConfirmMediaUploadedUseCase.kt b/src/main/kotlin/com/yapp2app/media/application/usecase/ConfirmMediaUploadedUseCase.kt new file mode 100644 index 0000000..b821b16 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/media/application/usecase/ConfirmMediaUploadedUseCase.kt @@ -0,0 +1,61 @@ +package com.yapp2app.media.application.usecase + +import com.yapp2app.common.annotation.UseCase +import com.yapp2app.common.api.dto.ResultCode +import com.yapp2app.common.exception.BusinessException +import com.yapp2app.common.transaction.TransactionRunner +import com.yapp2app.media.application.command.ConfirmMediaUploadedCommand +import com.yapp2app.media.application.port.MediaRepositoryPort +import com.yapp2app.media.application.port.MediaStoragePort +import com.yapp2app.media.application.result.ConfirmMediaUploadedResult + +/** + * fileName : VerifyMediaUseCase + * author : koo + * date : 2026. 1. 2. 오후 8:47 + * description : Object Storage에 정상적으로 저장됐는지 확인하는 usecase + */ +@UseCase +class ConfirmMediaUploadedUseCase( + private val mediaRepository: MediaRepositoryPort, + private val mediaStorage: MediaStoragePort, + private val transactionRunner: TransactionRunner, +) { + + fun execute(command: ConfirmMediaUploadedCommand): ConfirmMediaUploadedResult { + val media = mediaRepository.getMediaForUploadConfirmation(command.ownerId, command.mediaId) + ?: throw BusinessException(ResultCode.NOT_FOUND) + + // 이미 업로드된 경우 무시 + if (media.isUploaded()) { + return ConfirmMediaUploadedResult(true) + } + + // 오브젝트 스토리지에 해당 키가 존재하는지 확인 + val exists = mediaStorage.exists(media.storageKey) + + return transactionRunner.runNew { + val media = mediaRepository.getMediaForUploadConfirmation(command.ownerId, command.mediaId) + ?: throw BusinessException(ResultCode.NOT_FOUND) + + if (media.isUploaded() || exists) { + media.markAsUploaded() + ConfirmMediaUploadedResult(true) + } else { + ConfirmMediaUploadedResult(false) + } + } + } + + /** + * 보상 트랜잭션: media 상태를 INITIATED로 롤백 + * PhotoImage 저장 실패 시 호출 + */ + fun rollback(command: ConfirmMediaUploadedCommand) { + transactionRunner.runNew { + val media = mediaRepository.getMediaForUploadConfirmation(command.ownerId, command.mediaId) + ?: return@runNew + media.markAsInitiated() + } + } +} diff --git a/src/main/kotlin/com/yapp2app/media/application/usecase/DeleteMediaUseCase.kt b/src/main/kotlin/com/yapp2app/media/application/usecase/DeleteMediaUseCase.kt new file mode 100644 index 0000000..c4d1713 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/media/application/usecase/DeleteMediaUseCase.kt @@ -0,0 +1,114 @@ +package com.yapp2app.media.application.usecase + +import com.yapp2app.common.annotation.UseCase +import com.yapp2app.common.api.dto.ResultCode +import com.yapp2app.common.exception.BusinessException +import com.yapp2app.common.transaction.TransactionRunner +import com.yapp2app.media.application.command.DeleteMediaCommand +import com.yapp2app.media.application.command.DeleteMediasCommand +import com.yapp2app.media.application.port.MediaBinaryCachePort +import com.yapp2app.media.application.port.MediaRepositoryPort +import com.yapp2app.media.application.port.MediaStoragePort +import org.slf4j.LoggerFactory + +/** + * fileName : DeleteMediaUseCase + * author : koo + * date : 2026. 1. 3. 오전 5:13 + * description : media delete usecase + * - + */ +@UseCase +class DeleteMediaUseCase( + private val mediaRepository: MediaRepositoryPort, + private val mediaStorage: MediaStoragePort, + private val cache: MediaBinaryCachePort, + private val transactionRunner: TransactionRunner, +) { + + private val log = LoggerFactory.getLogger(javaClass) + + /** + * media 단건 삭제 usecase + */ + fun execute(command: DeleteMediaCommand) { + // DB media metadata 삭제 + val media = transactionRunner.run { + val foundMedia = mediaRepository.getActiveMedia(command.ownerId, command.mediaId) + ?: throw BusinessException(ResultCode.NOT_FOUND) + + foundMedia.markAsDeleteRequested() + foundMedia + } + + cache.evict(media.storageKey) // cache 무효화 시도 + + return runCatching { + // object storage 삭제 요청 + mediaStorage.deleteByKey(media.storageKey) + }.fold( + onSuccess = { + transactionRunner.runNew { + // 추후 scheduling 검토 + mediaRepository.delete(media.id!!) + } + }, + onFailure = { e -> + log.warn( + "Media delete failed. Will retry later. fileId={}, key={}", + media.id, + media.storageKey, + e, + ) + }, + ) + + // TODO : object storage 삭제 실패한 media에 대해 삭제 필요 + } + + /** + * media bulk 삭제 usecase + */ + fun execute(command: DeleteMediasCommand) { + // 삭제할 media 조회 + val medias = transactionRunner.run { + val foundMedias = + mediaRepository.getActiveMedias(command.ownerId, command.mediaIds) + + foundMedias.forEach { + it.markAsDeleteRequested() + cache.evict(it.storageKey) // cache 무효화 시도 + } + foundMedias + } + + val deletedMediaIds = mutableListOf() + val failedMedias = mutableListOf() + + // object storage 삭제 + medias.forEach { media -> + runCatching { + mediaStorage.deleteByKey(media.storageKey) + }.onSuccess { + deletedMediaIds.add(media.id!!) + }.onFailure { e -> + failedMedias.add(media.id!!) + log.warn( + "Media delete failed. Will retry later. mediaId={}, key={}", + media.id, + media.storageKey, + e, + ) + } + } + + // object storage 삭제 성공한 오브젝트에 대해 DB soft delete + if (deletedMediaIds.isNotEmpty()) { + transactionRunner.runNew { + mediaRepository.deleteAll(deletedMediaIds) + } + } + + // TODO : object storage 삭제 실패한 media에 대해 삭제 필요 + } +} diff --git a/src/main/kotlin/com/yapp2app/media/application/usecase/GenerateUploadTicketUseCase.kt b/src/main/kotlin/com/yapp2app/media/application/usecase/GenerateUploadTicketUseCase.kt new file mode 100644 index 0000000..943876f --- /dev/null +++ b/src/main/kotlin/com/yapp2app/media/application/usecase/GenerateUploadTicketUseCase.kt @@ -0,0 +1,53 @@ +package com.yapp2app.media.application.usecase + +import com.yapp2app.common.annotation.UseCase +import com.yapp2app.common.transaction.TransactionRunner +import com.yapp2app.media.application.command.GenerateUploadTicketCommand +import com.yapp2app.media.application.port.MediaRepositoryPort +import com.yapp2app.media.application.port.MediaStoragePort +import com.yapp2app.media.application.result.GenerateUploadTicketResult +import com.yapp2app.media.domain.MediaKey +import com.yapp2app.media.domain.entity.Media + +/** + * fileName : GenerateUploadTicketUseCase + * author : koo + * date : 2026. 1. 2. 오후 7:43 + * description : object storage 저장 usecase + */ +@UseCase +class GenerateUploadTicketUseCase( + private val mediaStorage: MediaStoragePort, + private val mediaRepository: MediaRepositoryPort, + private val transactionRunner: TransactionRunner, +) { + + fun execute(command: GenerateUploadTicketCommand): GenerateUploadTicketResult { + // storageKey 생성 + val storageKey = MediaKey.generate(command.mediaType, command.filename, command.contentType) + + // Media 엔티티 생성 및 저장 + val media = Media( + storageKey = storageKey, + ownerId = command.ownerId, + mediaType = command.mediaType, + contentType = command.contentType, + ) + + val savedMedia = transactionRunner.run { mediaRepository.save(media) } + + // Upload Ticket 발급 + val uploadTicket = mediaStorage.generateUploadTicket( + key = storageKey, + contentType = command.contentType, + ) + + return GenerateUploadTicketResult( + mediaId = savedMedia.id!!, + uploadUrl = uploadTicket.url, + method = uploadTicket.method, + expiresAt = uploadTicket.expiresAt, + contentType = command.contentType, + ) + } +} diff --git a/src/main/kotlin/com/yapp2app/media/application/usecase/GetMediasUseCase.kt b/src/main/kotlin/com/yapp2app/media/application/usecase/GetMediasUseCase.kt new file mode 100644 index 0000000..a6ac833 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/media/application/usecase/GetMediasUseCase.kt @@ -0,0 +1,44 @@ +package com.yapp2app.media.application.usecase + +import com.yapp2app.common.annotation.UseCase +import com.yapp2app.media.application.command.GetMediasCommand +import com.yapp2app.media.application.port.MediaBinaryCachePort +import com.yapp2app.media.application.port.MediaRepositoryPort +import com.yapp2app.media.application.port.MediaStoragePort +import com.yapp2app.media.application.result.GetMediasResult + +/** + * fileName : GetMediasUseCase + * author : koo + * date : 2026. 1. 3. 오전 3:39 + * description : media 정보 조회 usecase + */ +@UseCase +class GetMediasUseCase( + private val mediaRepository: MediaRepositoryPort, + private val mediaStorage: MediaStoragePort, + + private val cache: MediaBinaryCachePort, +) { + + fun execute(command: GetMediasCommand): GetMediasResult { + val medias = mediaRepository.getActiveMedias(command.ownerId, command.mediaIds) + + val mediaInfos = medias.map { it -> + val storageKey = it.storageKey + + val binaryData = cache.get(storageKey) + ?: mediaStorage.fetchBinaryByKey(storageKey).also { + cache.put(storageKey, it) + } + + GetMediasResult.MediaInfo( + mediaId = it.id!!, + binaryData = binaryData, + contentType = it.contentType, + ) + } + + return GetMediasResult(mediaInfos) + } +} diff --git a/src/main/kotlin/com/yapp2app/media/domain/MediaKey.kt b/src/main/kotlin/com/yapp2app/media/domain/MediaKey.kt index eef461a..4014764 100644 --- a/src/main/kotlin/com/yapp2app/media/domain/MediaKey.kt +++ b/src/main/kotlin/com/yapp2app/media/domain/MediaKey.kt @@ -1,6 +1,5 @@ package com.yapp2app.media.domain -import com.yapp2app.media.domain.MediaType import java.util.UUID /** @@ -11,9 +10,26 @@ import java.util.UUID */ object MediaKey { - fun generate(type: MediaType, filename: String): String { - val extension = filename.substringAfterLast('.', "") + private val CONTENT_TYPE_EXTENSIONS = mapOf( + "image/jpeg" to "jpg", + "image/png" to "png", + "image/gif" to "gif", + "image/webp" to "webp", + "image/heic" to "heic", + "image/heif" to "heif", + ) + fun generate(type: MediaType, filename: String, contentType: String): String { + val extension = extractExtension(filename, contentType) return "${type.prefix}/${UUID.randomUUID()}.$extension" } + + private fun extractExtension(filename: String, contentType: String): String { + val filenameExtension = filename.substringAfterLast('.', "") + if (filenameExtension.isNotBlank()) { + return filenameExtension + } + return CONTENT_TYPE_EXTENSIONS[contentType] + ?: contentType.substringAfterLast('/', "") + } } diff --git a/src/main/kotlin/com/yapp2app/media/domain/MediaType.kt b/src/main/kotlin/com/yapp2app/media/domain/MediaType.kt index 5ad825a..e21feb5 100644 --- a/src/main/kotlin/com/yapp2app/media/domain/MediaType.kt +++ b/src/main/kotlin/com/yapp2app/media/domain/MediaType.kt @@ -24,7 +24,7 @@ enum class MediaType(val prefix: String) { ATTACHMENT("attachments"), /** - * presigned나 업로드 검증, 변환 전 원본 등 + * 업로드 검증, 테스트 등 */ TEMP("temp"), } diff --git a/src/main/kotlin/com/yapp2app/media/domain/entity/Media.kt b/src/main/kotlin/com/yapp2app/media/domain/entity/Media.kt index 9e8bd52..1465a31 100644 --- a/src/main/kotlin/com/yapp2app/media/domain/entity/Media.kt +++ b/src/main/kotlin/com/yapp2app/media/domain/entity/Media.kt @@ -58,6 +58,10 @@ class Media( this.status = MediaStatus.DELETED } + fun markAsInitiated() { + this.status = MediaStatus.INITIATED + } + fun isUploaded(): Boolean = status == MediaStatus.UPLOADED } diff --git a/src/main/kotlin/com/yapp2app/media/infra/cache/FakeMediaBinaryCacheAdapter.kt b/src/main/kotlin/com/yapp2app/media/infra/cache/FakeMediaBinaryCacheAdapter.kt new file mode 100644 index 0000000..934e654 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/media/infra/cache/FakeMediaBinaryCacheAdapter.kt @@ -0,0 +1,26 @@ +package com.yapp2app.media.infra.cache + +import com.yapp2app.media.application.port.MediaBinaryCachePort +import org.springframework.stereotype.Component + +/** + * fileName : FakeMediaBinaryAdapter + * author : koo + * date : 2026. 1. 8. 오후 4:20 + * description : 항상 cache miss가 발생하는 cache adapter + */ +@Component +class FakeMediaBinaryCacheAdapter : MediaBinaryCachePort { + override fun get(key: String): ByteArray? { + // 캐시 미사용: 항상 miss + return null + } + + override fun put(key: String, value: ByteArray) { + // 캐시 미사용: 아무 동작도 하지 않음 + } + + override fun evict(key: String) { + // 캐시 미사용: 항상 evict skip + } +} diff --git a/src/main/kotlin/com/yapp2app/media/infra/persist/MediaRepositoryAdapter.kt b/src/main/kotlin/com/yapp2app/media/infra/persist/MediaRepositoryAdapter.kt new file mode 100644 index 0000000..1adf859 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/media/infra/persist/MediaRepositoryAdapter.kt @@ -0,0 +1,40 @@ +package com.yapp2app.media.infra.persist + +import com.yapp2app.media.application.port.MediaRepositoryPort +import com.yapp2app.media.domain.entity.Media +import com.yapp2app.media.domain.entity.MediaStatus +import com.yapp2app.media.infra.persist.jpa.JpaMediaRepository +import org.springframework.stereotype.Repository + +/** + * fileName : MediaRepositoryAdapter + * author : koo + * date : 2026. 1. 2. 오후 8:07 + * description : Media Repository Adapter + */ +@Repository +class MediaRepositoryAdapter(private val jpaRepository: JpaMediaRepository) : MediaRepositoryPort { + + override fun getActiveMedia(ownerId: Long, id: Long): Media? = + jpaRepository.findByOwnerIdAndIdAndStatus(ownerId, id, MediaStatus.UPLOADED) + + override fun getActiveMedias(ownerId: Long, ids: List): List = + jpaRepository.findAllByOwnerIdAndIdInAndStatus(ownerId, ids, MediaStatus.UPLOADED) + + override fun getMediaForUploadConfirmation(ownerId: Long, id: Long): Media? = + jpaRepository.findByOwnerIdAndIdAndStatusIn( + ownerId, + id, + listOf(MediaStatus.INITIATED, MediaStatus.UPLOADED), + ) + + override fun save(media: Media): Media = jpaRepository.save(media) + + override fun delete(id: Long) { + jpaRepository.deleteById(id) + } + + override fun deleteAll(ids: List) { + jpaRepository.deleteAllByIdInBatch(ids) + } +} diff --git a/src/main/kotlin/com/yapp2app/media/infra/persist/jpa/JpaMediaRepository.kt b/src/main/kotlin/com/yapp2app/media/infra/persist/jpa/JpaMediaRepository.kt new file mode 100644 index 0000000..9a86f94 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/media/infra/persist/jpa/JpaMediaRepository.kt @@ -0,0 +1,20 @@ +package com.yapp2app.media.infra.persist.jpa + +import com.yapp2app.media.domain.entity.Media +import com.yapp2app.media.domain.entity.MediaStatus +import org.springframework.data.jpa.repository.JpaRepository + +/** + * fileName : JpaMediaRepository + * author : koo + * date : 2026. 1. 2. 오후 8:08 + * description : media jpa repository + */ +interface JpaMediaRepository : JpaRepository { + + fun findByOwnerIdAndIdAndStatus(ownerId: Long, id: Long, status: MediaStatus): Media? + + fun findByOwnerIdAndIdAndStatusIn(ownerId: Long, id: Long, statuses: List): Media? + + fun findAllByOwnerIdAndIdInAndStatus(ownerId: Long, ids: List, status: MediaStatus): List +} diff --git a/src/main/kotlin/com/yapp2app/media/infra/s3/S3MediaStorageAdapter.kt b/src/main/kotlin/com/yapp2app/media/infra/s3/S3MediaStorageAdapter.kt deleted file mode 100644 index 90702b9..0000000 --- a/src/main/kotlin/com/yapp2app/media/infra/s3/S3MediaStorageAdapter.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.yapp2app.media.infra.s3 - -import com.yapp2app.media.application.dto.MediaRef -import com.yapp2app.media.application.port.MediaStoragePort -import com.yapp2app.media.domain.MediaType -import software.amazon.awssdk.services.s3.S3Client -import software.amazon.awssdk.services.s3.model.ListObjectsV2Request -import software.amazon.awssdk.services.s3.model.PutObjectRequest -import software.amazon.awssdk.services.s3.presigner.S3Presigner -import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest -import java.time.Duration - -/** - * fileName : S3MediaStorage - * author : koo - * date : 2025. 12. 19. 오전 2:40 - * description : 이미지 업로드(MediaStorage) S3 구현체 - */ -class S3MediaStorageAdapter( - private val s3Client: S3Client, - private val s3Presigner: S3Presigner, - private val bucketName: String, - private val baseUrl: String, -) : MediaStoragePort { - - override fun deleteByKey(key: String) { - s3Client.deleteObject { - it.bucket(bucketName).key(key) - } - } - - override fun findByKey(key: String): String = "$baseUrl/$key" - - override fun findAll(prefix: String): List { - val request = ListObjectsV2Request.builder() - .bucket(bucketName) - .prefix(prefix) - .build() - - val response = s3Client.listObjectsV2(request) - - return response.contents() - .map { s3Object -> - MediaRef( - key = s3Object.key(), - url = "$baseUrl/${s3Object.key()}", - type = MediaType.valueOf(s3Object.key().substringBefore("/").uppercase()), - ) - } - } - - override fun generatePresignedUrl(key: String, contentType: String, expirationMinutes: Long): String { - val putObjectRequest = PutObjectRequest.builder() - .bucket(bucketName) - .key(key) - .contentType(contentType) - .build() - - val presignRequest = PutObjectPresignRequest.builder() - .signatureDuration(Duration.ofMinutes(expirationMinutes)) - .putObjectRequest(putObjectRequest) - .build() - - val presignedRequest = s3Presigner.presignPutObject(presignRequest) - - return presignedRequest.url().toString() - } -} diff --git a/src/main/kotlin/com/yapp2app/media/infra/storage/fake/FakeMediaStorageConfig.kt b/src/main/kotlin/com/yapp2app/media/infra/storage/fake/FakeMediaStorageConfig.kt new file mode 100644 index 0000000..09522be --- /dev/null +++ b/src/main/kotlin/com/yapp2app/media/infra/storage/fake/FakeMediaStorageConfig.kt @@ -0,0 +1,51 @@ +package com.yapp2app.media.infra.storage.fake + +import com.yapp2app.media.application.dto.MediaRef +import com.yapp2app.media.application.port.MediaStoragePort +import com.yapp2app.media.domain.MediaType +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import java.time.Instant +import java.util.concurrent.ConcurrentHashMap + +/** + * fileName : FakeMediaStorageConfig + * author : koo + * date : 2026. 1. 8. 오후 6:32 + * description : test를 위한 Media Storage Config + */ +@Profile("test") +@Configuration +class FakeMediaStorageConfig { + + @Bean + fun fakeMediaStorage(): MediaStoragePort = FakeMediaStorageAdapter() +} + +class FakeMediaStorageAdapter : MediaStoragePort { + + private val storage = ConcurrentHashMap() + + override fun deleteByKey(key: String) { + storage.remove(key) + } + + override fun findByKey(key: String): String = "https://fake-storage.test/$key" + + override fun fetchBinaryByKey(key: String): ByteArray = storage[key] ?: ByteArray(0) + + override fun findAll(prefix: String): List = storage.keys + .filter { it.startsWith(prefix) } + .map { MediaRef(key = it, url = findByKey(it), type = MediaType.PHOTO_BOOTH) } + + override fun exists(key: String): Boolean = true + + override fun generateUploadTicket(key: String, contentType: String): MediaStoragePort.UploadTicket = + MediaStoragePort.UploadTicket( + url = "https://fake-storage.test/upload/$key", + method = "PUT", + expiresAt = Instant.now().plusSeconds(3600), + contentType = contentType, + ) +} diff --git a/src/main/kotlin/com/yapp2app/media/infra/s3/S3InMemoryBucketInitializer.kt b/src/main/kotlin/com/yapp2app/media/infra/storage/s3/S3InMemoryBucketInitializer.kt similarity index 98% rename from src/main/kotlin/com/yapp2app/media/infra/s3/S3InMemoryBucketInitializer.kt rename to src/main/kotlin/com/yapp2app/media/infra/storage/s3/S3InMemoryBucketInitializer.kt index 5b78452..cacc399 100644 --- a/src/main/kotlin/com/yapp2app/media/infra/s3/S3InMemoryBucketInitializer.kt +++ b/src/main/kotlin/com/yapp2app/media/infra/storage/s3/S3InMemoryBucketInitializer.kt @@ -1,4 +1,4 @@ -package com.yapp2app.media.infra.s3 +package com.yapp2app.media.infra.storage.s3 import com.yapp2app.auth.infra.security.properties.AppProperties import jakarta.annotation.PostConstruct diff --git a/src/main/kotlin/com/yapp2app/media/infra/storage/s3/S3MediaStorageAdapter.kt b/src/main/kotlin/com/yapp2app/media/infra/storage/s3/S3MediaStorageAdapter.kt new file mode 100644 index 0000000..432b138 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/media/infra/storage/s3/S3MediaStorageAdapter.kt @@ -0,0 +1,105 @@ +package com.yapp2app.media.infra.storage.s3 + +import com.yapp2app.media.application.dto.MediaRef +import com.yapp2app.media.application.port.MediaStoragePort +import com.yapp2app.media.domain.MediaType +import software.amazon.awssdk.core.ResponseBytes +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.GetObjectRequest +import software.amazon.awssdk.services.s3.model.GetObjectResponse +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request +import software.amazon.awssdk.services.s3.model.NoSuchKeyException +import software.amazon.awssdk.services.s3.model.PutObjectRequest +import software.amazon.awssdk.services.s3.model.S3Exception +import software.amazon.awssdk.services.s3.presigner.S3Presigner +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest +import java.time.Instant + +/** + * fileName : S3MediaStorage + * author : koo + * date : 2025. 12. 19. 오전 2:40 + * description : 이미지 업로드(MediaStorage) S3 구현체 + */ +class S3MediaStorageAdapter( + private val s3Client: S3Client, + private val s3Presigner: S3Presigner, + private val props: S3Properties, +) : MediaStoragePort { + + override fun deleteByKey(key: String) { + s3Client.deleteObject { + it.bucket(props.bucket).key(key) + } + } + + override fun findByKey(key: String): String = "${props.baseUrl}/$key" + + override fun fetchBinaryByKey(key: String): ByteArray { + val getObjectRequest = GetObjectRequest.builder() + .bucket(props.bucket) + .key(key) + .build() + + val responseBytes: ResponseBytes = + s3Client.getObjectAsBytes(getObjectRequest) + return responseBytes.asByteArray() + } + + override fun findAll(prefix: String): List { + val request = ListObjectsV2Request.builder() + .bucket(props.bucket) + .prefix(prefix) + .build() + + val response = s3Client.listObjectsV2(request) + + return response.contents() + .map { s3Object -> + MediaRef( + key = s3Object.key(), + url = "${props.baseUrl}/${s3Object.key()}", + type = MediaType.valueOf(s3Object.key().substringBefore("/").uppercase()), + ) + } + } + + override fun exists(key: String): Boolean = try { + s3Client.headObject { + it.bucket(props.bucket) + it.key(key) + } + true + } catch (_: NoSuchKeyException) { + false + } catch (e: S3Exception) { + // 404 (Not Found)인 경우만 false, 나머지는 throw + if (e.statusCode() == 404) { + false + } else { + throw e + } + } + + override fun generateUploadTicket(key: String, contentType: String): MediaStoragePort.UploadTicket { + val putObjectRequest = PutObjectRequest.builder() + .bucket(props.bucket) + .key(key) + .contentType(contentType) + .build() + + val presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(props.presignedUrlExpiration) + .putObjectRequest(putObjectRequest) + .build() + + val presignedRequest = s3Presigner.presignPutObject(presignRequest) + + return MediaStoragePort.UploadTicket( + url = presignedRequest.url().toString(), + method = "PUT", + expiresAt = Instant.now().plus(props.presignedUrlExpiration), + contentType = contentType, + ) + } +} diff --git a/src/main/kotlin/com/yapp2app/media/infra/s3/S3MediaStorageConfig.kt b/src/main/kotlin/com/yapp2app/media/infra/storage/s3/S3MediaStorageConfig.kt similarity index 80% rename from src/main/kotlin/com/yapp2app/media/infra/s3/S3MediaStorageConfig.kt rename to src/main/kotlin/com/yapp2app/media/infra/storage/s3/S3MediaStorageConfig.kt index 03de0cb..0ef4b32 100644 --- a/src/main/kotlin/com/yapp2app/media/infra/s3/S3MediaStorageConfig.kt +++ b/src/main/kotlin/com/yapp2app/media/infra/storage/s3/S3MediaStorageConfig.kt @@ -1,4 +1,4 @@ -package com.yapp2app.media.infra.s3 +package com.yapp2app.media.infra.storage.s3 import com.yapp2app.media.application.port.MediaStoragePort import org.springframework.context.annotation.Bean @@ -13,28 +13,28 @@ import software.amazon.awssdk.services.s3.presigner.S3Presigner import java.net.URI /** - * fileName : S3Config + * fileName : MediaStorageConfig * author : koo * date : 2025. 12. 19. 오전 2:40 - * description : S3 설정 + * description : ObjectStorage 설정 */ @Profile("!test") @Configuration -class S3MediaStorageConfig(private val props: S3Properties) { +class S3MediaStorageConfig(private val s3Props: S3Properties) { @Bean fun s3Client(): S3Client { val credentials = AwsBasicCredentials.create( - props.accessKey, - props.secretKey, + s3Props.accessKey, + s3Props.secretKey, ) val builder = S3Client.builder() - .region(Region.of(props.region)) + .region(Region.of(s3Props.region)) .credentialsProvider(StaticCredentialsProvider.create(credentials)) // LocalStack을 위한 엔드포인트 설정 - props.endpoint?.let { + s3Props.endpoint?.let { builder.endpointOverride(URI.create(it)) .forcePathStyle(true) // LocalStack은 path-style 필수 } @@ -45,16 +45,16 @@ class S3MediaStorageConfig(private val props: S3Properties) { @Bean fun s3Presigner(): S3Presigner { val credentials = AwsBasicCredentials.create( - props.accessKey, - props.secretKey, + s3Props.accessKey, + s3Props.secretKey, ) val builder = S3Presigner.builder() - .region(Region.of(props.region)) + .region(Region.of(s3Props.region)) .credentialsProvider(StaticCredentialsProvider.create(credentials)) // LocalStack을 위한 엔드포인트 설정 - props.endpoint?.let { + s3Props.endpoint?.let { builder.endpointOverride(URI.create(it)) .serviceConfiguration( S3Configuration.builder() @@ -70,7 +70,6 @@ class S3MediaStorageConfig(private val props: S3Properties) { fun mediaStorage(s3Client: S3Client, s3Presigner: S3Presigner): MediaStoragePort = S3MediaStorageAdapter( s3Client = s3Client, s3Presigner = s3Presigner, - bucketName = props.bucket, - baseUrl = props.baseUrl, + props = s3Props, ) } diff --git a/src/main/kotlin/com/yapp2app/media/infra/s3/S3Properties.kt b/src/main/kotlin/com/yapp2app/media/infra/storage/s3/S3Properties.kt similarity index 80% rename from src/main/kotlin/com/yapp2app/media/infra/s3/S3Properties.kt rename to src/main/kotlin/com/yapp2app/media/infra/storage/s3/S3Properties.kt index f87d390..2b32a18 100644 --- a/src/main/kotlin/com/yapp2app/media/infra/s3/S3Properties.kt +++ b/src/main/kotlin/com/yapp2app/media/infra/storage/s3/S3Properties.kt @@ -1,6 +1,7 @@ -package com.yapp2app.media.infra.s3 +package com.yapp2app.media.infra.storage.s3 import org.springframework.boot.context.properties.ConfigurationProperties +import java.time.Duration /** * fileName : S3Properties @@ -16,4 +17,5 @@ data class S3Properties( val bucket: String, val endpoint: String? = null, val baseUrl: String = "", + val presignedUrlExpiration: Duration, ) diff --git a/src/main/kotlin/com/yapp2app/photo/api/controller/PhotoController.kt b/src/main/kotlin/com/yapp2app/photo/api/controller/PhotoController.kt new file mode 100644 index 0000000..bfb98da --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/api/controller/PhotoController.kt @@ -0,0 +1,139 @@ +package com.yapp2app.photo.api.controller + +import com.yapp2app.common.api.document.RequiresSecurity +import com.yapp2app.common.api.dto.BaseResponse +import com.yapp2app.photo.api.converter.PhotoImageCommandConverter +import com.yapp2app.photo.api.converter.PhotoImageResultConverter +import com.yapp2app.photo.api.dto.DeletePhotosRequest +import com.yapp2app.photo.api.dto.GetPhotosResponse +import com.yapp2app.photo.api.dto.UpdatePhotoRequest +import com.yapp2app.photo.api.dto.UploadPhotoRequest +import com.yapp2app.photo.api.dto.UploadPhotoResponse +import com.yapp2app.photo.application.usecase.DeletePhotoUseCase +import com.yapp2app.photo.application.usecase.GetPhotosUseCase +import com.yapp2app.photo.application.usecase.UpdatePhotoUseCase +import com.yapp2app.photo.application.usecase.UploadPhotoUseCase +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +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.RequestParam +import org.springframework.web.bind.annotation.RestController + +/** + * fileName : PhotoController + * author : koo + * date : 2026. 1. 2. 오후 8:23 + * description : PhotoImage api endpoint + */ +@RequiresSecurity +@Tag(name = "photo image", description = "아카이빙 사진 API") +@RestController +@RequestMapping("/api/photos") +class PhotoController( + private val uploadPhotoUseCase: UploadPhotoUseCase, + private val getPhotosUseCase: GetPhotosUseCase, + private val deletePhotoUseCase: DeletePhotoUseCase, + private val updatePhotoUseCase: UpdatePhotoUseCase, + + private val commandConverter: PhotoImageCommandConverter, + private val resultConverter: PhotoImageResultConverter, +) { + + @Operation( + summary = "사진 등록 API", + description = """presigned 발급 API로 url을 발급받아 S3에 이미지를 업로드한 후에 호출합니다. + S3 이미지 업로드가 보장되지 않으므로 S3 이미지 업로드가 완료되었는지 서버에서 검증 후에 메타데이터를 데이터베이스에 저장됩니다.""", + ) + @PostMapping + fun uploadPhoto( + @AuthenticationPrincipal(expression = "id") userId: Long, + @Valid @RequestBody request: UploadPhotoRequest, + ): BaseResponse { + val command = commandConverter.toUploadPhotoCommand(userId, request) + + val result = uploadPhotoUseCase.execute(command) + + val response = resultConverter.toUploadPhotoResponse(result) + + return BaseResponse(data = response) + } + + @Operation( + summary = "사진 목록 API", + description = "사진 목록을 조회합니다. Offset 기반 페이징을 지원합니다.", + ) + @GetMapping + fun photoList( + @AuthenticationPrincipal(expression = "id") userId: Long, + @RequestParam(required = false) folderId: Long?, + @RequestParam(defaultValue = "0") @Min(0) page: Int, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) size: Int, + ): BaseResponse { + val command = commandConverter.toGetPhotosCommand(userId, folderId, page, size) + + val result = getPhotosUseCase.execute(command) + + val response = resultConverter.toGetPhotosResponse(result) + + return BaseResponse(data = response) + } + + @Operation( + summary = "사진 삭제 API", + description = "지정된 사진 한 장을 삭제합니다.", + ) + @DeleteMapping("/{photoId}") + fun deletePhoto( + @AuthenticationPrincipal(expression = "id") userId: Long, + @PathVariable photoId: Long, + ): BaseResponse { + val command = commandConverter.toDeletePhotoCommand(userId, photoId) + + deletePhotoUseCase.execute(command) + + return BaseResponse() + } + + @Operation( + summary = "사진 선택 삭제 API", + description = "body에 포함된 사진들을 삭제합니다.", + ) + @DeleteMapping + fun deletePhotos( + @AuthenticationPrincipal(expression = "id") userId: Long, + @Valid @RequestBody request: DeletePhotosRequest, + ): BaseResponse { + val command = commandConverter.toDeletePhotosCommand(userId, request) + + deletePhotoUseCase.execute(command) + + return BaseResponse() + } + + @Operation( + summary = "사진 갱신 API", + description = "사진 정보를 갱신합니다.", + ) + @PatchMapping("/{photoId}") + fun updatePhoto( + @AuthenticationPrincipal(expression = "id") userId: Long, + @PathVariable photoId: Long, + @Valid @RequestBody request: UpdatePhotoRequest, + ): BaseResponse { + val command = commandConverter.toUpdatePhotoCommand(userId, photoId, request) + + updatePhotoUseCase.execute(command) + + return BaseResponse() + } +} diff --git a/src/main/kotlin/com/yapp2app/photo/api/converter/PhotoImageCommandConverter.kt b/src/main/kotlin/com/yapp2app/photo/api/converter/PhotoImageCommandConverter.kt new file mode 100644 index 0000000..79cb1ad --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/api/converter/PhotoImageCommandConverter.kt @@ -0,0 +1,51 @@ +package com.yapp2app.photo.api.converter + +import com.yapp2app.photo.api.dto.DeletePhotosRequest +import com.yapp2app.photo.api.dto.UpdatePhotoRequest +import com.yapp2app.photo.api.dto.UploadPhotoRequest +import com.yapp2app.photo.application.command.DeletePhotoCommand +import com.yapp2app.photo.application.command.DeletePhotosCommand +import com.yapp2app.photo.application.command.GetPhotosCommand +import com.yapp2app.photo.application.command.UpdatePhotoCommand +import com.yapp2app.photo.application.command.UploadPhotoCommand +import org.springframework.stereotype.Component + +/** + * fileName : PhotoImageCommandConverter + * author : koo + * date : 2026. 1. 2. 오후 8:30 + * description : Photo image application layer command 변경을 위한 converter + */ +@Component +class PhotoImageCommandConverter { + + fun toUploadPhotoCommand(userId: Long, request: UploadPhotoRequest): UploadPhotoCommand = UploadPhotoCommand( + userId = userId, + mediaId = request.mediaId!!, + folderId = request.folderId, + memo = request.memo, + ) + + fun toGetPhotosCommand(userId: Long, folderId: Long?, page: Int, size: Int): GetPhotosCommand = GetPhotosCommand( + userId = userId, + folderId = folderId, + page = page, + size = size, + ) + + fun toDeletePhotoCommand(userId: Long, photoId: Long): DeletePhotoCommand = DeletePhotoCommand( + userId = userId, + photoId = photoId, + ) + + fun toDeletePhotosCommand(userId: Long, request: DeletePhotosRequest) = DeletePhotosCommand( + userId = userId, + photoIds = request.photoIds, + ) + + fun toUpdatePhotoCommand(userId: Long, photoId: Long, request: UpdatePhotoRequest) = UpdatePhotoCommand( + userId = userId, + photoId = photoId, + memo = request.memo, + ) +} diff --git a/src/main/kotlin/com/yapp2app/photo/api/converter/PhotoImageResultConverter.kt b/src/main/kotlin/com/yapp2app/photo/api/converter/PhotoImageResultConverter.kt new file mode 100644 index 0000000..0737c48 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/api/converter/PhotoImageResultConverter.kt @@ -0,0 +1,32 @@ +package com.yapp2app.photo.api.converter + +import com.yapp2app.photo.api.dto.GetPhotosResponse +import com.yapp2app.photo.api.dto.UploadPhotoResponse +import com.yapp2app.photo.application.result.GetPhotosResult +import com.yapp2app.photo.application.result.UploadPhotoResult +import org.springframework.stereotype.Component + +/** + * fileName : PhotoImageResponseConverter + * author : koo + * date : 2026. 1. 2. 오후 8:30 + * description : Photo image api layer response 변경을 위한 converter + */ +@Component +class PhotoImageResultConverter { + + fun toUploadPhotoResponse(result: UploadPhotoResult): UploadPhotoResponse = UploadPhotoResponse(result.photoId) + + fun toGetPhotosResponse(result: GetPhotosResult): GetPhotosResponse = GetPhotosResponse( + items = result.photos.map { + GetPhotosResponse.PhotoInfo( + photoId = it.photoId, + imageBinary = it.imageBinary, + folderId = it.folderId, + contentType = it.contentType, + createdAt = it.createdAt, + ) + }, + hasNext = result.hasNext, + ) +} diff --git a/src/main/kotlin/com/yapp2app/photo/api/dto/PhotoImageRequest.kt b/src/main/kotlin/com/yapp2app/photo/api/dto/PhotoImageRequest.kt new file mode 100644 index 0000000..d650b03 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/api/dto/PhotoImageRequest.kt @@ -0,0 +1,28 @@ +package com.yapp2app.photo.api.dto + +import jakarta.annotation.Nullable +import jakarta.validation.constraints.NotEmpty +import jakarta.validation.constraints.NotNull + +/** + * fileName : PhotoImageRequest + * author : koo + * date : 2026. 1. 2. 오후 8:27 + * description : Photo image domain 요청 + */ +data class UploadPhotoRequest( + @field:NotNull(message = "mediaId는 필수 입력값입니다.") + val mediaId: Long?, + + @field:Nullable + val folderId: Long?, + + val memo: String?, +) + +data class DeletePhotosRequest( + @field:NotEmpty(message = "photoIds가 비어있습니다.") + val photoIds: List, +) + +data class UpdatePhotoRequest(val memo: String?) diff --git a/src/main/kotlin/com/yapp2app/photo/api/dto/PhotoImageResponse.kt b/src/main/kotlin/com/yapp2app/photo/api/dto/PhotoImageResponse.kt new file mode 100644 index 0000000..80918fd --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/api/dto/PhotoImageResponse.kt @@ -0,0 +1,19 @@ +package com.yapp2app.photo.api.dto + +/** + * fileName : PhotoImageResponse + * author : koo + * date : 2026. 1. 2. 오후 8:28 + * description : Photo image domain 응답 + */ +data class UploadPhotoResponse(val photoId: Long) + +data class GetPhotosResponse(val items: List, val hasNext: Boolean) { + data class PhotoInfo( + val photoId: Long, + val imageBinary: ByteArray, + val folderId: Long?, + val contentType: String, + val createdAt: String, + ) +} diff --git a/src/main/kotlin/com/yapp2app/photo/application/command/PhotoImageCommand.kt b/src/main/kotlin/com/yapp2app/photo/application/command/PhotoImageCommand.kt new file mode 100644 index 0000000..8909445 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/application/command/PhotoImageCommand.kt @@ -0,0 +1,17 @@ +package com.yapp2app.photo.application.command + +/** + * fileName : PhotoImageCommand + * author : koo + * date : 2026. 1. 2. 오후 8:28 + * description : Photo image domain command + */ +data class UploadPhotoCommand(val userId: Long, val mediaId: Long, val folderId: Long?, val memo: String?) + +data class GetPhotosCommand(val userId: Long, val folderId: Long?, val page: Int = 0, val size: Int = 20) + +data class DeletePhotoCommand(val userId: Long, val photoId: Long) + +data class DeletePhotosCommand(val userId: Long, val photoIds: List) + +data class UpdatePhotoCommand(val userId: Long, val photoId: Long, val memo: String?) diff --git a/src/main/kotlin/com/yapp2app/photo/application/contract/MediaContract.kt b/src/main/kotlin/com/yapp2app/photo/application/contract/MediaContract.kt new file mode 100644 index 0000000..7f19923 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/application/contract/MediaContract.kt @@ -0,0 +1,22 @@ +package com.yapp2app.photo.application.contract + +/** + * fileName : MediaContract + * author : koo + * date : 2026. 1. 16. 오후 10:30 + * description : + */ +enum class MediaAvailability { + AVAILABLE, + UNAVAILABLE, +} + +data class MediaInfo(val mediaId: Long, val contentType: String, val binaryData: ByteArray) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is MediaInfo) return false + return mediaId == other.mediaId + } + + override fun hashCode(): Int = mediaId.hashCode() +} diff --git a/src/main/kotlin/com/yapp2app/photo/application/port/FolderRepositoryPort.kt b/src/main/kotlin/com/yapp2app/photo/application/port/FolderRepositoryPort.kt index a0d9931..c5b613c 100644 --- a/src/main/kotlin/com/yapp2app/photo/application/port/FolderRepositoryPort.kt +++ b/src/main/kotlin/com/yapp2app/photo/application/port/FolderRepositoryPort.kt @@ -12,8 +12,8 @@ interface FolderRepositoryPort { fun save(folder: Folder): Folder - fun deleteOwnedFolder(userId: Long, folderId: Long) - fun deleteOwnedFolders(userId: Long, folderIds: List) + fun deleteOwnedFolder(userId: Long, folderId: Long): Int + fun deleteOwnedFolders(userId: Long, folderIds: List): Int fun listOwnedFolders(userId: Long): List diff --git a/src/main/kotlin/com/yapp2app/photo/application/port/MediaClientPort.kt b/src/main/kotlin/com/yapp2app/photo/application/port/MediaClientPort.kt new file mode 100644 index 0000000..5e480f4 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/application/port/MediaClientPort.kt @@ -0,0 +1,27 @@ +package com.yapp2app.photo.application.port + +import com.yapp2app.photo.application.contract.MediaAvailability +import com.yapp2app.photo.application.contract.MediaInfo + +/** + * fileName : MediaClient + * author : koo + * date : 2026. 1. 2. 오후 11:58 + * description : Media Client 호출을 위한 인터페이스 + */ +interface MediaClientPort { + + fun verifyMediaUploaded(ownerId: Long, mediaId: Long): MediaAvailability + + fun getMediaBinaries(ownerId: Long, mediaIds: List): List + + fun deleteMedia(ownerId: Long, mediaId: Long) + + fun deleteMedias(ownerId: Long, mediaIds: List) + + /** + * 보상 트랜잭션: media 상태를 INITIATED로 롤백 + * PhotoImage 저장 실패 시 호출 + */ + fun rollbackMediaUploaded(ownerId: Long, mediaId: Long) +} diff --git a/src/main/kotlin/com/yapp2app/photo/application/port/PhotoImageRepositoryPort.kt b/src/main/kotlin/com/yapp2app/photo/application/port/PhotoImageRepositoryPort.kt new file mode 100644 index 0000000..1d4b8c7 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/application/port/PhotoImageRepositoryPort.kt @@ -0,0 +1,21 @@ +package com.yapp2app.photo.application.port + +import com.yapp2app.photo.domain.entity.PhotoImage + +/** + * fileName : PhotoImageRepositoryPort + * author : koo + * date : 2026. 1. 2. 오후 8:26 + * description : Photo image repository port + */ +interface PhotoImageRepositoryPort { + + fun save(photoImage: PhotoImage): PhotoImage + + fun listOwnedPhotos(userId: Long, folderId: Long?, offset: Int, limit: Int): List + + fun deleteOwnedPhoto(userId: Long, photoId: Long): PhotoImage? + fun deleteOwnedPhotos(userId: Long, photoIds: List): List + + fun getOwnedPhoto(userId: Long, photoId: Long): PhotoImage? +} diff --git a/src/main/kotlin/com/yapp2app/photo/application/result/PhotoImageResult.kt b/src/main/kotlin/com/yapp2app/photo/application/result/PhotoImageResult.kt new file mode 100644 index 0000000..13f44a9 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/application/result/PhotoImageResult.kt @@ -0,0 +1,29 @@ +package com.yapp2app.photo.application.result + +/** + * fileName : PhotoImageResult + * author : koo + * date : 2026. 1. 2. 오후 8:28 + * description : photo image application 결과 + */ +data class UploadPhotoResult(val photoId: Long) + +data class GetPhotosResult(val photos: List, val hasNext: Boolean) { + + data class PhotoInfo( + val photoId: Long, + val imageBinary: ByteArray, + val folderId: Long?, + val contentType: String, + val createdAt: String, + ) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is PhotoInfo) return false + return photoId == other.photoId + } + + override fun hashCode(): Int = photoId.hashCode() + } +} diff --git a/src/main/kotlin/com/yapp2app/photo/application/usecase/DeleteFolderUseCase.kt b/src/main/kotlin/com/yapp2app/photo/application/usecase/DeleteFolderUseCase.kt index e77b023..4b42c2b 100644 --- a/src/main/kotlin/com/yapp2app/photo/application/usecase/DeleteFolderUseCase.kt +++ b/src/main/kotlin/com/yapp2app/photo/application/usecase/DeleteFolderUseCase.kt @@ -19,20 +19,20 @@ class DeleteFolderUseCase(private val folderRepository: FolderRepositoryPort) { @Transactional fun execute(command: DeleteFolderCommand) { - val folder = folderRepository.getOwnedFolder(command.userId, command.folderId) - ?: throw BusinessException(ResultCode.NOT_FOUND) + val deletedCount = folderRepository.deleteOwnedFolder(command.userId, command.folderId) - folderRepository.deleteOwnedFolder(command.userId, command.folderId) + if (deletedCount == 0) throw BusinessException(ResultCode.NOT_FOUND) } @Transactional fun execute(command: DeleteFoldersCommand) { - val folders = folderRepository.getOwnedFolders(command.userId, command.folderIds) + val deletedCount = folderRepository.deleteOwnedFolders( + command.userId, + command.folderIds, + ) - if (folders.size != command.folderIds.size) { + if (deletedCount != command.folderIds.size) { throw BusinessException(ResultCode.NOT_FOUND) } - - folderRepository.deleteOwnedFolders(command.userId, command.folderIds) } } diff --git a/src/main/kotlin/com/yapp2app/photo/application/usecase/DeletePhotoUseCase.kt b/src/main/kotlin/com/yapp2app/photo/application/usecase/DeletePhotoUseCase.kt new file mode 100644 index 0000000..fcb5ff7 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/application/usecase/DeletePhotoUseCase.kt @@ -0,0 +1,50 @@ +package com.yapp2app.photo.application.usecase + +import com.yapp2app.common.annotation.UseCase +import com.yapp2app.common.api.dto.ResultCode +import com.yapp2app.common.exception.BusinessException +import com.yapp2app.common.transaction.TransactionRunner +import com.yapp2app.photo.application.command.DeletePhotoCommand +import com.yapp2app.photo.application.command.DeletePhotosCommand +import com.yapp2app.photo.application.port.MediaClientPort +import com.yapp2app.photo.application.port.PhotoImageRepositoryPort + +/** + * fileName : DeletePhotoUseCase + * author : koo + * date : 2026. 1. 3. 오전 5:05 + * description : 사진 삭제 usecase + */ +@UseCase +class DeletePhotoUseCase( + private val photoImageRepository: PhotoImageRepositoryPort, + private val mediaClient: MediaClientPort, + private val transactionRunner: TransactionRunner, +) { + + fun execute(command: DeletePhotoCommand) { + // 사진 삭제 + val photo = transactionRunner.run { + photoImageRepository.deleteOwnedPhoto( + command.userId, + command.photoId, + ) + } ?: throw BusinessException(ResultCode.NOT_FOUND) + + // 미디어 삭제 요청 + mediaClient.deleteMedia(command.userId, photo.mediaId) + } + + fun execute(command: DeletePhotosCommand) { + val photos = transactionRunner.run { + photoImageRepository.deleteOwnedPhotos( + command.userId, + command.photoIds, + ) + } + + val mediaIds = photos.map { it.mediaId }.toList() + + mediaClient.deleteMedias(command.userId, mediaIds) + } +} diff --git a/src/main/kotlin/com/yapp2app/photo/application/usecase/GetPhotosUseCase.kt b/src/main/kotlin/com/yapp2app/photo/application/usecase/GetPhotosUseCase.kt new file mode 100644 index 0000000..52bbaae --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/application/usecase/GetPhotosUseCase.kt @@ -0,0 +1,82 @@ +package com.yapp2app.photo.application.usecase + +import com.yapp2app.common.annotation.UseCase +import com.yapp2app.common.transaction.TransactionRunner +import com.yapp2app.photo.application.command.GetPhotosCommand +import com.yapp2app.photo.application.port.MediaClientPort +import com.yapp2app.photo.application.port.PhotoImageRepositoryPort +import com.yapp2app.photo.application.result.GetPhotosResult +import org.slf4j.LoggerFactory + +/** + * fileName : GetPhotosUseCase + * author : koo + * date : 2026. 1. 3. 오전 3:29 + * description : photoImage 목록 조회 + * TODO : 요구사항 변경에 따라 paging 추가 가능성 있음 + */ +@UseCase +class GetPhotosUseCase( + private val photoImageRepository: PhotoImageRepositoryPort, + private val mediaClient: MediaClientPort, + private val transactionRunner: TransactionRunner, +) { + + private val log = LoggerFactory.getLogger(javaClass) + + fun execute(command: GetPhotosCommand): GetPhotosResult { + // size + 1개 조회하여 hasNext 판단 + val fetchSize = command.size + 1 + + val photos = transactionRunner.readOnly { + photoImageRepository.listOwnedPhotos( + userId = command.userId, + folderId = command.folderId, + offset = command.page * command.size, + limit = fetchSize, + ) + } + + if (photos.isEmpty()) { + return GetPhotosResult(emptyList(), hasNext = false) + } + + // hasNext 판단: size + 1개 조회했는데 실제로 그만큼 있으면 다음 페이지 존재 + val hasNext = photos.size > command.size + + // 실제 반환할 사진 목록 (size개만) + val photosToReturn = if (hasNext) photos.dropLast(1) else photos + + // 실제 binary 조회 (페이징된 결과에 대해서만) + val mediaContents = mediaClient.getMediaBinaries( + command.userId, + photosToReturn.map { it.mediaId }, + ) + + val mediaByFileId = mediaContents.associateBy { it.mediaId } + + // 아직 저장되지 않은 이미지가 있다면 일부만 먼저 반환, eventually consistent + val result = photosToReturn.mapNotNull { + val media = mediaByFileId[it.mediaId] + ?: run { + log.info( + "Media not found yet. photoId={}, fileId={}, userId={}", + it.id, + it.mediaId, + command.userId, + ) + return@mapNotNull null + } + + GetPhotosResult.PhotoInfo( + photoId = it.id!!, + imageBinary = media.binaryData, + folderId = it.folderId, + contentType = media.contentType, + createdAt = it.createdAt.toString(), + ) + }.toList() + + return GetPhotosResult(result, hasNext) + } +} diff --git a/src/main/kotlin/com/yapp2app/photo/application/usecase/UpdatePhotoUseCase.kt b/src/main/kotlin/com/yapp2app/photo/application/usecase/UpdatePhotoUseCase.kt new file mode 100644 index 0000000..2b56c87 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/application/usecase/UpdatePhotoUseCase.kt @@ -0,0 +1,31 @@ +package com.yapp2app.photo.application.usecase + +import com.yapp2app.common.annotation.UseCase +import com.yapp2app.common.api.dto.ResultCode +import com.yapp2app.common.exception.BusinessException +import com.yapp2app.photo.application.command.UpdatePhotoCommand +import com.yapp2app.photo.application.port.PhotoImageRepositoryPort +import org.springframework.transaction.annotation.Transactional + +/** + * fileName : UpdatePhotoUseCase + * author : koo + * date : 2026. 1. 9. 오후 3:53 + * description : 사진 업데이트 UseCase + */ +@UseCase +class UpdatePhotoUseCase(private val photoImageRepository: PhotoImageRepositoryPort) { + + /** + * 개인 리소스만 변경하고, 하나의 기기만 로그인 가능하기 때문에 동시성 고려를 하지 않았습니다. + * 스펙 변경에 따라 동시성 고려가 필요할 수 있습니다. (e.g. 여러 기기 로그인..) + */ + @Transactional + fun execute(command: UpdatePhotoCommand) { + val photo = ( + photoImageRepository.getOwnedPhoto(command.userId, command.photoId) + ?: throw BusinessException(ResultCode.NOT_FOUND) + ) + command.memo?.let { photo.memo = it } + } +} diff --git a/src/main/kotlin/com/yapp2app/photo/application/usecase/UploadPhotoUseCase.kt b/src/main/kotlin/com/yapp2app/photo/application/usecase/UploadPhotoUseCase.kt new file mode 100644 index 0000000..f81a26c --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/application/usecase/UploadPhotoUseCase.kt @@ -0,0 +1,56 @@ +package com.yapp2app.photo.application.usecase + +import com.yapp2app.common.annotation.UseCase +import com.yapp2app.common.api.dto.ResultCode +import com.yapp2app.common.exception.BusinessException +import com.yapp2app.common.transaction.TransactionRunner +import com.yapp2app.photo.application.command.UploadPhotoCommand +import com.yapp2app.photo.application.contract.MediaAvailability +import com.yapp2app.photo.application.port.MediaClientPort +import com.yapp2app.photo.application.port.PhotoImageRepositoryPort +import com.yapp2app.photo.application.result.UploadPhotoResult +import com.yapp2app.photo.domain.entity.PhotoImage + +/** + * fileName : UploadPhotoUseCase + * author : koo + * date : 2026. 1. 2. 오후 8:24 + * description : photoImage upload usecase + */ +@UseCase +class UploadPhotoUseCase( + private val mediaClient: MediaClientPort, + private val photoImageRepository: PhotoImageRepositoryPort, + private val transactionRunner: TransactionRunner, +) { + + fun execute(command: UploadPhotoCommand): UploadPhotoResult { + // media가 object storage에 정상적으로 저장되었는지 확인 + val availability = mediaClient.verifyMediaUploaded( + ownerId = command.userId, + mediaId = command.mediaId, + ) + + // TODO : 실패한 경우 처리, 재시도 or 실패 간주 + // 현재는 전체 usecase 실패로 간주 + if (availability != MediaAvailability.AVAILABLE) { + throw BusinessException(ResultCode.UPLOAD_FAILED) + } + + val photo = PhotoImage( + userId = command.userId, + mediaId = command.mediaId, + folderId = command.folderId, + memo = command.memo, + ) + + try { + val savedPhoto = transactionRunner.run { photoImageRepository.save(photo) } + return UploadPhotoResult(savedPhoto.id!!) + } catch (e: Exception) { + // 보상 트랜잭션: media 상태를 INITIATED로 롤백 + mediaClient.rollbackMediaUploaded(command.userId, command.mediaId) + throw e + } + } +} diff --git a/src/main/kotlin/com/yapp2app/photo/domain/entity/PhotoImage.kt b/src/main/kotlin/com/yapp2app/photo/domain/entity/PhotoImage.kt index 591ec1b..946ebd9 100644 --- a/src/main/kotlin/com/yapp2app/photo/domain/entity/PhotoImage.kt +++ b/src/main/kotlin/com/yapp2app/photo/domain/entity/PhotoImage.kt @@ -7,14 +7,16 @@ import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.Table +import org.hibernate.annotations.DynamicUpdate /** * fileName : PhotoImage * author : koo * date : 2025. 12. 23. 오후 7:13 - * description : 사용자의 사진 엔티티. url 대신 fileId로 접근 + * description : 사용자의 사진 엔티티. url 대신 mediaId로 접근 */ @Entity +@DynamicUpdate @Table(name = "TB_photo_image") class PhotoImage( @Id @@ -24,9 +26,12 @@ class PhotoImage( @Column(name = "user_id", nullable = false) val userId: Long, - @Column(name = "file_id", nullable = false, length = 64, unique = true) - val fileId: String, + @Column(name = "media_id", nullable = false) + val mediaId: Long, @Column(name = "folder_id", nullable = true) var folderId: Long? = null, + + @Column(name = "memo", nullable = true) + var memo: String? = null, ) : BaseTimeEntity() diff --git a/src/main/kotlin/com/yapp2app/photo/infra/client/LocalMediaClient.kt b/src/main/kotlin/com/yapp2app/photo/infra/client/LocalMediaClient.kt new file mode 100644 index 0000000..3002ec9 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/infra/client/LocalMediaClient.kt @@ -0,0 +1,70 @@ +package com.yapp2app.photo.infra.client + +import com.yapp2app.media.application.command.ConfirmMediaUploadedCommand +import com.yapp2app.media.application.command.DeleteMediaCommand +import com.yapp2app.media.application.command.DeleteMediasCommand +import com.yapp2app.media.application.command.GetMediasCommand +import com.yapp2app.media.application.usecase.ConfirmMediaUploadedUseCase +import com.yapp2app.media.application.usecase.DeleteMediaUseCase +import com.yapp2app.media.application.usecase.GetMediasUseCase +import com.yapp2app.photo.application.contract.MediaAvailability +import com.yapp2app.photo.application.contract.MediaInfo +import com.yapp2app.photo.application.port.MediaClientPort +import com.yapp2app.photo.application.port.MediaClientPort.* +import org.springframework.stereotype.Component + +/** + * fileName : LocalMediaClient + * author : koo + * date : 2026. 1. 3. 오전 12:00 + * description : monolithic architecture media client + * - media service 분리 시 OpenFeign, EventPublisher/Consumer로 변경 + */ +@Component +class LocalMediaClient( + private val confirmMediaUploadedUseCase: ConfirmMediaUploadedUseCase, + private val getMediasUseCase: GetMediasUseCase, + private val deleteMediaUseCase: DeleteMediaUseCase, +) : MediaClientPort { + + override fun verifyMediaUploaded(ownerId: Long, mediaId: Long): MediaAvailability { + val result = confirmMediaUploadedUseCase.execute( + ConfirmMediaUploadedCommand( + ownerId = ownerId, + mediaId = mediaId, + ), + ) + + return if (result.success) { + MediaAvailability.AVAILABLE + } else { + MediaAvailability.UNAVAILABLE + } + } + + override fun getMediaBinaries(ownerId: Long, mediaIds: List): List { + val result = getMediasUseCase.execute(GetMediasCommand(ownerId, mediaIds)) + + return result.medias.map { + MediaInfo( + mediaId = it.mediaId, + contentType = it.contentType, + binaryData = it.binaryData, + ) + }.toList() + } + + override fun deleteMedia(ownerId: Long, mediaId: Long) { + deleteMediaUseCase.execute(DeleteMediaCommand(ownerId, mediaId)) + } + + override fun deleteMedias(ownerId: Long, mediaIds: List) { + deleteMediaUseCase.execute(DeleteMediasCommand(ownerId, mediaIds)) + } + + override fun rollbackMediaUploaded(ownerId: Long, mediaId: Long) { + confirmMediaUploadedUseCase.rollback( + ConfirmMediaUploadedCommand(ownerId = ownerId, mediaId = mediaId), + ) + } +} diff --git a/src/main/kotlin/com/yapp2app/photo/infra/persist/FolderRepositoryAdapter.kt b/src/main/kotlin/com/yapp2app/photo/infra/persist/FolderRepositoryAdapter.kt index 4e15b19..99c7a19 100644 --- a/src/main/kotlin/com/yapp2app/photo/infra/persist/FolderRepositoryAdapter.kt +++ b/src/main/kotlin/com/yapp2app/photo/infra/persist/FolderRepositoryAdapter.kt @@ -2,6 +2,7 @@ package com.yapp2app.photo.infra.persist import com.yapp2app.photo.application.port.FolderRepositoryPort import com.yapp2app.photo.domain.entity.Folder +import com.yapp2app.photo.infra.persist.jpa.FolderQueryRepository import com.yapp2app.photo.infra.persist.jpa.JpaFolderRepository import org.springframework.stereotype.Repository @@ -12,17 +13,18 @@ import org.springframework.stereotype.Repository * description : File 영속성에 대한 Adapter (command + query) */ @Repository -class FolderRepositoryAdapter(private val jpaRepository: JpaFolderRepository) : FolderRepositoryPort { +class FolderRepositoryAdapter( + private val jpaRepository: JpaFolderRepository, + private val queryRepository: FolderQueryRepository, +) : FolderRepositoryPort { override fun save(folder: Folder): Folder = jpaRepository.save(folder) - override fun deleteOwnedFolder(userId: Long, folderId: Long) { - jpaRepository.deleteByUserIdAndId(userId, folderId) - } + override fun deleteOwnedFolder(userId: Long, folderId: Long): Int = + queryRepository.deleteOwnedFolder(userId, folderId) - override fun deleteOwnedFolders(userId: Long, folderIds: List) { - jpaRepository.deleteAllByUserIdAndIdIn(userId, folderIds) - } + override fun deleteOwnedFolders(userId: Long, folderIds: List): Int = + queryRepository.deleteOwnedFolders(userId, folderIds) override fun listOwnedFolders(userId: Long): List = jpaRepository.findAllByUserId(userId) diff --git a/src/main/kotlin/com/yapp2app/photo/infra/persist/PhotoImageRepositoryAdapter.kt b/src/main/kotlin/com/yapp2app/photo/infra/persist/PhotoImageRepositoryAdapter.kt new file mode 100644 index 0000000..b31d413 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/infra/persist/PhotoImageRepositoryAdapter.kt @@ -0,0 +1,49 @@ +package com.yapp2app.photo.infra.persist + +import com.yapp2app.photo.application.port.PhotoImageRepositoryPort +import com.yapp2app.photo.domain.entity.PhotoImage +import com.yapp2app.photo.infra.persist.jpa.JpaPhotoImageRepository +import com.yapp2app.photo.infra.persist.jpa.PhotoImageQueryRepository +import org.springframework.stereotype.Repository + +/** + * fileName : PhotoImageRepositoryAdapter + * author : koo + * date : 2026. 1. 2. 오후 8:25 + * description : Photo image Repository Adapter + */ +@Repository +class PhotoImageRepositoryAdapter( + private val jpaRepository: JpaPhotoImageRepository, + private val queryRepository: PhotoImageQueryRepository, +) : PhotoImageRepositoryPort { + + override fun save(photoImage: PhotoImage): PhotoImage = jpaRepository.save(photoImage) + + override fun listOwnedPhotos(userId: Long, folderId: Long?, offset: Int, limit: Int): List = + queryRepository.findOwnedPhotos(userId, folderId, offset, limit) + + override fun deleteOwnedPhoto(userId: Long, photoId: Long): PhotoImage? { + val photo = jpaRepository.findByUserIdAndId(userId, photoId) + ?: return null + + jpaRepository.delete(photo) + + return photo + } + + override fun deleteOwnedPhotos(userId: Long, photoIds: List): List { + val photos = jpaRepository.findAllByUserIdAndIdIn(userId, photoIds) + + if (photos.isEmpty()) { + return emptyList() + } + + jpaRepository.deleteAll(photos) + + return photos + } + + override fun getOwnedPhoto(userId: Long, photoId: Long): PhotoImage? = + jpaRepository.findByUserIdAndId(userId, photoId) +} diff --git a/src/main/kotlin/com/yapp2app/photo/infra/persist/jpa/FolderQueryRepository.kt b/src/main/kotlin/com/yapp2app/photo/infra/persist/jpa/FolderQueryRepository.kt new file mode 100644 index 0000000..60fef14 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/infra/persist/jpa/FolderQueryRepository.kt @@ -0,0 +1,25 @@ +package com.yapp2app.photo.infra.persist.jpa + +import com.querydsl.jpa.impl.JPAQueryFactory +import com.yapp2app.photo.domain.entity.QFolder.folder +import org.springframework.stereotype.Repository + +/** + * fileName : FolderQueryRepository + * author : koo + * date : 2026. 1. 16. 오후 10:48 + * description : Folder Querydsl 구현체 + */ +@Repository +class FolderQueryRepository(private val queryFactory: JPAQueryFactory) { + + fun deleteOwnedFolder(userId: Long, folderId: Long): Int = queryFactory + .delete(folder) + .where(folder.userId.eq(userId), folder.id.eq(folderId)) + .execute().toInt() + + fun deleteOwnedFolders(userId: Long, folderIds: List): Int = queryFactory + .delete(folder) + .where(folder.userId.eq(userId), folder.id.`in`(folderIds)) + .execute().toInt() +} diff --git a/src/main/kotlin/com/yapp2app/photo/infra/persist/jpa/JpaFolderRepository.kt b/src/main/kotlin/com/yapp2app/photo/infra/persist/jpa/JpaFolderRepository.kt index 24f3de1..0608db8 100644 --- a/src/main/kotlin/com/yapp2app/photo/infra/persist/jpa/JpaFolderRepository.kt +++ b/src/main/kotlin/com/yapp2app/photo/infra/persist/jpa/JpaFolderRepository.kt @@ -18,8 +18,4 @@ interface JpaFolderRepository : JpaRepository { fun findAllByUserIdAndIdIn(userId: Long, folderIds: List): List fun existsByUserIdAndName(userId: Long, name: String): Boolean - - fun deleteByUserIdAndId(userId: Long, folderId: Long) - - fun deleteAllByUserIdAndIdIn(userId: Long, folderIds: List) } diff --git a/src/main/kotlin/com/yapp2app/photo/infra/persist/jpa/JpaPhotoImageRepository.kt b/src/main/kotlin/com/yapp2app/photo/infra/persist/jpa/JpaPhotoImageRepository.kt new file mode 100644 index 0000000..3aea2c5 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/infra/persist/jpa/JpaPhotoImageRepository.kt @@ -0,0 +1,17 @@ +package com.yapp2app.photo.infra.persist.jpa + +import com.yapp2app.photo.domain.entity.PhotoImage +import org.springframework.data.jpa.repository.JpaRepository + +/** + * fileName : JpaPhotoImageRepository + * author : koo + * date : 2026. 1. 2. 오후 8:25 + * description : photo image jpa repository + */ +interface JpaPhotoImageRepository : JpaRepository { + + fun findByUserIdAndId(userId: Long, id: Long): PhotoImage? + + fun findAllByUserIdAndIdIn(userId: Long, ids: List): List +} diff --git a/src/main/kotlin/com/yapp2app/photo/infra/persist/jpa/PhotoImageQueryRepository.kt b/src/main/kotlin/com/yapp2app/photo/infra/persist/jpa/PhotoImageQueryRepository.kt new file mode 100644 index 0000000..f7a642a --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/infra/persist/jpa/PhotoImageQueryRepository.kt @@ -0,0 +1,27 @@ +package com.yapp2app.photo.infra.persist.jpa + +import com.querydsl.jpa.impl.JPAQueryFactory +import com.yapp2app.photo.domain.entity.PhotoImage +import com.yapp2app.photo.domain.entity.QPhotoImage.photoImage +import org.springframework.stereotype.Repository + +/** + * fileName : PhotoImageQueryRepository + * author : koo + * date : 2026. 1. 14. + * description : PhotoImage QueryDSL Repository for pagination + */ +@Repository +class PhotoImageQueryRepository(private val queryFactory: JPAQueryFactory) { + + fun findOwnedPhotos(userId: Long, folderId: Long?, offset: Int, limit: Int): List = queryFactory + .selectFrom(photoImage) + .where( + photoImage.userId.eq(userId), + folderId?.let { photoImage.folderId.eq(it) }, + ) + .orderBy(photoImage.createdAt.desc()) + .offset(offset.toLong()) + .limit(limit.toLong()) + .fetch() +} diff --git a/src/main/resources/application-staging.yaml b/src/main/resources/application-staging.yaml index 9c39066..d045554 100644 --- a/src/main/resources/application-staging.yaml +++ b/src/main/resources/application-staging.yaml @@ -59,5 +59,4 @@ aws: access-key: ENC(fXervUuOTP2A4AwymiZDlo3+l5nZjZ5ObhKMMzGXrNEaqo1MTZaHE+bEnMBsooHmjPiUqg5Jc502FETsVGDB5A==) secret-key: ENC(/rlccjRKWaheGJ2eDHBAnTMKu6pWkQ6D4LhrKjniCKMMHH8tkoQ7l0VpZEr9UO8GmiMdmjrcK5pGznCZlzVGSf/9mGAcxa9Ezdoql7A/B2c=) region: ap-northeast-2 - bucket: koosco-commerce-performance-results-ap-northeast-2 - base-url: http://localhost:4566/yapp-local + bucket: yapp-neki-staging-ap-northeast-2 diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index d0e2d8f..5c73d36 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -37,3 +37,4 @@ aws: region: ${AWS_S3_REGION:ap-northeast-2} bucket: ${AWS_S3_BUCKET:} base-url: ${AWS_S3_BASE_URL:} + presigned-url-expiration: 10m diff --git a/src/main/resources/db/migration/V2__create_folder_and_photo_image_table.sql b/src/main/resources/db/migration/V2__create_folder_and_photo_image_table.sql new file mode 100644 index 0000000..5c3acc5 --- /dev/null +++ b/src/main/resources/db/migration/V2__create_folder_and_photo_image_table.sql @@ -0,0 +1,95 @@ +-- Create folder table +CREATE TABLE TB_FOLDER +( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + cover_photo_id BIGINT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT uk_folder_user_name UNIQUE (user_id, name) +); + +-- Add comments for folder table +COMMENT +ON TABLE TB_FOLDER IS '사용자 사진 폴더 테이블'; +COMMENT +ON COLUMN TB_FOLDER.id IS '폴더 고유 ID'; +COMMENT +ON COLUMN TB_FOLDER.user_id IS '사용자 ID'; +COMMENT +ON COLUMN TB_FOLDER.name IS '폴더 이름'; +COMMENT +ON COLUMN TB_FOLDER.cover_photo_id IS '커버 사진'; +COMMENT +ON COLUMN TB_FOLDER.created_at IS '생성일시'; +COMMENT +ON COLUMN TB_FOLDER.updated_at IS '수정일시'; + +-- Create photo_image table +CREATE TABLE TB_PHOTO_IMAGE +( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + media_id BIGINT NOT NULL, + folder_id BIGINT, + memo VARCHAR(255), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Add comments for photo_image table +COMMENT +ON TABLE TB_PHOTO_IMAGE IS '사용자 사진 이미지 테이블'; +COMMENT +ON COLUMN TB_PHOTO_IMAGE.id IS '사진 고유 ID'; +COMMENT +ON COLUMN TB_PHOTO_IMAGE.user_id IS '사용자 ID'; +COMMENT +ON COLUMN TB_PHOTO_IMAGE.media_id IS '미디어 파일 ID (TB_MEDIA 테이블 참조)'; +COMMENT +ON COLUMN TB_PHOTO_IMAGE.folder_id IS '폴더 ID (nullable, 폴더 삭제 시 NULL)'; +COMMENT +ON COLUMN TB_PHOTO_IMAGE.memo IS '메모'; +COMMENT +ON COLUMN TB_PHOTO_IMAGE.created_at IS '생성일시'; +COMMENT +ON COLUMN TB_PHOTO_IMAGE.updated_at IS '수정일시'; + +-- Create media table +CREATE TABLE TB_MEDIA +( + id BIGSERIAL PRIMARY KEY, + storage_key VARCHAR(255) NOT NULL, + owner_id BIGINT NOT NULL, + media_type VARCHAR(30) NOT NULL, + status VARCHAR(30) NOT NULL DEFAULT 'INITIATED', + content_type VARCHAR(100) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Add index for media table +CREATE INDEX idx_media_owner_id ON TB_MEDIA (owner_id); +CREATE INDEX idx_media_storage_key ON TB_MEDIA (storage_key); + +-- Add comments for media table +COMMENT +ON TABLE TB_MEDIA IS '미디어 파일 테이블'; +COMMENT +ON COLUMN TB_MEDIA.id IS '미디어 고유 ID'; +COMMENT +ON COLUMN TB_MEDIA.storage_key IS '스토리지 키 (S3 키 등)'; +COMMENT +ON COLUMN TB_MEDIA.owner_id IS '소유자 ID'; +COMMENT +ON COLUMN TB_MEDIA.media_type IS '미디어 타입 (USER_PROFILE, PHOTO_BOOTH, ATTACHMENT, TEMP)'; +COMMENT +ON COLUMN TB_MEDIA.status IS '상태 (INITIATED, UPLOADED, FAILED, DELETE_REQUESTED, DELETED)'; +COMMENT +ON COLUMN TB_MEDIA.content_type IS '컨텐츠 타입 (image/jpeg, image/png 등)'; +COMMENT +ON COLUMN TB_MEDIA.created_at IS '생성일시'; +COMMENT +ON COLUMN TB_MEDIA.updated_at IS '수정일시'; diff --git a/src/main/resources/db/migration/V2__create_folder_and_photo_image_tables.sql b/src/main/resources/db/migration/V2__create_folder_and_photo_image_tables.sql deleted file mode 100644 index 2dc0e80..0000000 --- a/src/main/resources/db/migration/V2__create_folder_and_photo_image_tables.sql +++ /dev/null @@ -1,67 +0,0 @@ --- Create folder table -CREATE TABLE TB_FOLDER ( - id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL, - name VARCHAR(255) NOT NULL, - cover_photo_id BIGINT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT uk_folder_user_name UNIQUE (user_id, name) -); - --- Add comments for folder table -COMMENT ON TABLE TB_FOLDER IS '사용자 사진 폴더 테이블'; -COMMENT ON COLUMN TB_FOLDER.id IS '폴더 고유 ID'; -COMMENT ON COLUMN TB_FOLDER.user_id IS '사용자 ID'; -COMMENT ON COLUMN TB_FOLDER.name IS '폴더 이름'; -COMMENT ON COLUMN TB_FOLDER.created_at IS '생성일시'; -COMMENT ON COLUMN TB_FOLDER.updated_at IS '수정일시'; - --- Create photo_image table -CREATE TABLE TB_PHOTO_IMAGE ( - id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL, - file_id VARCHAR(64) NOT NULL UNIQUE, - folder_id BIGINT, - status VARCHAR(30) NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- Add comments for photo_image table -COMMENT ON TABLE TB_PHOTO_IMAGE IS '사용자 사진 이미지 테이블'; -COMMENT ON COLUMN TB_PHOTO_IMAGE.id IS '사진 고유 ID'; -COMMENT ON COLUMN TB_PHOTO_IMAGE.user_id IS '사용자 ID'; -COMMENT ON COLUMN TB_PHOTO_IMAGE.file_id IS '파일 고유 ID (S3 키 등)'; -COMMENT ON COLUMN TB_PHOTO_IMAGE.folder_id IS '폴더 ID (nullable, 폴더 삭제 시 NULL)'; -COMMENT ON COLUMN TB_PHOTO_IMAGE.status IS '업로드 상태 (INITIATED, UPLOADED, VERIFIED, FAILED)'; -COMMENT ON COLUMN TB_PHOTO_IMAGE.created_at IS '생성일시'; -COMMENT ON COLUMN TB_PHOTO_IMAGE.updated_at IS '수정일시'; - --- Create media table -CREATE TABLE TB_MEDIA ( - id BIGSERIAL PRIMARY KEY, - storage_key VARCHAR(255) NOT NULL, - owner_id BIGINT NOT NULL, - media_type VARCHAR(30) NOT NULL, - status VARCHAR(30) NOT NULL DEFAULT 'INITIATED', - content_type VARCHAR(100) NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- Add index for media table -CREATE INDEX idx_media_owner_id ON TB_MEDIA(owner_id); -CREATE INDEX idx_media_storage_key ON TB_MEDIA(storage_key); - --- Add comments for media table -COMMENT ON TABLE TB_MEDIA IS '미디어 파일 테이블'; -COMMENT ON COLUMN TB_MEDIA.id IS '미디어 고유 ID'; -COMMENT ON COLUMN TB_MEDIA.storage_key IS '스토리지 키 (S3 키 등)'; -COMMENT ON COLUMN TB_MEDIA.owner_id IS '소유자 ID'; -COMMENT ON COLUMN TB_MEDIA.media_type IS '미디어 타입 (USER_PROFILE, PHOTO_BOOTH)'; -COMMENT ON COLUMN TB_MEDIA.status IS '상태 (INITIATED, UPLOADED, FAILED, DELETE_REQUESTED, DELETED)'; -COMMENT ON COLUMN TB_MEDIA.content_type IS '컨텐츠 타입 (image/jpeg, image/png 등)'; -COMMENT ON COLUMN TB_MEDIA.created_at IS '생성일시'; -COMMENT ON COLUMN TB_MEDIA.updated_at IS '수정일시'; diff --git a/src/test/kotlin/com/yapp2app/e2e/media/GenerateUploadTicketE2ETest.kt b/src/test/kotlin/com/yapp2app/e2e/media/GenerateUploadTicketE2ETest.kt new file mode 100644 index 0000000..78280c6 --- /dev/null +++ b/src/test/kotlin/com/yapp2app/e2e/media/GenerateUploadTicketE2ETest.kt @@ -0,0 +1,190 @@ +package com.yapp2app.e2e.media + +import com.yapp2app.common.api.dto.ResultCode +import com.yapp2app.media.api.dto.GenerateUploadTicketRequest +import com.yapp2app.media.domain.MediaType +import com.yapp2app.media.domain.entity.MediaStatus +import com.yapp2app.user.domain.entity.User +import io.restassured.RestAssured +import io.restassured.http.ContentType +import org.assertj.core.api.Assertions.assertThat +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.notNullValue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.HttpStatus +import org.springframework.test.context.ActiveProfiles + +/** + * fileName : GenerateUploadTicketE2ETest + * author : koo + * date : 2026. 1. 20. + * description : POST /api/media/upload E2E 테스트 + */ +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class GenerateUploadTicketE2ETest : MediaE2ETestBase() { + + @LocalServerPort + private var port: Int = 0 + + private lateinit var accessToken: String + private lateinit var testUser: User + + @BeforeEach + fun setUp() { + RestAssured.port = port + RestAssured.baseURI = "http://localhost" + + val (user, token) = createTestUserAndToken() + testUser = user + accessToken = token + } + + @Test + @DisplayName("presigned URL 발급 성공 - Media가 INITIATED 상태로 생성된다") + fun givenValidRequest_whenGenerateUploadTicket_thenMediaCreatedWithInitiatedStatus() { + // given + val request = GenerateUploadTicketRequest( + filename = "test-photo.jpg", + contentType = "image/jpeg", + mediaType = MediaType.PHOTO_BOOTH, + ) + + // when + val mediaId = RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(request) + .`when`() + .post("/api/media/upload") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + .body("data.mediaId", notNullValue()) + .body("data.uploadUrl", notNullValue()) + .body("data.method", equalTo("PUT")) + .extract() + .path("data.mediaId") + .toLong() + + // then - Media가 INITIATED 상태로 생성되었는지 확인 + val media = mediaRepository.findById(mediaId).orElseThrow() + assertThat(media.status).isEqualTo(MediaStatus.INITIATED) + assertThat(media.ownerId).isEqualTo(testUser.id) + assertThat(media.mediaType).isEqualTo(MediaType.PHOTO_BOOTH) + assertThat(media.contentType).isEqualTo("image/jpeg") + } + + @Test + @DisplayName("filename에 확장자가 있으면 storageKey에 해당 확장자가 포함된다") + fun givenFilenameWithExtension_whenGenerateUploadTicket_thenStorageKeyContainsExtension() { + // given + val request = GenerateUploadTicketRequest( + filename = "my-photo.png", + contentType = "image/png", + mediaType = MediaType.PHOTO_BOOTH, + ) + + // when + val mediaId = RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(request) + .`when`() + .post("/api/media/upload") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .extract() + .path("data.mediaId") + .toLong() + + // then - storageKey에 확장자가 포함되어 있는지 확인 + val media = mediaRepository.findById(mediaId).orElseThrow() + assertThat(media.storageKey).endsWith(".png") + assertThat(media.storageKey).startsWith("photo-booth/") + } + + @Test + @DisplayName("filename에 확장자가 없으면 contentType에서 확장자를 추출한다") + fun givenFilenameWithoutExtension_whenGenerateUploadTicket_thenExtractExtensionFromContentType() { + // given + val request = GenerateUploadTicketRequest( + filename = "photo-without-extension", + contentType = "image/png", + mediaType = MediaType.PHOTO_BOOTH, + ) + + // when + val mediaId = RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(request) + .`when`() + .post("/api/media/upload") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .extract() + .path("data.mediaId") + .toLong() + + // then - contentType에서 확장자가 추출되어 storageKey에 포함되어 있는지 확인 + val media = mediaRepository.findById(mediaId).orElseThrow() + assertThat(media.storageKey).endsWith(".png") + assertThat(media.storageKey).startsWith("photo-booth/") + } + + @Test + @DisplayName("image/jpeg contentType은 jpg 확장자로 변환된다") + fun givenJpegContentType_whenGenerateUploadTicket_thenStorageKeyEndsWithJpg() { + // given + val request = GenerateUploadTicketRequest( + filename = "photo", + contentType = "image/jpeg", + mediaType = MediaType.PHOTO_BOOTH, + ) + + // when + val mediaId = RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(request) + .`when`() + .post("/api/media/upload") + .then() + .statusCode(HttpStatus.OK.value()) + .extract() + .path("data.mediaId") + .toLong() + + // then + val media = mediaRepository.findById(mediaId).orElseThrow() + assertThat(media.storageKey).endsWith(".jpg") + } + + @Test + @DisplayName("인증되지 않은 사용자는 403 에러를 반환한다") + fun givenNoAuth_whenGenerateUploadTicket_thenReturnsForbidden() { + // given + val request = GenerateUploadTicketRequest( + filename = "test.jpg", + contentType = "image/jpeg", + mediaType = MediaType.PHOTO_BOOTH, + ) + + // when & then + RestAssured.given() + .contentType(ContentType.JSON) + .body(request) + .`when`() + .post("/api/media/upload") + .then() + .statusCode(HttpStatus.FORBIDDEN.value()) + } +} diff --git a/src/test/kotlin/com/yapp2app/e2e/media/MediaE2ETestBase.kt b/src/test/kotlin/com/yapp2app/e2e/media/MediaE2ETestBase.kt new file mode 100644 index 0000000..cc5cbf5 --- /dev/null +++ b/src/test/kotlin/com/yapp2app/e2e/media/MediaE2ETestBase.kt @@ -0,0 +1,43 @@ +package com.yapp2app.e2e.media + +import com.yapp2app.e2e.E2ETestBase +import com.yapp2app.media.domain.MediaType +import com.yapp2app.media.domain.entity.Media +import com.yapp2app.media.domain.entity.MediaStatus +import com.yapp2app.media.infra.persist.jpa.JpaMediaRepository +import org.junit.jupiter.api.AfterEach +import org.springframework.beans.factory.annotation.Autowired + +/** + * fileName : MediaE2ETestBase + * author : koo + * date : 2026. 1. 20. + * description : Media E2E 테스트를 위한 Base class + */ +abstract class MediaE2ETestBase : E2ETestBase() { + + @Autowired + protected lateinit var mediaRepository: JpaMediaRepository + + @AfterEach + override fun tearDown() { + mediaRepository.deleteAllInBatch() + super.tearDown() + } + + protected fun createMedia( + ownerId: Long, + status: MediaStatus = MediaStatus.INITIATED, + storageKey: String = "test-key-${System.currentTimeMillis()}", + mediaType: MediaType = MediaType.PHOTO_BOOTH, + contentType: String = "image/jpeg", + ): Media = mediaRepository.save( + Media( + storageKey = storageKey, + ownerId = ownerId, + mediaType = mediaType, + status = status, + contentType = contentType, + ), + ) +} diff --git a/src/test/kotlin/com/yapp2app/e2e/photo/image/DeletePhotoE2ETest.kt b/src/test/kotlin/com/yapp2app/e2e/photo/image/DeletePhotoE2ETest.kt new file mode 100644 index 0000000..7c44d5e --- /dev/null +++ b/src/test/kotlin/com/yapp2app/e2e/photo/image/DeletePhotoE2ETest.kt @@ -0,0 +1,114 @@ +package com.yapp2app.e2e.photo.image + +import com.yapp2app.common.api.dto.ResultCode +import com.yapp2app.media.domain.entity.MediaStatus +import com.yapp2app.user.domain.entity.User +import io.restassured.RestAssured +import org.hamcrest.Matchers.equalTo +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.HttpStatus +import org.springframework.test.context.ActiveProfiles + +/** + * fileName : DeletePhotoE2ETest + * author : koo + * date : 2026. 1. 8. + * description : DELETE /api/photos/{photoId} E2E 테스트 + */ +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class DeletePhotoE2ETest : PhotoImageE2ETestBase() { + + @LocalServerPort + private var port: Int = 0 + + private lateinit var accessToken: String + private lateinit var testUser: User + + @BeforeEach + fun setUp() { + RestAssured.port = port + RestAssured.baseURI = "http://localhost" + + val (user, token) = createTestUserAndToken() + testUser = user + accessToken = token + } + + @Test + @DisplayName("사진 삭제 성공") + fun givenValidPhotoId_whenDeletePhoto_thenReturnsSuccess() { + // given + val media = createMedia(ownerId = testUser.id!!, status = MediaStatus.UPLOADED) + val photo = createPhotoImage(userId = testUser.id!!, mediaId = media.id!!) + + // when & then + RestAssured.given() + .header("Authorization", "Bearer $accessToken") + .`when`() + .delete("/api/photos/${photo.id}") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + } + + @Test + @DisplayName("존재하지 않는 사진 삭제 시 NOT_FOUND 에러를 반환한다") + fun givenNonExistentPhotoId_whenDeletePhoto_thenReturnsNotFound() { + // given + val nonExistentPhotoId = 999999L + + // when & then + RestAssured.given() + .header("Authorization", "Bearer $accessToken") + .`when`() + .delete("/api/photos/$nonExistentPhotoId") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("success", equalTo(false)) + .body("resultCode", equalTo(ResultCode.NOT_FOUND.code)) + } + + @Test + @DisplayName("다른 사용자의 사진 삭제 시도 시 NOT_FOUND 에러를 반환한다") + fun givenOtherUserPhoto_whenDeletePhoto_thenReturnsNotFound() { + // given + val (otherUser, _) = createTestUserAndToken(email = "other@example.com") + val media = createMedia(ownerId = otherUser.id!!, status = MediaStatus.UPLOADED) + val otherUserPhoto = createPhotoImage(userId = otherUser.id, mediaId = media.id!!) + + // when & then + RestAssured.given() + .header("Authorization", "Bearer $accessToken") + .`when`() + .delete("/api/photos/${otherUserPhoto.id}") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("success", equalTo(false)) + .body("resultCode", equalTo(ResultCode.NOT_FOUND.code)) + } + + @Test + @DisplayName("폴더에 포함된 사진 삭제 성공") + fun givenPhotoInFolder_whenDeletePhoto_thenReturnsSuccess() { + // given + val folder = createFolder(userId = testUser.id!!) + val media = createMedia(ownerId = testUser.id!!, status = MediaStatus.UPLOADED) + val photo = createPhotoImage(userId = testUser.id!!, mediaId = media.id!!, folderId = folder.id) + + // when & then + RestAssured.given() + .header("Authorization", "Bearer $accessToken") + .`when`() + .delete("/api/photos/${photo.id}") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + } +} diff --git a/src/test/kotlin/com/yapp2app/e2e/photo/image/DeletePhotosE2ETest.kt b/src/test/kotlin/com/yapp2app/e2e/photo/image/DeletePhotosE2ETest.kt new file mode 100644 index 0000000..b330da0 --- /dev/null +++ b/src/test/kotlin/com/yapp2app/e2e/photo/image/DeletePhotosE2ETest.kt @@ -0,0 +1,177 @@ +package com.yapp2app.e2e.photo.image + +import com.yapp2app.common.api.dto.ResultCode +import com.yapp2app.media.domain.entity.MediaStatus +import com.yapp2app.photo.api.dto.DeletePhotosRequest +import com.yapp2app.user.domain.entity.User +import io.restassured.RestAssured +import io.restassured.http.ContentType +import org.hamcrest.Matchers.equalTo +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.HttpStatus +import org.springframework.test.context.ActiveProfiles + +/** + * fileName : DeletePhotosE2ETest + * author : koo + * date : 2026. 1. 8. + * description : DELETE /api/photos (bulk delete) E2E 테스트 + */ +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class DeletePhotosE2ETest : PhotoImageE2ETestBase() { + + @LocalServerPort + private var port: Int = 0 + + private lateinit var accessToken: String + private lateinit var testUser: User + + @BeforeEach + fun setUp() { + RestAssured.port = port + RestAssured.baseURI = "http://localhost" + + val (user, token) = createTestUserAndToken() + testUser = user + accessToken = token + } + + @Test + @DisplayName("여러 사진 일괄 삭제 성공") + fun givenValidPhotoIds_whenDeletePhotos_thenReturnsSuccess() { + // given + val media1 = createMedia(ownerId = testUser.id!!, status = MediaStatus.UPLOADED) + val media2 = createMedia(ownerId = testUser.id!!, status = MediaStatus.UPLOADED) + val media3 = createMedia(ownerId = testUser.id!!, status = MediaStatus.UPLOADED) + + val photo1 = createPhotoImage(userId = testUser.id!!, mediaId = media1.id!!) + val photo2 = createPhotoImage(userId = testUser.id!!, mediaId = media2.id!!) + val photo3 = createPhotoImage(userId = testUser.id!!, mediaId = media3.id!!) + + // when & then + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(DeletePhotosRequest(photoIds = listOf(photo1.id!!, photo2.id!!, photo3.id!!))) + .`when`() + .delete("/api/photos") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + } + + @Test + @DisplayName("photoIds가 비어있는 경우 400 에러를 반환한다") + fun givenEmptyPhotoIds_whenDeletePhotos_thenReturnsBadRequest() { + // when & then + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(DeletePhotosRequest(photoIds = emptyList())) + .`when`() + .delete("/api/photos") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("success", equalTo(false)) + .body("resultCode", equalTo(ResultCode.INVALID_PARAMETER.code)) + } + + @Test + @DisplayName("photoIds가 null인 경우 400 에러를 반환한다") + fun givenNullPhotoIds_whenDeletePhotos_thenReturnsBadRequest() { + // given + val requestBody = """ + { + "photoIds": null + } + """.trimIndent() + + // when & then + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(requestBody) + .`when`() + .delete("/api/photos") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("success", equalTo(false)) + .body("resultCode", equalTo(ResultCode.INVALID_PARAMETER.code)) + } + + @Test + @DisplayName("존재하지 않는 사진 ID가 포함되어도 나머지 사진은 삭제된다") + fun givenMixedValidAndInvalidPhotoIds_whenDeletePhotos_thenDeletesValidPhotos() { + // given + val media = createMedia(ownerId = testUser.id!!, status = MediaStatus.UPLOADED) + val photo = createPhotoImage(userId = testUser.id!!, mediaId = media.id!!) + val notExistPhotoId = 999999L + + // when & then + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(DeletePhotosRequest(photoIds = listOf(photo.id!!, notExistPhotoId))) + .`when`() + .delete("/api/photos") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + } + + @Test + @DisplayName("다른 사용자의 사진 ID가 포함되어도 본인 사진만 삭제된다") + fun givenOtherUserPhotoIds_whenDeletePhotos_thenDeletesOnlyOwnPhotos() { + // given + val (otherUser, _) = createTestUserAndToken(email = "other@example.com") + + val myMedia = createMedia(ownerId = testUser.id!!, status = MediaStatus.UPLOADED) + val myPhoto = createPhotoImage(userId = testUser.id!!, mediaId = myMedia.id!!) + + val otherMedia = createMedia(ownerId = otherUser.id!!, status = MediaStatus.UPLOADED) + val otherPhoto = createPhotoImage(userId = otherUser.id, mediaId = otherMedia.id!!) + + // when & then + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(DeletePhotosRequest(photoIds = listOf(myPhoto.id!!, otherPhoto.id!!))) + .`when`() + .delete("/api/photos") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + } + + @Test + @DisplayName("폴더에 포함된 사진들 일괄 삭제 성공") + fun givenPhotosInFolder_whenDeletePhotos_thenReturnsSuccess() { + // given + val folder = createFolder(userId = testUser.id!!) + val media1 = createMedia(ownerId = testUser.id!!, status = MediaStatus.UPLOADED) + val media2 = createMedia(ownerId = testUser.id!!, status = MediaStatus.UPLOADED) + + val photo1 = createPhotoImage(userId = testUser.id!!, mediaId = media1.id!!, folderId = folder.id) + val photo2 = createPhotoImage(userId = testUser.id!!, mediaId = media2.id!!, folderId = folder.id) + + // when & then + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(DeletePhotosRequest(photoIds = listOf(photo1.id!!, photo2.id!!))) + .`when`() + .delete("/api/photos") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + } +} diff --git a/src/test/kotlin/com/yapp2app/e2e/photo/image/GetPhotosE2ETest.kt b/src/test/kotlin/com/yapp2app/e2e/photo/image/GetPhotosE2ETest.kt new file mode 100644 index 0000000..b4be840 --- /dev/null +++ b/src/test/kotlin/com/yapp2app/e2e/photo/image/GetPhotosE2ETest.kt @@ -0,0 +1,149 @@ +package com.yapp2app.e2e.photo.image + +import com.yapp2app.common.api.dto.ResultCode +import com.yapp2app.media.domain.entity.MediaStatus +import com.yapp2app.user.domain.entity.User +import io.restassured.RestAssured +import org.hamcrest.Matchers.empty +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.hasSize +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.HttpStatus +import org.springframework.test.context.ActiveProfiles + +/** + * fileName : GetPhotosE2ETest + * author : koo + * date : 2026. 1. 8. + * description : GET /api/photos E2E 테스트 + */ +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class GetPhotosE2ETest : PhotoImageE2ETestBase() { + + @LocalServerPort + private var port: Int = 0 + + private lateinit var accessToken: String + private lateinit var testUser: User + + @BeforeEach + fun setUp() { + RestAssured.port = port + RestAssured.baseURI = "http://localhost" + + val (user, token) = createTestUserAndToken() + testUser = user + accessToken = token + } + + @Test + @DisplayName("사진 목록 조회 성공 - 전체 사진 조회") + fun givenPhotosExist_whenGetPhotos_thenReturnsPhotoList() { + // given + val media1 = createMedia(ownerId = testUser.id!!, status = MediaStatus.UPLOADED) + val media2 = createMedia(ownerId = testUser.id!!, status = MediaStatus.UPLOADED) + createPhotoImage(userId = testUser.id!!, mediaId = media1.id!!) + createPhotoImage(userId = testUser.id!!, mediaId = media2.id!!) + + // when & then + RestAssured.given() + .header("Authorization", "Bearer $accessToken") + .`when`() + .get("/api/photos") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + .body("data.items", hasSize(2)) + } + + @Test + @DisplayName("사진 목록 조회 성공 - 폴더별 사진 조회") + fun givenPhotosInFolder_whenGetPhotosByFolderId_thenReturnsFilteredPhotos() { + // given + val folder = createFolder(userId = testUser.id!!) + val media1 = createMedia(ownerId = testUser.id!!, status = MediaStatus.UPLOADED) + val media2 = createMedia(ownerId = testUser.id!!, status = MediaStatus.UPLOADED) + val media3 = createMedia(ownerId = testUser.id!!, status = MediaStatus.UPLOADED) + + createPhotoImage(userId = testUser.id!!, mediaId = media1.id!!, folderId = folder.id) + createPhotoImage(userId = testUser.id!!, mediaId = media2.id!!, folderId = folder.id) + createPhotoImage(userId = testUser.id!!, mediaId = media3.id!!, folderId = null) + + // when & then + RestAssured.given() + .header("Authorization", "Bearer $accessToken") + .queryParam("folderId", folder.id) + .`when`() + .get("/api/photos") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + .body("data.items", hasSize(2)) + } + + @Test + @DisplayName("사진 목록 조회 성공 - 사진이 없는 경우 빈 목록 반환") + fun givenNoPhotos_whenGetPhotos_thenReturnsEmptyList() { + // given - no photos created + println("accessToken: $accessToken") + println("testUser.id: ${testUser.id}") + + // when & then + RestAssured.given() + .log().all() + .header("Authorization", "Bearer $accessToken") + .`when`() + .get("/api/photos") + .then() + .log().all() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + .body("data.items", empty()) + } + + @Test + @DisplayName("다른 사용자의 사진은 조회되지 않는다") + fun givenOtherUserPhotos_whenGetPhotos_thenReturnsEmptyList() { + // given + val (otherUser, _) = createTestUserAndToken(email = "other@example.com") + val media = createMedia(ownerId = otherUser.id!!, status = MediaStatus.UPLOADED) + createPhotoImage(userId = otherUser.id!!, mediaId = media.id!!) + + // when & then + RestAssured.given() + .header("Authorization", "Bearer $accessToken") + .`when`() + .get("/api/photos") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("data.items", empty()) + } + + @Test + @DisplayName("존재하지 않는 폴더로 조회 시 빈 목록을 반환한다") + fun givenNonExistentFolderId_whenGetPhotos_thenReturnsEmptyList() { + // given + val media = createMedia(ownerId = testUser.id!!, status = MediaStatus.UPLOADED) + createPhotoImage(userId = testUser.id!!, mediaId = media.id!!, folderId = null) + + // when & then + RestAssured.given() + .header("Authorization", "Bearer $accessToken") + .queryParam("folderId", 999999L) + .`when`() + .get("/api/photos") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("data.items", empty()) + } +} diff --git a/src/test/kotlin/com/yapp2app/e2e/photo/image/PhotoImageE2ETestBase.kt b/src/test/kotlin/com/yapp2app/e2e/photo/image/PhotoImageE2ETestBase.kt new file mode 100644 index 0000000..ba97b4b --- /dev/null +++ b/src/test/kotlin/com/yapp2app/e2e/photo/image/PhotoImageE2ETestBase.kt @@ -0,0 +1,70 @@ +package com.yapp2app.e2e.photo.image + +import com.yapp2app.e2e.E2ETestBase +import com.yapp2app.media.domain.MediaType +import com.yapp2app.media.domain.entity.Media +import com.yapp2app.media.domain.entity.MediaStatus +import com.yapp2app.media.infra.persist.jpa.JpaMediaRepository +import com.yapp2app.photo.domain.entity.Folder +import com.yapp2app.photo.domain.entity.PhotoImage +import com.yapp2app.photo.infra.persist.jpa.JpaFolderRepository +import com.yapp2app.photo.infra.persist.jpa.JpaPhotoImageRepository +import org.junit.jupiter.api.AfterEach +import org.springframework.beans.factory.annotation.Autowired + +/** + * fileName : PhotoImageE2ETestBase + * author : koo + * date : 2026. 1. 8. 오후 7:42 + * description : PhotoImage E2E 테스트를 위한 Base class + */ +abstract class PhotoImageE2ETestBase : E2ETestBase() { + + @Autowired + protected lateinit var folderRepository: JpaFolderRepository + + @Autowired + protected lateinit var photoImageRepository: JpaPhotoImageRepository + + @Autowired + protected lateinit var mediaRepository: JpaMediaRepository + + @AfterEach + override fun tearDown() { + photoImageRepository.deleteAllInBatch() + folderRepository.deleteAllInBatch() + mediaRepository.deleteAllInBatch() + super.tearDown() + } + + protected fun createFolder(userId: Long, name: String = "테스트 폴더"): Folder = folderRepository.save( + Folder( + userId = userId, + name = name, + ), + ) + + protected fun createMedia( + ownerId: Long, + status: MediaStatus = MediaStatus.UPLOADED, + mediaType: MediaType = MediaType.PHOTO_BOOTH, + contentType: String = "image/jpeg", + ): Media = mediaRepository.save( + Media( + storageKey = "test-storage-key-${System.currentTimeMillis()}", + ownerId = ownerId, + mediaType = mediaType, + status = status, + contentType = contentType, + ), + ) + + protected fun createPhotoImage(userId: Long, mediaId: Long, folderId: Long? = null): PhotoImage = + photoImageRepository.save( + PhotoImage( + userId = userId, + mediaId = mediaId, + folderId = folderId, + ), + ) +} diff --git a/src/test/kotlin/com/yapp2app/e2e/photo/image/UpdatePhotoE2ETest.kt b/src/test/kotlin/com/yapp2app/e2e/photo/image/UpdatePhotoE2ETest.kt new file mode 100644 index 0000000..39e84c5 --- /dev/null +++ b/src/test/kotlin/com/yapp2app/e2e/photo/image/UpdatePhotoE2ETest.kt @@ -0,0 +1,103 @@ +package com.yapp2app.e2e.photo.image + +import com.yapp2app.common.api.dto.ResultCode +import com.yapp2app.media.domain.entity.MediaStatus +import com.yapp2app.photo.api.dto.UpdatePhotoRequest +import com.yapp2app.user.domain.entity.User +import io.restassured.RestAssured +import io.restassured.http.ContentType +import org.hamcrest.Matchers.equalTo +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.HttpStatus +import org.springframework.test.context.ActiveProfiles + +/** + * fileName : UpdatePhotoE2ETest + * author : koo + * date : 2026. 1. 9. 오후 5:26 + * description : PATCH /api/photos/{photoId} E2E 테스트 + */ +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class UpdatePhotoE2ETest : PhotoImageE2ETestBase() { + + @LocalServerPort + private var port: Int = 0 + + private lateinit var accessToken: String + private lateinit var testUser: User + + @BeforeEach + fun setUp() { + RestAssured.port = port + RestAssured.baseURI = "http://localhost" + + val (user, token) = createTestUserAndToken() + testUser = user + accessToken = token + } + + @Test + @DisplayName("사진 메모 수정 성공") + fun givenValidMemo_whenUpdatePhoto_thenReturnsSuccess() { + // given + val media = createMedia(ownerId = testUser.id!!, status = MediaStatus.UPLOADED) + val photo = createPhotoImage(userId = testUser.id!!, mediaId = media.id!!) + + // when & then + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(UpdatePhotoRequest("new memo")) + .`when`() + .patch("/api/photos/${photo.id}") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + } + + @Test + @DisplayName("수정하는 메모가 null인 경우 성공한다") + fun givenNullMemo_whenUpdatePhoto_thenReturnSuccess() { + // given + val media = createMedia(ownerId = testUser.id!!, status = MediaStatus.UPLOADED) + val photo = createPhotoImage(userId = testUser.id!!, mediaId = media.id!!) + + // when & then + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(UpdatePhotoRequest(null)) + .`when`() + .patch("/api/photos/${photo.id}") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + } + + @Test + @DisplayName("수정하는 메모가 blank인 경우 성공한다") + fun givenBlankMemo_whenUpdatePhoto_thenReturnSuccess() { + // given + val media = createMedia(ownerId = testUser.id!!, status = MediaStatus.UPLOADED) + val photo = createPhotoImage(userId = testUser.id!!, mediaId = media.id!!) + + // when & then + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(UpdatePhotoRequest("")) + .`when`() + .patch("/api/photos/${photo.id}") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + } +} diff --git a/src/test/kotlin/com/yapp2app/e2e/photo/image/UploadPhotoE2ETest.kt b/src/test/kotlin/com/yapp2app/e2e/photo/image/UploadPhotoE2ETest.kt new file mode 100644 index 0000000..37ca85b --- /dev/null +++ b/src/test/kotlin/com/yapp2app/e2e/photo/image/UploadPhotoE2ETest.kt @@ -0,0 +1,158 @@ +package com.yapp2app.e2e.photo.image + +import com.yapp2app.common.api.dto.ResultCode +import com.yapp2app.media.domain.entity.MediaStatus +import com.yapp2app.photo.api.dto.UploadPhotoRequest +import com.yapp2app.user.domain.entity.User +import io.restassured.RestAssured +import io.restassured.http.ContentType +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.notNullValue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.HttpStatus +import org.springframework.test.context.ActiveProfiles + +/** + * fileName : UploadPhotoE2ETest + * author : koo + * date : 2026. 1. 8. 오후 7:43 + * description : POST /api/photos E2E 테스트 + */ +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class UploadPhotoE2ETest : PhotoImageE2ETestBase() { + + @LocalServerPort + private var port: Int = 0 + + private lateinit var accessToken: String + private lateinit var testUser: User + + @BeforeEach + fun setUp() { + RestAssured.port = port + RestAssured.baseURI = "http://localhost" + + val (user, token) = createTestUserAndToken() + testUser = user + accessToken = token + } + + @Test + @DisplayName("사진 업로드 성공 - 폴더 없이 업로드") + fun givenValidMediaId_whenUploadPhoto_thenReturnsSuccess() { + // given + val media = createMedia(ownerId = testUser.id!!, status = MediaStatus.UPLOADED) + + // when & then + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(UploadPhotoRequest(media.id, null, null)) + .`when`() + .post("/api/photos") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + .body("data.photoId", notNullValue()) + } + + @Test + @DisplayName("사진 업로드 성공 - 폴더와 함께 업로드") + fun givenValidMediaIdAndFolderId_whenUploadPhoto_thenReturnsSuccess() { + // given + val media = createMedia(ownerId = testUser.id!!, status = MediaStatus.UPLOADED) + val folder = createFolder(userId = testUser.id!!) + + // when & then + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(UploadPhotoRequest(media.id, folder.id, null)) + .`when`() + .post("/api/photos") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + .body("data.photoId", notNullValue()) + } + + @Test + @DisplayName("mediaId가 누락된 경우 400 에러를 반환한다") + fun givenMissingMediaId_whenUploadPhoto_thenReturnsBadRequest() { + // when & then + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(UploadPhotoRequest(null, null, null)) + .`when`() + .post("/api/photos") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("success", equalTo(false)) + .body("resultCode", equalTo(ResultCode.INVALID_PARAMETER.code)) + } + + @Test + @DisplayName("INITIATED 상태의 미디어로 사진 업로드 시 자동으로 UPLOADED로 전환되어 성공한다") + fun givenMediaInitiated_whenUploadPhoto_thenMediaBecomesUploadedAndSuccess() { + // given + val media = createMedia(ownerId = testUser.id!!, status = MediaStatus.INITIATED) + + // when & then + // FakeMediaStorageAdapter.exists()가 항상 true를 반환하므로 + // INITIATED → UPLOADED 전환 후 성공해야 함 + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(UploadPhotoRequest(media.id, null, null)) + .`when`() + .post("/api/photos") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("data.photoId", notNullValue()) + } + + @Test + @DisplayName("다른 사용자의 미디어로 업로드 시 NOT_FOUND 에러를 반환한다") + fun givenOtherUserMedia_whenUploadPhoto_thenReturnsNotFound() { + // given + val (otherUser, _) = createTestUserAndToken(email = "other@example.com") + val media = createMedia(ownerId = otherUser.id!!, status = MediaStatus.UPLOADED) + + // when & then + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(UploadPhotoRequest(media.id, null, null)) + .`when`() + .post("/api/photos") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("success", equalTo(false)) + .body("resultCode", equalTo(ResultCode.NOT_FOUND.code)) + } + + @Test + @DisplayName("존재하지 않는 미디어로 업로드 시 NOT_FOUND 에러를 반환한다") + fun givenNonExistentMediaId_whenUploadPhoto_thenReturnsNotFound() { + // when & then + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(UploadPhotoRequest(999999, null, null)) + .`when`() + .post("/api/photos") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("success", equalTo(false)) + .body("resultCode", equalTo(ResultCode.NOT_FOUND.code)) + } +}