Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
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 @@ -36,11 +36,12 @@ class MediaController(
summary = "미디어 업로드 ticket 발급",
description = """
미디어 업로드를 위한 ticket을 발급받습니다.
Binary를 body에 담아 발급받은 uploadTicket URL로 PUT 요청을 하면 됩니다.

Workflow:
1. 이 API를 호출하여 업로드 ticket 발급
2. 각 ticket의 uploadTicket URL로 파일 업로드 (S3 직접 업로드)
3. POST /api/photos/bulk API 호출하여 메타데이터 등록
3. POST /api/photos API 호출하여 메타데이터 등록

mediaType:
* USER_PROFILE("user-profiles") : 사용자 프로필
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.yapp2app.photo.api.converter

import com.yapp2app.common.properties.AppProperties
import com.yapp2app.photo.api.dto.CreateFolderResponse
import com.yapp2app.photo.api.dto.GetAllFolderResponse
import com.yapp2app.photo.application.result.CreateFolderResult
Expand All @@ -13,16 +14,25 @@ import org.springframework.stereotype.Component
* description :
*/
@Component
class FolderResultConverter {
class FolderResultConverter(private val appProperties: AppProperties) {

companion object {
private const val IMAGE_URL_PATH = "/file/image/"
}

fun toGetAllFoldersResponse(result: GetFoldersResult): GetAllFolderResponse = GetAllFolderResponse(
result.items.map {
items = result.items.map {
GetAllFolderResponse.FolderInfo(
it.folderId,
it.name,
latestImageUrl = it.imageObjectKey?.let { key -> toImageUrl(key) },
totalCount = it.count,
)
},

)

fun toCreateFolderResponse(result: CreateFolderResult): CreateFolderResponse = CreateFolderResponse(result.folderId)

private fun toImageUrl(objectKey: String): String = "${appProperties.server.url}$IMAGE_URL_PATH$objectKey"
}
4 changes: 4 additions & 0 deletions src/main/kotlin/com/yapp2app/photo/api/dto/FolderResponse.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,9 @@ data class GetAllFolderResponse(
val folderId: Long,
@field:Schema(description = "폴더명", example = "즐겨찾기")
val name: String,
@field:Schema(description = "가장 최근 추가한 이미지", example = "https://dev-yapp.suitestudy.com:4641/file/image/...")
val latestImageUrl: String?,
@field:Schema(description = "사진 개수", example = "10")
val totalCount: Long,
)
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.yapp2app.photo.api.dto

import io.swagger.v3.oas.annotations.media.Schema
import jakarta.annotation.Nullable
import jakarta.validation.Valid
import jakarta.validation.constraints.NotEmpty
Expand Down Expand Up @@ -34,3 +35,9 @@ data class DeletePhotosRequest(
)

data class UpdatePhotoRequest(val memo: String?)

data class UpdatePhotoFavoriteRequest(
@field:Schema(description = "변경하고자 하는 즐겨찾기 상태", example = "true")
@field:NotNull(message = "favorite은 필수값입니다.")
val favorite: Boolean?,
)

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.yapp2app.photo.application.contract

/**
* fileName : FolderWithStats
* author : koo
* date : 2026. 1. 28.
* description : Folder with aggregated statistics (photo count and cover image info)
*/
data class FolderWithStats(
val folderId: Long,
val name: String,
val coverImageStorageKey: String?,
val photoCount: Long,
)
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.yapp2app.photo.application.port

import com.yapp2app.photo.application.contract.FolderWithStats
import com.yapp2app.photo.domain.entity.Folder

/**
Expand All @@ -16,6 +17,8 @@ interface FolderRepositoryPort {

fun listOwnedFolders(userId: Long): List<Folder>

fun listOwnedFoldersWithStats(userId: Long): List<FolderWithStats>

fun getOwnedFolder(userId: Long, folderId: Long): Folder?
fun getOwnedFolders(userId: Long, folderIds: List<Long>): List<Folder>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface PhotoImageRepositoryPort {

fun listOwnedPhotosWithFavorite(
userId: Long,
folderId: Long?,
offset: Int,
limit: Int,
sortOrder: SortOrder,
Expand All @@ -37,4 +38,9 @@ interface PhotoImageRepositoryPort {
fun getLatestOwnedPhoto(userId: Long): PhotoImage?

fun updatePhotosFolderIdToNull(userId: Long, folderIds: List<Long>): Int

/**
* 삭제 예정인 사진들이 속한 폴더 ID 조회
*/
fun getAffectedFolderIds(userId: Long, photoIds: List<Long>): List<Long>
Copy link
Contributor

Choose a reason for hiding this comment

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

미사용 코드 제거

}
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ package com.yapp2app.photo.application.result
data class CreateFolderResult(val folderId: Long)

data class GetFoldersResult(val items: List<FolderInfo>) {
data class FolderInfo(val folderId: Long, val name: String)
data class FolderInfo(val folderId: Long, val name: String, val imageObjectKey: String?, val count: Long)
Copy link
Contributor

Choose a reason for hiding this comment

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

다른 어그리거트 처럼 변수명을 통일하면 좋을 것 같습니다!

Suggested change
data class FolderInfo(val folderId: Long, val name: String, val imageObjectKey: String?, val count: Long)
data class FolderInfo(val folderId: Long, val name: String, val storageKey: String?, val count: Long)

}
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,20 @@ class DeletePhotosUseCase(
private val photoImageRepository: PhotoImageRepositoryPort,
private val favoriteImageRepository: FavoriteImageRepositoryPort,
private val mediaClient: MediaClientPort,

private val transactionRunner: TransactionRunner,
) {

fun execute(command: DeletePhotosCommand) {
val photos = transactionRunner.run {
val deletedPhotos = transactionRunner.run {
favoriteImageRepository.deleteAll(command.userId, command.photoIds)

photoImageRepository.deleteOwnedPhotos(
command.userId,
command.photoIds,
)
}

mediaClient.deleteMedias(command.userId, photos.map { it.mediaId })
mediaClient.deleteMedias(command.userId, deletedPhotos.map { it.mediaId })
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,17 @@ class GetFoldersUseCase(private val folderRepository: FolderRepositoryPort) {

@Transactional(readOnly = true)
fun execute(command: GetFoldersCommand): GetFoldersResult {
val folders = folderRepository.listOwnedFolders(command.userId)
.map { GetFoldersResult.FolderInfo(it.id!!, it.name) }
.toList()
val foldersWithStats = folderRepository.listOwnedFoldersWithStats(command.userId)

return GetFoldersResult(folders)
val items = foldersWithStats.map { folder ->
GetFoldersResult.FolderInfo(
folderId = folder.folderId,
name = folder.name,
imageObjectKey = folder.coverImageStorageKey,
count = folder.photoCount,
)
}

return GetFoldersResult(items = items)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class GetPhotosUseCase(

val photosWithFavorite: List<PhotoWithFavorite> = photoImageRepository.listOwnedPhotosWithFavorite(
userId = command.userId,
folderId = command.folderId,
offset = command.page * command.size,
limit = fetchSize,
sortOrder = command.sortOrder,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ import com.yapp2app.photo.domain.entity.PhotoImage
class UploadPhotosUseCase(
private val mediaClient: MediaClientPort,
private val photoImageRepository: PhotoImageRepositoryPort,
private val transactionRunner: TransactionRunner,
private val folderRepository: FolderRepositoryPort,

private val transactionRunner: TransactionRunner,
) {

fun execute(command: UploadPhotoCommand) {
Expand All @@ -37,26 +38,8 @@ class UploadPhotosUseCase(
mediaIds = mediaIds,
)

// 업로드 실패한 media가 있는지 확인
val unavailableMediaIds = availabilities
.filter { it.value != MediaAvailability.AVAILABLE }
.keys

if (unavailableMediaIds.isNotEmpty()) {
// 성공한 media들도 롤백 (상태를 INITIATED로 되돌림)
val successfulMediaIds = availabilities
.filter { it.value == MediaAvailability.AVAILABLE }
.keys
.toList()

if (successfulMediaIds.isNotEmpty()) {
mediaClient.rollbackMediasUploaded(command.userId, successfulMediaIds)
}

throw BusinessException(ResultCode.UPLOAD_FAILED)
}
rollbackIfFailed(command.userId, availabilities)

// PhotoImage 엔티티 생성
val photos = command.uploads.map { upload ->
PhotoImage(
userId = command.userId,
Expand Down Expand Up @@ -92,4 +75,23 @@ class UploadPhotosUseCase(
?: throw BusinessException(ResultCode.NOT_FOUND)
}
}

private fun rollbackIfFailed(userId: Long, availabilities: Map<Long, MediaAvailability>) {
val unavailableMediaIds = availabilities
.filter { it.value != MediaAvailability.AVAILABLE }
.keys

if (unavailableMediaIds.isNotEmpty()) {
val successfulMediaIds = availabilities
.filter { it.value == MediaAvailability.AVAILABLE }
.keys
.toList()

if (successfulMediaIds.isNotEmpty()) {
mediaClient.rollbackMediasUploaded(userId, successfulMediaIds)
}

throw BusinessException(ResultCode.UPLOAD_FAILED)
}
}
}
3 changes: 0 additions & 3 deletions src/main/kotlin/com/yapp2app/photo/domain/entity/Folder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,4 @@ class Folder(

@Column(name = "name", nullable = false)
var name: String,

@Column(name = "cover_photo_id")
var coverPhotoId: Long? = null,
) : BaseTimeEntity()
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.yapp2app.photo.infra.persist

import com.yapp2app.photo.application.contract.FolderWithStats
import com.yapp2app.photo.application.port.FolderRepositoryPort
import com.yapp2app.photo.domain.entity.Folder
import com.yapp2app.photo.infra.persist.jpa.FolderQueryRepository
Expand All @@ -25,6 +26,9 @@ class FolderRepositoryAdapter(

override fun listOwnedFolders(userId: Long): List<Folder> = jpaRepository.findAllByUserId(userId)

override fun listOwnedFoldersWithStats(userId: Long): List<FolderWithStats> =
queryRepository.findOwnedFoldersWithStats(userId)

override fun getOwnedFolders(userId: Long, folderIds: List<Long>): List<Folder> =
jpaRepository.findAllByUserIdAndIdIn(userId, folderIds)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,11 @@ class PhotoImageRepositoryAdapter(

override fun listOwnedPhotosWithFavorite(
userId: Long,
folderId: Long?,
offset: Int,
limit: Int,
sortOrder: SortOrder,
): List<PhotoWithFavorite> = queryRepository.findOwnedPhotosWithFavorite(userId, offset, limit, sortOrder)
): List<PhotoWithFavorite> = queryRepository.findOwnedPhotosWithFavorite(userId, folderId, offset, limit, sortOrder)

override fun listOwnedFavoritePhotos(
userId: Long,
Expand Down Expand Up @@ -65,4 +66,7 @@ class PhotoImageRepositoryAdapter(

override fun updatePhotosFolderIdToNull(userId: Long, folderIds: List<Long>): Int =
queryRepository.updatePhotosFolderIdToNull(userId, folderIds)

override fun getAffectedFolderIds(userId: Long, photoIds: List<Long>): List<Long> =
Copy link
Contributor

Choose a reason for hiding this comment

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

미사용 코드 제거

queryRepository.getAffectedFolderIds(userId, photoIds)
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package com.yapp2app.photo.infra.persist.jpa

import com.querydsl.jpa.JPAExpressions
import com.querydsl.jpa.impl.JPAQueryFactory
import com.yapp2app.media.domain.entity.QMedia.media
import com.yapp2app.photo.application.contract.FolderWithStats
import com.yapp2app.photo.domain.entity.QFolder.folder
import com.yapp2app.photo.domain.entity.QPhotoImage
import com.yapp2app.photo.domain.entity.QPhotoImage.photoImage
import org.springframework.stereotype.Repository

/**
Expand All @@ -21,4 +26,60 @@ class FolderQueryRepository(private val queryFactory: JPAQueryFactory) {
.where(folder.userId.eq(userId), folder.id.`in`(folderIds))
.execute().toInt()
}

fun findOwnedFoldersWithStats(userId: Long): List<FolderWithStats> {
val folderStats = queryFactory
.select(folder.id, folder.name, photoImage.id.count())
.from(folder)
.leftJoin(photoImage).on(
photoImage.folderId.eq(folder.id),
photoImage.userId.eq(userId),
)
.where(folder.userId.eq(userId))
.groupBy(folder.id, folder.name)
.fetch()

if (folderStats.isEmpty()) return emptyList()

val folderIdsWithPhotos = folderStats
.filter { it.get(photoImage.id.count())!! > 0L }
.mapNotNull { it.get(folder.id) }

val coverMap = if (folderIdsWithPhotos.isNotEmpty()) {
val latestPhoto = QPhotoImage("latestPhoto")
val maxIdSubquery = QPhotoImage("maxIdSubquery")

queryFactory
.select(latestPhoto.folderId, media.storageKey)
.from(latestPhoto)
.join(media).on(media.id.eq(latestPhoto.mediaId))
.where(
latestPhoto.userId.eq(userId),
latestPhoto.folderId.`in`(folderIdsWithPhotos),
latestPhoto.id.eq(
JPAExpressions
.select(maxIdSubquery.id.max())
.from(maxIdSubquery)
.where(
maxIdSubquery.folderId.eq(latestPhoto.folderId),
maxIdSubquery.userId.eq(userId),
),
),
)
.fetch()
.associate { it.get(latestPhoto.folderId)!! to it.get(media.storageKey)!! }
} else {
emptyMap()
}

return folderStats.map { tuple ->
val folderId = tuple.get(folder.id)!!
FolderWithStats(
folderId = folderId,
name = tuple.get(folder.name)!!,
coverImageStorageKey = coverMap[folderId],
photoCount = tuple.get(photoImage.id.count())!!,
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,7 @@ interface JpaPhotoImageRepository : JpaRepository<PhotoImage, Long> {
fun findAllByUserIdAndIdIn(userId: Long, ids: List<Long>): List<PhotoImage>

fun existsByUserIdAndId(userId: Long, photoId: Long): Boolean

/** test **/
fun findByUserIdAndFolderId(userId: Long, folderId: Long?): List<PhotoImage>
}
Loading