-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/#28 #31
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feat/#28 #31
Changes from all commits
7090277
fba1645
f9be9ec
6b2f15a
805e2cb
625c5fb
67847e1
16f9ac6
a422130
8c20986
b2877f7
327a80b
51a773a
390c4c4
098651c
1e5fbd8
a41f4f2
bf8a628
1f5b294
6926d63
0621fbf
5dfb2eb
7a33aa0
2ce42c0
dae914a
92b0b91
4e35b36
e765236
79ac297
cd169c2
f778a03
f3917a1
2facf15
04ee439
28876b9
7c123b5
7e3ad32
d6ab8c6
ab6ec5e
ca7269b
688eab5
4346584
ea82ddd
443cb7f
084dedf
f1119b7
36f39e9
37f6e11
1c07dff
f3d3219
ce78202
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<GenerateUploadTicketResponse> { | ||
| val command = commandConverter.toGenerateUploadTicketCommand(ownerId, request) | ||
|
|
||
| val result = generateUploadTicketUseCase.execute(command) | ||
|
|
||
| val response = resultConverter.toGenerateUploadTicketResponse(result) | ||
|
|
||
| return BaseResponse(data = response) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Long>) | ||
|
|
||
| data class GetMediasCommand(val ownerId: Long, val mediaIds: List<Long>) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Long>): List<Media> | ||
| fun getMediaForUploadConfirmation(ownerId: Long, id: Long): Media? | ||
|
|
||
| fun save(media: Media): Media | ||
|
|
||
| fun delete(id: Long) | ||
| fun deleteAll(ids: List<Long>) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<MediaInfo>) { | ||
| data class MediaInfo(val mediaId: Long, val binaryData: ByteArray, val contentType: String) { | ||
koosco marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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() | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. presignedUrl 발급받은 후 [POST] /api/photos 사진등록 API를 호출하는데, 이부분에서 계속 예외가 발생합니다! .markAsUploaded() 처리하는 부분이 아래 return 부분밖에없는데, mediaRepository.getActiveMedia() 의 조건에 MediaStatus.UPLOADED이 있어서 그런 것 같은데 한번 확인 해주세요! 추가로 제가 스테이징 환경으로 한번 로킬에서 실행해보고 presignedUrl 로 업로드를 해본 결과 사진은 잘 저장됐습니다!
근데 사진 파일명에 확장자가 빠져서 들어가더라구요
GenerateUploadTicketUseCase 클래스에서 command.contentType이 image/png로 들어가는거 확인했는데도 실제 S3에서는 확장자가 빠져있습니다! 이것도 한번 확인해주세요! |
||
|
|
||
| // 이미 업로드된 경우 무시 | ||
| if (media.isUploaded()) { | ||
| return ConfirmMediaUploadedResult(true) | ||
| } | ||
|
|
||
| // 오브젝트 스토리지에 해당 키가 존재하는지 확인 | ||
| val exists = mediaStorage.exists(media.storageKey) | ||
|
|
||
| return transactionRunner.runNew { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. execute내에서는 트랜잭션이 없는데 새로운 트랜잭션으로 작업하는 이유가 궁금합니다!
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Media가 다른 도메인에서 호출되는 경우가 많고, 모놀리식 아키텍처 특성상 실수로 다른 곳에서 트랜잭션이 시작된 후 호출될 수 있다고 생각했습니다. 기존 트랜잭션과 별개로 Media 도메인에서 트랜잭션이 독립적으로 실행되는 것을 보장하기 위해 REQUIRES_NEW로 두었습니다. 기존 트랜잭션이 없다면 동일하게 동작하기 때문에 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() | ||
| } | ||
| } | ||
| } | ||

Uh oh!
There was an error while loading. Please reload this page.