Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
7090277
feat: base time entity
koosco Jan 8, 2026
fba1645
feat: photo domain entity 추가
koosco Jan 8, 2026
f9be9ec
feat: jpa auditing config 추가
koosco Jan 8, 2026
6b2f15a
feat: PhotoImage UseCase 추가
koosco Jan 8, 2026
805e2cb
feat: PhotoImage persist jpa 구현체 추가
koosco Jan 8, 2026
625c5fb
feat: Local Media Client 구현체 추가
koosco Jan 8, 2026
67847e1
feat: Media UseCase 추가
koosco Jan 8, 2026
16f9ac6
feat: Media infra 계층 구현체 추가
koosco Jan 8, 2026
a422130
feat: RequiresSecurity class level target 추가
koosco Jan 8, 2026
8c20986
feat: file upload fail result code 추가
koosco Jan 8, 2026
b2877f7
feat: usecase 애노테이션 추가
koosco Jan 8, 2026
327a80b
feat: folder, photo, media flyway schema
koosco Jan 8, 2026
51a773a
chore: S3 구현체를 infra/storage/s3로 분리
koosco Jan 8, 2026
390c4c4
fix: jasypt config test profile 비활성화
koosco Jan 8, 2026
098651c
test: test profile 지정
koosco Jan 8, 2026
1e5fbd8
fix: S3 presigned 만료시간, contentType 추가
koosco Jan 8, 2026
a41f4f2
test: PhotoImage E2E Test 추가
koosco Jan 8, 2026
bf8a628
fix: PhotoController bean validation 추가
koosco Jan 8, 2026
1f5b294
test: PhotoImageE2ETestBase 추가
koosco Jan 8, 2026
6926d63
chore: rest assured 의존성 추가
koosco Jan 8, 2026
0621fbf
fix: AuthTokenProvider jwt에 providerType String을 저장하도록 수정
koosco Jan 8, 2026
5dfb2eb
feat: FakeMediaStorage 구현체 추가
koosco Jan 8, 2026
7a33aa0
feat: JpaPhotoImage 목록 조회 구현
koosco Jan 8, 2026
2ce42c0
fix: Jwt 검증 실패시에도 계속 진행되도록 수정
koosco Jan 8, 2026
dae914a
fix: mediaId null이 0으로 casting되는 오류 수정
koosco Jan 8, 2026
92b0b91
docs: media swagger 추가
koosco Jan 8, 2026
4e35b36
Merge branch 'staging' into feat/#28
koosco Jan 8, 2026
e765236
chore: GenerateUploadTicketUseCase 불필요 주석 삭제
koosco Jan 8, 2026
79ac297
fix: DeleteMediaUseCase cache 무효화 로직 추가
koosco Jan 8, 2026
cd169c2
ref: DeleteFolder 검증 로직 개선
koosco Jan 8, 2026
f778a03
fix: testcode resultCode 수정
koosco Jan 8, 2026
f3917a1
fix: 401 E2ETest 삭제
koosco Jan 8, 2026
2facf15
fix: memo 추가
koosco Jan 9, 2026
04ee439
apply spotless
koosco Jan 9, 2026
28876b9
chore: 사용하지 않는 메서드 제거
koosco Jan 9, 2026
7c123b5
fix: UploadPhotoE2ETest memo 추가
koosco Jan 9, 2026
7e3ad32
feat: photoImage Patch API
koosco Jan 9, 2026
d6ab8c6
Merge branch 'staging' into feat/#28
koosco Jan 9, 2026
ab6ec5e
Merge branch 'staging' into feat/#28
koosco Jan 13, 2026
ca7269b
ref: 사진 목록 조회 paging 처리
koosco Jan 13, 2026
688eab5
ref: Folder 삭제 query를 querydsl로 변경
koosco Jan 16, 2026
4346584
chore: 사용하지 않는 메서드 정리
koosco Jan 16, 2026
ea82ddd
ref: photo domain contract 파일 분리
koosco Jan 16, 2026
443cb7f
feat: PhotoImage dynamic update 추가
koosco Jan 16, 2026
084dedf
Merge branch 'staging' into feat/#28
koosco Jan 20, 2026
f1119b7
fix: 사진 등록 후 INITIAL media를 포함하여 조회
koosco Jan 20, 2026
36f39e9
test: 사진 업로드 e2e test
koosco Jan 20, 2026
37f6e11
fix: 사진 extension이 저장되지 않는 문제 수정
koosco Jan 20, 2026
1c07dff
fix: photo 저장 실패시 롤백
koosco Jan 20, 2026
f3d3219
chore: staging S3 bucket 변경
koosco Jan 20, 2026
ce78202
fix: 이미지 조회 contentType 필드 추가
koosco Jan 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
1 change: 1 addition & 0 deletions src/main/kotlin/com/yapp2app/common/api/dto/ResultCode.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "해당하는 폴더가 존재합니다."),

Expand Down
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
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
)
}
Expand Down Expand Up @@ -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,
)
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
@@ -1,6 +1,7 @@
package com.yapp2app.media.application.port

import com.yapp2app.media.application.dto.MediaRef
import java.time.Instant

/**
* fileName : MediaStorage
Expand All @@ -14,7 +15,14 @@ interface MediaStoragePort {

fun findByKey(key: String): String

fun fetchBinaryByKey(key: String): ByteArray

fun findAll(prefix: String): List<MediaRef>

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)
}
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) {
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

presignedUrl 발급받은 후 [POST] /api/photos 사진등록 API를 호출하는데, 이부분에서 계속 예외가 발생합니다!

.markAsUploaded() 처리하는 부분이 아래 return 부분밖에없는데, mediaRepository.getActiveMedia() 의 조건에 MediaStatus.UPLOADED이 있어서 그런 것 같은데 한번 확인 해주세요!

추가로 제가 스테이징 환경으로 한번 로킬에서 실행해보고 presignedUrl 로 업로드를 해본 결과 사진은 잘 저장됐습니다!

image 근데 사진 파일명에 확장자가 빠져서 들어가더라구요

GenerateUploadTicketUseCase 클래스에서 command.contentType이 image/png로 들어가는거 확인했는데도 실제 S3에서는 확장자가 빠져있습니다! 이것도 한번 확인해주세요!


// 이미 업로드된 경우 무시
if (media.isUploaded()) {
return ConfirmMediaUploadedResult(true)
}

// 오브젝트 스토리지에 해당 키가 존재하는지 확인
val exists = mediaStorage.exists(media.storageKey)

return transactionRunner.runNew {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

execute내에서는 트랜잭션이 없는데 새로운 트랜잭션으로 작업하는 이유가 궁금합니다!

Copy link
Member Author

Choose a reason for hiding this comment

The 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()
}
}
}
Loading