Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
157 commits
Select commit Hold shift + click to select a range
f6d324e
chore: spotless plugin 추가
koosco Dec 2, 2025
3639825
feat: build, format make 명령어 추가
koosco Dec 2, 2025
f639287
chore: makefile PHONY에 stop 명령어 추가
koosco Dec 2, 2025
dff00e2
chore: jpa, flyway 의존성 추가
koosco Dec 2, 2025
60dd4ca
fix: make run, start SPRING_PROFILES 인자 추가
koosco Dec 2, 2025
f0c201b
feat: clear-h2 명령어 추가
koosco Dec 2, 2025
4f5bde7
chore: app url, version 설정 추가
koosco Dec 5, 2025
cb52047
chore: swagger config 추가
koosco Dec 5, 2025
7efaebe
feat: test용 controller 추가
koosco Dec 5, 2025
bf91a99
chore : swagger 메서드 파라미터 오타 수정
koosco Dec 5, 2025
0ca0699
Revert "feat: test용 controller 추가"
koosco Dec 5, 2025
a3a0931
Revert "chore : swagger 메서드 파라미터 오타 수정"
koosco Dec 5, 2025
ba1771c
Revert "feat: test용 controller 추가"
koosco Dec 5, 2025
a99283f
Merge pull request #2 from Yapp-App-2/chore/#1
koosco Dec 5, 2025
fee5bff
chore: jdbc 의존성 제거 및 r2dbc 의존성 추가
koosco Dec 11, 2025
5698fa8
chore: local 개발용 postgres docker compose 추가
koosco Dec 11, 2025
66bbfe3
chore: make h2-clear 명령어 삭제
koosco Dec 11, 2025
d132070
chore/#3 -> staging: Merge Commit
Darren4641 Dec 11, 2025
ed6c58f
chore/#3 -> staging: Merge Commit
koosco Dec 11, 2025
ff39d48
infra : terraform s3 module 추가
koosco Dec 14, 2025
0c2cb4e
infra : terraform aws provider 추가
koosco Dec 14, 2025
68d9223
infra : terraform staging workspace 추가
koosco Dec 14, 2025
602af22
feat/#9 -> staging: Squash Merge
Darren4641 Dec 18, 2025
038f632
fix: Referer 보안 이슈 방지를 위해 Ip 기반 조건 설정
koosco Dec 18, 2025
77a36f2
apply code formatter
koosco Dec 18, 2025
d5995ac
fix: s3 private preset versioning 비활성화
koosco Dec 18, 2025
38cfbf6
fix: S3 public access 제거
koosco Dec 18, 2025
709e1c5
feat: transactionRunner 추가
koosco Dec 18, 2025
d45fe78
chore/#2 -> staging: merge commit
Darren4641 Dec 21, 2025
e6da525
feat/#4 -> staging: merge commit
Darren4641 Dec 21, 2025
de559fb
chore: S3 의존성 추가
koosco Dec 21, 2025
9579e95
chore: local s3 test용 local stack container 추가
koosco Dec 21, 2025
adcc096
feat: image storage 인터페이스 추가
koosco Dec 21, 2025
c5fdaf6
feat: S3 storage 구현체 추가
koosco Dec 21, 2025
c8494af
feat: Cors 설정 추가
koosco Dec 21, 2025
148b56e
test: local test용 object CRD API 추가
koosco Dec 21, 2025
a3caf24
Merge branch 'staging' into feat/#6
koosco Dec 21, 2025
e6cbf9e
fix: S3 초기화 로그 제거
koosco Dec 21, 2025
42d7be2
ref: cors 중복 설정 제거
koosco Dec 21, 2025
371a5b3
chore: api 계층으로 cors 설정 이동
koosco Dec 21, 2025
afede2d
chore: code quality check pipeline 추가
koosco Dec 21, 2025
742e1ab
feat: 폴더, 포토 이미지 엔티티 추가
koosco Dec 23, 2025
d7f9ad0
feat: Jpa Auditing 설정 추가
koosco Dec 23, 2025
3666ead
feat: Usecase 식별용 annotation 추가
koosco Dec 23, 2025
0b39343
feat: jpa folder 영속성 Port 추가
koosco Dec 23, 2025
c49b854
feat: folder 기본 API
koosco Dec 23, 2025
a0a0a12
docs: file API swagger schema 추가
koosco Dec 23, 2025
952a4e9
chore/#9 -> staging merge commit
Darren4641 Dec 24, 2025
3335bc3
feat/#6 -> staging merge commit
Darren4641 Dec 26, 2025
0064d5d
feat/#14 -> staging mrege commit
Darren4641 Dec 26, 2025
ee8dea4
feat: 로컬 회원가입 api
koosco Dec 28, 2025
4c13609
fix: UserPrincipal 사용자 id 추가
koosco Dec 28, 2025
43ff8f4
feat: PasswordEncoder config
koosco Dec 28, 2025
ed4c7a7
chore: SecurityConfig 네이밍 변경
koosco Dec 28, 2025
13b421e
feat: 로그인, 토큰 발급 api
koosco Dec 28, 2025
e0a5ded
feat/#16 -> staging merge commit
Darren4641 Dec 28, 2025
485535b
Merge branch 'staging' into feat/#11
koosco Dec 28, 2025
c637106
feat: folder api converter 추가
koosco Dec 28, 2025
6040958
chore: 폴더명 변경 photobooth -> photo
koosco Dec 28, 2025
a9ea0cc
apply spotless
koosco Dec 28, 2025
a86a931
feat: E2E Test용 Base 클래스
koosco Dec 30, 2025
ddfe9bd
feat: Folder E2E Test용 Base 클래스
koosco Dec 30, 2025
ddaa2ae
fix: test jasypt 설정 제거
koosco Dec 30, 2025
cb35b4d
feat: test Media 구현체 추가
koosco Dec 30, 2025
275c05b
ref: application layer에 맞게 Folder Port 메서드 시그니처 수정
koosco Dec 30, 2025
f0c2c91
fix: 폴더 생성 중복 검사 로직
koosco Dec 30, 2025
deef531
fix: 폴더명 변경 중복 검사
koosco Dec 30, 2025
63e4d2b
fix: 폴더 목록 조회 메서드 시그니처 변경
koosco Dec 30, 2025
a049a69
fix: 폴더 목록 삭제 원자성 검증
koosco Dec 30, 2025
611643a
fix: 폴더 생성 응답 추가
koosco Dec 30, 2025
66d43a8
fix: 폴더 jpa 조회 메서드 추가
koosco Dec 30, 2025
5ed3d5a
fix: 일관성을 위해 ExceptionDto code 변수명 resultCode로 변경
koosco Dec 30, 2025
66e6602
test: Folder API E2E Test 추가
koosco Dec 30, 2025
ac9cc3a
feat: 폴더 conflict code 추가
koosco Dec 30, 2025
571b2ed
fix: JasyptTest Spring 의존성 제거
koosco Dec 30, 2025
47cd8f7
fix: r2dbc 설정 제거
koosco Dec 31, 2025
2cb481e
fix: jasypt 환경변수를 생성자 주입으로 변경
koosco Dec 31, 2025
a77addd
chore: Media Aggregate 분리
koosco Jan 2, 2026
6b0e9cd
finx/#19 -> staging merge commit
Darren4641 Jan 3, 2026
33938a9
fix/#21 -> staging merge commit
Darren4641 Jan 3, 2026
56539f3
ref/#26 -> staging merge commit
Darren4641 Jan 3, 2026
94febd9
fix: DeleteFolder n+1 문제 해결
koosco Jan 8, 2026
b91eaa0
fix: RequiresSecurity class Target 추가
koosco Jan 8, 2026
64a7475
Merge branch 'staging' into feat/#11
koosco Jan 8, 2026
17c28f4
fix: Media 엔티티 추가
koosco Jan 8, 2026
cef3d3e
feat/#11 -> staging merge commit
Darren4641 Jan 8, 2026
820853f
Feat/#7 (#18)
Darren4641 Jan 9, 2026
9ed5faa
fix: oauth로그인 정책 변경
Darren4641 Jan 10, 2026
7dfafef
Merge pull request #37 from YAPP-Github/feat/#36
Darren4641 Jan 10, 2026
dafd4ec
fix: login관련 e2e 테스트코드 제거
Darren4641 Jan 10, 2026
8f4ebe9
fix: spotless 적용
Darren4641 Jan 10, 2026
5bdd291
feat/#36 -> staging merge commit
Darren4641 Jan 10, 2026
2141077
fix: test코드 임시 제거
Darren4641 Jan 10, 2026
b76e383
Merge pull request #40 from YAPP-Github/feat/#36
Darren4641 Jan 10, 2026
8ec5a3b
test: HttpStatus 변경
Darren4641 Jan 10, 2026
bcedc0c
Merge pull request #41 from YAPP-Github/feat/#36
Darren4641 Jan 10, 2026
2c4ee58
fix: acceess, refresh Token 만료 시간 변경
Darren4641 Jan 11, 2026
4e24036
Merge pull request #42 from YAPP-Github/feat/#36
Darren4641 Jan 11, 2026
0534696
chore: querydsl 설정
koosco Jan 12, 2026
066baab
chore/#45 -> staging merge commit
Darren4641 Jan 12, 2026
b307855
chore: flyway 비활성화
koosco Jan 12, 2026
02ac88a
chore/#49 -> staging merge commit
Darren4641 Jan 12, 2026
3fc7e4b
Feat/#23 (#24)
koosco Jan 12, 2026
f9ca6b6
Fix/#44 카카오 OIDC 공개키 캐싱 처리 (#46)
Darren4641 Jan 13, 2026
91ebe59
fix: AuthFilter Authentication type 변경, 테스트 수정
koosco Jan 13, 2026
4f378d4
fix/#53 -> staging merge commit
Darren4641 Jan 13, 2026
69e7556
feat/#28 -> staging merge commit
koosco Jan 20, 2026
f5efd8d
feat/#47 -> staging merge commit
Darren4641 Jan 20, 2026
c3e484f
test: e2e 테스트 코드 BaseResponse success 체킹 제거
Darren4641 Jan 20, 2026
c2f44a9
feat/#56 -> staging merge commit
koosco Jan 20, 2026
d8fee1e
feat/#59 -> staging merge commit
koosco Jan 21, 2026
10c168f
fix/#29 -> staging merge commit
koosco Jan 21, 2026
387f22a
feat/#43 -> merge commit
Darren4641 Jan 24, 2026
f9c4f31
feat/#35 -> staging merge commit
koosco Jan 24, 2026
6ba8f0a
chore/#63 -> staging merge commit
koosco Jan 24, 2026
c1ee81b
fix/#66 -> staging merge commit
Darren4641 Jan 24, 2026
812f0fd
feat/#58 -> staging merge commit
koosco Jan 25, 2026
69388a3
chore: github-ci 추가, deploy 단계 test 제거
Jan 26, 2026
dbe48f9
chore/#68 -> merge commit
koosco Jan 26, 2026
8d5e793
feat/#74 -> staging merge commit
koosco Jan 26, 2026
bd0dc05
fix/#65 -> staging merge commit
koosco Jan 26, 2026
4724957
ref/#69-> staging merge commit
koosco Jan 26, 2026
420f778
docs/#71 -> staging merge commit
Darren4641 Jan 27, 2026
0694ab1
fix/#77 -> staging merge commit
Darren4641 Jan 27, 2026
d036d9a
fix/#83 -> staging merge commit
koosco Jan 28, 2026
e38e329
fix/#82-> staging merge commit
Darren4641 Jan 29, 2026
02cfcf2
fix/#92 -> staging merge commit
koosco Jan 29, 2026
218d123
feat/#84 -> staging merge commit
koosco Jan 29, 2026
eaca660
feat/#89 -> staging merge commit
Darren4641 Jan 29, 2026
61eeb87
feat/#93 -> staging merge commit
Darren4641 Jan 30, 2026
0a74148
feat/#85 -> staging merge commit
koosco Jan 30, 2026
7acfd26
feat#/97 -> staging merge commit
koosco Jan 30, 2026
574d17d
feat#/96 -> staging merge commit
Darren4641 Jan 30, 2026
c356786
fix/#100 -> staging merge commit
koosco Jan 31, 2026
8de405d
fix/#102 -> staging merge commit
Darren4641 Jan 31, 2026
c551b97
fix: staging flyway 활성화
Darren4641 Feb 3, 2026
34a89c5
fix/#106 -> staging merge commit
Darren4641 Feb 4, 2026
51b6f51
fix/#109 -> staging merge commit
Darren4641 Feb 4, 2026
8ac61d3
chore/#104 -> staging merge commit
Darren4641 Feb 5, 2026
a20c6c2
fix/#105 -> staging merge commit
koosco Feb 5, 2026
45cb55f
feat: 포즈목록 조회시 scrap 여부 반환
Darren4641 Feb 6, 2026
e1f7567
fix/#113 -> staging merge commit
koosco Feb 6, 2026
55e0b7f
fix/#116 -> staging merge commit
Darren4641 Feb 8, 2026
bcedba1
fix/#119 -> staging merge commit
Darren4641 Feb 8, 2026
30dee19
chore/#122 -> staging merge commit
Darren4641 Feb 12, 2026
2be6328
fix/#125 -> staging merge commit
Darren4641 Feb 12, 2026
efa477a
fix/#128 -> staging merge commit
Darren4641 Feb 12, 2026
90a4217
chore/#130 -> staging merge commit
Darren4641 Feb 12, 2026
e755faf
Merge branch 'main' into staging
Darren4641 Feb 12, 2026
2e63039
feat/#120 -> staging merge commit
koosco Feb 13, 2026
c6b058d
fix/#127 -> staging merge commit
koosco Feb 14, 2026
45233a9
fix/#138 -> staging merge commit
koosco Feb 14, 2026
4e0d107
fix/#135 -> staging merge commit
koosco Feb 14, 2026
11420b1
fix/#139 -> staging merge commit
koosco Feb 17, 2026
16e166c
fix/#141 -> staging merge commit
Darren4641 Feb 17, 2026
f64f421
Merge branch 'main' into staging
Darren4641 Feb 17, 2026
1befb5b
fix: 버전 1.0.0
Darren4641 Feb 17, 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
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ val mockkVersion = "1.13.10"
val ktlintVersion = "1.5.0"

