Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
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
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
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
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
3 changes: 2 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ dependencies {

// JPA
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
runtimeOnly("org.postgresql:postgresql")

// flyway (DB schema migration)
implementation("org.flywaydb:flyway-core")
Expand All @@ -90,7 +91,7 @@ dependencies {
testImplementation("io.kotest.extensions:kotest-extensions-spring:$kotestExtensionsVersion")
testImplementation("io.mockk:mockk:$mockkVersion")
testRuntimeOnly("com.h2database:h2")
runtimeOnly("org.postgresql:postgresql")
testImplementation("io.rest-assured:rest-assured")
}

spotless {
Expand Down
14 changes: 14 additions & 0 deletions src/main/kotlin/com/yapp2app/common/annotation/UseCase.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.yapp2app.common.annotation

import org.springframework.stereotype.Component

/**
* fileName : UseCase
* author : koo
* date : 2025. 12. 23. 오후 6:51
* description : usecase annotation
*/
@Component
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class UseCase
2 changes: 2 additions & 0 deletions src/main/kotlin/com/yapp2app/common/api/dto/ResultCode.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ enum class ResultCode(val code: String, var message: String) {
NOT_FOUND("D-04", "데이터를 찾을 수 없습니다."),
ALREADY_REQUEST("D-05", "이미 처리된 요청입니다."),

CONFLICT_FOLDER("D-06", message = "해당하는 폴더가 존재합니다."),

EXPIRED_TOKEN_ERROR("D-997", "토큰이 만료되었습니다."),
INVALID_TOKEN_ERROR("D-998", "토큰이 올바르지 않습니다."),
SECURITY_ERROR("D-999", "인증에 실패하였습니다."),
Expand Down
2 changes: 2 additions & 0 deletions src/main/kotlin/com/yapp2app/common/config/JasyptConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import org.jasypt.encryption.pbe.config.SimpleStringPBEConfig
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile

/**
* fileName : JasyptConfig
* author : darren
* date : 2025. 12. 24.
* description : Jasypt 암호화 설정
*/
@Profile("!test")
@Configuration
class JasyptConfig {

Expand Down
27 changes: 27 additions & 0 deletions src/main/kotlin/com/yapp2app/common/domain/BaseTimeEntity.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.yapp2app.common.domain

import jakarta.persistence.Column
import jakarta.persistence.EntityListeners
import jakarta.persistence.MappedSuperclass
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime

/**
* fileName : BaseTimeEntity
* author : koo
* date : 2025. 12. 23. 오후 7:09
* description : 생성일, 수정일 base entity
*/
@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
abstract class BaseTimeEntity(
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
var createdAt: LocalDateTime? = null,

@LastModifiedDate
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ data class FieldErrorDetail(var field: String, var message: String)

data class ExceptionMsg(
val message: String,
val code: String,
val resultCode: String,
val success: Boolean,
val errors: List<FieldErrorDetail>? = null,
) : Serializable
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class ExceptionHandler {
fun businessExceptionHandler(ex: BusinessException): ResponseEntity<ExceptionMsg> {
val temp = ResponseEntity(
ExceptionMsg(
code = ex.resultCode.code,
resultCode = ex.resultCode.code,
message = ex.resultCode.message,
success = false,
errors = emptyList(),
Expand All @@ -59,7 +59,7 @@ class ExceptionHandler {

return ResponseEntity(
ExceptionMsg(
code = ResultCode.INVALID_PARAMETER.code,
resultCode = ResultCode.INVALID_PARAMETER.code,
message = errors.get(0).message,
success = false,
errors = errors,
Expand Down Expand Up @@ -104,7 +104,7 @@ class ExceptionHandler {
}
return ResponseEntity(
ExceptionMsg(
code = ResultCode.INVALID_PARAMETER.code,
resultCode = ResultCode.INVALID_PARAMETER.code,
message = ResultCode.INVALID_PARAMETER.message,
success = false,
errors = errors,
Expand All @@ -119,7 +119,7 @@ class ExceptionHandler {
request: WebRequest,
): ResponseEntity<ExceptionMsg> = ResponseEntity(
ExceptionMsg(
code = ResultCode.INVALID_PARAMETER.code,
resultCode = ResultCode.INVALID_PARAMETER.code,
message = ResultCode.INVALID_PARAMETER.message,
success = false,
errors = listOf(
Expand All @@ -139,7 +139,7 @@ class ExceptionHandler {
val enumValues = ex.requiredType!!.enumConstants?.joinToString(", ")
ResponseEntity(
ExceptionMsg(
code = ResultCode.INVALID_PARAMETER.code,
resultCode = ResultCode.INVALID_PARAMETER.code,
message = ResultCode.INVALID_PARAMETER.message,
success = false,
errors = listOf(
Expand All @@ -154,7 +154,7 @@ class ExceptionHandler {
} else {
ResponseEntity(
ExceptionMsg(
code = ResultCode.INVALID_PARAMETER.code,
resultCode = ResultCode.INVALID_PARAMETER.code,
message = ResultCode.INVALID_PARAMETER.message,
success = false,
errors = listOf(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.yapp2app.common.infra.config

import org.springframework.context.annotation.Configuration
import org.springframework.data.jpa.repository.config.EnableJpaAuditing

/**
* fileName : JpaAuditingConfig
* author : koo
* date : 2025. 12. 23. 오후 7:10
* description : Jpa Auditing Config
*/
@Configuration
@EnableJpaAuditing
class JpaAuditingConfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.yapp2app.common.infra.media.fake

import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile

/**
* fileName : FakeS3MediaStorage
* author : koo
* date : 2025. 12. 28. 오후 10:30
* description :
*/
@Profile("test")
@Configuration
class FakeS3MediaStorage
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.yapp2app.common.infra.media.s3
import com.yapp2app.common.media.MediaStorage
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider
import software.amazon.awssdk.regions.Region
Expand All @@ -17,6 +18,7 @@ import java.net.URI
* date : 2025. 12. 19. 오전 2:40
* description : S3 설정
*/
@Profile("!test")
@Configuration
class S3MediaStorageConfig(private val props: S3Properties) {

Expand Down
127 changes: 127 additions & 0 deletions src/main/kotlin/com/yapp2app/photo/api/controller/FolderController.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package com.yapp2app.photo.api.controller

import com.yapp2app.common.api.dto.BaseResponse
import com.yapp2app.photo.api.converter.FolderCommandConverter
import com.yapp2app.photo.api.converter.FolderResultConverter
import com.yapp2app.photo.api.dto.CreateFolderRequest
import com.yapp2app.photo.api.dto.CreateFolderResponse
import com.yapp2app.photo.api.dto.DeleteFoldersRequest
import com.yapp2app.photo.api.dto.GetAllFolderResponse
import com.yapp2app.photo.api.dto.UpdateFolderRequest
import com.yapp2app.photo.application.usecase.CreateFolderUseCase
import com.yapp2app.photo.application.usecase.DeleteFolderUseCase
import com.yapp2app.photo.application.usecase.GetFoldersUseCase
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 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.RestController

/**
* fileName : FolderController
* author : koo
* date : 2025. 12. 23. 오후 7:58
* description : Folder aggregate에 대한 api endpoint
*/
@Tag(name = "folder", description = "폴더 API")
@RestController
@RequestMapping("/api/folders")
class FolderController(
private val createFolderUseCase: CreateFolderUseCase,
private val getFoldersUseCase: GetFoldersUseCase,
private val deleteFolderUseCase: DeleteFolderUseCase,
private val updateFolderUseCase: UpdateFolderUseCase,
private val commandConverter: FolderCommandConverter,
private val resultConverter: FolderResultConverter,
) {

@Operation(
summary = "폴더 생성 API",
description = "폴더를 생성합니다.",
)
@PostMapping
fun createFolder(
@AuthenticationPrincipal(expression = "id") userId: Long,
@Valid @RequestBody request: CreateFolderRequest,
): BaseResponse<CreateFolderResponse> {
val command = commandConverter.toCreateFolderCommand(request, userId)

val result = createFolderUseCase.execute(command)

val response = resultConverter.toCreateFolderResponse(result)

return BaseResponse(data = response)
}

@Operation(
summary = "폴더 목록 조회 API",
description = "폴더 목록을 조회합니다.",
)
@GetMapping
fun getAllFolder(@AuthenticationPrincipal(expression = "id") userId: Long): BaseResponse<GetAllFolderResponse> {
val command = commandConverter.toGetFoldersCommand(userId)

val result = getFoldersUseCase.execute(command)

val response = resultConverter.toGetAllFoldersResponse(result)

return BaseResponse(data = response)
}

@Operation(
summary = "폴더 삭제 API",
description = "단건 폴더 삭제를 합니다.",
)
@DeleteMapping("/{folderId}")
fun deleteFolder(
@AuthenticationPrincipal(expression = "id") userId: Long,
@PathVariable folderId: Long,
): BaseResponse<Any> {
val command = commandConverter.toDeleteFolderCommand(userId, folderId)

deleteFolderUseCase.execute(command)

return BaseResponse()
}

@Operation(
summary = "폴더 선택 삭제 API",
description = "여러 개의 폴더를 선택하여 삭제합니다.",
)
@DeleteMapping
fun deleteFolders(
@AuthenticationPrincipal(expression = "id") userId: Long,
@Valid @RequestBody request: DeleteFoldersRequest,
): BaseResponse<Any> {
val command = commandConverter.toDeleteFoldersCommand(request, userId)

deleteFolderUseCase.execute(command)

return BaseResponse()
}

@Operation(
summary = "폴더 갱신 API",
description = "폴더 정보를 갱신합니다.",
)
@PatchMapping("/{folderId}")
fun updateFolder(
@AuthenticationPrincipal(expression = "id") userId: Long,
@PathVariable folderId: Long,
@Valid @RequestBody request: UpdateFolderRequest,
): BaseResponse<Any> {
val command = commandConverter.toUpdateFolderCommand(request, folderId, userId)

updateFolderUseCase.execute(command)

return BaseResponse()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.yapp2app.photo.api.converter

import com.yapp2app.photo.api.dto.CreateFolderRequest
import com.yapp2app.photo.api.dto.DeleteFoldersRequest
import com.yapp2app.photo.api.dto.UpdateFolderRequest
import com.yapp2app.photo.application.command.CreateFolderCommand
import com.yapp2app.photo.application.command.DeleteFolderCommand
import com.yapp2app.photo.application.command.DeleteFoldersCommand
import com.yapp2app.photo.application.command.GetFoldersCommand
import com.yapp2app.photo.application.command.UpdateFolderCommand
import org.springframework.stereotype.Component

/**
* fileName : FolderCommandConverter
* author : koo
* date : 2025. 12. 28. 오후 9:44
* description :
*/
@Component
class FolderCommandConverter {

fun toCreateFolderCommand(request: CreateFolderRequest, userId: Long): CreateFolderCommand =
CreateFolderCommand(userId, request.name)

fun toGetFoldersCommand(userId: Long): GetFoldersCommand = GetFoldersCommand(userId)

fun toDeleteFolderCommand(userId: Long, folderId: Long) = DeleteFolderCommand(userId, folderId)

fun toDeleteFoldersCommand(request: DeleteFoldersRequest, userId: Long) =
DeleteFoldersCommand(userId, request.folderIds)

fun toUpdateFolderCommand(request: UpdateFolderRequest, folderId: Long, userId: Long) =
UpdateFolderCommand(userId, folderId, request.name)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.yapp2app.photo.api.converter

import com.yapp2app.photo.api.dto.CreateFolderResponse
import com.yapp2app.photo.api.dto.GetAllFolderResponse
import com.yapp2app.photo.application.result.CreateFolderResult
import com.yapp2app.photo.application.result.GetFoldersResult
import org.springframework.stereotype.Component

/**
* fileName : FolderDtoMapper
* author : koo
* date : 2025. 12. 28. 오후 9:41
* description :
*/
@Component
class FolderResultConverter {

fun toGetAllFoldersResponse(result: GetFoldersResult): GetAllFolderResponse = GetAllFolderResponse(
result.items.map {
GetAllFolderResponse.FolderInfo(
it.folderId,
it.name,
)
},
)

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