group = "com.yapp2app"
version = "0.0.1"
version = "1.0.0"

java {
toolchain {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import org.springframework.web.bind.MissingServletRequestParameterException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.context.request.WebRequest
import org.springframework.web.method.annotation.HandlerMethodValidationException
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
import java.util.function.Consumer

Expand Down Expand Up @@ -114,6 +115,16 @@ class ExceptionHandler {
HttpStatus.BAD_REQUEST,
)

@ExceptionHandler(HandlerMethodValidationException::class)
fun handleMethodValidationExceptionHandler(ex: HandlerMethodValidationException): ResponseEntity<ExceptionMsg> =
ResponseEntity(
ExceptionMsg(
resultCode = ResultCode.INVALID_PARAMETER.code,
message = ResultCode.INVALID_PARAMETER.message,
),
HttpStatus.BAD_REQUEST,
Comment on lines +119 to +125

Choose a reason for hiding this comment

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

medium

HandlerMethodValidationException에는 어떤 파라미터가 유효성 검사에 실패했는지에 대한 구체적인 정보가 포함되어 있습니다. 현재 구현은 일반적인 INVALID_PARAMETER 메시지를 반환하고 있어 디버깅이 어려울 수 있습니다. MethodArgumentNotValidException 핸들러와 유사하게, 예외 객체에서 실제 유효성 검사 실패 메시지를 추출하여 반환하는 것이 좋습니다. 이렇게 하면 클라이언트가 어떤 값이 잘못되었는지 명확하게 알 수 있습니다.

Suggested change
fun handleMethodValidationExceptionHandler(ex: HandlerMethodValidationException): ResponseEntity<ExceptionMsg> =
ResponseEntity(
ExceptionMsg(
resultCode = ResultCode.INVALID_PARAMETER.code,
message = ResultCode.INVALID_PARAMETER.message,
),
HttpStatus.BAD_REQUEST,
fun handleMethodValidationExceptionHandler(ex: HandlerMethodValidationException): ResponseEntity<ExceptionMsg> =
ResponseEntity(
ExceptionMsg(
resultCode = ResultCode.INVALID_PARAMETER.code,
message = ex.allErrors.firstOrNull()?.defaultMessage ?: ResultCode.INVALID_PARAMETER.message,
),
HttpStatus.BAD_REQUEST,
)

)

@ExceptionHandler(MethodArgumentTypeMismatchException::class)
fun handleTypeMismatchHandler(ex: MethodArgumentTypeMismatchException): ResponseEntity<ExceptionMsg> =
if (ex.requiredType?.isEnum == true) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import com.yapp2app.photo.application.usecase.UpdateFolderUseCase
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.Valid
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
Expand Down Expand Up @@ -72,8 +73,11 @@ class FolderController(
description = "폴더 목록을 조회합니다.",
)
@GetMapping
fun getAllFolder(@AuthenticationPrincipal(expression = "id") userId: Long): BaseResponse<GetAllFolderResponse> {
val command = commandConverter.toGetFoldersCommand(userId)
fun getAllFolder(
@AuthenticationPrincipal(expression = "id") userId: Long,
@RequestParam("limit") @Min(1) limit: Int?,

Choose a reason for hiding this comment

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

high

limit 파라미터는 Int? 타입으로 선언되어 선택적으로 사용될 수 있음을 나타내지만, @RequestParam("limit") 어노테이션은 기본적으로 required=true로 동작합니다. 따라서 클라이언트가 limit 파라미터를 전달하지 않으면 MissingServletRequestParameterException이 발생하여 API가 실패하게 됩니다. limit 파라미터를 선택적으로 만드려면 @RequestParam(name = "limit", required = false)로 명시해야 합니다.

Suggested change
@RequestParam("limit") @Min(1) limit: Int?,
@RequestParam(name = "limit", required = false) @Min(1) limit: Int?,

): BaseResponse<GetAllFolderResponse> {
val command = commandConverter.toGetFoldersCommand(userId, limit)

val result = getFoldersUseCase.execute(command)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class FolderCommandConverter {
fun toCreateFolderCommand(request: CreateFolderRequest, userId: Long): CreateFolderCommand =
CreateFolderCommand(userId, request.name!!)

fun toGetFoldersCommand(userId: Long): GetFoldersCommand = GetFoldersCommand(userId)
fun toGetFoldersCommand(userId: Long, limit: Int?): GetFoldersCommand = GetFoldersCommand(userId, limit)

fun toDeleteFoldersCommand(request: DeleteFoldersRequest, userId: Long, deletePhotos: Boolean) =
DeleteFoldersCommand(userId, request.folderIds, deletePhotos)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class PhotoImageCommandConverter {
memo = item.memo,
)
},
favorite = request.favorite ?: false,
)

fun toGetPhotosCommand(
Expand Down
4 changes: 2 additions & 2 deletions src/main/kotlin/com/yapp2app/photo/api/dto/FolderRequest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import jakarta.validation.constraints.Size
data class CreateFolderRequest(
@field:Schema(description = "폴더명", example = "즐겨찾기")
@field:NotBlank(message = "폴더명은 필수입니다.")
@field:Size(min = 1, max = 16, message = "폴더명은 1 ~ 16자 사이여야 합니다.")
@field:Size(min = 1, max = 10, message = "폴더명은 1자 이상 10자 이하여야 합니다.")
val name: String?,
)

Expand All @@ -30,7 +30,7 @@ data class DeleteFoldersRequest(
data class UpdateFolderRequest(
@field:Schema(description = "변경할 폴더명", example = "대학교 친구")
@field:NotBlank(message = "폴더명은 필수입니다.")
@field:Size(min = 1, max = 16, message = "폴더명은 1 ~ 16자 사이여야 합니다.")
@field:Size(min = 1, max = 10, message = "폴더명은 1자 이상 10자 이하여야 합니다.")
val name: String?,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import jakarta.annotation.Nullable
import jakarta.validation.Valid
import jakarta.validation.constraints.NotEmpty
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size

/**
* fileName : PhotoImageRequest
Expand All @@ -18,8 +19,11 @@ data class UploadPhotoRequest(

@field:NotEmpty(message = "uploads가 비어있습니다.")
@field:Valid
@field:jakarta.validation.constraints.Size(max = 10, message = "한 번에 최대 10장까지 업로드할 수 있습니다.")
@field:Size(max = 10, message = "한 번에 최대 10장까지 업로드할 수 있습니다.")
val uploads: List<UploadPhotoItem>,

@field:Schema(description = "업로드 사진 즐겨찾기 등록 여부", example = "true")
val favorite: Boolean? = null,
) {
data class UploadPhotoItem(
@field:NotNull(message = "mediaId는 필수 입력값입니다.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ data class CreateFolderCommand(val userId: Long, val name: String)

data class DeleteFoldersCommand(val userId: Long, val folderIds: List<Long>, val deletePhotos: Boolean = false)

data class GetFoldersCommand(val userId: Long)
data class GetFoldersCommand(val userId: Long, val limit: Int?)

data class UpdateFolderCommand(val userId: Long, val folderId: Long, val newName: String)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import com.yapp2app.common.domain.vo.SortOrder
* date : 2026. 1. 2. 오후 8:28
* description : Photo image domain command
*/
data class UploadPhotoCommand(val userId: Long, val folderId: Long?, val uploads: List<UploadItem>) {
data class UploadPhotoCommand(
val userId: Long,
val folderId: Long?,
val uploads: List<UploadItem>,
val favorite: Boolean,
) {
data class UploadItem(val mediaId: Long, val memo: String?)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ interface FavoriteImageRepositoryPort {

fun add(userId: Long, photoId: Long)

fun addAll(userId: Long, photoIds: List<Long>)

fun delete(userId: Long, photoId: Long)

fun deleteAll(userId: Long, photoIds: List<Long>)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ interface FolderRepositoryPort {

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

fun listOwnedFoldersWithStats(userId: Long): List<FolderWithStats>
fun listOwnedFoldersWithStats(userId: Long, limit: Int?): 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 @@ -19,6 +19,8 @@ interface PhotoImageRepositoryPort {

fun saveAll(photoImages: List<PhotoImage>): List<PhotoImage>

fun getRegisteredMediaIds(mediaIds: List<Long>): Set<Long>

/**
* 조회
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class GetFoldersUseCase(private val folderRepository: FolderRepositoryPort) {

@Transactional(readOnly = true)
fun execute(command: GetFoldersCommand): GetFoldersResult {
val foldersWithStats = folderRepository.listOwnedFoldersWithStats(command.userId)
val foldersWithStats = folderRepository.listOwnedFoldersWithStats(command.userId, command.limit)

val items = foldersWithStats.map { folder ->
GetFoldersResult.FolderInfo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ 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.FavoriteImageRepositoryPort
import com.yapp2app.photo.application.port.FolderRepositoryPort
import com.yapp2app.photo.application.port.MediaClientPort
import com.yapp2app.photo.application.port.PhotoImageRepositoryPort
import com.yapp2app.photo.domain.entity.PhotoImage

/**
* fileName : BulkUploadPhotoUseCase
* fileName : UploadPhotosUseCase
* author : koo
* date : 2026. 1. 20.
* description : 다중 사진 업로드 UseCase (최대 10장)
Expand All @@ -22,6 +23,7 @@ class UploadPhotosUseCase(
private val mediaClient: MediaClientPort,
private val photoImageRepository: PhotoImageRepositoryPort,
private val folderRepository: FolderRepositoryPort,
private val favoriteImageRepository: FavoriteImageRepositoryPort,

private val transactionRunner: TransactionRunner,
) {
Expand All @@ -30,17 +32,18 @@ class UploadPhotosUseCase(
validateNoDuplicateMediaIds(command.uploads)
validateFolderOwnership(command.userId, command.folderId)

val mediaIds = command.uploads.map { it.mediaId }
val newUploads = filterNewUploads(command.uploads)
if (newUploads.isEmpty()) return

val newMediaIds = newUploads.map { it.mediaId }

// 모든 media가 object storage에 정상적으로 저장되었는지 일괄 확인
val availabilities = mediaClient.verifyMediasUploaded(
ownerId = command.userId,
mediaIds = mediaIds,
mediaIds = newMediaIds,
)

rollbackIfFailed(command.userId, availabilities)

val photos = command.uploads.map { upload ->
val photos = newUploads.map { upload ->
PhotoImage(
userId = command.userId,
mediaId = upload.mediaId,
Expand All @@ -51,15 +54,27 @@ class UploadPhotosUseCase(

try {
transactionRunner.run {
photoImageRepository.saveAll(photos)
val savedPhotos = photoImageRepository.saveAll(photos)
if (command.favorite) {
favoriteImageRepository.addAll(command.userId, savedPhotos.map { it.id!! })
}
}
} catch (e: BusinessException) {
if (e.resultCode == ResultCode.ALREADY_REQUEST) return
mediaClient.rollbackMediasUploaded(command.userId, newMediaIds)
throw e
} catch (e: Exception) {
// 보상 트랜잭션: 모든 media 상태를 INITIATED로 롤백
mediaClient.rollbackMediasUploaded(command.userId, mediaIds)
mediaClient.rollbackMediasUploaded(command.userId, newMediaIds)
throw e
}
}

private fun filterNewUploads(uploads: List<UploadPhotoCommand.UploadItem>): List<UploadPhotoCommand.UploadItem> {
val mediaIds = uploads.map { it.mediaId }
val existingMediaIds = photoImageRepository.getRegisteredMediaIds(mediaIds)
return uploads.filter { it.mediaId !in existingMediaIds }
}

private fun validateNoDuplicateMediaIds(uploads: List<UploadPhotoCommand.UploadItem>) {
val mediaIds = uploads.map { it.mediaId }
val duplicates = mediaIds.groupingBy { it }.eachCount().filter { it.value > 1 }.keys
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ class FavoriteImageRepositoryAdapter(
}
}

override fun addAll(userId: Long, photoIds: List<Long>) {
if (photoIds.isEmpty()) return
val favorites = photoIds.map { photoId -> FavoritePhoto(FavoritePhotoId(userId, photoId)) }
jpaRepository.saveAll(favorites)
}

override fun delete(userId: Long, photoId: Long) = jpaRepository.deleteById(
FavoritePhotoId(userId, photoId),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ class FolderRepositoryAdapter(

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

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

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
@@ -1,11 +1,14 @@
package com.yapp2app.photo.infra.persist

import com.yapp2app.common.api.dto.ResultCode
import com.yapp2app.common.domain.vo.SortOrder
import com.yapp2app.common.exception.BusinessException
import com.yapp2app.photo.application.contract.PhotoWithFavorite
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.dao.DataIntegrityViolationException
import org.springframework.stereotype.Repository

/**
Expand All @@ -24,7 +27,14 @@ class PhotoImageRepositoryAdapter(
queryRepository.findOwnedPhotoWithFavorite(userId, photoId)

override fun save(photoImage: PhotoImage): PhotoImage = jpaRepository.save(photoImage)
override fun saveAll(photoImages: List<PhotoImage>): List<PhotoImage> = jpaRepository.saveAll(photoImages)
override fun saveAll(photoImages: List<PhotoImage>): List<PhotoImage> = try {
jpaRepository.saveAll(photoImages)
} catch (e: DataIntegrityViolationException) {
throw BusinessException(ResultCode.ALREADY_REQUEST)
}

override fun getRegisteredMediaIds(mediaIds: List<Long>): Set<Long> =
queryRepository.getRegisteredMediaIds(mediaIds)

override fun listOwnedPhotos(userId: Long, offset: Int, limit: Int, sortOrder: SortOrder): List<PhotoImage> =
queryRepository.findOwnedPhotos(userId, offset, limit, sortOrder)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,20 @@ class FolderQueryRepository(private val queryFactory: JPAQueryFactory) {
.execute().toInt()
}

fun findOwnedFoldersWithStats(userId: Long): List<FolderWithStats> {
fun findOwnedFoldersWithStats(userId: Long, limit: Int?): List<FolderWithStats> {
val latestPhotoDate = photoImage.createdAt.max()

val folderStats = queryFactory
.select(folder.id, folder.name, photoImage.id.count())
.select(folder.id, folder.name, photoImage.id.count(), latestPhotoDate)
.from(folder)
.leftJoin(photoImage).on(
photoImage.folderId.eq(folder.id),
photoImage.userId.eq(userId),
)
.where(folder.userId.eq(userId))
.groupBy(folder.id, folder.name)
.orderBy(latestPhotoDate.desc().nullsLast(), folder.createdAt.desc())
.apply { limit?.let { limit(it.toLong()) } }
.fetch()

if (folderStats.isEmpty()) return emptyList()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,17 @@ class PhotoImageQueryRepository(private val queryFactory: JPAQueryFactory) {
.fetch()
}

fun getRegisteredMediaIds(mediaIds: List<Long>): Set<Long> {
if (mediaIds.isEmpty()) return emptySet()

return queryFactory
.select(photoImage.mediaId)
.from(photoImage)
.where(photoImage.mediaId.`in`(mediaIds))
.fetch()
.toSet()
}

fun removePhotosFromFolder(userId: Long, folderId: Long, photoIds: List<Long>): Int {
if (photoIds.isEmpty()) return 0

Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/application-staging.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ app:

oauth:
kakao:
androidClientId: ENC(Qzg5YG7q+laisg0oYQi100q1q3RRCBR8gkHtAEcgEPkmNj7Jmz+58693hruGEova3zfVL6C5N6oKR8tVHRa7fysnNTSkTvAcx3uxTcUZhR4=)
androidClientId: ENC(lDI6cw226mDsd0R8PMdfQZdksG0cl+UN05E4+hJI7KguRGIn1+oBB/mLDhkXfp+43Oq7gkQCo7vKLrZMxzFDLQhbeb7Mx8KMEyBCjw7LfSQ=)
iosClientId: ENC(0KhQuAcBG2ddDzIS/WhvDbb+l+SbuCiYE/ANF8OVMvtvQcEwsY8xiFlr5lp0IAKyLEk75SSoIhM5picVY7xjV0+DPl/vA6lt8CA4Ae17Hd8=)
clientSecret: ENC(PjnJy0hcYdqBkqvSGqCcEp61czlJpJ1n4nYMCkAcFdo7ASIJrbBUqxJay41zVg8kG45KiPhYbxTfJe+X9vQlp5qZfIbZuVtAc85SHLexQIc=)
jwksUri: https://kauth.kakao.com/.well-known/jwks.json
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE TB_PHOTO_IMAGE
ADD CONSTRAINT uk_photo_image_media_id UNIQUE (media_id);
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- 기존 데이터 중 10자 초과 폴더명을 10자로 잘라냄
UPDATE TB_FOLDER SET name = LEFT(name, 10) WHERE LENGTH(name) > 10;

-- 폴더명 최대 길이를 10자로 제한
ALTER TABLE TB_FOLDER ALTER COLUMN name TYPE VARCHAR(10);
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,18 @@ class CreateFolderE2ETest : FolderE2ETestBase() {
.statusCode(HttpStatus.BAD_REQUEST.value())
.body("resultCode", equalTo(ResultCode.CONFLICT_FOLDER.code))
}

@Test
@DisplayName("폴더명이 10자를 초과하면 400 에러를 반환한다")
fun givenTooLongFolderName_whenCreateFolder_thenReturnsBadRequest() {
RestAssured.given()
.contentType(ContentType.JSON)
.header("Authorization", "Bearer $accessToken")
.body(CreateFolderRequest(name = "일이삼사오육칠팔구십일")) // 11자
.`when`()
.post("/api/folders")
.then()
.statusCode(HttpStatus.BAD_REQUEST.value())
.body("resultCode", equalTo(ResultCode.INVALID_PARAMETER.code))
}
}
Loading